repame_shell/gesture.rs
1//! First-gesture hook for host policies that gate audio behind one.
2//!
3//! Browsers refuse to start an `AudioContext` outside a user gesture, so an
4//! engine opened at boot comes up suspended. Games call
5//! [`on_first_user_gesture`] with [`repame_audio::Audio::unlock`] and get a
6//! resumed device; there is no DOM code in the game.
7
8/// Set once a real gesture has been seen, so the hook fires exactly once even
9/// though the listeners stay attached.
10static SEEN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
11
12/// Guards listener installation, so polling and callback registration can
13/// both happen in either order.
14#[cfg(target_arch = "wasm32")]
15static INSTALLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
16
17/// Late-bound callback, so a game that registered [`on_first_user_gesture`]
18/// after something already triggered [`install`] still gets its call.
19#[cfg(target_arch = "wasm32")]
20static CALLBACK: std::sync::Mutex<Option<Box<dyn Fn() + Send + Sync>>> =
21 std::sync::Mutex::new(None);
22
23/// Run `f` on the first user gesture on this page.
24///
25/// The listener is attached to `window` for `pointerdown`, `touchstart` and
26/// `keydown`, so mouse, touch and keyboard all arm the device. It stays
27/// attached for the page's life; [`SEEN`] makes every later call a no-op, which
28/// keeps the closure alive cheaply instead of tearing listeners down mid-gesture.
29///
30/// Off wasm this does nothing, so callers need no cfg.
31pub fn on_first_user_gesture(f: impl Fn() + Send + Sync + 'static) {
32 #[cfg(target_arch = "wasm32")]
33 {
34 let mut slot = CALLBACK.lock().unwrap_or_else(|e| e.into_inner());
35 if slot.is_none() {
36 *slot = Some(Box::new(f));
37 }
38 install();
39 }
40 #[cfg(not(target_arch = "wasm32"))]
41 let _ = f;
42}
43
44#[cfg(not(target_arch = "wasm32"))]
45fn install() {}
46
47/// Whether a user gesture has been seen.
48///
49/// Installs the listeners on first call, so a game that prefers polling its
50/// frame loop over registering a callback needs no separate setup step.
51pub fn user_gesture_seen() -> bool {
52 install();
53 SEEN.load(std::sync::atomic::Ordering::SeqCst)
54}
55
56#[cfg(target_arch = "wasm32")]
57fn install() {
58 use std::sync::atomic::Ordering;
59
60 use wasm_bindgen::JsCast;
61 use wasm_bindgen::closure::Closure;
62
63 if INSTALLED.swap(true, Ordering::SeqCst) {
64 return;
65 }
66 let global = js_sys::global();
67 let Ok(add) = js_sys::Reflect::get(
68 &global,
69 &wasm_bindgen::JsValue::from_str("addEventListener"),
70 ) else {
71 return;
72 };
73 let Some(add) = add.dyn_ref::<js_sys::Function>() else {
74 return;
75 };
76 // Leaked into the page on purpose: the closure must outlive this call for
77 // the listener to stay callable, and one 24-byte handler for the life of
78 // the page is cheaper than the bookkeeping to remove it.
79 let handler = Closure::wrap(Box::new(|| {
80 if !SEEN.swap(true, Ordering::SeqCst)
81 && let Some(f) = CALLBACK.lock().unwrap_or_else(|e| e.into_inner()).take()
82 {
83 f();
84 }
85 }) as Box<dyn FnMut()>)
86 .into_js_value();
87 for event in ["pointerdown", "touchstart", "keydown"] {
88 let _ = add.call2(&global, &wasm_bindgen::JsValue::from_str(event), &handler);
89 }
90}