diff --git a/gui/src/firstrun.rs b/gui/src/firstrun/mod.rs similarity index 73% rename from gui/src/firstrun.rs rename to gui/src/firstrun/mod.rs index 970c361..6166ef3 100644 --- a/gui/src/firstrun.rs +++ b/gui/src/firstrun/mod.rs @@ -1,6 +1,10 @@ //! First-login modal. Phase 10 implements the body of `run`. -#![allow(dead_code)] // body lands in Phase 10. +#![allow(dead_code)] // body lands in Phase 10 Task 2. + +mod watchdog; +#[allow(unused_imports)] // wired into run() in Phase 10 Task 2. +pub(crate) use watchdog::WatchdogState; use crate::app_context::AppContext; diff --git a/gui/src/firstrun/watchdog.rs b/gui/src/firstrun/watchdog.rs new file mode 100644 index 0000000..de1231b --- /dev/null +++ b/gui/src/firstrun/watchdog.rs @@ -0,0 +1,55 @@ +//! Tracks the last-activity timestamp; the GTK timer queries this each tick +//! to decide whether 60s of idle has elapsed. + +use std::time::{Duration, Instant}; + +pub(crate) struct WatchdogState { + last_activity: Instant, + timeout: Duration, +} + +impl WatchdogState { + pub fn new(timeout: Duration) -> Self { + Self { + last_activity: Instant::now(), + timeout, + } + } + + pub fn poke(&mut self) { + self.last_activity = Instant::now(); + } + + /// True iff `timeout` has passed since the last poke. Once expired, + /// stays expired until the caller drops + recreates. + pub fn expired(&self) -> bool { + self.last_activity.elapsed() >= self.timeout + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_state_is_not_expired() { + let w = WatchdogState::new(Duration::from_secs(60)); + assert!(!w.expired()); + } + + #[test] + fn zero_timeout_is_immediately_expired() { + let w = WatchdogState::new(Duration::from_millis(0)); + std::thread::sleep(Duration::from_millis(2)); + assert!(w.expired()); + } + + #[test] + fn poke_resets_the_clock() { + let mut w = WatchdogState::new(Duration::from_millis(100)); + std::thread::sleep(Duration::from_millis(150)); + assert!(w.expired()); + w.poke(); + assert!(!w.expired()); + } +}