1#[cfg(not(target_arch = "wasm32"))]
38use std::sync::Mutex;
39use std::{
40 collections::{BTreeMap, BTreeSet},
41 sync::{
42 Arc, OnceLock,
43 atomic::{AtomicU64, Ordering},
44 },
45};
46
47use crate::registry::{RecoveryGate, ServiceRegistry};
48
49#[derive(Clone, Debug, PartialEq, Eq)]
51pub struct Product {
52 pub id: String,
55 pub display_price: String,
60 pub title: String,
62 pub description: String,
64}
65
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
68pub enum StorePhase {
69 #[default]
74 Unavailable,
75 Blocked,
86 Connecting,
89 Ready,
91}
92
93impl StorePhase {
94 pub fn cannot_sell(self) -> bool {
100 matches!(self, Self::Unavailable | Self::Blocked)
101 }
102
103 pub fn may_yet_change(self) -> bool {
108 matches!(self, Self::Unavailable | Self::Connecting)
109 }
110}
111
112#[derive(Clone, Debug, Default, PartialEq, Eq)]
114pub struct StoreState {
115 pub phase: StorePhase,
117 pub products: Vec<Product>,
121 pub owned: BTreeSet<String>,
124 pub orders: BTreeMap<String, String>,
138 pub error: Option<String>,
141 pub busy: bool,
144}
145
146impl StoreState {
147 pub fn owns(&self, product_id: &str) -> bool {
149 self.owned.contains(product_id)
150 }
151
152 pub fn order_id(&self, product_id: &str) -> Option<&str> {
156 self.orders.get(product_id).map(String::as_str)
157 }
158
159 pub fn product(&self, product_id: &str) -> Option<&Product> {
161 self.products.iter().find(|p| p.id == product_id)
162 }
163
164 pub fn display_price(&self, product_id: &str) -> Option<&str> {
166 self.product(product_id).map(|p| p.display_price.as_str())
167 }
168}
169
170#[derive(Clone, Debug, PartialEq, Eq)]
172pub enum PurchaseEvent {
173 Purchased(String),
175 Cancelled,
177 Pending,
181 Failed(String),
183 Restored {
187 restored: usize,
189 },
190}
191
192pub trait Purchases: Send + Sync {
198 fn configure(&self, product_ids: &[&str]);
201
202 fn state(&self) -> StoreState;
204
205 fn purchase(&self, product_id: &str);
207
208 fn restore(&self);
212
213 fn take_event(&self) -> Option<PurchaseEvent>;
215
216 fn is_connected(&self) -> bool;
217
218 fn reconnect(&self);
219}
220
221pub type PurchasesRef = Arc<dyn Purchases>;
223
224struct NoPurchases;
225
226impl Purchases for NoPurchases {
227 fn configure(&self, _product_ids: &[&str]) {}
228
229 fn state(&self) -> StoreState {
230 StoreState::default()
231 }
232
233 fn purchase(&self, _product_id: &str) {}
234
235 fn restore(&self) {}
236
237 fn take_event(&self) -> Option<PurchaseEvent> {
238 None
239 }
240
241 fn is_connected(&self) -> bool {
242 false
243 }
244
245 fn reconnect(&self) {}
246}
247
248static PLATFORM_PURCHASES: ServiceRegistry<dyn Purchases> = ServiceRegistry::new();
249static NO_PURCHASES: OnceLock<PurchasesRef> = OnceLock::new();
250static DEFAULT_PURCHASES: OnceLock<PurchasesRef> = OnceLock::new();
251static PURCHASE_RECOVERY: RecoveryGate = RecoveryGate::new();
252
253struct PlatformPurchases;
254
255fn registered_purchases() -> PurchasesRef {
256 PLATFORM_PURCHASES
257 .get_or_warn("purchases")
258 .unwrap_or_else(|| NO_PURCHASES.get_or_init(|| Arc::new(NoPurchases)).clone())
259}
260
261fn active_purchases() -> PurchasesRef {
262 let purchases = registered_purchases();
263 if purchases.is_connected() {
264 PURCHASE_RECOVERY.succeeded();
265 } else if PURCHASE_RECOVERY.try_start() {
266 purchases.reconnect();
267 }
268 purchases
269}
270
271impl Purchases for PlatformPurchases {
272 fn configure(&self, product_ids: &[&str]) {
273 active_purchases().configure(product_ids);
274 }
275
276 fn state(&self) -> StoreState {
277 active_purchases().state()
278 }
279
280 fn purchase(&self, product_id: &str) {
281 active_purchases().purchase(product_id);
282 }
283
284 fn restore(&self) {
285 active_purchases().restore();
286 }
287
288 fn take_event(&self) -> Option<PurchaseEvent> {
289 active_purchases().take_event()
290 }
291
292 fn is_connected(&self) -> bool {
293 registered_purchases().is_connected()
294 }
295
296 fn reconnect(&self) {
297 registered_purchases().reconnect();
298 }
299}
300
301#[cfg(not(target_arch = "wasm32"))]
302type StoreListener = Arc<dyn Fn() + Send + Sync>;
303#[cfg(target_arch = "wasm32")]
304type StoreListener = std::rc::Rc<dyn Fn()>;
305
306#[cfg(not(target_arch = "wasm32"))]
307fn store_listeners() -> &'static Mutex<Vec<(u64, StoreListener)>> {
308 static LISTENERS: OnceLock<Mutex<Vec<(u64, StoreListener)>>> = OnceLock::new();
309 LISTENERS.get_or_init(|| Mutex::new(Vec::new()))
310}
311
312#[cfg(target_arch = "wasm32")]
313thread_local! {
314 static STORE_LISTENERS: std::cell::RefCell<Vec<(u64, StoreListener)>> = const { std::cell::RefCell::new(Vec::new()) };
315}
316
317static NEXT_STORE_LISTENER_ID: AtomicU64 = AtomicU64::new(1);
318
319pub struct StoreObserver {
321 id: u64,
322}
323
324impl Drop for StoreObserver {
325 fn drop(&mut self) {
326 #[cfg(not(target_arch = "wasm32"))]
327 if let Ok(mut listeners) = store_listeners().lock() {
328 listeners.retain(|(id, _)| *id != self.id);
329 }
330 #[cfg(target_arch = "wasm32")]
331 STORE_LISTENERS.with(|listeners| listeners.borrow_mut().retain(|(id, _)| *id != self.id));
332 }
333}
334
335#[cfg(not(target_arch = "wasm32"))]
347pub fn observe_store_news(listener: impl Fn() + Send + Sync + 'static) -> StoreObserver {
348 let id = NEXT_STORE_LISTENER_ID.fetch_add(1, Ordering::Relaxed);
349 if let Ok(mut listeners) = store_listeners().lock() {
350 listeners.push((id, Arc::new(listener)));
351 }
352 StoreObserver { id }
353}
354
355#[cfg(target_arch = "wasm32")]
357pub fn observe_store_news(listener: impl Fn() + 'static) -> StoreObserver {
358 let id = NEXT_STORE_LISTENER_ID.fetch_add(1, Ordering::Relaxed);
359 STORE_LISTENERS.with(|listeners| {
360 listeners
361 .borrow_mut()
362 .push((id, std::rc::Rc::new(listener)));
363 });
364 StoreObserver { id }
365}
366
367pub fn note_store_news() {
369 #[cfg(not(target_arch = "wasm32"))]
370 let listeners = store_listeners()
371 .lock()
372 .map(|listeners| {
373 listeners
374 .iter()
375 .map(|(_, listener)| Arc::clone(listener))
376 .collect::<Vec<_>>()
377 })
378 .unwrap_or_default();
379 #[cfg(target_arch = "wasm32")]
380 let listeners = STORE_LISTENERS.with(|listeners| {
381 listeners
382 .borrow()
383 .iter()
384 .map(|(_, listener)| std::rc::Rc::clone(listener))
385 .collect::<Vec<_>>()
386 });
387 for listener in listeners {
388 listener();
389 }
390}
391
392pub fn set_platform_purchases(purchases: PurchasesRef) {
393 PLATFORM_PURCHASES.set(purchases);
394 PURCHASE_RECOVERY.succeeded();
395}
396
397pub fn clear_platform_purchases() {
399 PLATFORM_PURCHASES.clear();
400}
401
402pub fn purchases() -> PurchasesRef {
405 DEFAULT_PURCHASES
406 .get_or_init(|| Arc::new(PlatformPurchases))
407 .clone()
408}
409
410pub fn store_available() -> bool {
412 PLATFORM_PURCHASES.get().is_some()
413}
414
415pub fn configure(product_ids: &[&str]) {
417 purchases().configure(product_ids);
418}
419
420pub fn store_state() -> StoreState {
422 purchases().state()
423}
424
425pub fn purchase(product_id: &str) {
427 purchases().purchase(product_id);
428}
429
430pub fn restore() {
432 purchases().restore();
433}
434
435pub fn take_event() -> Option<PurchaseEvent> {
437 purchases().take_event()
438}
439
440#[allow(non_snake_case)]
447#[track_caller]
448pub fn rememberStoreState() -> cranpose_core::State<StoreState> {
449 let updates = cranpose_core::rememberEventStream((), |sender| {
450 observe_store_news(move || sender.send(store_state()))
451 });
452 cranpose_core::collectAsState(updates, (), store_state())
453}
454
455#[allow(non_snake_case)]
460#[track_caller]
461pub fn rememberPurchaseEvents() -> cranpose_core::EventStream<PurchaseEvent> {
462 cranpose_core::rememberEventStream((), |sender| {
463 observe_store_news(move || {
464 while let Some(event) = take_event() {
465 sender.send(event);
466 }
467 })
468 })
469}
470
471#[cfg(test)]
472mod tests {
473 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
474
475 use super::*;
476
477 #[test]
478 fn default_backend_sells_nothing_and_owns_nothing() {
479 let _guard = crate::registry::test_service_guard();
480 clear_platform_purchases();
481 let state = store_state();
482 assert_eq!(state.phase, StorePhase::Unavailable);
483 assert!(state.owned.is_empty());
484 assert!(!state.owns("com.example.pro"));
485 assert!(!store_available());
486 configure(&["com.example.pro"]);
487 purchase("com.example.pro");
488 restore();
489 assert_eq!(take_event(), None);
490 }
491
492 #[test]
493 fn the_two_phases_that_cannot_sell_differ_on_whether_waiting_helps() {
494 assert!(StorePhase::Unavailable.cannot_sell());
495 assert!(StorePhase::Blocked.cannot_sell());
496 assert!(!StorePhase::Connecting.cannot_sell());
497 assert!(!StorePhase::Ready.cannot_sell());
498
499 assert!(StorePhase::Unavailable.may_yet_change());
500 assert!(StorePhase::Connecting.may_yet_change());
501 assert!(
502 !StorePhase::Blocked.may_yet_change(),
503 "a store that has said no is what the phase exists to say"
504 );
505 assert!(!StorePhase::Ready.may_yet_change());
506 }
507
508 #[test]
509 fn nothing_is_owned_by_default_and_blocked_is_not_the_default() {
510 assert_eq!(StorePhase::default(), StorePhase::Unavailable);
511 }
512
513 #[test]
514 fn installed_backend_answers_prices_and_ownership() {
515 let _guard = crate::registry::test_service_guard();
516 struct Fake;
517 impl Purchases for Fake {
518 fn configure(&self, _product_ids: &[&str]) {}
519 fn state(&self) -> StoreState {
520 StoreState {
521 phase: StorePhase::Ready,
522 products: vec![Product {
523 id: "com.example.pro".into(),
524 display_price: "34,99 €".into(),
525 title: "Pro".into(),
526 description: "Everything unlocked".into(),
527 }],
528 owned: BTreeSet::from(["com.example.pro".to_string()]),
529 orders: BTreeMap::from([(
530 "com.example.pro".to_string(),
531 "GPA.1234-5678".to_string(),
532 )]),
533 error: None,
534 busy: false,
535 }
536 }
537 fn purchase(&self, _product_id: &str) {}
538 fn restore(&self) {}
539 fn take_event(&self) -> Option<PurchaseEvent> {
540 Some(PurchaseEvent::Purchased("com.example.pro".into()))
541 }
542 fn is_connected(&self) -> bool {
543 true
544 }
545 fn reconnect(&self) {}
546 }
547 set_platform_purchases(Arc::new(Fake));
548 let state = store_state();
549 assert_eq!(state.phase, StorePhase::Ready);
550 assert!(state.owns("com.example.pro"));
551 assert_eq!(state.order_id("com.example.pro"), Some("GPA.1234-5678"));
552 assert_eq!(state.order_id("com.example.free"), None);
553 assert_eq!(state.display_price("com.example.pro"), Some("34,99 €"));
554 assert_eq!(state.display_price("com.example.nope"), None);
555 assert!(store_available());
556 assert_eq!(
557 take_event(),
558 Some(PurchaseEvent::Purchased("com.example.pro".into()))
559 );
560 clear_platform_purchases();
561 }
562
563 #[test]
564 fn dead_store_reconnects_before_frame_state_is_read() {
565 let _guard = crate::registry::test_service_guard();
566 struct Reconnecting {
567 alive: AtomicBool,
568 reconnects: AtomicUsize,
569 }
570 impl Purchases for Reconnecting {
571 fn configure(&self, _product_ids: &[&str]) {}
572 fn state(&self) -> StoreState {
573 StoreState {
574 phase: if self.alive.load(Ordering::Acquire) {
575 StorePhase::Ready
576 } else {
577 StorePhase::Unavailable
578 },
579 ..StoreState::default()
580 }
581 }
582 fn purchase(&self, _product_id: &str) {}
583 fn restore(&self) {}
584 fn take_event(&self) -> Option<PurchaseEvent> {
585 None
586 }
587 fn is_connected(&self) -> bool {
588 self.alive.load(Ordering::Acquire)
589 }
590 fn reconnect(&self) {
591 self.reconnects.fetch_add(1, Ordering::AcqRel);
592 self.alive.store(true, Ordering::Release);
593 }
594 }
595 clear_platform_purchases();
596 let purchases = Arc::new(Reconnecting {
597 alive: AtomicBool::new(false),
598 reconnects: AtomicUsize::new(0),
599 });
600 set_platform_purchases(purchases.clone());
601 assert_eq!(store_state().phase, StorePhase::Ready);
602 assert_eq!(purchases.reconnects.load(Ordering::Acquire), 1);
603 clear_platform_purchases();
604 }
605
606 #[test]
607 fn store_observers_receive_news_until_dropped() {
608 let calls = Arc::new(AtomicUsize::new(0));
609 let seen = Arc::clone(&calls);
610 let observer = observe_store_news(move || {
611 seen.fetch_add(1, Ordering::Relaxed);
612 });
613 note_store_news();
614 assert_eq!(calls.load(Ordering::Relaxed), 1);
615 drop(observer);
616 note_store_news();
617 assert_eq!(calls.load(Ordering::Relaxed), 1);
618 }
619}