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, ®istry) {
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}