Skip to main content

entracte_lib/scheduler/
run_loop.rs

1use std::sync::atomic::{AtomicI64, Ordering};
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3
4use sysinfo::{ProcessesToUpdate, System};
5use tauri::{AppHandle, Emitter};
6use tauri_plugin_notification::NotificationExt;
7use tokio::time::sleep;
8use user_idle::UserIdle;
9
10use crate::dnd;
11use crate::hooks::{self, HookContext, HookEvent};
12use crate::stats::{EventPayload, GuardReason, Logger};
13
14use super::overlay::deliver_break;
15use super::pause::{persist_pause, PauseState};
16use super::screen_time::{persist_screen_time, rollover_if_new_day, should_remind_screen_time};
17use super::settings::{delivery_for, effective_long_hints, effective_micro_hints, Settings};
18use super::timers::{
19    current_minutes, decide_bedtime, in_window, interval_break_due, local_today_string, parse_hhmm,
20    prebreak_warn_due, should_defer_for_typing, should_fire_fixed_now, BedtimeAction,
21};
22use super::types::{BreakDelivery, BreakKind, SuppressReason};
23use super::Scheduler;
24
25pub(super) async fn run_loop(app: AppHandle, sched: Scheduler) {
26    let mut sysinfo_system: Option<System> = None;
27    // `Instant - Duration` panics if the result would precede the
28    // monotonic clock's start, which on a freshly-booted Windows runner
29    // (clock younger than 60s) means a hard crash before the first tick.
30    let mut last_app_refresh = Instant::now()
31        .checked_sub(Duration::from_secs(60))
32        .unwrap_or_else(Instant::now);
33    let mut app_pause_active = false;
34
35    loop {
36        sleep(Duration::from_secs(1)).await;
37
38        let now = Instant::now();
39        let mut just_resumed = false;
40        {
41            let mut state = sched.pause_state.lock().await;
42            if let PauseState::PausedUntil(Some(t)) = *state {
43                if now >= t {
44                    *state = PauseState::Running;
45                    just_resumed = true;
46                }
47            }
48            if !matches!(*state, PauseState::Running) {
49                continue;
50            }
51        }
52        if just_resumed {
53            persist_pause(&sched.pause_path, &PauseState::Running);
54            sched.logger.log(EventPayload::PauseEnd);
55            let _ = app.emit("pause:changed", false);
56        }
57
58        // Reset before re-evaluating guards. Each branch below writes
59        // its `SuppressReason` if it fires; if none fire the value
60        // stays at 0 and the tray returns to the Normal icon.
61        sched.auto_suppress_reason.store(0, Ordering::Relaxed);
62
63        let s = sched.settings.lock().await.clone();
64        let now_min = current_minutes();
65
66        // `UserIdle::get_time()` round-trips to the windowing system on X11 /
67        // Wayland and isn't free on macOS either, so fetch once per tick and
68        // reuse for screen-time, idle-suppression, and the typing-defer check.
69        let idle_secs = match UserIdle::get_time() {
70            Ok(i) => i.as_seconds(),
71            Err(e) => {
72                warn_user_idle_failure(&e);
73                // Falling back to 0 means "active" so screen-time and
74                // typing-defer behave conservatively rather than silently
75                // suppressing breaks. The rate-limited warning above
76                // surfaces the failure to operators.
77                0
78            }
79        };
80        let is_active = idle_secs < s.micro_idle_reset_secs;
81        let today_str = local_today_string();
82        let budget_secs = s.daily_screen_time_budget_minutes.saturating_mul(60);
83        let remind_again_secs = s.daily_screen_time_remind_again_minutes.saturating_mul(60);
84        let mut fire_screen_time_reminder = false;
85        {
86            let mut st = sched.screen_time.lock().await;
87            let rolled = rollover_if_new_day(&mut st, &today_str);
88            let mut changed = rolled;
89            if is_active {
90                st.seconds = st.seconds.saturating_add(1);
91                changed = true;
92            }
93            if should_remind_screen_time(
94                s.daily_screen_time_enabled,
95                st.seconds,
96                budget_secs,
97                st.last_reminder_epoch_secs,
98                remind_again_secs,
99                super::pause::now_epoch_secs(),
100            ) {
101                st.last_reminder_epoch_secs = Some(super::pause::now_epoch_secs());
102                fire_screen_time_reminder = true;
103                changed = true;
104            }
105            if changed {
106                persist_screen_time(&sched.screen_time_path, &st);
107            }
108        }
109        if fire_screen_time_reminder {
110            notify_screen_time_budget(&app, s.daily_screen_time_budget_minutes);
111            let _ = app.emit("screen_time:reminder", s.daily_screen_time_budget_minutes);
112        }
113
114        // The fixed-time dedupe key is `(local-date, minute-of-day)`, so
115        // midnight rollover is handled naturally: a new date string never
116        // matches yesterday's stored entry. No explicit reset needed here.
117
118        let bedtime_decision = {
119            let t = sched.timers.lock().await;
120            decide_bedtime(
121                s.bedtime_enabled,
122                now_min,
123                s.bedtime_start_minutes,
124                s.bedtime_end_minutes,
125                s.bedtime_interval_secs,
126                t.last_sleep,
127                now,
128            )
129        };
130        if !matches!(bedtime_decision, BedtimeAction::NotInWindow) {
131            if matches!(bedtime_decision, BedtimeAction::Fire) {
132                let intensity = sched.stats.lock().await.intensity();
133                super::overlay::fire_break(
134                    &app,
135                    &sched.current_break,
136                    BreakKind::Sleep,
137                    s.bedtime_duration_secs,
138                    true,
139                    s.monitor_placement,
140                    super::settings::is_windowed_mode(BreakKind::Sleep, &s),
141                    false,
142                    false,
143                    s.sleep_hints.clone(),
144                    s.hint_rotate_seconds,
145                    if s.break_health_enabled {
146                        intensity
147                    } else {
148                        0.0
149                    },
150                );
151                hooks::run_hooks(
152                    &s,
153                    HookEvent::BreakStart,
154                    HookContext::with_kind_duration(BreakKind::Sleep, s.bedtime_duration_secs),
155                );
156                sched.logger.log(EventPayload::BreakStart {
157                    kind: BreakKind::Sleep,
158                    duration_secs: s.bedtime_duration_secs,
159                    enforceable: true,
160                });
161                let mut t = sched.timers.lock().await;
162                t.last_sleep = Some(Instant::now());
163                t.last_micro = Instant::now();
164                t.last_long = Instant::now();
165                t.micro_deferred_since = None;
166                t.long_deferred_since = None;
167                t.active_break = Some(BreakKind::Sleep);
168            } else {
169                let mut t = sched.timers.lock().await;
170                t.last_micro = Instant::now();
171                t.last_long = Instant::now();
172                t.micro_deferred_since = None;
173                t.long_deferred_since = None;
174            }
175            // Bedtime has its own tray snapshot (`TrayCountdownSnapshot::Bedtime`),
176            // so we don't need to store a `SuppressReason` here — the
177            // tray reads `bedtime_active` directly and shows the moon icon.
178            continue;
179        }
180        sched.timers.lock().await.last_sleep = None;
181
182        // Live readings for the guard decision. Short-circuit each
183        // call on the matching setting so `dnd::is_active()` and the
184        // process-scan only run when the user has opted in.
185        let dnd_live = s.pause_during_dnd && dnd::is_active();
186        let camera_live = s.pause_during_camera && sched.camera_active.load(Ordering::Relaxed);
187        let video_live = s.pause_during_video && sched.video_active.load(Ordering::Relaxed);
188        if s.app_pause_enabled && !s.app_pause_list.is_empty() {
189            if last_app_refresh.elapsed() >= Duration::from_secs(5) {
190                let sys = sysinfo_system.get_or_insert_with(System::new);
191                sys.refresh_processes(ProcessesToUpdate::All, false);
192                app_pause_active = sys.processes().values().any(|p| {
193                    let proc_name = p.name().to_string_lossy().to_string();
194                    s.app_pause_list
195                        .iter()
196                        .any(|target| process_match(&proc_name, target))
197                });
198                last_app_refresh = Instant::now();
199            }
200        } else {
201            sysinfo_system = None;
202            app_pause_active = false;
203        }
204
205        if let Some(outcome) = evaluate_guards(
206            &s,
207            now_min,
208            dnd_live,
209            camera_live,
210            video_live,
211            app_pause_active,
212        ) {
213            let mut t = sched.timers.lock().await;
214            if let Some(guard_reason) = outcome.log_as {
215                log_suppressions(&sched.logger, &s, &t, guard_reason);
216            }
217            t.last_micro = Instant::now();
218            t.last_long = Instant::now();
219            t.micro_deferred_since = None;
220            t.long_deferred_since = None;
221            sched
222                .auto_suppress_reason
223                .store(outcome.reason.as_u8(), Ordering::Relaxed);
224            continue;
225        }
226
227        let long_fixed_due = s.long_enabled
228            && matches!(s.long_schedule_mode.as_str(), "fixed" | "both")
229            && s.long_fixed_times
230                .iter()
231                .filter_map(|t| parse_hhmm(t))
232                .any(|m| m == now_min);
233        let micro_fixed_due = s.micro_enabled
234            && matches!(s.micro_schedule_mode.as_str(), "fixed" | "both")
235            && s.micro_fixed_times
236                .iter()
237                .filter_map(|t| parse_hhmm(t))
238                .any(|m| m == now_min);
239
240        if long_fixed_due || micro_fixed_due {
241            let (fire_long, fire_micro) = {
242                let t = sched.timers.lock().await;
243                (
244                    long_fixed_due
245                        && should_fire_fixed_now(
246                            &today_str,
247                            now_min,
248                            t.last_long_fixed_fire.as_ref(),
249                        ),
250                    micro_fixed_due
251                        && should_fire_fixed_now(
252                            &today_str,
253                            now_min,
254                            t.last_micro_fixed_fire.as_ref(),
255                        ),
256                )
257            };
258            // Fixed-time fires bypass the idle gate: the clock is the signal, not user activity.
259            if fire_long {
260                let enforceable = s.long_enforceable || s.strict_mode;
261                let intensity = sched.stats.lock().await.intensity();
262                let delivery = delivery_for(BreakKind::Long, &s);
263                deliver_break(
264                    &app,
265                    &sched.current_break,
266                    delivery,
267                    BreakKind::Long,
268                    s.long_duration_secs,
269                    enforceable,
270                    s.monitor_placement,
271                    s.long_manual_finish,
272                    s.postpone_enabled && !s.strict_mode,
273                    effective_long_hints(&s),
274                    s.hint_rotate_seconds,
275                    if s.break_health_enabled {
276                        intensity
277                    } else {
278                        0.0
279                    },
280                );
281                sched.logger.log(EventPayload::BreakStart {
282                    kind: BreakKind::Long,
283                    duration_secs: s.long_duration_secs,
284                    enforceable,
285                });
286                let mut t = sched.timers.lock().await;
287                t.last_long = Instant::now();
288                t.last_micro = Instant::now();
289                t.long_warned = false;
290                t.micro_warned = false;
291                t.long_deferred_since = None;
292                t.micro_deferred_since = None;
293                if matches!(delivery, BreakDelivery::Overlay | BreakDelivery::Windowed) {
294                    t.active_break = Some(BreakKind::Long);
295                }
296                t.last_long_fixed_fire = Some((today_str.clone(), now_min));
297                continue;
298            }
299            if fire_micro {
300                let enforceable = s.micro_enforceable || s.strict_mode;
301                let intensity = sched.stats.lock().await.intensity();
302                let delivery = delivery_for(BreakKind::Micro, &s);
303                deliver_break(
304                    &app,
305                    &sched.current_break,
306                    delivery,
307                    BreakKind::Micro,
308                    s.micro_duration_secs,
309                    enforceable,
310                    s.monitor_placement,
311                    s.micro_manual_finish,
312                    s.postpone_enabled && !s.strict_mode,
313                    effective_micro_hints(&s),
314                    s.hint_rotate_seconds,
315                    if s.break_health_enabled {
316                        intensity
317                    } else {
318                        0.0
319                    },
320                );
321                sched.logger.log(EventPayload::BreakStart {
322                    kind: BreakKind::Micro,
323                    duration_secs: s.micro_duration_secs,
324                    enforceable,
325                });
326                let mut t = sched.timers.lock().await;
327                t.last_micro = Instant::now();
328                t.micro_warned = false;
329                t.micro_deferred_since = None;
330                if matches!(delivery, BreakDelivery::Overlay | BreakDelivery::Windowed) {
331                    t.active_break = Some(BreakKind::Micro);
332                }
333                t.last_micro_fixed_fire = Some((today_str.clone(), now_min));
334                continue;
335            }
336        }
337
338        let micro_interval_active = matches!(s.micro_schedule_mode.as_str(), "interval" | "both");
339        let long_interval_active = matches!(s.long_schedule_mode.as_str(), "interval" | "both");
340
341        let (micro_idle_suppressed, long_idle_suppressed) = (
342            idle_secs >= s.micro_idle_reset_secs,
343            idle_secs >= s.long_idle_reset_secs,
344        );
345
346        if micro_idle_suppressed || long_idle_suppressed {
347            let mut t = sched.timers.lock().await;
348            log_suppressions(&sched.logger, &s, &t, GuardReason::Idle);
349            if micro_idle_suppressed {
350                t.last_micro = Instant::now();
351                t.micro_deferred_since = None;
352            }
353            if long_idle_suppressed {
354                t.last_long = Instant::now();
355                t.long_deferred_since = None;
356            }
357            if micro_idle_suppressed && long_idle_suppressed {
358                continue;
359            }
360        }
361
362        let tick_now = Instant::now();
363
364        if s.prebreak_notification_enabled && s.prebreak_notification_seconds > 0 {
365            let mut t = sched.timers.lock().await;
366            if prebreak_warn_due(
367                s.long_enabled,
368                long_interval_active,
369                t.last_long,
370                s.long_interval_secs,
371                s.prebreak_notification_seconds,
372                t.long_warned,
373                long_idle_suppressed,
374                tick_now,
375            ) {
376                notify_break_coming(&app, BreakKind::Long, s.prebreak_notification_seconds);
377                t.long_warned = true;
378            }
379            if prebreak_warn_due(
380                s.micro_enabled,
381                micro_interval_active,
382                t.last_micro,
383                s.micro_interval_secs,
384                s.prebreak_notification_seconds,
385                t.micro_warned,
386                micro_idle_suppressed,
387                tick_now,
388            ) {
389                notify_break_coming(&app, BreakKind::Micro, s.prebreak_notification_seconds);
390                t.micro_warned = true;
391            }
392        }
393
394        let (should_fire_long, should_fire_micro) = {
395            let t = sched.timers.lock().await;
396            (
397                interval_break_due(
398                    s.long_enabled,
399                    long_interval_active,
400                    t.last_long,
401                    s.long_interval_secs,
402                    long_idle_suppressed,
403                    tick_now,
404                ),
405                interval_break_due(
406                    s.micro_enabled,
407                    micro_interval_active,
408                    t.last_micro,
409                    s.micro_interval_secs,
410                    micro_idle_suppressed,
411                    tick_now,
412                ),
413            )
414        };
415
416        if should_fire_long || should_fire_micro {
417            let mut t = sched.timers.lock().await;
418            let kind = if should_fire_long {
419                BreakKind::Long
420            } else {
421                BreakKind::Micro
422            };
423            let deferred_since = match kind {
424                BreakKind::Long => t.long_deferred_since,
425                BreakKind::Micro => t.micro_deferred_since,
426                BreakKind::Sleep => None,
427            };
428            let defer = should_defer_for_typing(
429                s.delay_break_if_typing,
430                idle_secs,
431                s.typing_grace_secs,
432                deferred_since,
433                s.typing_max_deferral_secs,
434                tick_now,
435            );
436            if defer {
437                let newly_deferred = deferred_since.is_none();
438                match kind {
439                    BreakKind::Long => {
440                        if newly_deferred {
441                            t.long_deferred_since = Some(tick_now);
442                            sched.logger.log(EventPayload::GuardSuppress {
443                                kind: BreakKind::Long,
444                                reason: GuardReason::Typing,
445                            });
446                        }
447                    }
448                    BreakKind::Micro => {
449                        if newly_deferred {
450                            t.micro_deferred_since = Some(tick_now);
451                            sched.logger.log(EventPayload::GuardSuppress {
452                                kind: BreakKind::Micro,
453                                reason: GuardReason::Typing,
454                            });
455                        }
456                    }
457                    BreakKind::Sleep => {}
458                }
459                continue;
460            }
461        }
462
463        if should_fire_long {
464            let enforceable = s.long_enforceable || s.strict_mode;
465            let intensity = sched.stats.lock().await.intensity();
466            let delivery = delivery_for(BreakKind::Long, &s);
467            deliver_break(
468                &app,
469                &sched.current_break,
470                delivery,
471                BreakKind::Long,
472                s.long_duration_secs,
473                enforceable,
474                s.monitor_placement,
475                s.long_manual_finish,
476                s.postpone_enabled && !s.strict_mode,
477                effective_long_hints(&s),
478                s.hint_rotate_seconds,
479                if s.break_health_enabled {
480                    intensity
481                } else {
482                    0.0
483                },
484            );
485            hooks::run_hooks(
486                &s,
487                HookEvent::BreakStart,
488                HookContext::with_kind_duration(BreakKind::Long, s.long_duration_secs),
489            );
490            sched.logger.log(EventPayload::BreakStart {
491                kind: BreakKind::Long,
492                duration_secs: s.long_duration_secs,
493                enforceable,
494            });
495            let mut t = sched.timers.lock().await;
496            t.last_long = Instant::now();
497            t.last_micro = Instant::now();
498            t.long_warned = false;
499            t.micro_warned = false;
500            t.long_deferred_since = None;
501            t.micro_deferred_since = None;
502            if matches!(delivery, BreakDelivery::Overlay | BreakDelivery::Windowed) {
503                t.active_break = Some(BreakKind::Long);
504            }
505        } else if should_fire_micro {
506            let enforceable = s.micro_enforceable || s.strict_mode;
507            let intensity = sched.stats.lock().await.intensity();
508            let delivery = delivery_for(BreakKind::Micro, &s);
509            deliver_break(
510                &app,
511                &sched.current_break,
512                delivery,
513                BreakKind::Micro,
514                s.micro_duration_secs,
515                enforceable,
516                s.monitor_placement,
517                s.micro_manual_finish,
518                s.postpone_enabled && !s.strict_mode,
519                effective_micro_hints(&s),
520                s.hint_rotate_seconds,
521                if s.break_health_enabled {
522                    intensity
523                } else {
524                    0.0
525                },
526            );
527            hooks::run_hooks(
528                &s,
529                HookEvent::BreakStart,
530                HookContext::with_kind_duration(BreakKind::Micro, s.micro_duration_secs),
531            );
532            sched.logger.log(EventPayload::BreakStart {
533                kind: BreakKind::Micro,
534                duration_secs: s.micro_duration_secs,
535                enforceable,
536            });
537            let mut t = sched.timers.lock().await;
538            t.last_micro = Instant::now();
539            t.micro_warned = false;
540            t.micro_deferred_since = None;
541            if matches!(delivery, BreakDelivery::Overlay | BreakDelivery::Windowed) {
542                t.active_break = Some(BreakKind::Micro);
543            }
544        }
545    }
546}
547
548/// Rate-limit window for repeated `UserIdle::get_time` failure warnings.
549/// One log line per 60 s is enough to surface a persistent platform-API
550/// breakage without spamming the log file once per tick.
551const USER_IDLE_WARN_INTERVAL_SECS: i64 = 60;
552
553/// Epoch seconds (`SystemTime::UNIX_EPOCH`) at which the last UserIdle
554/// failure was logged; `0` means "never warned yet" (also the at-rest
555/// value before the scheduler boots).
556static USER_IDLE_LAST_WARN_EPOCH: AtomicI64 = AtomicI64::new(0);
557
558/// Convert `SystemTime::now()` to seconds since the Unix epoch. Returns
559/// `0` if the system clock is somehow before 1970 — same fallback as
560/// the "never warned" sentinel, which simply means the next warn fires.
561fn now_epoch_secs_for_warn() -> i64 {
562    SystemTime::now()
563        .duration_since(UNIX_EPOCH)
564        .map(|d| d.as_secs() as i64)
565        .unwrap_or(0)
566}
567
568/// Result of evaluating the per-tick suppression guards: either no
569/// guard fires, or exactly one wins and dictates the tray icon
570/// (`reason`) plus whether the event-log records a `GuardSuppress`
571/// entry (`log_as`).
572///
573/// `work_window` deliberately doesn't log — it's a scheduled silence
574/// (the user said "no breaks outside 09:00–17:00"), not an unexpected
575/// suppression worth logging once per second.
576#[derive(Debug, Clone, Copy, PartialEq, Eq)]
577pub(super) struct GuardOutcome {
578    pub reason: SuppressReason,
579    pub log_as: Option<GuardReason>,
580}
581
582/// Pure decision: given the per-tick guard inputs, return which
583/// `SuppressReason` should fire (if any) and whether the run-loop
584/// should also write a `GuardSuppress` event for it.
585///
586/// Precedence (first match wins, mirroring the run-loop order):
587/// work_window → dnd → camera → video → app_pause. The run-loop is
588/// expected to short-circuit expensive checks before passing them in
589/// (e.g. only calling `dnd::is_active()` when `pause_during_dnd` is
590/// set), so the booleans here are "is the condition live right now",
591/// and the function applies the setting gates itself.
592pub(super) fn evaluate_guards(
593    s: &Settings,
594    now_min: u32,
595    dnd_active: bool,
596    camera_active: bool,
597    video_active: bool,
598    app_pause_active: bool,
599) -> Option<GuardOutcome> {
600    if s.work_window_enabled && !in_window(now_min, s.work_start_minutes, s.work_end_minutes) {
601        return Some(GuardOutcome {
602            reason: SuppressReason::WorkWindow,
603            log_as: None,
604        });
605    }
606    if s.pause_during_dnd && dnd_active {
607        return Some(GuardOutcome {
608            reason: SuppressReason::Dnd,
609            log_as: Some(GuardReason::Dnd),
610        });
611    }
612    if s.pause_during_camera && camera_active {
613        return Some(GuardOutcome {
614            reason: SuppressReason::Camera,
615            log_as: Some(GuardReason::Camera),
616        });
617    }
618    if s.pause_during_video && video_active {
619        return Some(GuardOutcome {
620            reason: SuppressReason::Video,
621            log_as: Some(GuardReason::Video),
622        });
623    }
624    if s.app_pause_enabled && !s.app_pause_list.is_empty() && app_pause_active {
625        return Some(GuardOutcome {
626            reason: SuppressReason::AppPause,
627            log_as: Some(GuardReason::AppPause),
628        });
629    }
630    None
631}
632
633/// Decide whether enough time has elapsed since the last UserIdle warn
634/// to fire another one, and update the timestamp atomically if so.
635///
636/// Pure (modulo the atomic): inputs are `now` and the cell, output is
637/// just the gate. Split out so the rate-limit logic can be unit-tested
638/// without touching `log::warn!`.
639fn user_idle_warn_throttle(cell: &AtomicI64, now_epoch: i64, min_interval_secs: i64) -> bool {
640    let prev = cell.load(Ordering::Relaxed);
641    if prev != 0 && now_epoch.saturating_sub(prev) < min_interval_secs {
642        return false;
643    }
644    cell.store(now_epoch, Ordering::Relaxed);
645    true
646}
647
648/// Surface a `UserIdle::get_time` error to the log, at most once per
649/// `USER_IDLE_WARN_INTERVAL_SECS`. Without this gate the production
650/// code silently fell back to "0 = active" forever, so a broken
651/// platform call (X11 down, macOS API change, Wayland portal denied)
652/// would invisibly break idle suppression and screen-time tracking.
653fn warn_user_idle_failure(err: &user_idle::Error) {
654    if user_idle_warn_throttle(
655        &USER_IDLE_LAST_WARN_EPOCH,
656        now_epoch_secs_for_warn(),
657        USER_IDLE_WARN_INTERVAL_SECS,
658    ) {
659        log::warn!("scheduler: UserIdle::get_time failed (treating user as active): {err}");
660    }
661}
662
663fn notify_break_coming(app: &AppHandle, kind: BreakKind, seconds: u64) {
664    let title = match kind {
665        BreakKind::Micro => "Micro break coming up",
666        BreakKind::Long => "Long break coming up",
667        BreakKind::Sleep => "Bedtime reminder coming up",
668    };
669    let body = format!("Starting in {}s", seconds);
670    let _ = app.notification().builder().title(title).body(body).show();
671}
672
673fn notify_screen_time_budget(app: &AppHandle, budget_minutes: u64) {
674    let hours = budget_minutes / 60;
675    let mins = budget_minutes % 60;
676    let body = if hours > 0 && mins == 0 {
677        format!(
678            "You've been at the screen {} hour{} — time to wrap up.",
679            hours,
680            if hours == 1 { "" } else { "s" }
681        )
682    } else if hours == 0 {
683        format!("You've been at the screen {mins} minutes — time to wrap up.")
684    } else {
685        format!("You've been at the screen {hours}h {mins}m — time to wrap up.")
686    };
687    let _ = app
688        .notification()
689        .builder()
690        .title("Time to wind down")
691        .body(body)
692        .show();
693}
694
695fn log_suppressions(
696    logger: &Logger,
697    s: &Settings,
698    t: &super::timers::BreakTimers,
699    reason: GuardReason,
700) {
701    if s.micro_enabled && t.last_micro.elapsed() >= Duration::from_secs(s.micro_interval_secs) {
702        logger.log(EventPayload::GuardSuppress {
703            kind: BreakKind::Micro,
704            reason,
705        });
706    }
707    if s.long_enabled && t.last_long.elapsed() >= Duration::from_secs(s.long_interval_secs) {
708        logger.log(EventPayload::GuardSuppress {
709            kind: BreakKind::Long,
710            reason,
711        });
712    }
713}
714
715// Case-insensitive token match for the app-pause list. We tokenise the
716// running process name on non-alphanumeric boundaries (`.`, `-`, `_`,
717// whitespace, path separators) and accept a target that EITHER equals a
718// token OR is the prefix of a token whose remainder is digits — the
719// `obs64.exe`/`chrome32` Windows versioning convention. That keeps
720// `zoom` matching Zoom (`zoom.us`, `Zoom Meeting Helper`) while
721// rejecting `zoominfo` and `azoomatic`. Multi-token targets (e.g.
722// `osascript -e`) fall back to substring so power-users can still
723// match a distinctive snippet.
724fn process_match(running: &str, target: &str) -> bool {
725    let r = running.to_lowercase();
726    let t = target.to_lowercase();
727    if t.is_empty() {
728        return false;
729    }
730    let target_is_single_token = t.chars().all(|c| c.is_alphanumeric());
731    if !target_is_single_token {
732        return r.contains(&t);
733    }
734    r.split(|c: char| !c.is_alphanumeric()).any(|tok| {
735        if tok == t {
736            return true;
737        }
738        if let Some(suffix) = tok.strip_prefix(t.as_str()) {
739            !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())
740        } else {
741            false
742        }
743    })
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn process_match_matches_whole_token() {
752        // Pre-fix this matched anything containing the substring.
753        assert!(process_match("zoom.us", "zoom"));
754        assert!(process_match("OBS Studio", "obs"));
755        assert!(process_match("zoom", "zoom"));
756        assert!(process_match(
757            "/Applications/zoom.us.app/Contents/MacOS/zoom.us",
758            "zoom"
759        ));
760        assert!(process_match("Zoom Meeting Helper", "zoom"));
761    }
762
763    #[test]
764    fn process_match_rejects_substring_collisions() {
765        // The motivating regression: a Zoom-pause rule should not silently
766        // also pause for ZoomInfo or unrelated tools that contain "zoom".
767        assert!(!process_match("zoominfo.exe", "zoom"));
768        assert!(!process_match("azoomatic", "zoom"));
769        assert!(!process_match("doomsday", "doom"));
770    }
771
772    #[test]
773    fn process_match_allows_digit_versioned_binaries() {
774        // Windows often versions binaries with a digit suffix — the OBS
775        // Studio binary is `obs64.exe`, Firefox ships `firefox64.exe`,
776        // etc. Users entering `obs` expect those to match.
777        assert!(process_match("obs64.exe", "obs"));
778        assert!(process_match("OBS32.exe", "obs"));
779        assert!(process_match("firefox64.exe", "firefox"));
780        // But `firefoxnightly.exe` should not — letters after the prefix.
781        assert!(!process_match("firefoxnightly.exe", "firefox"));
782    }
783
784    #[test]
785    fn process_match_rejects_unrelated_apps() {
786        assert!(!process_match("safari", "zoom"));
787        assert!(!process_match("", "zoom"));
788    }
789
790    #[test]
791    fn process_match_falls_back_to_substring_for_multi_token_targets() {
792        // Power-users who type a distinctive multi-token snippet expect
793        // substring semantics — splitting `osascript -e` into tokens would
794        // make it match anything with osascript or -e separately.
795        assert!(process_match("/usr/bin/osascript -e foo", "osascript -e"));
796        assert!(!process_match("osascript", "osascript -e"));
797    }
798
799    #[test]
800    fn process_match_empty_target_never_matches() {
801        // Defensive: a blank line in the pause list shouldn't pause for
802        // every process on the system.
803        assert!(!process_match("zoom.us", ""));
804        assert!(!process_match("", ""));
805    }
806
807    // Fix #1: anchoring `last_app_refresh` 60s before boot used to be
808    // `Instant::now() - Duration::from_secs(60)`, which panics if the
809    // monotonic clock is younger than 60s (cold-boot Windows runners).
810    #[test]
811    fn boot_anchor_never_panics_when_clock_is_young() {
812        // Mimic the run-loop initialiser; if the underflow protection is
813        // missing the `.checked_sub(...).unwrap_or_else(now)` chain
814        // returns a valid `Instant` instead of panicking.
815        let anchor = Instant::now()
816            .checked_sub(Duration::from_secs(60))
817            .unwrap_or_else(Instant::now);
818        // Anchor must not be after "now" — either it is 60s in the past
819        // (clock old enough) or it equals "now" (clock too young).
820        let now = Instant::now();
821        assert!(anchor <= now);
822    }
823
824    // Fix #5: `warn_user_idle_failure` must surface platform errors but
825    // only at most once per `USER_IDLE_WARN_INTERVAL_SECS`. The pure
826    // throttle helper is the actual decision gate.
827    #[test]
828    fn user_idle_warn_throttle_fires_first_warning() {
829        let cell = AtomicI64::new(0);
830        assert!(user_idle_warn_throttle(&cell, 1000, 60));
831        assert_eq!(cell.load(Ordering::Relaxed), 1000);
832    }
833
834    #[test]
835    fn user_idle_warn_throttle_suppresses_within_window() {
836        let cell = AtomicI64::new(1000);
837        assert!(!user_idle_warn_throttle(&cell, 1030, 60));
838        assert!(!user_idle_warn_throttle(&cell, 1059, 60));
839        // Cell unchanged when throttled.
840        assert_eq!(cell.load(Ordering::Relaxed), 1000);
841    }
842
843    #[test]
844    fn user_idle_warn_throttle_refires_after_window() {
845        let cell = AtomicI64::new(1000);
846        assert!(user_idle_warn_throttle(&cell, 1060, 60));
847        assert_eq!(cell.load(Ordering::Relaxed), 1060);
848        // Subsequent within new window suppressed.
849        assert!(!user_idle_warn_throttle(&cell, 1075, 60));
850    }
851
852    #[test]
853    fn user_idle_warn_throttle_handles_clock_jumping_backwards() {
854        // System clock going backwards (NTP correction) shouldn't
855        // deadlock the throttle — saturating_sub returns 0, which is
856        // < min_interval, so we suppress and don't update the cell.
857        let cell = AtomicI64::new(2000);
858        assert!(!user_idle_warn_throttle(&cell, 1500, 60));
859        assert_eq!(cell.load(Ordering::Relaxed), 2000);
860    }
861
862    // ----- evaluate_guards: pure per-tick suppression decision -----
863
864    fn settings_for_guards(
865        work_window: bool,
866        dnd: bool,
867        camera: bool,
868        video: bool,
869        app_pause_with_targets: bool,
870    ) -> Settings {
871        Settings {
872            work_window_enabled: work_window,
873            work_start_minutes: 9 * 60,
874            work_end_minutes: 17 * 60,
875            pause_during_dnd: dnd,
876            pause_during_camera: camera,
877            pause_during_video: video,
878            app_pause_enabled: app_pause_with_targets,
879            app_pause_list: if app_pause_with_targets {
880                vec!["zoom".to_string()]
881            } else {
882                Vec::new()
883            },
884            ..Settings::default()
885        }
886    }
887
888    const INSIDE_WORK_WINDOW: u32 = 10 * 60;
889    const OUTSIDE_WORK_WINDOW: u32 = 20 * 60;
890
891    #[test]
892    fn evaluate_guards_returns_none_when_all_off() {
893        let s = settings_for_guards(false, false, false, false, false);
894        assert!(evaluate_guards(&s, INSIDE_WORK_WINDOW, true, true, true, true).is_none());
895    }
896
897    #[test]
898    fn evaluate_guards_work_window_inside_returns_none() {
899        // work_window_enabled with a current minute inside [start,end)
900        // is the happy path — no suppression.
901        let s = settings_for_guards(true, false, false, false, false);
902        assert!(evaluate_guards(&s, INSIDE_WORK_WINDOW, false, false, false, false).is_none());
903    }
904
905    #[test]
906    fn evaluate_guards_work_window_outside_fires_silently() {
907        // Outside-hours suppression doesn't log — it's a scheduled
908        // silence, not an unexpected event.
909        let s = settings_for_guards(true, false, false, false, false);
910        let outcome = evaluate_guards(&s, OUTSIDE_WORK_WINDOW, false, false, false, false).unwrap();
911        assert_eq!(outcome.reason, SuppressReason::WorkWindow);
912        assert!(
913            outcome.log_as.is_none(),
914            "work_window suppression must never log",
915        );
916    }
917
918    #[test]
919    fn evaluate_guards_dnd_fires_only_when_setting_and_state_both_true() {
920        let s_off = settings_for_guards(false, false, false, false, false);
921        assert!(evaluate_guards(&s_off, INSIDE_WORK_WINDOW, true, false, false, false).is_none());
922
923        let s_on = settings_for_guards(false, true, false, false, false);
924        let outcome =
925            evaluate_guards(&s_on, INSIDE_WORK_WINDOW, true, false, false, false).unwrap();
926        assert_eq!(outcome.reason, SuppressReason::Dnd);
927        assert_eq!(outcome.log_as, Some(GuardReason::Dnd));
928
929        // Setting on but state false → no suppression.
930        assert!(evaluate_guards(&s_on, INSIDE_WORK_WINDOW, false, false, false, false).is_none());
931    }
932
933    #[test]
934    fn evaluate_guards_camera_logs_camera_reason() {
935        let s = settings_for_guards(false, false, true, false, false);
936        let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, false, true, false, false).unwrap();
937        assert_eq!(outcome.reason, SuppressReason::Camera);
938        assert_eq!(outcome.log_as, Some(GuardReason::Camera));
939    }
940
941    #[test]
942    fn evaluate_guards_video_logs_video_reason() {
943        let s = settings_for_guards(false, false, false, true, false);
944        let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, false, false, true, false).unwrap();
945        assert_eq!(outcome.reason, SuppressReason::Video);
946        assert_eq!(outcome.log_as, Some(GuardReason::Video));
947    }
948
949    #[test]
950    fn evaluate_guards_app_pause_requires_nonempty_target_list() {
951        // app_pause_enabled but the list is empty → not a valid match,
952        // so the guard must not fire even when app_pause_active is true.
953        let mut s = settings_for_guards(false, false, false, false, true);
954        s.app_pause_list.clear();
955        assert!(evaluate_guards(&s, INSIDE_WORK_WINDOW, false, false, false, true).is_none());
956
957        let with_target = settings_for_guards(false, false, false, false, true);
958        let outcome =
959            evaluate_guards(&with_target, INSIDE_WORK_WINDOW, false, false, false, true).unwrap();
960        assert_eq!(outcome.reason, SuppressReason::AppPause);
961        assert_eq!(outcome.log_as, Some(GuardReason::AppPause));
962    }
963
964    #[test]
965    fn evaluate_guards_work_window_outranks_every_other_guard() {
966        // First-match-wins precedence: even with every live signal
967        // firing simultaneously, work_window short-circuits the rest
968        // (and stays silent, per its no-log policy).
969        let s = settings_for_guards(true, true, true, true, true);
970        let outcome = evaluate_guards(&s, OUTSIDE_WORK_WINDOW, true, true, true, true).unwrap();
971        assert_eq!(outcome.reason, SuppressReason::WorkWindow);
972        assert!(outcome.log_as.is_none());
973    }
974
975    #[test]
976    fn evaluate_guards_dnd_outranks_camera_video_app_pause() {
977        let s = settings_for_guards(true, true, true, true, true);
978        // Inside the work window, so work_window does NOT fire.
979        let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, true, true, true, true).unwrap();
980        assert_eq!(outcome.reason, SuppressReason::Dnd);
981    }
982
983    #[test]
984    fn evaluate_guards_camera_outranks_video_and_app_pause() {
985        let s = settings_for_guards(true, true, true, true, true);
986        let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, false, true, true, true).unwrap();
987        assert_eq!(outcome.reason, SuppressReason::Camera);
988    }
989
990    #[test]
991    fn evaluate_guards_video_outranks_app_pause() {
992        let s = settings_for_guards(true, true, true, true, true);
993        let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, false, false, true, true).unwrap();
994        assert_eq!(outcome.reason, SuppressReason::Video);
995    }
996
997    #[test]
998    fn evaluate_guards_app_pause_only_when_higher_guards_quiet() {
999        let s = settings_for_guards(true, true, true, true, true);
1000        let outcome = evaluate_guards(&s, INSIDE_WORK_WINDOW, false, false, false, true).unwrap();
1001        assert_eq!(outcome.reason, SuppressReason::AppPause);
1002    }
1003}