Skip to main content

entracte_lib/scheduler/
mod.rs

1mod break_stats;
2mod commands;
3mod overlay;
4mod pause;
5mod run_loop;
6mod screen_time;
7mod settings;
8mod timers;
9mod tray_countdown;
10mod types;
11
12use std::path::PathBuf;
13use std::sync::atomic::{AtomicBool, AtomicU8};
14use std::sync::Arc;
15
16use log::warn;
17use tauri::AppHandle;
18use tokio::sync::Mutex;
19
20use crate::camera;
21use crate::config::{self, Profile, ProfilesFile};
22use crate::stats::Logger;
23use crate::video;
24
25pub use break_stats::BreakStats;
26// Glob re-exports for the command modules: `#[tauri::command]` generates a
27// sibling wrapper (`__cmd__<name>`) that `tauri::generate_handler!` looks up
28// next to the function. Both have to be reachable at `scheduler::<name>` for
29// the handler invocation in lib.rs to resolve.
30pub use commands::breaks::*;
31pub use commands::hooks::*;
32pub use commands::profiles::*;
33pub use commands::settings::*;
34pub use commands::stats::*;
35pub use pause::PauseState;
36pub use settings::Settings;
37// `MonitorPlacement` only has consumers inside `config::tests`; preserve the
38// pre-split flat path so the test doesn't have to know the new module layout.
39#[allow(unused_imports)]
40pub use settings::MonitorPlacement;
41pub use tray_countdown::{format_countdown, TrayCountdownSnapshot};
42// `SuppressReason` is the tray's view of why breaks are paused; only
43// consumed from `tray::tests` (the tray UI uses it via pattern matching
44// on `TrayCountdownSnapshot::Suppressed`, which doesn't name the type).
45#[allow(unused_imports)]
46pub use types::SuppressReason;
47pub use types::{BreakKind, LastBreakInfo};
48
49use timers::BreakTimers;
50
51use pause::restore_pause_state;
52use screen_time::ScreenTimeState as InternalScreenTimeState;
53use timers::local_today_string;
54use types::BreakEvent as InternalBreakEvent;
55
56/// Live, mutable state for the break scheduler.
57///
58/// Constructed once in `lib::run` and shared across the app via
59/// `tauri::State` and `Arc`-cloning. Every mutable field sits behind a
60/// `tokio::Mutex` (or a `std::sync::Mutex` for the renderer-bound
61/// `current_break` slot, which only needs short critical sections).
62/// `Clone` is cheap — it bumps the inner `Arc`s.
63///
64/// The persisted paths (`config_path`, `pause_path`, etc.) are captured
65/// at construction so the scheduler can write them back without
66/// re-resolving Tauri's `app_data_dir` each tick.
67///
68/// ## Locking convention: no nested async mutexes across `.await`
69///
70/// Every call site in this module releases a `tokio::Mutex` guard
71/// before acquiring the next one across an `.await` point. The pattern
72/// is "snapshot then act":
73///
74/// ```ignore
75/// let s = sched.settings.lock().await.clone();      // release before next lock
76/// let name = sched.active_profile_name.lock().await.clone();
77/// let mut profiles = sched.profiles.lock().await;   // safe — others released
78/// ```
79///
80/// Following this rule, deadlock becomes structurally impossible — the
81/// classic "thread A holds X waiting for Y, thread B holds Y waiting
82/// for X" cycle cannot form if guards never overlap on `.await`.
83///
84/// **What this rules out:**
85/// - `let s = sched.settings.lock().await; let p = sched.profiles.lock().await;`
86///   (holding `settings` across the `profiles` acquisition)
87/// - `let g = sched.timers.lock().await; some_async_fn(&sched).await;`
88///   (holding any guard across a call that may itself lock the same scheduler)
89///
90/// **What it allows:**
91/// - Re-acquiring the same lock back-to-back to mutate after an awaited
92///   side-effect (write to disk, emit event). Each scope drops first.
93/// - The std `current_break` mutex, which is only ever taken inside
94///   short non-async blocks (see `overlay::fire_break`).
95/// - Short synchronous emits (`app.emit("evt", &single_field)`) that
96///   borrow a guard expression in the argument list and drop it at the
97///   end of the statement — the emit itself does not `.await` and
98///   yields no scheduler lock.
99/// - Reading two unrelated single-field snapshots back-to-back inside
100///   one command (see `get_postpone_state`): clone the first, drop, then
101///   acquire the second. Brief observational skew is fine for renderer
102///   queries that never make causal decisions across the pair.
103///
104/// If a new code path genuinely needs nested holds — say, an atomic
105/// read-modify-write across two pieces of state — consolidate them
106/// into one struct under one mutex instead of introducing the nesting.
107#[derive(Clone)]
108pub struct Scheduler {
109    pub settings: Arc<Mutex<Settings>>,
110    pub pause_state: Arc<Mutex<PauseState>>,
111    pub camera_active: Arc<AtomicBool>,
112    pub video_active: Arc<AtomicBool>,
113    /// 0 = not auto-suppressed; otherwise `SuppressReason::from_u8`
114    /// decodes which guard fired. The tray reads this each tick to
115    /// pick between the Inactive icon + reason tooltip vs the Normal
116    /// icon. Atomic instead of a mutex so the per-tick read is free.
117    pub auto_suppress_reason: Arc<AtomicU8>,
118    pub config_path: PathBuf,
119    pub pause_path: PathBuf,
120    pub events_path: PathBuf,
121    pub screen_time_path: PathBuf,
122    pub timers: Arc<Mutex<BreakTimers>>,
123    pub stats: Arc<Mutex<BreakStats>>,
124    pub screen_time: Arc<Mutex<InternalScreenTimeState>>,
125    pub current_break: Arc<std::sync::Mutex<Option<InternalBreakEvent>>>,
126    pub logger: Logger,
127    pub profiles: Arc<Mutex<Vec<Profile>>>,
128    pub active_profile_name: Arc<Mutex<String>>,
129    pub hook_dialog_busy: Arc<AtomicBool>,
130}
131
132impl Scheduler {
133    /// Load persisted state from disk and spawn the camera / video
134    /// monitor threads. Does **not** start the main scheduler loop —
135    /// call `spawn` for that, after `app.manage`-ing the result.
136    pub fn new(
137        config_path: PathBuf,
138        pause_path: PathBuf,
139        events_path: PathBuf,
140        screen_time_path: PathBuf,
141    ) -> Self {
142        let camera_active = Arc::new(AtomicBool::new(false));
143        camera::spawn_monitor(camera_active.clone());
144        let video_active = Arc::new(AtomicBool::new(false));
145        video::spawn_monitor(video_active.clone());
146        let auto_suppress_reason = Arc::new(AtomicU8::new(0));
147        let profiles_file = config::load(&config_path);
148        let initial = profiles_file.active_settings();
149        let active_name = profiles_file.active.clone();
150        let logger = Logger::spawn(events_path.clone());
151        let pause_state = restore_pause_state(&pause_path);
152        let today = local_today_string();
153        let screen_time = InternalScreenTimeState::from_snapshot(
154            crate::screen_time_store::load(&screen_time_path),
155            &today,
156        );
157        Self {
158            settings: Arc::new(Mutex::new(initial)),
159            pause_state: Arc::new(Mutex::new(pause_state)),
160            camera_active,
161            video_active,
162            auto_suppress_reason,
163            config_path,
164            pause_path,
165            events_path,
166            screen_time_path,
167            timers: Arc::new(Mutex::new(BreakTimers::new())),
168            stats: Arc::new(Mutex::new(BreakStats::default())),
169            screen_time: Arc::new(Mutex::new(screen_time)),
170            current_break: Arc::new(std::sync::Mutex::new(None)),
171            logger,
172            profiles: Arc::new(Mutex::new(profiles_file.profiles)),
173            active_profile_name: Arc::new(Mutex::new(active_name)),
174            hook_dialog_busy: Arc::new(AtomicBool::new(false)),
175        }
176    }
177
178    /// Launch the 1Hz scheduler loop on the Tauri async runtime. Safe
179    /// to call exactly once per `Scheduler` instance.
180    pub fn spawn(&self, app: AppHandle) {
181        let me = self.clone();
182        tauri::async_runtime::spawn(async move {
183            run_loop::run_loop(app, me).await;
184        });
185    }
186
187    /// Build the on-disk shape (`{ profiles, active }`) by snapshotting
188    /// the in-memory profile list. Used by `persist_profiles`.
189    pub async fn snapshot_profiles_file(&self) -> ProfilesFile {
190        ProfilesFile {
191            profiles: self.profiles.lock().await.clone(),
192            active: self.active_profile_name.lock().await.clone(),
193        }
194    }
195}
196
197/// Snapshot the profile list + active name and atomically write them
198/// to disk. Called after every profile mutation (create / rename /
199/// delete / reorder / reset) so a crash never loses a change.
200pub async fn persist_profiles(sched: &Scheduler) {
201    let file = sched.snapshot_profiles_file().await;
202    if let Err(e) = config::save(&sched.config_path, &file) {
203        warn!(
204            "config: failed to save {}: {e}",
205            sched.config_path.display()
206        );
207    }
208}