Skip to main content

entracte_lib/scheduler/
mod.rs

1mod break_stats;
2pub(crate) mod chores;
3mod commands;
4pub(crate) mod content_pack;
5mod exports;
6mod hotkeys;
7pub(crate) mod idle;
8mod overlay;
9pub(crate) mod overlay_watchdog;
10mod pause;
11mod routines;
12mod run_loop;
13mod screen_time;
14pub(crate) mod session_lock;
15mod settings;
16mod timers;
17mod tray_countdown;
18mod types;
19
20use std::path::PathBuf;
21use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
22use std::sync::Arc;
23use std::time::Duration;
24
25/// How often the off-tick task re-evaluates installed detector plugins.
26/// Detectors gate breaks, which fire on the order of minutes, so a few
27/// seconds of latency on a context change is imperceptible while keeping the
28/// wasm work infrequent.
29const DETECTOR_EVAL_INTERVAL: Duration = Duration::from_secs(5);
30
31use log::warn;
32use tauri::AppHandle;
33use tokio::sync::Mutex;
34
35use crate::camera;
36use crate::config::{self, Profile, ProfilesFile};
37use crate::stats::Logger;
38use crate::video;
39
40pub use break_stats::BreakStats;
41// Glob re-exports for the command modules: `#[tauri::command]` generates a
42// sibling wrapper (`__cmd__<name>`) that `tauri::generate_handler!` looks up
43// next to the function. Both have to be reachable at `scheduler::<name>` for
44// the handler invocation in lib.rs to resolve.
45pub use commands::backup::*;
46pub use commands::breaks::*;
47pub use commands::chores::*;
48pub use commands::content_pack::*;
49pub use commands::hooks::*;
50pub use commands::plugins::*;
51pub use commands::profiles::*;
52pub use commands::settings::*;
53pub use commands::stats::*;
54pub use hotkeys::apply_hotkeys;
55pub use pause::PauseState;
56// Glob so the `#[tauri::command]` `__cmd__get_routines` sibling resolves at
57// `scheduler::get_routines` for the handler in lib.rs (same reason the
58// command modules above are re-exported with `*`).
59pub use routines::*;
60pub use settings::Settings;
61// `MonitorPlacement` only has consumers inside `config::tests`; preserve the
62// pre-split flat path so the test doesn't have to know the new module layout.
63#[allow(unused_imports)]
64pub use settings::MonitorPlacement;
65pub use settings::UpdateChannel;
66pub use tray_countdown::{format_countdown, TrayCountdownSnapshot};
67// `SuppressReason` is the tray's view of why breaks are paused; only
68// consumed from `tray::tests` (the tray UI uses it via pattern matching
69// on `TrayCountdownSnapshot::Suppressed`, which doesn't name the type).
70#[allow(unused_imports)]
71pub use types::SuppressReason;
72pub use types::{BreakKind, LastBreakInfo};
73// Re-exported for out-of-scheduler consumers (e.g. plugin manifest tests
74// constructing routines); the crate otherwise names it via `super::types`.
75#[allow(unused_imports)]
76pub use types::{BreathPattern, BreathSounds, RoutineStep};
77
78use timers::BreakTimers;
79
80use chores::ChoresState as InternalChoresState;
81use pause::restore_pause_state;
82use screen_time::ScreenTimeState as InternalScreenTimeState;
83use timers::local_today_string;
84use types::BreakEvent as InternalBreakEvent;
85
86/// Recover from poison rather than silently swallow or panic. If the
87/// `current_break` mutex was poisoned by a panicking writer, we still
88/// want to publish into the slot — losing the publish breaks tray
89/// countdown + cold-mount overlay invariants. The inner data is
90/// always valid (we never leave the slot in a half-written state
91/// between locks), so taking it back is safe.
92pub(crate) fn lock_current_break<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
93    m.lock().unwrap_or_else(|p| {
94        warn!("current_break mutex was poisoned; recovering inner data");
95        p.into_inner()
96    })
97}
98
99/// Live, mutable state for the break scheduler.
100///
101/// Constructed once in `lib::run` and shared across the app via
102/// `tauri::State` and `Arc`-cloning. Every mutable field sits behind a
103/// `tokio::Mutex` (or a `std::sync::Mutex` for the renderer-bound
104/// `current_break` slot, which only needs short critical sections).
105/// `Clone` is cheap — it bumps the inner `Arc`s.
106///
107/// The persisted paths (`config_path`, `pause_path`, etc.) are captured
108/// at construction so the scheduler can write them back without
109/// re-resolving Tauri's `app_data_dir` each tick.
110///
111/// ## Locking convention: no nested async mutexes across `.await`
112///
113/// Every call site in this module releases a `tokio::Mutex` guard
114/// before acquiring the next one across an `.await` point. The pattern
115/// is "snapshot then act":
116///
117/// ```ignore
118/// let s = sched.settings.lock().await.clone();      // release before next lock
119/// let name = sched.active_profile_name.lock().await.clone();
120/// let mut profiles = sched.profiles.lock().await;   // safe — others released
121/// ```
122///
123/// Following this rule, deadlock becomes structurally impossible — the
124/// classic "thread A holds X waiting for Y, thread B holds Y waiting
125/// for X" cycle cannot form if guards never overlap on `.await`.
126///
127/// **What this rules out:**
128/// - `let s = sched.settings.lock().await; let p = sched.profiles.lock().await;`
129///   (holding `settings` across the `profiles` acquisition)
130/// - `let g = sched.timers.lock().await; some_async_fn(&sched).await;`
131///   (holding any guard across a call that may itself lock the same scheduler)
132///
133/// **What it allows:**
134/// - Re-acquiring the same lock back-to-back to mutate after an awaited
135///   side-effect (write to disk, emit event). Each scope drops first.
136/// - The std `current_break` mutex, which is only ever taken inside
137///   short non-async blocks (see `overlay::fire_break`).
138/// - Short synchronous emits (`app.emit("evt", &single_field)`) that
139///   borrow a guard expression in the argument list and drop it at the
140///   end of the statement — the emit itself does not `.await` and
141///   yields no scheduler lock.
142/// - Reading two unrelated single-field snapshots back-to-back inside
143///   one command (see `get_postpone_state`): clone the first, drop, then
144///   acquire the second. Brief observational skew is fine for renderer
145///   queries that never make causal decisions across the pair.
146///
147/// If a new code path genuinely needs nested holds — say, an atomic
148/// read-modify-write across two pieces of state — consolidate them
149/// into one struct under one mutex instead of introducing the nesting.
150#[derive(Clone)]
151pub struct Scheduler {
152    pub settings: Arc<Mutex<Settings>>,
153    pub pause_state: Arc<Mutex<PauseState>>,
154    pub camera_active: Arc<AtomicBool>,
155    pub video_active: Arc<AtomicBool>,
156    /// Whether any installed detector plugin currently votes to suppress
157    /// breaks. Written by the off-tick detector-eval task, read by the 1Hz
158    /// loop's suppression chain — like `camera_active`, an atomic so the
159    /// per-tick read is free.
160    pub plugin_suppress: Arc<AtomicBool>,
161    /// 0 = not auto-suppressed; otherwise `SuppressReason::from_u8`
162    /// decodes which guard fired. The tray reads this each tick to
163    /// pick between the Inactive icon + reason tooltip vs the Normal
164    /// icon. Atomic instead of a mutex so the per-tick read is free.
165    pub auto_suppress_reason: Arc<AtomicU8>,
166    pub config_path: PathBuf,
167    pub pause_path: PathBuf,
168    pub events_path: PathBuf,
169    pub screen_time_path: PathBuf,
170    /// The day's chore "post-it" (`chores.json` beside `screen_time.json`):
171    /// a daily-reset list the user enters and the overlay surfaces as a
172    /// long-break nudge. Global, not per-profile — a chore list is about
173    /// the day, not the active settings profile.
174    pub chores_path: PathBuf,
175    /// Installed content plugins + the merge-and-track record of what each
176    /// added, persisted to `plugins.json` beside `settings.json`. See
177    /// `docs/developer/plugin-api-design.md`.
178    pub plugins_path: PathBuf,
179    pub plugins: Arc<Mutex<crate::plugins::PluginRegistry>>,
180    /// Single-flight guard for the plugin-install confirmation dialog,
181    /// mirroring [`Scheduler::hook_dialog_busy`].
182    pub plugin_dialog_busy: Arc<AtomicBool>,
183    pub timers: Arc<Mutex<BreakTimers>>,
184    pub stats: Arc<Mutex<BreakStats>>,
185    pub screen_time: Arc<Mutex<InternalScreenTimeState>>,
186    pub chores: Arc<Mutex<InternalChoresState>>,
187    pub current_break: Arc<std::sync::Mutex<Option<InternalBreakEvent>>>,
188    pub logger: Logger,
189    pub profiles: Arc<Mutex<Vec<Profile>>>,
190    pub active_profile_name: Arc<Mutex<String>>,
191    pub hook_dialog_busy: Arc<AtomicBool>,
192    /// Whether first-run onboarding has been completed. Mirrors
193    /// `ProfilesFile::onboarding_completed`; persisted back to disk via
194    /// `snapshot_profiles_file`. Atomic so the IPC commands can read and
195    /// flip it without taking the profiles lock.
196    pub onboarding_completed: Arc<AtomicBool>,
197    /// Set by the backup-import flow while it's mid-restore. The run
198    /// loop short-circuits each tick while this is true so it can't
199    /// fire a break with mid-write state (e.g. new events.jsonl on
200    /// disk but old settings still in memory).
201    pub import_in_progress: Arc<AtomicBool>,
202}
203
204impl Scheduler {
205    /// Load persisted state from disk and spawn the camera / video
206    /// monitor threads. Does **not** start the main scheduler loop —
207    /// call `spawn` for that, after `app.manage`-ing the result.
208    pub fn new(
209        config_path: PathBuf,
210        pause_path: PathBuf,
211        events_path: PathBuf,
212        screen_time_path: PathBuf,
213        chores_path: PathBuf,
214    ) -> Self {
215        let camera_active = Arc::new(AtomicBool::new(false));
216        camera::spawn_monitor(camera_active.clone());
217        let video_active = Arc::new(AtomicBool::new(false));
218        video::spawn_monitor(video_active.clone());
219        let auto_suppress_reason = Arc::new(AtomicU8::new(0));
220        let profiles_file = config::load(&config_path);
221        let initial = profiles_file.active_settings();
222        let active_name = profiles_file.active.clone();
223        let logger = Logger::spawn(events_path.clone());
224        let pause_state = restore_pause_state(&pause_path);
225        let today = local_today_string();
226        let screen_time = InternalScreenTimeState::from_snapshot(
227            crate::screen_time_store::load(&screen_time_path),
228            &today,
229        );
230        let chores =
231            InternalChoresState::from_snapshot(crate::chores_store::load(&chores_path), &today);
232        let plugins_path = plugins_path_for(&config_path);
233        let plugins = crate::plugin_store::load(&plugins_path);
234        Self {
235            settings: Arc::new(Mutex::new(initial)),
236            pause_state: Arc::new(Mutex::new(pause_state)),
237            camera_active,
238            video_active,
239            plugin_suppress: Arc::new(AtomicBool::new(false)),
240            auto_suppress_reason,
241            config_path,
242            pause_path,
243            events_path,
244            screen_time_path,
245            chores_path,
246            plugins_path,
247            plugins: Arc::new(Mutex::new(plugins)),
248            plugin_dialog_busy: Arc::new(AtomicBool::new(false)),
249            timers: Arc::new(Mutex::new(BreakTimers::new())),
250            stats: Arc::new(Mutex::new(BreakStats::default())),
251            screen_time: Arc::new(Mutex::new(screen_time)),
252            chores: Arc::new(Mutex::new(chores)),
253            current_break: Arc::new(std::sync::Mutex::new(None)),
254            logger,
255            onboarding_completed: Arc::new(AtomicBool::new(profiles_file.onboarding_completed)),
256            profiles: Arc::new(Mutex::new(profiles_file.profiles)),
257            active_profile_name: Arc::new(Mutex::new(active_name)),
258            hook_dialog_busy: Arc::new(AtomicBool::new(false)),
259            import_in_progress: Arc::new(AtomicBool::new(false)),
260        }
261    }
262
263    /// Resolve the day's chore nudge for a firing break. Long breaks only —
264    /// micro is too short and bedtime is for winding down. Rolls the list
265    /// over at local midnight, advances the rotation cursor so consecutive
266    /// long breaks suggest different tasks, and persists whenever either
267    /// changed. Shared by the scheduled-fire and manual-trigger paths so the
268    /// rollover/rotation logic lives in exactly one place.
269    pub(crate) async fn resolve_chore_prompt(&self, kind: BreakKind) -> Option<String> {
270        let today = local_today_string();
271        let mut c = self.chores.lock().await;
272        let rolled = chores::rollover_if_new_day(&mut c, &today);
273        let picked = chores::prompt_for_break(kind, &mut c);
274        if rolled || picked.is_some() {
275            chores::persist_chores(&self.chores_path, &c);
276        }
277        picked
278    }
279
280    /// Launch the 1Hz scheduler loop on the Tauri async runtime. Safe
281    /// to call exactly once per `Scheduler` instance.
282    pub fn spawn(&self, app: AppHandle) {
283        let me = self.clone();
284        tauri::async_runtime::spawn(async move {
285            run_loop::run_loop(app, me).await;
286        });
287        self.spawn_detector_eval();
288    }
289
290    /// Run the installed detector plugins off the 1Hz tick, on a throttled
291    /// interval, and publish their aggregate verdict to `plugin_suppress` for
292    /// the run loop to read. The wasm work happens on a blocking thread so it
293    /// never stalls the scheduler tick; building/running a detector is
294    /// fail-closed (a broken detector never suppresses). No detectors → the
295    /// flag is cleared and the loop short-circuits.
296    fn spawn_detector_eval(&self) {
297        let registry = self.plugins.clone();
298        let plugins_path = self.plugins_path.clone();
299        let suppress = self.plugin_suppress.clone();
300        tauri::async_runtime::spawn(async move {
301            let mut interval = tokio::time::interval(DETECTOR_EVAL_INTERVAL);
302            loop {
303                interval.tick().await;
304                let snapshots = registry.lock().await.detector_snapshots();
305                if snapshots.is_empty() {
306                    suppress.store(false, Ordering::Relaxed);
307                    continue;
308                }
309                let path = plugins_path.clone();
310                let verdict = tauri::async_runtime::spawn_blocking(move || {
311                    crate::plugins::any_detector_suppresses(&snapshots, |id| {
312                        crate::plugin_store::load_module(&path, id).ok()
313                    })
314                })
315                .await
316                .unwrap_or(false);
317                suppress.store(verdict, Ordering::Relaxed);
318            }
319        });
320    }
321
322    /// Sibling of `Scheduler::new` for the integration-test rig
323    /// (`test_support`). Builds a Scheduler with file paths anchored in
324    /// `dir`, *without* spawning the camera / video / run-loop side
325    /// threads. The logger thread is started so events still reach
326    /// `events_path`; the TempDir's drop reaps the directory.
327    ///
328    /// Colocated with `new` on purpose: adding a field to `Scheduler`
329    /// forces the compiler to touch both sites in the same review,
330    /// preventing the test stub from drifting out of sync with
331    /// production construction.
332    #[cfg(test)]
333    pub(crate) fn for_test(profiles: Vec<Profile>, active: &str, dir: &std::path::Path) -> Self {
334        let mut active_settings = profiles
335            .iter()
336            .find(|p| p.name == active)
337            .map(|p| p.settings.clone())
338            .unwrap_or_default();
339        active_settings.rebuild_derived();
340        let events_path = dir.join("events.jsonl");
341        Self {
342            settings: Arc::new(Mutex::new(active_settings)),
343            pause_state: Arc::new(Mutex::new(PauseState::Running)),
344            camera_active: Arc::new(AtomicBool::new(false)),
345            video_active: Arc::new(AtomicBool::new(false)),
346            plugin_suppress: Arc::new(AtomicBool::new(false)),
347            auto_suppress_reason: Arc::new(AtomicU8::new(0)),
348            config_path: dir.join("settings.json"),
349            pause_path: dir.join("pause.json"),
350            events_path: events_path.clone(),
351            screen_time_path: dir.join("screen_time.json"),
352            chores_path: dir.join("chores.json"),
353            plugins_path: dir.join("plugins.json"),
354            plugins: Arc::new(Mutex::new(crate::plugins::PluginRegistry::default())),
355            plugin_dialog_busy: Arc::new(AtomicBool::new(false)),
356            timers: Arc::new(Mutex::new(BreakTimers::new())),
357            stats: Arc::new(Mutex::new(BreakStats::default())),
358            screen_time: Arc::new(Mutex::new(InternalScreenTimeState::from_snapshot(
359                crate::screen_time_store::ScreenTimeSnapshot::default(),
360                &local_today_string(),
361            ))),
362            chores: Arc::new(Mutex::new(InternalChoresState::from_snapshot(
363                crate::chores_store::ChoresSnapshot::default(),
364                &local_today_string(),
365            ))),
366            current_break: Arc::new(std::sync::Mutex::new(None)),
367            logger: Logger::spawn(events_path),
368            onboarding_completed: Arc::new(AtomicBool::new(true)),
369            profiles: Arc::new(Mutex::new(profiles)),
370            active_profile_name: Arc::new(Mutex::new(active.to_string())),
371            hook_dialog_busy: Arc::new(AtomicBool::new(false)),
372            import_in_progress: Arc::new(AtomicBool::new(false)),
373        }
374    }
375
376    /// Build the on-disk shape (`{ profiles, active }`) by snapshotting
377    /// the in-memory profile list. Used by `persist_profiles`.
378    pub async fn snapshot_profiles_file(&self) -> ProfilesFile {
379        ProfilesFile {
380            profiles: self.profiles.lock().await.clone(),
381            active: self.active_profile_name.lock().await.clone(),
382            onboarding_completed: self.onboarding_completed.load(Ordering::Relaxed),
383        }
384    }
385}
386
387/// Snapshot the profile list + active name and atomically write them
388/// to disk. Called after every profile mutation (create / rename /
389/// delete / reorder / reset) so a crash never loses a change.
390pub async fn persist_profiles(sched: &Scheduler) {
391    let file = sched.snapshot_profiles_file().await;
392    if let Err(e) = config::save(&sched.config_path, &file) {
393        warn!(
394            "config: failed to save {}: {e}",
395            sched.config_path.display()
396        );
397    }
398}
399
400/// The plugin registry lives beside `settings.json` in the same config dir.
401/// Derived rather than threaded through `Scheduler::new` so adding it didn't
402/// change the constructor signature.
403pub(crate) fn plugins_path_for(config_path: &std::path::Path) -> PathBuf {
404    match config_path.parent() {
405        Some(dir) => dir.join("plugins.json"),
406        None => PathBuf::from("plugins.json"),
407    }
408}
409
410/// Atomically persist the installed-plugin registry. Called after every
411/// install / uninstall so a crash never loses the merge-and-track record.
412pub async fn persist_plugins(sched: &Scheduler) {
413    let registry = sched.plugins.lock().await.clone();
414    if let Err(e) = crate::plugin_store::save(&sched.plugins_path, &registry) {
415        warn!(
416            "plugin_store: failed to save {}: {e}",
417            sched.plugins_path.display()
418        );
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::plugins_path_for;
425    use std::path::PathBuf;
426
427    #[test]
428    fn plugins_path_is_beside_settings() {
429        let p = plugins_path_for(&PathBuf::from("/cfg/dir/settings.json"));
430        assert_eq!(p, PathBuf::from("/cfg/dir/plugins.json"));
431    }
432
433    #[test]
434    fn plugins_path_falls_back_when_config_has_no_parent() {
435        let p = plugins_path_for(&PathBuf::from("settings.json"));
436        // A bare filename has a parent of "" — joining still yields the file.
437        assert_eq!(p.file_name().unwrap(), "plugins.json");
438    }
439}