Skip to main content

ic_auth_client/
idle_manager.rs

1use futures::channel::{mpsc, oneshot};
2use gloo_utils::window;
3use std::{
4    mem,
5    sync::{Arc, Mutex},
6};
7use wasm_bindgen::{closure::Closure, prelude::*};
8use wasm_bindgen_futures::spawn_local;
9use web_sys::{Event, Window};
10
11type Callback = Box<dyn FnMut() + Send>;
12type JsCallback = Closure<dyn FnMut(Event)>;
13
14const EVENTS: [&str; 6] = [
15    "load",
16    "mousedown",
17    "mousemove",
18    "keydown",
19    "touchstart",
20    "wheel",
21];
22
23/// The relevant state of JavaScript
24struct JsContext {
25    event_handlers: Vec<(String, JsCallback)>,
26    window: Window,
27}
28
29impl JsContext {
30    fn new() -> Self {
31        Self {
32            event_handlers: Vec::new(),
33            window: window(),
34        }
35    }
36
37    fn add_event_listener(&mut self, event_type: &str, callback: JsCallback) {
38        self.window
39            .add_event_listener_with_callback(event_type, callback.as_ref().unchecked_ref())
40            .expect("should add event listener");
41        self.event_handlers.push((event_type.to_string(), callback));
42    }
43
44    fn remove_all_listeners(&mut self) {
45        for (event_type, handler) in self.event_handlers.drain(..) {
46            self.window
47                .remove_event_listener_with_callback(&event_type, handler.as_ref().unchecked_ref())
48                .expect("should remove event listener");
49        }
50    }
51
52    fn clear_timeout(&self, timeout_id: i32) {
53        self.window.clear_timeout_with_handle(timeout_id);
54    }
55
56    fn set_timeout(&self, closure: &Closure<dyn FnMut()>, timeout: i32) -> Result<i32, JsValue> {
57        self.window
58            .set_timeout_with_callback_and_timeout_and_arguments_0(
59                closure.as_ref().unchecked_ref(),
60                timeout,
61            )
62    }
63}
64
65impl Drop for JsContext {
66    fn drop(&mut self) {
67        self.remove_all_listeners();
68    }
69}
70
71// Context contains only states that can be shared among threads
72#[derive(Default)]
73struct Context {
74    callbacks: Arc<Mutex<Vec<Callback>>>,
75}
76
77enum JsMessage {
78    ResetTimer(u32),
79    Cleanup,
80    CleanupWithCallbacks,
81    ScrollDebounce(u32),
82}
83
84struct JsHandler {
85    context: JsContext,
86    receiver: mpsc::Receiver<JsMessage>,
87    sender: mpsc::Sender<JsMessage>,
88    current_timer: Option<i32>,
89    current_scroll_debounce_timer: Option<i32>,
90    exit_closure: Option<Closure<dyn FnMut()>>,
91    reset_closure: Option<Closure<dyn FnMut()>>,
92    callbacks: Arc<Mutex<Vec<Callback>>>,
93    idle_timeout: u32,
94}
95
96impl JsHandler {
97    fn new(
98        receiver: mpsc::Receiver<JsMessage>,
99        sender: mpsc::Sender<JsMessage>,
100        callbacks: Arc<Mutex<Vec<Callback>>>,
101        idle_timeout: u32,
102    ) -> Self {
103        Self {
104            context: JsContext::new(),
105            receiver,
106            sender,
107            current_timer: None,
108            current_scroll_debounce_timer: None,
109            exit_closure: None,
110            reset_closure: None,
111            callbacks,
112            idle_timeout,
113        }
114    }
115
116    fn get_handler_sender(&self) -> mpsc::Sender<JsMessage> {
117        self.sender.clone()
118    }
119
120    async fn run(&mut self) {
121        use futures::StreamExt;
122        while let Some(msg) = self.receiver.next().await {
123            match msg {
124                JsMessage::ResetTimer(timeout) => self.handle_reset_timer(timeout),
125                JsMessage::Cleanup => self.handle_cleanup(false),
126                JsMessage::CleanupWithCallbacks => self.handle_cleanup(true),
127                JsMessage::ScrollDebounce(delay) => self.handle_scroll_debounce(delay),
128            }
129        }
130    }
131
132    fn handle_reset_timer(&mut self, timeout: u32) {
133        // Clear existing timeout if it exists
134        if let Some(timer_id) = self.current_timer.take() {
135            self.context.clear_timeout(timer_id);
136        }
137
138        let actual_timeout = if timeout == 0 {
139            self.idle_timeout // Use the manager's idle_timeout to set a new timer
140        } else {
141            timeout
142        };
143
144        // If actual_timeout is 0, just reset the timer without setting a new one
145        if actual_timeout == 0 {
146            return;
147        }
148
149        // Create a sender for the IdleManager to send the Cleanup message
150        let (sender, oneshot_receiver) = oneshot::channel();
151
152        // Use a closure to handle the timeout
153        let exit_closure = Closure::once(move || {
154            // Send a message that will be received by the oneshot_receiver
155            let _ = sender.send(());
156        });
157
158        // Set the timeout with the closure
159        match self
160            .context
161            .set_timeout(&exit_closure, actual_timeout as i32)
162        {
163            Ok(timer_id) => {
164                self.current_timer = Some(timer_id);
165                self.exit_closure = Some(exit_closure);
166
167                // Spawn a task to handle the oneshot receiver
168                let mut sender = self.get_handler_sender();
169                spawn_local(async move {
170                    // Wait for the oneshot message
171                    if oneshot_receiver.await.is_ok() {
172                        // Then send the cleanup message to the handler
173                        use futures::SinkExt;
174                        let _ = sender.send(JsMessage::CleanupWithCallbacks).await;
175                    }
176                });
177            }
178            Err(_) => {
179                // If setting timeout fails, drop the closure
180                drop(exit_closure);
181            }
182        }
183    }
184
185    fn handle_cleanup(&mut self, execute: bool) {
186        // Clear existing timeouts
187        if let Some(timer_id) = self.current_timer.take() {
188            self.context.clear_timeout(timer_id);
189        }
190
191        if let Some(timer_id) = self.current_scroll_debounce_timer.take() {
192            self.context.clear_timeout(timer_id);
193        }
194
195        // Remove all event listeners
196        self.context.remove_all_listeners();
197
198        // Drop closures
199        self.exit_closure = None;
200        self.reset_closure = None;
201
202        // Execute callbacks
203        if execute && let Ok(mut callbacks) = self.callbacks.lock() {
204            for callback in callbacks.iter_mut() {
205                (callback)();
206            }
207        }
208    }
209
210    fn handle_scroll_debounce(&mut self, delay: u32) {
211        // Clear existing scroll debounce timeout if it exists
212        if let Some(timer_id) = self.current_scroll_debounce_timer.take() {
213            self.context.clear_timeout(timer_id);
214        }
215
216        // Create a sender for the debounce timer
217        let (sender, oneshot_receiver) = oneshot::channel();
218
219        // Use a closure to handle the timeout
220        let reset_closure = Closure::once(move || {
221            // Send a message that will be received by the oneshot_receiver
222            let _ = sender.send(());
223        });
224
225        // Set the timeout with the closure
226        match self.context.set_timeout(&reset_closure, delay as i32) {
227            Ok(timer_id) => {
228                self.current_scroll_debounce_timer = Some(timer_id);
229                self.reset_closure = Some(reset_closure);
230
231                // Spawn a task to handle the oneshot receiver
232                let mut sender = self.get_handler_sender();
233                spawn_local(async move {
234                    // Wait for the oneshot message
235                    if oneshot_receiver.await.is_ok() {
236                        // Then send the reset timer message to the handler
237                        use futures::SinkExt;
238                        let _ = sender.send(JsMessage::ResetTimer(0)).await;
239                    }
240                });
241            }
242            Err(_) => {
243                // If setting timeout fails, drop the closure
244                drop(reset_closure);
245            }
246        }
247    }
248}
249
250/// IdleManager is a struct that manages idle state and events.
251/// It provides functionality to register callbacks that are triggered when the system becomes idle,
252/// and to reset the idle timer when certain events occur.
253#[derive(Clone)]
254pub struct IdleManager {
255    context: Arc<Mutex<Context>>,
256    idle_timeout: u32,
257    js_sender: Arc<Mutex<mpsc::Sender<JsMessage>>>,
258    is_initialized: Arc<Mutex<bool>>,
259}
260
261impl std::fmt::Debug for IdleManager {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        f.debug_struct("IdleManager")
264            .field("idle_timeout", &self.idle_timeout)
265            .field("callbacks", &{
266                if let Ok(context) = self.context.lock() {
267                    if let Ok(callbacks) = context.callbacks.lock() {
268                        callbacks.len()
269                    } else {
270                        0
271                    }
272                } else {
273                    0
274                }
275            })
276            .field("js_sender", &"<mpsc channel>")
277            .finish()
278    }
279}
280
281impl Drop for IdleManager {
282    fn drop(&mut self) {
283        use futures::SinkExt;
284        let mut sender_clone = self.js_sender.lock().unwrap().clone();
285        spawn_local(async move {
286            let _ = sender_clone.send(JsMessage::Cleanup).await;
287        });
288    }
289}
290
291impl IdleManager {
292    /// Default idle timeout duration in milliseconds (10 minutes).
293    const DEFAULT_IDLE_TIMEOUT: u32 = 10 * 60 * 1000;
294    /// Default scroll debounce duration in milliseconds.
295    const DEFAULT_SCROLL_DEBOUNCE: u32 = 100;
296
297    /// Constructs a new [`IdleManager`] with the given options.
298    pub fn new(options: Option<IdleManagerOptions>) -> Self {
299        let callbacks = options
300            .as_ref()
301            .map(|options| options.on_idle.clone())
302            .unwrap_or_else(|| Arc::new(Mutex::new(Vec::new())));
303
304        let idle_timeout = options
305            .as_ref()
306            .and_then(|options| options.idle_timeout)
307            .unwrap_or(Self::DEFAULT_IDLE_TIMEOUT);
308
309        let (sender, receiver) = mpsc::channel(10);
310        let js_sender = Arc::new(Mutex::new(sender.clone()));
311
312        // Start the JS handler in a separate task
313        let handler_receiver = receiver;
314        let handler_sender = sender;
315        let callbacks_for_handler = callbacks.clone();
316        let idle_timeout_for_handler = idle_timeout;
317        spawn_local(async move {
318            let mut handler = JsHandler::new(
319                handler_receiver,
320                handler_sender,
321                callbacks_for_handler,
322                idle_timeout_for_handler,
323            );
324            handler.run().await;
325        });
326
327        let instance = Self {
328            context: Arc::new(Mutex::new(Context { callbacks })),
329            idle_timeout,
330            js_sender,
331            is_initialized: Arc::new(Mutex::new(false)),
332        };
333
334        instance.initialize_event_listeners(&options);
335        instance.reset_timer();
336        instance
337    }
338
339    fn initialize_event_listeners(&self, options: &Option<IdleManagerOptions>) {
340        let mut is_initialized = self.is_initialized.lock().unwrap();
341        if *is_initialized {
342            return;
343        }
344
345        let mut js_context = JsContext::new();
346
347        for event_type in EVENTS.iter() {
348            let sender = self.js_sender.lock().unwrap().clone();
349            let callback = Closure::wrap(Box::new(move |_: Event| {
350                use futures::SinkExt;
351                let mut sender_clone = sender.clone();
352                spawn_local(async move {
353                    let _ = sender_clone.send(JsMessage::ResetTimer(0)).await;
354                });
355            }) as Box<dyn FnMut(Event)>);
356
357            js_context.add_event_listener(event_type, callback);
358        }
359
360        if let Some(true) = options.as_ref().and_then(|options| options.capture_scroll) {
361            let sender = self.js_sender.lock().unwrap().clone();
362            let scroll_debounce = options
363                .as_ref()
364                .and_then(|options| options.scroll_debounce)
365                .unwrap_or(Self::DEFAULT_SCROLL_DEBOUNCE);
366
367            let callback = Closure::wrap(Box::new(move |_: Event| {
368                use futures::SinkExt;
369                let mut sender_clone = sender.clone();
370                spawn_local(async move {
371                    let _ = sender_clone
372                        .send(JsMessage::ScrollDebounce(scroll_debounce))
373                        .await;
374                });
375            }) as Box<dyn FnMut(Event)>);
376
377            js_context.add_event_listener("scroll", callback);
378        }
379
380        *is_initialized = true;
381        Box::leak(Box::new(js_context));
382    }
383
384    /// Registers a callback to be executed when the system becomes idle.
385    pub fn register_callback<F>(&self, callback: F)
386    where
387        F: FnMut() + Send + 'static,
388    {
389        if let Ok(context) = self.context.lock() {
390            if let Ok(mut callbacks) = context.callbacks.lock() {
391                callbacks.push(Box::new(callback));
392            }
393        }
394    }
395
396    /// Exits the idle state, cancels any timeouts, removes event listeners, and executes all registered callbacks.
397    pub fn exit(&mut self) {
398        use futures::SinkExt;
399        // Send cleanup message to JS handler
400        let mut sender_clone = self.js_sender.lock().unwrap().clone();
401        spawn_local(async move {
402            let _ = sender_clone.send(JsMessage::CleanupWithCallbacks).await;
403        });
404
405        // The callbacks will be executed by JsHandler::handle_cleanup
406    }
407    /// Resets the idle timer, cancelling any existing timeout and setting a new one.
408    fn reset_timer(&self) {
409        use futures::SinkExt;
410        let mut sender_clone = self.js_sender.lock().unwrap().clone();
411        let timeout = self.idle_timeout;
412        spawn_local(async move {
413            let _ = sender_clone.send(JsMessage::ResetTimer(timeout)).await;
414        });
415    }
416}
417/// IdleManagerOptions is a struct that contains options for configuring an [`IdleManager`].
418#[derive(Default, Clone)]
419pub struct IdleManagerOptions {
420    /// A callback function to be executed when the system becomes idle.
421    pub on_idle: Arc<Mutex<Vec<Callback>>>,
422    /// The duration of inactivity after which the system is considered idle in milliseconds.
423    pub idle_timeout: Option<u32>,
424    /// A flag indicating whether to capture scroll events.
425    pub capture_scroll: Option<bool>,
426    /// A delay for debouncing scroll events in milliseconds.
427    pub scroll_debounce: Option<u32>,
428}
429
430impl std::fmt::Debug for IdleManagerOptions {
431    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432        let callback_count = if let Ok(callbacks) = self.on_idle.lock() {
433            callbacks.len()
434        } else {
435            0
436        };
437        f.debug_struct("IdleManagerOptions")
438            .field("on_idle", &format!("{} callbacks", callback_count))
439            .field("idle_timeout", &self.idle_timeout)
440            .field("capture_scroll", &self.capture_scroll)
441            .field("scroll_debounce", &self.scroll_debounce)
442            .finish()
443    }
444}
445
446impl IdleManagerOptions {
447    /// Returns a new `IdleManagerOptionsBuilder` to construct an `IdleManagerOptions` struct.
448    pub fn builder() -> IdleManagerOptionsBuilder {
449        IdleManagerOptionsBuilder::default()
450    }
451}
452
453/// Builder for the [`IdleManagerOptions`].
454#[derive(Default)]
455pub struct IdleManagerOptionsBuilder {
456    on_idle: Vec<Callback>,
457    idle_timeout: Option<u32>,
458    capture_scroll: Option<bool>,
459    scroll_debounce: Option<u32>,
460}
461
462impl IdleManagerOptionsBuilder {
463    /// A callback function to be executed when the system becomes idle.
464    pub fn on_idle(&mut self, on_idle: fn()) -> &mut Self {
465        self.on_idle
466            .push(Box::new(on_idle) as Box<dyn FnMut() + Send>);
467        self
468    }
469
470    /// The duration of inactivity after which the system is considered idle in milliseconds.
471    pub fn idle_timeout(&mut self, idle_timeout: u32) -> &mut Self {
472        self.idle_timeout = Some(idle_timeout);
473        self
474    }
475
476    /// A flag indicating whether to capture scroll events.
477    pub fn capture_scroll(&mut self, capture_scroll: bool) -> &mut Self {
478        self.capture_scroll = Some(capture_scroll);
479        self
480    }
481
482    /// A delay for debouncing scroll events in milliseconds.
483    pub fn scroll_debounce(&mut self, scroll_debounce: u32) -> &mut Self {
484        self.scroll_debounce = Some(scroll_debounce);
485        self
486    }
487
488    /// Builds the [`IdleManagerOptions`] struct.
489    pub fn build(&mut self) -> IdleManagerOptions {
490        IdleManagerOptions {
491            on_idle: Arc::new(Mutex::new(mem::take(&mut self.on_idle))),
492            idle_timeout: self.idle_timeout,
493            capture_scroll: self.capture_scroll,
494            scroll_debounce: self.scroll_debounce,
495        }
496    }
497}
498
499#[allow(dead_code)]
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use crate::util::sleep::sleep;
504    use wasm_bindgen_test::*;
505
506    wasm_bindgen_test_configure!(run_in_browser);
507
508    #[wasm_bindgen_test]
509    async fn test_idle_manager() {
510        let options = IdleManagerOptions::builder().idle_timeout(500).build();
511
512        let idle_manager = IdleManager::new(Some(options));
513
514        let callback = Arc::new(Mutex::new(false));
515        let callback_clone = callback.clone();
516        idle_manager.register_callback(move || {
517            *callback_clone.lock().unwrap() = true;
518        });
519
520        assert!(!*callback.lock().unwrap());
521
522        // Wait for the idle timeout to trigger
523        sleep(2000).await;
524
525        assert!(*callback.lock().unwrap());
526    }
527
528    #[wasm_bindgen_test]
529    async fn test_idle_manager_with_reset_timer() {
530        let options = IdleManagerOptions::builder().idle_timeout(1000).build();
531
532        let idle_manager = IdleManager::new(Some(options));
533
534        let callback = Arc::new(Mutex::new(false));
535        let callback_clone = callback.clone();
536        idle_manager.register_callback(move || {
537            *callback_clone.lock().unwrap() = true;
538        });
539
540        assert!(!*callback.lock().unwrap());
541
542        sleep(500).await;
543
544        // Trigger a mousemove event
545        let window = window();
546        let event = window.document().unwrap().create_event("Event").unwrap();
547        event.init_event("mousemove");
548        window.dispatch_event(&event).unwrap();
549
550        sleep(700).await;
551
552        assert!(!*callback.lock().unwrap());
553
554        // Wait for the idle timeout to trigger
555        sleep(500).await;
556
557        assert!(*callback.lock().unwrap());
558    }
559
560    #[wasm_bindgen_test]
561    async fn test_idle_manager_with_scroll_debounce_1() {
562        let options = IdleManagerOptions::builder()
563            .idle_timeout(1000)
564            .capture_scroll(true)
565            .scroll_debounce(500)
566            .build();
567
568        let idle_manager = IdleManager::new(Some(options));
569
570        let callback = Arc::new(Mutex::new(false));
571        let callback_clone = callback.clone();
572        idle_manager.register_callback(move || {
573            *callback_clone.lock().unwrap() = true;
574        });
575
576        assert!(!*callback.lock().unwrap());
577
578        let window = window();
579        let event = window.document().unwrap().create_event("Event").unwrap();
580        event.init_event("scroll");
581
582        for _ in 0..7 {
583            sleep(200).await;
584            window.dispatch_event(&event).unwrap();
585        }
586
587        assert!(*callback.lock().unwrap());
588    }
589
590    #[wasm_bindgen_test]
591    async fn test_idle_manager_with_scroll_debounce_2() {
592        let options = IdleManagerOptions::builder()
593            .idle_timeout(1000)
594            .capture_scroll(true)
595            .scroll_debounce(500)
596            .build();
597
598        let idle_manager = IdleManager::new(Some(options));
599
600        let callback = Arc::new(Mutex::new(false));
601        let callback_clone = callback.clone();
602        idle_manager.register_callback(move || {
603            *callback_clone.lock().unwrap() = true;
604        });
605
606        let window = window();
607        let event = window.document().unwrap().create_event("Event").unwrap();
608        event.init_event("scroll");
609        window.dispatch_event(&event).unwrap();
610
611        assert!(!*callback.lock().unwrap());
612
613        sleep(1200).await;
614
615        assert!(!*callback.lock().unwrap());
616
617        sleep(700).await;
618
619        assert!(*callback.lock().unwrap());
620    }
621}