Skip to main content

rightkit_tauri_shell/
idle.rs

1//! Webview idle / resource management for tray-resident apps.
2//!
3//! Donor: MailRight `webview_idle.rs`. The state machine is kept verbatim (generation-stamped timer
4//! so a stale timer never suspends a webview the person already brought back). When the main window
5//! has been hidden for `suspend_after` the webview is suspended; showing it resumes it.
6//!
7//! Platform actions:
8//! * Windows: WebView2 `SetIsVisible` plus `TrySuspend` / `Resume` (ported from the donor; compiled
9//!   only on Windows and not exercised on this macOS host).
10//! * macOS / Linux: WebKit already throttles a hidden view, there is no public suspend call, so the
11//!   shell tracks the state and emits `rightkit-shell://idle` for the app to pause its own timers.
12//!
13//! Controller sequencing (Windows semantics, matching ScrapeRight's `webview_idle.rs`): suspend is
14//! one operation on the WebView2 thread, `SetIsVisible(false)` then `TrySuspend`, and `TrySuspend`
15//! is never called when hiding failed. Resume is one operation, `Resume` then `SetIsVisible(true)`,
16//! and the show is skipped when `Resume` failed. A failed step stays pending in [`Applied`] and is
17//! retried by the next resume request; a resume with nothing hidden or suspended by this shell
18//! makes no controller call. The calls go through the internal [`Controller`] trait so the order
19//! is tested with a fake.
20//!
21//! Hide/show paths owned by the shell (`rk_shell_window hide|show`, tray toggle, close-to-tray,
22//! reveal) feed the machine automatically (a minimise too, with `IdleOptions::start_on_minimize`); an app that hides its window itself calls
23//! [`note_hidden`] / [`note_visible`] (or the `rk_shell_idle_note` command).
24
25use std::fmt::Display;
26use std::panic::{catch_unwind, AssertUnwindSafe};
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::{Arc, Mutex};
29use std::time::{Duration, Instant};
30
31use serde::Serialize;
32use tauri::{AppHandle, Emitter, Manager, Wry};
33
34use crate::{ShellState, EVENT_IDLE};
35
36/// Tray / minimise to suspend delay (MailRight, locked 2026-07-06).
37pub const DEFAULT_SUSPEND_AFTER: Duration = Duration::from_secs(30);
38
39/// Longest suspend delay accepted (a larger host or per-hide value is clamped to it).
40pub const MAX_SUSPEND_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
41
42/// When the WebView2 controller is hidden after the main window hides.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum ControllerHide {
45    /// At once, when the window hides (the MailRight donor behaviour and the default): rendering
46    /// stops immediately, and `TrySuspend` later finds the controller already hidden.
47    #[default]
48    Immediate,
49    /// When the idle timer fires, just before `TrySuspend` (WebView2 requires a hidden controller
50    /// to suspend). The webview keeps rendering for `suspend_after` first, so a window that is
51    /// minimised or hidden only briefly never loses its compositor surface.
52    OnTimer,
53}
54
55/// Whether `rightkit-shell://idle` events are emitted.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum IdleEvents {
58    /// Every transition on every platform (the default; macOS and Linux apps pause their own
59    /// timers from these).
60    #[default]
61    Always,
62    /// Only where the platform really suspends the webview (Windows). On macOS and Linux nothing
63    /// is suspended, so nothing is emitted.
64    NativeSuspendOnly,
65}
66
67impl IdleEvents {
68    /// True when events are emitted on a platform with (`native_suspend`) or without a real suspend.
69    pub fn emits(self, native_suspend: bool) -> bool {
70        match self {
71            IdleEvents::Always => true,
72            IdleEvents::NativeSuspendOnly => native_suspend,
73        }
74    }
75}
76
77/// What starts the idle period of the main webview.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum IdleTrigger {
80    /// A window hide the shell or host reports (tray, close-to-tray, `note_hidden`), and a minimise
81    /// when `IdleOptions::start_on_minimize` is set. The behaviour hosts already have.
82    #[default]
83    HiddenOrMinimised,
84    /// A minimise only (whole-app: it acts on the webview of the window that was minimised). A
85    /// tray hide, close-to-tray or `note_hidden` does not start idle, and a minimise always does,
86    /// whatever `start_on_minimize` says. For an app with a single web view that only wants to stop
87    /// work while minimised: on Windows `SetIsVisible(false)` then `TrySuspend`, nothing closed;
88    /// the restore is `Resume` (when suspended) then `SetIsVisible(true)`.
89    Minimised,
90}
91
92/// Where an idle request came from (see [`source_starts_idle`]).
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum IdleSource {
95    /// A window hide (tray, close-to-tray, app-reported).
96    Hide,
97    /// The main window was minimised.
98    Minimise,
99}
100
101/// Whether a request of `source` may start the idle period under `trigger`. Pure.
102pub fn source_starts_idle(trigger: IdleTrigger, source: IdleSource) -> bool {
103    !matches!(
104        (trigger, source),
105        (IdleTrigger::Minimised, IdleSource::Hide)
106    )
107}
108
109/// Per-hide delay source held by [`IdleOptions`]; set with [`IdleOptions::suspend_after_with`].
110#[derive(Clone)]
111pub struct SuspendAfterProvider(Arc<dyn Fn() -> Option<Duration> + Send + Sync>);
112
113impl std::fmt::Debug for SuspendAfterProvider {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.write_str("SuspendAfterProvider(..)")
116    }
117}
118
119/// Two options are equal when they share the same provider instance (closures have no value
120/// equality), so a clone is equal to its source.
121impl PartialEq for SuspendAfterProvider {
122    fn eq(&self, other: &Self) -> bool {
123        Arc::ptr_eq(&self.0, &other.0)
124    }
125}
126
127impl Eq for SuspendAfterProvider {}
128
129/// Per-host idle options (see [`crate::ShellBuilder::webview_idle_options`]). The default is the
130/// behaviour `webview_idle(DEFAULT_SUSPEND_AFTER)` has always had.
131///
132/// Not `Copy`: it may hold a delay provider (see [`IdleOptions::suspend_after_with`]). It cannot be
133/// built with a struct literal outside this crate, not even with `..IdleOptions::default()`,
134/// because the provider field is private. Build it with the setters:
135/// `IdleOptions::default().suspend_after(d).start_on_minimize(true)`.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct IdleOptions {
138    /// Hidden this long before the webview is suspended (clamped to [`MAX_SUSPEND_AFTER`]). With a
139    /// provider set, this is the value used whenever the provider returns `None` or fails.
140    pub suspend_after: Duration,
141    /// Also start the idle timer when the main window is minimised, not only on a hide the shell or
142    /// host reports (`note_hidden`). Restoring the window ends it. Default off.
143    pub start_on_minimize: bool,
144    pub controller_hide: ControllerHide,
145    pub events: IdleEvents,
146    /// What starts idle; `IdleTrigger::Minimised` makes it minimise-only.
147    pub trigger: IdleTrigger,
148    /// Per-hide delay source; `None` keeps the fixed `suspend_after`.
149    suspend_after_fn: Option<SuspendAfterProvider>,
150}
151
152impl Default for IdleOptions {
153    fn default() -> Self {
154        Self {
155            suspend_after: DEFAULT_SUSPEND_AFTER,
156            start_on_minimize: false,
157            controller_hide: ControllerHide::Immediate,
158            events: IdleEvents::Always,
159            trigger: IdleTrigger::HiddenOrMinimised,
160            suspend_after_fn: None,
161        }
162    }
163}
164
165impl IdleOptions {
166    /// Set the fixed suspend delay (see the field of the same name).
167    pub fn suspend_after(mut self, suspend_after: Duration) -> Self {
168        self.suspend_after = suspend_after;
169        self
170    }
171
172    /// Also start the idle timer when the main window is minimised.
173    pub fn start_on_minimize(mut self, start_on_minimize: bool) -> Self {
174        self.start_on_minimize = start_on_minimize;
175        self
176    }
177
178    /// Choose when the controller is hidden.
179    pub fn controller_hide(mut self, controller_hide: ControllerHide) -> Self {
180        self.controller_hide = controller_hide;
181        self
182    }
183
184    /// Choose what starts idle (see [`IdleTrigger`]).
185    pub fn trigger(mut self, trigger: IdleTrigger) -> Self {
186        self.trigger = trigger;
187        self
188    }
189
190    /// Minimise-only idle: `true` is `trigger(IdleTrigger::Minimised)` (a minimise starts idle, a
191    /// tray hide does not), `false` restores the default trigger.
192    pub fn on_minimise(mut self, on_minimise: bool) -> Self {
193        self.trigger = if on_minimise {
194            IdleTrigger::Minimised
195        } else {
196            IdleTrigger::HiddenOrMinimised
197        };
198        self
199    }
200
201    /// Whether a minimise of the main window feeds the idle machine.
202    pub fn minimise_enabled(&self) -> bool {
203        self.start_on_minimize || self.trigger == IdleTrigger::Minimised
204    }
205
206    /// Choose which idle events are emitted.
207    pub fn events(mut self, events: IdleEvents) -> Self {
208        self.events = events;
209        self
210    }
211
212    /// Read the suspend delay at each hide instead of fixing it at build time.
213    ///
214    /// `provider` is called once per hide transition (a window hide, or the start of a minimise
215    /// when `start_on_minimize` is set), never at build time. Its `Some(d)` is the delay for that
216    /// hide, clamped to [`MAX_SUSPEND_AFTER`]; `None` uses the fixed `suspend_after`. A panic in
217    /// `provider` is caught and also uses the fixed `suspend_after`. An explicit per-hide delay
218    /// (`note_hidden_after`, `note_minimized_after`, `rk_shell_idle_note`) takes precedence and the
219    /// provider is not called for that hide.
220    ///
221    /// Takes `self`, so it chains onto the default and the other setters:
222    /// `IdleOptions::default().suspend_after_with(|| Some(read_env()))`.
223    pub fn suspend_after_with(
224        mut self,
225        provider: impl Fn() -> Option<Duration> + Send + Sync + 'static,
226    ) -> Self {
227        self.suspend_after_fn = Some(SuspendAfterProvider(Arc::new(provider)));
228        self
229    }
230
231    /// The suspend delay for one hide, evaluating the provider now (once per call). Precedence:
232    /// `per_hide` if given, else the provider's `Some`, else `host_default` (the fixed
233    /// `suspend_after`). The result is clamped to [`MAX_SUSPEND_AFTER`].
234    pub fn hide_delay(&self, host_default: Duration, per_hide: Option<Duration>) -> Duration {
235        effective_delay(host_default, per_hide.or_else(|| self.provided_delay()))
236    }
237
238    /// The provider's value for this hide. `None` when there is no provider, it returns `None`, or
239    /// it panics (the panic is contained here so the idle machinery keeps running).
240    fn provided_delay(&self) -> Option<Duration> {
241        let provider = self.suspend_after_fn.as_ref()?;
242        catch_unwind(AssertUnwindSafe(|| (*provider.0)()))
243            .ok()
244            .flatten()
245    }
246}
247
248/// The delay for one hide: the per-hide override if given, else the host default, clamped.
249pub fn effective_delay(host_default: Duration, per_hide: Option<Duration>) -> Duration {
250    per_hide.unwrap_or(host_default).min(MAX_SUSPEND_AFTER)
251}
252
253/// When the controller is hidden for one window hide.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub struct HidePlan {
256    /// Hide the controller as the window hides.
257    pub now: bool,
258    /// Hide the controller when the timer fires.
259    pub on_timer: bool,
260}
261
262/// `controller_already_hidden`: the OS hid the window already (close-to-tray), so no call is needed.
263pub fn hide_plan(mode: ControllerHide, controller_already_hidden: bool) -> HidePlan {
264    HidePlan {
265        now: mode == ControllerHide::Immediate && !controller_already_hidden,
266        on_timer: mode == ControllerHide::OnTimer && !controller_already_hidden,
267    }
268}
269
270/// What a minimise or restore of the main window asks of the idle machine.
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum MinimizeStep {
273    Start,
274    End,
275    None,
276}
277
278/// Remembers that a minimise (not a tray hide) started the idle timer, so repeated resize events
279/// do not restart it and a restore ends only what a minimise began.
280#[derive(Debug, Default)]
281pub struct MinimizeTracker {
282    active: AtomicBool,
283}
284
285impl MinimizeTracker {
286    pub fn step(&self, minimized: bool) -> MinimizeStep {
287        let was = self.active.swap(minimized, Ordering::SeqCst);
288        match (was, minimized) {
289            (false, true) => MinimizeStep::Start,
290            (true, false) => MinimizeStep::End,
291            _ => MinimizeStep::None,
292        }
293    }
294
295    /// The window is visible again by another route.
296    pub fn clear(&self) {
297        self.active.store(false, Ordering::SeqCst);
298    }
299}
300
301/// Which platform webview action failed.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
303#[serde(rename_all = "lowercase")]
304pub enum IdlePhase {
305    Suspend,
306    Resume,
307}
308
309/// A suspend or resume of the main webview that the platform reported as failed. Delivered to the
310/// callback set with [`crate::ShellBuilder::webview_idle_on_error`] so the host can log it.
311#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
312pub struct IdleFailure {
313    pub phase: IdlePhase,
314    /// Label of the webview window the action targeted.
315    pub window: String,
316    /// The platform's message (for WebView2, the HRESULT text).
317    pub error: String,
318}
319
320/// Receives [`IdleFailure`]s. May run on a WebView2 callback thread, so it must be `Send + Sync`.
321pub type IdleErrorCallback = Arc<dyn Fn(&IdleFailure) + Send + Sync>;
322
323/// Carries failures from a platform callback (possibly on another thread) to the host callback.
324#[derive(Clone)]
325pub(crate) struct Reporter {
326    window: String,
327    sink: Option<IdleErrorCallback>,
328}
329
330impl Reporter {
331    #[cfg_attr(not(windows), allow(dead_code))]
332    pub(crate) fn fail(&self, phase: IdlePhase, error: impl Display) {
333        if let Some(sink) = &self.sink {
334            sink(&IdleFailure {
335                phase,
336                window: self.window.clone(),
337                error: error.to_string(),
338            });
339        }
340    }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub enum IdleAction {
345    HideControllerAndStartTimer,
346    ShowController,
347    SuspendNow,
348    ResumeAndShowController,
349    None,
350}
351
352#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
353pub struct IdleState {
354    hidden: bool,
355    suspended: bool,
356    generation: u64,
357}
358
359impl IdleState {
360    pub fn on_hidden(&mut self) -> (u64, IdleAction) {
361        if self.suspended {
362            self.hidden = true;
363            return (self.generation, IdleAction::None);
364        }
365        self.hidden = true;
366        self.generation = self.generation.wrapping_add(1);
367        (self.generation, IdleAction::HideControllerAndStartTimer)
368    }
369
370    pub fn on_visible(&mut self) -> IdleAction {
371        let was_hidden = self.hidden;
372        self.hidden = false;
373        self.generation = self.generation.wrapping_add(1);
374        if std::mem::take(&mut self.suspended) {
375            IdleAction::ResumeAndShowController
376        } else if was_hidden {
377            IdleAction::ShowController
378        } else {
379            IdleAction::None
380        }
381    }
382
383    pub fn on_timer(&mut self, generation: u64) -> IdleAction {
384        if self.hidden && !self.suspended && self.generation == generation {
385            self.suspended = true;
386            IdleAction::SuspendNow
387        } else {
388            IdleAction::None
389        }
390    }
391
392    /// The platform suspend did not happen (hiding the controller or `TrySuspend` failed): the
393    /// webview is still running, so forget the suspend. A later hide starts a fresh timer.
394    pub fn on_suspend_failed(&mut self) {
395        self.suspended = false;
396    }
397
398    /// `(hidden, suspended)`.
399    pub fn snapshot(&self) -> (bool, bool) {
400        (self.hidden, self.suspended)
401    }
402}
403
404/// A real suspend or resume of the main webview (see [`IdleTransition`]).
405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
406#[serde(rename_all = "lowercase")]
407pub enum IdleTransitionKind {
408    Suspended,
409    Resumed,
410}
411
412/// Delivered to the callback set with [`crate::ShellBuilder::webview_idle_on_transition`], once per
413/// transition, after the platform calls succeeded.
414#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
415pub struct IdleTransition {
416    pub kind: IdleTransitionKind,
417    /// Label of the webview window.
418    pub window: String,
419    /// `Suspended`: how long the window had been hidden. `Resumed`: how long the webview was
420    /// suspended. (For `Suspended` on Windows the `TrySuspend` request was accepted; its later
421    /// completion error still goes to the error callback.)
422    pub elapsed: Duration,
423}
424
425/// Receives [`IdleTransition`]s. Runs on the thread that applied the transition.
426pub type IdleTransitionCallback = Arc<dyn Fn(&IdleTransition) + Send + Sync>;
427
428/// The WebView2 controller calls the sequencing needs. Windows implements them on the real
429/// controller; elsewhere they succeed without effect; tests record and fail them.
430pub(crate) trait Controller {
431    fn set_visible(&mut self, visible: bool) -> Result<(), String>;
432    /// Request `TrySuspend` (accepted, not completed).
433    fn try_suspend(&mut self) -> Result<(), String>;
434    fn resume(&mut self) -> Result<(), String>;
435}
436
437/// What this shell has done to the controller and not yet undone.
438#[derive(Debug, Default)]
439pub(crate) struct Applied {
440    /// This shell hid the controller.
441    pub(crate) hidden: bool,
442    /// This shell suspended the webview.
443    pub(crate) suspended: bool,
444    suspended_at: Option<Instant>,
445    /// `Resume` succeeded but the show did not: the resumed hook fires when the retry succeeds.
446    resumed_for: Option<Duration>,
447}
448
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub(crate) struct StepFailure {
451    pub(crate) phase: IdlePhase,
452    pub(crate) error: String,
453}
454
455/// What a resume request did.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub(crate) enum ResumeStep {
458    /// Nothing was hidden or suspended by this shell: no controller call was made.
459    Nothing,
460    /// The controller was shown (it had not been suspended).
461    Shown,
462    /// The webview was resumed and shown after being suspended for the duration.
463    Resumed(Duration),
464}
465
466impl Applied {
467    /// Hide the controller (once). A failure leaves the webview visible and running.
468    pub(crate) fn hide(&mut self, c: &mut dyn Controller) -> Result<(), StepFailure> {
469        if self.hidden {
470            return Ok(());
471        }
472        c.set_visible(false).map_err(|error| StepFailure {
473            phase: IdlePhase::Suspend,
474            error: format!("SetIsVisible(false): {error}"),
475        })?;
476        self.hidden = true;
477        Ok(())
478    }
479
480    /// Hide, then suspend; the suspend is not attempted when the hide failed. When `TrySuspend`
481    /// itself fails the controller stays hidden (the window is hidden) but nothing is suspended.
482    pub(crate) fn hide_and_suspend(
483        &mut self,
484        c: &mut dyn Controller,
485        now: Instant,
486    ) -> Result<(), StepFailure> {
487        self.hide(c)?;
488        if self.suspended {
489            return Ok(());
490        }
491        c.try_suspend().map_err(|error| StepFailure {
492            phase: IdlePhase::Suspend,
493            error: format!("TrySuspend: {error}"),
494        })?;
495        self.suspended = true;
496        self.suspended_at = Some(now);
497        Ok(())
498    }
499
500    /// Resume, then show; the show is not attempted when the resume failed. Whatever failed stays
501    /// pending, so the next call retries exactly that step.
502    pub(crate) fn resume_and_show(
503        &mut self,
504        c: &mut dyn Controller,
505        now: Instant,
506    ) -> Result<ResumeStep, StepFailure> {
507        let mut resumed = None;
508        if self.suspended {
509            c.resume().map_err(|error| StepFailure {
510                phase: IdlePhase::Resume,
511                error: format!("Resume: {error}"),
512            })?;
513            self.suspended = false;
514            let for_ = self
515                .suspended_at
516                .take()
517                .map(|at| now.saturating_duration_since(at))
518                .unwrap_or_default();
519            self.resumed_for = Some(for_);
520        }
521        if self.hidden {
522            c.set_visible(true).map_err(|error| StepFailure {
523                phase: IdlePhase::Resume,
524                error: format!("SetIsVisible(true): {error}"),
525            })?;
526            self.hidden = false;
527        }
528        if let Some(for_) = self.resumed_for.take() {
529            resumed = Some(for_);
530        }
531        Ok(match resumed {
532            Some(for_) => ResumeStep::Resumed(for_),
533            None => ResumeStep::Nothing,
534        })
535    }
536}
537
538impl Applied {
539    /// A resume request that found the controller hidden but not suspended reports `Shown`.
540    fn classify(&self, before_hidden: bool, step: ResumeStep) -> ResumeStep {
541        match step {
542            ResumeStep::Nothing if before_hidden => ResumeStep::Shown,
543            other => other,
544        }
545    }
546}
547
548/// Runtime half kept in [`ShellState`].
549pub struct IdleHost {
550    pub suspend_after: Duration,
551    pub options: IdleOptions,
552    pub minimize: MinimizeTracker,
553    pub state: Mutex<IdleState>,
554    /// How many times the platform suspend / resume action succeeded (diagnostics, QA).
555    pub counters: Mutex<(u32, u32)>,
556    /// Receives platform suspend / resume failures. `None` keeps them silent (the default).
557    pub on_error: Option<IdleErrorCallback>,
558    /// Receives suspended / resumed transitions. `None` (the default) changes nothing.
559    pub on_transition: Option<IdleTransitionCallback>,
560    pub(crate) applied: Mutex<Applied>,
561}
562
563impl IdleHost {
564    pub fn new(suspend_after: Duration) -> Self {
565        Self {
566            suspend_after,
567            options: IdleOptions {
568                suspend_after,
569                ..IdleOptions::default()
570            },
571            minimize: MinimizeTracker::default(),
572            state: Mutex::new(IdleState::default()),
573            counters: Mutex::new((0, 0)),
574            on_error: None,
575            on_transition: None,
576            applied: Mutex::new(Applied::default()),
577        }
578    }
579
580    /// Replace the options; the timeout follows `options.suspend_after`.
581    pub fn with_options(mut self, options: IdleOptions) -> Self {
582        self.suspend_after = options.suspend_after.min(MAX_SUSPEND_AFTER);
583        self.options = IdleOptions {
584            suspend_after: self.suspend_after,
585            ..options
586        };
587        self
588    }
589
590    /// Install (or clear, with `None`) the failure callback.
591    pub fn with_on_error(mut self, on_error: Option<IdleErrorCallback>) -> Self {
592        self.on_error = on_error;
593        self
594    }
595
596    /// Install (or clear, with `None`) the transition callback.
597    pub fn with_on_transition(mut self, on_transition: Option<IdleTransitionCallback>) -> Self {
598        self.on_transition = on_transition;
599        self
600    }
601
602    /// Deliver a failure to the callback, if one is installed. Platform code and tests use this.
603    pub fn report_failure(&self, phase: IdlePhase, window: &str, error: &str) {
604        self.reporter(window).fail(phase, error);
605    }
606
607    pub(crate) fn reporter(&self, window: &str) -> Reporter {
608        Reporter {
609            window: window.to_owned(),
610            sink: self.on_error.clone(),
611        }
612    }
613
614    /// Deliver a transition to the callback, if one is installed.
615    pub(crate) fn transition(&self, kind: IdleTransitionKind, window: &str, elapsed: Duration) {
616        if let Some(sink) = &self.on_transition {
617            sink(&IdleTransition {
618                kind,
619                window: window.to_owned(),
620                elapsed,
621            });
622        }
623    }
624}
625
626#[derive(Debug, Clone, Serialize)]
627#[serde(rename_all = "camelCase")]
628pub struct IdleStatus {
629    pub enabled: bool,
630    pub hidden: bool,
631    pub suspended: bool,
632    pub suspend_after_ms: u64,
633    pub suspends: u32,
634    pub resumes: u32,
635    /// Whether the platform has a real suspend call (Windows) or only the state and event.
636    pub native_suspend: bool,
637}
638
639pub fn status(app: &AppHandle<Wry>) -> IdleStatus {
640    let state = app.state::<ShellState>();
641    match &state.idle {
642        Some(h) => {
643            let (hidden, suspended) = h.state.lock().unwrap_or_else(|e| e.into_inner()).snapshot();
644            let (suspends, resumes) = *h.counters.lock().unwrap_or_else(|e| e.into_inner());
645            IdleStatus {
646                enabled: true,
647                hidden,
648                suspended,
649                suspend_after_ms: h.suspend_after.as_millis() as u64,
650                suspends,
651                resumes,
652                native_suspend: cfg!(windows),
653            }
654        }
655        None => IdleStatus {
656            enabled: false,
657            hidden: false,
658            suspended: false,
659            suspend_after_ms: 0,
660            suspends: 0,
661            resumes: 0,
662            native_suspend: cfg!(windows),
663        },
664    }
665}
666
667fn emit(app: &AppHandle<Wry>, host: &IdleHost, state: &str) {
668    if host.options.events.emits(cfg!(windows)) {
669        let _ = app.emit(EVENT_IDLE, serde_json::json!({"state": state}));
670    }
671}
672
673/// The main window was hidden (minimise, tray, app-driven). `controller_already_hidden` skips the
674/// redundant WebView2 visibility call on close-to-tray, where the OS hid the window already.
675pub fn note_hidden(app: &AppHandle<Wry>, controller_already_hidden: bool) {
676    note_hidden_after(app, controller_already_hidden, None);
677}
678
679/// [`note_hidden`] with a delay for this hide only (`None`: the host's `suspend_after`).
680pub fn note_hidden_after(
681    app: &AppHandle<Wry>,
682    controller_already_hidden: bool,
683    suspend_after: Option<Duration>,
684) {
685    let state = app.state::<ShellState>();
686    let Some(host) = &state.idle else { return };
687    if !source_starts_idle(host.options.trigger, IdleSource::Hide) {
688        return;
689    }
690    start_hide(app, controller_already_hidden, suspend_after);
691}
692
693/// The shared hide path behind [`note_hidden_after`] and a minimise (the trigger gate has run).
694fn start_hide(
695    app: &AppHandle<Wry>,
696    controller_already_hidden: bool,
697    suspend_after: Option<Duration>,
698) {
699    let state = app.state::<ShellState>();
700    let Some(host) = &state.idle else { return };
701    let (generation, action) = host
702        .state
703        .lock()
704        .unwrap_or_else(|e| e.into_inner())
705        .on_hidden();
706    if action != IdleAction::HideControllerAndStartTimer {
707        return;
708    }
709    emit(app, host, "hidden");
710    let hidden_at = Instant::now();
711    let plan = hide_plan(host.options.controller_hide, controller_already_hidden);
712    if plan.now {
713        hide_controller(app);
714    }
715    // Evaluated once, here, after the transition check: a hide that did not start the timer does
716    // not call the provider.
717    let delay = host.options.hide_delay(host.suspend_after, suspend_after);
718    let app = app.clone();
719    let _ = std::thread::Builder::new()
720        .name("rk-shell-idle".into())
721        .spawn(move || {
722            std::thread::sleep(delay);
723            suspend_controller(&app, generation, hidden_at);
724        });
725}
726
727/// Hide the controller as the window hides (`ControllerHide::Immediate`). One operation on the
728/// WebView2 thread; skipped when the window was shown again before it ran.
729fn hide_controller(app: &AppHandle<Wry>) {
730    let state = app.state::<ShellState>();
731    let Some(host) = &state.idle else { return };
732    let reporter = host.reporter(&state.main_label);
733    let app = app.clone();
734    let run = platform::with_controller(&app.clone(), move |c| {
735        let state = app.state::<ShellState>();
736        let Some(host) = &state.idle else { return };
737        let mut applied = host.applied.lock().unwrap_or_else(|e| e.into_inner());
738        let still_hidden = host
739            .state
740            .lock()
741            .unwrap_or_else(|e| e.into_inner())
742            .snapshot()
743            .0;
744        if !still_hidden {
745            return;
746        }
747        if let Err(f) = applied.hide(c) {
748            host.reporter(&state.main_label).fail(f.phase, f.error);
749        }
750    });
751    if let Err(e) = run {
752        reporter.fail(IdlePhase::Suspend, e);
753    }
754}
755
756/// The idle timer fired: decide on the WebView2 thread (so a show that raced the timer wins),
757/// then hide and suspend as one operation.
758fn suspend_controller(app: &AppHandle<Wry>, generation: u64, hidden_at: Instant) {
759    let state = app.state::<ShellState>();
760    let Some(host) = &state.idle else { return };
761    let reporter = host.reporter(&state.main_label);
762    let handle = app.clone();
763    let run = platform::with_controller(app, move |c| {
764        let state = handle.state::<ShellState>();
765        let Some(host) = &state.idle else { return };
766        let mut applied = host.applied.lock().unwrap_or_else(|e| e.into_inner());
767        let action = host
768            .state
769            .lock()
770            .unwrap_or_else(|e| e.into_inner())
771            .on_timer(generation);
772        if action != IdleAction::SuspendNow {
773            return;
774        }
775        let now = Instant::now();
776        match applied.hide_and_suspend(c, now) {
777            Ok(()) => {
778                host.counters.lock().unwrap_or_else(|e| e.into_inner()).0 += 1;
779                emit(&handle, host, "suspended");
780                host.transition(
781                    IdleTransitionKind::Suspended,
782                    &state.main_label,
783                    now.saturating_duration_since(hidden_at),
784                );
785            }
786            Err(f) => {
787                host.state
788                    .lock()
789                    .unwrap_or_else(|e| e.into_inner())
790                    .on_suspend_failed();
791                host.reporter(&state.main_label).fail(f.phase, f.error);
792            }
793        }
794    });
795    if let Err(e) = run {
796        reporter.fail(IdlePhase::Suspend, e);
797    }
798}
799
800/// What a minimise or restore asks of the idle machine, with the delay resolved.
801#[derive(Debug, Clone, Copy, PartialEq, Eq)]
802pub enum MinimizeAction {
803    /// Start the idle period; the controller is hidden per the options; suspend after the delay.
804    Hide {
805        delay: Duration,
806    },
807    Visible,
808    None,
809}
810
811/// The decision behind [`note_minimized_after`]: the per-hide override applies to a minimise
812/// exactly as it does to [`note_hidden_after`].
813pub fn minimize_action(
814    host_default: Duration,
815    step: MinimizeStep,
816    per_hide: Option<Duration>,
817) -> MinimizeAction {
818    match step {
819        MinimizeStep::Start => MinimizeAction::Hide {
820            delay: effective_delay(host_default, per_hide),
821        },
822        MinimizeStep::End => MinimizeAction::Visible,
823        MinimizeStep::None => MinimizeAction::None,
824    }
825}
826
827/// The main window was minimised (`true`) or restored (`false`). Does nothing unless the host set
828/// `IdleOptions::start_on_minimize` or `IdleTrigger::Minimised` (`on_minimise(true)`). On macOS and
829/// Linux the machine and the `rightkit-shell://idle` event run but nothing is suspended (no
830/// platform call exists there; the app pauses its own timers from the event). A restore ends only an idle period a minimise began.
831pub fn note_minimized(app: &AppHandle<Wry>, minimized: bool) {
832    note_minimized_after(app, minimized, None);
833}
834
835/// [`note_minimized`] with a delay for this minimise only (`None`: the host's `suspend_after`),
836/// the same override [`note_hidden_after`] takes.
837pub fn note_minimized_after(
838    app: &AppHandle<Wry>,
839    minimized: bool,
840    suspend_after: Option<Duration>,
841) {
842    let state = app.state::<ShellState>();
843    let Some(host) = &state.idle else { return };
844    if !host.options.minimise_enabled() {
845        return;
846    }
847    let step = host.minimize.step(minimized);
848    // A minimise that starts the timer is a hide transition: resolve its delay once, here, with the
849    // same precedence `note_hidden_after` uses, so the provider is not consulted twice.
850    let per_hide = match step {
851        MinimizeStep::Start => Some(host.options.hide_delay(host.suspend_after, suspend_after)),
852        MinimizeStep::End | MinimizeStep::None => suspend_after,
853    };
854    match minimize_action(host.suspend_after, step, per_hide) {
855        MinimizeAction::Hide { delay } => start_hide(app, false, Some(delay)),
856        MinimizeAction::Visible => note_visible(app),
857        MinimizeAction::None => {}
858    }
859}
860
861/// The main window is visible again. Resume then show as one operation on the WebView2 thread;
862/// a no-op for the controller unless this shell hid or suspended it. A step that failed earlier
863/// is retried here.
864pub fn note_visible(app: &AppHandle<Wry>) {
865    let state = app.state::<ShellState>();
866    let Some(host) = &state.idle else { return };
867    host.minimize.clear();
868    let action = host
869        .state
870        .lock()
871        .unwrap_or_else(|e| e.into_inner())
872        .on_visible();
873    let reporter = host.reporter(&state.main_label);
874    let handle = app.clone();
875    let run = platform::with_controller(app, move |c| {
876        let state = handle.state::<ShellState>();
877        let Some(host) = &state.idle else { return };
878        let mut applied = host.applied.lock().unwrap_or_else(|e| e.into_inner());
879        let before_hidden = applied.hidden;
880        let now = Instant::now();
881        match applied.resume_and_show(c, now) {
882            Ok(step) => match applied.classify(before_hidden, step) {
883                ResumeStep::Resumed(for_) => {
884                    host.counters.lock().unwrap_or_else(|e| e.into_inner()).1 += 1;
885                    emit(&handle, host, "resumed");
886                    host.transition(IdleTransitionKind::Resumed, &state.main_label, for_);
887                }
888                ResumeStep::Shown => emit(&handle, host, "visible"),
889                ResumeStep::Nothing => {
890                    if matches!(
891                        action,
892                        IdleAction::ShowController | IdleAction::ResumeAndShowController
893                    ) {
894                        emit(&handle, host, "visible");
895                    }
896                }
897            },
898            Err(f) => host.reporter(&state.main_label).fail(f.phase, f.error),
899        }
900    });
901    if let Err(e) = run {
902        reporter.fail(IdlePhase::Resume, e);
903    }
904}
905
906#[cfg(windows)]
907mod platform {
908    use crate::winapi::webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2_3;
909    use crate::winapi::windows::core::Interface;
910    use tauri::{AppHandle, Manager, Wry};
911
912    use super::Controller;
913    use crate::ShellState;
914
915    /// The real controller, as three closures built inside the `with_webview` callback.
916    struct Wv2<'a> {
917        visible: &'a dyn Fn(bool) -> Result<(), String>,
918        suspend: &'a dyn Fn() -> Result<(), String>,
919        resume: &'a dyn Fn() -> Result<(), String>,
920    }
921
922    impl Controller for Wv2<'_> {
923        fn set_visible(&mut self, visible: bool) -> Result<(), String> {
924            (self.visible)(visible)
925        }
926        fn try_suspend(&mut self) -> Result<(), String> {
927            (self.suspend)()
928        }
929        fn resume(&mut self) -> Result<(), String> {
930            (self.resume)()
931        }
932    }
933
934    /// Run `f` on the WebView2 thread against the main window's controller. `Err`: the closure
935    /// could not be dispatched (window missing, webview gone); `f` did not run.
936    pub fn with_controller(
937        app: &AppHandle<Wry>,
938        f: impl FnOnce(&mut dyn Controller) + Send + 'static,
939    ) -> Result<(), String> {
940        let label = app.state::<ShellState>().main_label.clone();
941        let w = app
942            .get_webview_window(&label)
943            .ok_or_else(|| "main window not found".to_string())?;
944        let report = app
945            .state::<ShellState>()
946            .idle
947            .as_ref()
948            .map(|h| h.reporter(&label));
949        w.with_webview(move |webview| {
950            let controller = webview.controller();
951            let visible = |v: bool| -> Result<(), String> {
952                // SAFETY: COM call on the live controller inside the webview closure.
953                unsafe { controller.SetIsVisible(v) }.map_err(|e| e.to_string())
954            };
955            let core3 = || -> Result<ICoreWebView2_3, String> {
956                // SAFETY: as above.
957                let core = unsafe { controller.CoreWebView2() }.map_err(|e| e.to_string())?;
958                core.cast::<ICoreWebView2_3>().map_err(|e| e.to_string())
959            };
960            let suspend = || -> Result<(), String> {
961                let core3 = core3()?;
962                let completion = report.clone();
963                let handler = crate::winapi::webview2_com::TrySuspendCompletedHandler::create(
964                    Box::new(move |error, _suspended| {
965                        if let (Err(e), Some(r)) = (error, &completion) {
966                            r.fail(super::IdlePhase::Suspend, e);
967                        }
968                        Ok(())
969                    }),
970                );
971                // SAFETY: as above.
972                unsafe { core3.TrySuspend(&handler) }.map_err(|e| e.to_string())
973            };
974            let resume = || -> Result<(), String> {
975                let core3 = core3()?;
976                // SAFETY: as above.
977                unsafe { core3.Resume() }.map_err(|e| e.to_string())
978            };
979            let mut c = Wv2 {
980                visible: &visible,
981                suspend: &suspend,
982                resume: &resume,
983            };
984            f(&mut c);
985        })
986        .map_err(|e| e.to_string())
987    }
988}
989
990#[cfg(not(windows))]
991mod platform {
992    use tauri::{AppHandle, Wry};
993
994    use super::Controller;
995
996    /// No webview suspend call here: every step succeeds without effect, so the state, counters,
997    /// events and hooks behave as on Windows.
998    struct Noop;
999
1000    impl Controller for Noop {
1001        fn set_visible(&mut self, _: bool) -> Result<(), String> {
1002            Ok(())
1003        }
1004        fn try_suspend(&mut self) -> Result<(), String> {
1005            Ok(())
1006        }
1007        fn resume(&mut self) -> Result<(), String> {
1008            Ok(())
1009        }
1010    }
1011
1012    pub fn with_controller(
1013        _: &AppHandle<Wry>,
1014        f: impl FnOnce(&mut dyn Controller) + Send + 'static,
1015    ) -> Result<(), String> {
1016        f(&mut Noop);
1017        Ok(())
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024    use std::collections::HashSet;
1025    use std::sync::atomic::AtomicUsize;
1026
1027    /// Records every call in order; any named call can be made to fail.
1028    #[derive(Default)]
1029    struct Fake {
1030        calls: Vec<&'static str>,
1031        failing: HashSet<&'static str>,
1032    }
1033
1034    impl Fake {
1035        fn failing(names: &[&'static str]) -> Self {
1036            Self {
1037                failing: names.iter().copied().collect(),
1038                ..Self::default()
1039            }
1040        }
1041        fn call(&mut self, name: &'static str) -> Result<(), String> {
1042            self.calls.push(name);
1043            if self.failing.contains(name) {
1044                Err(format!("{name} failed"))
1045            } else {
1046                Ok(())
1047            }
1048        }
1049    }
1050
1051    impl Controller for Fake {
1052        fn set_visible(&mut self, visible: bool) -> Result<(), String> {
1053            self.call(if visible { "show" } else { "hide" })
1054        }
1055        fn try_suspend(&mut self) -> Result<(), String> {
1056            self.call("suspend")
1057        }
1058        fn resume(&mut self) -> Result<(), String> {
1059            self.call("resume")
1060        }
1061    }
1062
1063    #[test]
1064    fn suspend_is_hide_then_suspend() {
1065        let (mut a, mut c) = (Applied::default(), Fake::default());
1066        a.hide_and_suspend(&mut c, Instant::now()).unwrap();
1067        assert_eq!(c.calls, ["hide", "suspend"]);
1068        assert!(a.hidden && a.suspended);
1069    }
1070
1071    #[test]
1072    fn a_failed_hide_means_no_suspend_and_a_running_webview() {
1073        let (mut a, mut c) = (Applied::default(), Fake::failing(&["hide"]));
1074        let f = a.hide_and_suspend(&mut c, Instant::now()).unwrap_err();
1075        assert_eq!(f.phase, IdlePhase::Suspend);
1076        assert_eq!(c.calls, ["hide"], "TrySuspend must not run");
1077        assert!(!a.hidden && !a.suspended);
1078    }
1079
1080    #[test]
1081    fn an_immediate_hide_is_not_repeated_by_the_suspend() {
1082        let (mut a, mut c) = (Applied::default(), Fake::default());
1083        a.hide(&mut c).unwrap();
1084        a.hide_and_suspend(&mut c, Instant::now()).unwrap();
1085        assert_eq!(c.calls, ["hide", "suspend"]);
1086    }
1087
1088    #[test]
1089    fn a_failed_immediate_hide_is_retried_by_the_suspend() {
1090        let (mut a, mut c) = (Applied::default(), Fake::failing(&["hide"]));
1091        assert!(a.hide(&mut c).is_err());
1092        c.failing.clear();
1093        a.hide_and_suspend(&mut c, Instant::now()).unwrap();
1094        assert_eq!(c.calls, ["hide", "hide", "suspend"]);
1095    }
1096
1097    #[test]
1098    fn a_failed_try_suspend_leaves_the_controller_hidden_not_suspended() {
1099        let (mut a, mut c) = (Applied::default(), Fake::failing(&["suspend"]));
1100        assert!(a.hide_and_suspend(&mut c, Instant::now()).is_err());
1101        assert!(a.hidden && !a.suspended);
1102        // Resume then shows only.
1103        c.calls.clear();
1104        assert_eq!(
1105            a.resume_and_show(&mut c, Instant::now()).unwrap(),
1106            ResumeStep::Nothing
1107        );
1108        assert_eq!(c.calls, ["show"]);
1109    }
1110
1111    #[test]
1112    fn resume_is_resume_then_show() {
1113        let (mut a, mut c) = (Applied::default(), Fake::default());
1114        let t0 = Instant::now();
1115        a.hide_and_suspend(&mut c, t0).unwrap();
1116        c.calls.clear();
1117        let step = a
1118            .resume_and_show(&mut c, t0 + Duration::from_secs(5))
1119            .unwrap();
1120        assert_eq!(c.calls, ["resume", "show"]);
1121        assert_eq!(step, ResumeStep::Resumed(Duration::from_secs(5)));
1122        assert!(!a.hidden && !a.suspended);
1123    }
1124
1125    #[test]
1126    fn a_failed_resume_means_no_show_and_an_error_then_retries() {
1127        let (mut a, mut c) = (Applied::default(), Fake::default());
1128        a.hide_and_suspend(&mut c, Instant::now()).unwrap();
1129        c.calls.clear();
1130        c.failing.insert("resume");
1131        let f = a.resume_and_show(&mut c, Instant::now()).unwrap_err();
1132        assert_eq!(f.phase, IdlePhase::Resume);
1133        assert_eq!(c.calls, ["resume"], "no show after a failed resume");
1134        assert!(a.hidden && a.suspended, "still pending");
1135        c.failing.clear();
1136        c.calls.clear();
1137        assert!(matches!(
1138            a.resume_and_show(&mut c, Instant::now()),
1139            Ok(ResumeStep::Resumed(_))
1140        ));
1141        assert_eq!(c.calls, ["resume", "show"]);
1142    }
1143
1144    #[test]
1145    fn a_failed_show_is_surfaced_and_retried_on_the_next_request_without_resuming_again() {
1146        let (mut a, mut c) = (Applied::default(), Fake::default());
1147        a.hide_and_suspend(&mut c, Instant::now()).unwrap();
1148        c.calls.clear();
1149        c.failing.insert("show");
1150        let f = a.resume_and_show(&mut c, Instant::now()).unwrap_err();
1151        assert_eq!(f.phase, IdlePhase::Resume);
1152        assert_eq!(c.calls, ["resume", "show"]);
1153        assert!(a.hidden && !a.suspended);
1154        c.failing.clear();
1155        c.calls.clear();
1156        let step = a.resume_and_show(&mut c, Instant::now()).unwrap();
1157        assert_eq!(c.calls, ["show"]);
1158        assert!(
1159            matches!(step, ResumeStep::Resumed(_)),
1160            "resumed hook still fires once"
1161        );
1162        // And a further request is a no-op.
1163        c.calls.clear();
1164        assert_eq!(
1165            a.resume_and_show(&mut c, Instant::now()).unwrap(),
1166            ResumeStep::Nothing
1167        );
1168        assert!(c.calls.is_empty());
1169    }
1170
1171    #[test]
1172    fn resume_with_nothing_suspended_makes_no_call() {
1173        let (mut a, mut c) = (Applied::default(), Fake::default());
1174        assert_eq!(
1175            a.resume_and_show(&mut c, Instant::now()).unwrap(),
1176            ResumeStep::Nothing
1177        );
1178        assert!(c.calls.is_empty());
1179    }
1180
1181    #[test]
1182    fn a_hide_alone_is_undone_by_a_show_alone() {
1183        let (mut a, mut c) = (Applied::default(), Fake::default());
1184        a.hide(&mut c).unwrap();
1185        c.calls.clear();
1186        let step = a.resume_and_show(&mut c, Instant::now()).unwrap();
1187        assert_eq!(c.calls, ["show"]);
1188        assert_eq!(a.classify(true, step), ResumeStep::Shown);
1189    }
1190
1191    #[test]
1192    fn a_failed_suspend_is_forgotten_by_the_state_machine() {
1193        let mut s = IdleState::default();
1194        let (g, _) = s.on_hidden();
1195        assert_eq!(s.on_timer(g), IdleAction::SuspendNow);
1196        s.on_suspend_failed();
1197        assert_eq!(s.snapshot(), (true, false));
1198        let (_, action) = s.on_hidden();
1199        assert_eq!(action, IdleAction::HideControllerAndStartTimer);
1200    }
1201
1202    #[test]
1203    fn the_transition_hook_fires_once_per_call_and_is_silent_when_unset() {
1204        let count = Arc::new(AtomicUsize::new(0));
1205        let seen = Arc::new(Mutex::new(Vec::new()));
1206        let (c2, s2) = (count.clone(), seen.clone());
1207        let host = IdleHost::new(Duration::from_secs(1)).with_on_transition(Some(Arc::new(
1208            move |t: &IdleTransition| {
1209                c2.fetch_add(1, Ordering::SeqCst);
1210                s2.lock().unwrap().push(t.clone());
1211            },
1212        )));
1213        host.transition(
1214            IdleTransitionKind::Suspended,
1215            "main",
1216            Duration::from_secs(30),
1217        );
1218        host.transition(IdleTransitionKind::Resumed, "main", Duration::from_secs(4));
1219        assert_eq!(count.load(Ordering::SeqCst), 2);
1220        let seen = seen.lock().unwrap();
1221        assert_eq!(seen[0].kind, IdleTransitionKind::Suspended);
1222        assert_eq!(seen[0].window, "main");
1223        assert_eq!(seen[0].elapsed, Duration::from_secs(30));
1224        assert_eq!(seen[1].kind, IdleTransitionKind::Resumed);
1225        // Unset: no panic, no effect.
1226        IdleHost::new(Duration::from_secs(1)).transition(
1227            IdleTransitionKind::Suspended,
1228            "main",
1229            Duration::ZERO,
1230        );
1231    }
1232
1233    /// One minimise-only cycle as the shell drives it: tracker, state machine, then the controller.
1234    struct Cycle {
1235        tracker: MinimizeTracker,
1236        state: IdleState,
1237        applied: Applied,
1238        c: Fake,
1239    }
1240
1241    impl Cycle {
1242        fn new() -> Self {
1243            Self {
1244                tracker: MinimizeTracker::default(),
1245                state: IdleState::default(),
1246                applied: Applied::default(),
1247                c: Fake::default(),
1248            }
1249        }
1250        /// A minimise (`true`) or restore (`false`); `timer` lets the idle timer fire after a minimise.
1251        fn minimised(&mut self, minimised: bool, timer: bool) {
1252            match self.tracker.step(minimised) {
1253                MinimizeStep::Start => {
1254                    let (g, _) = self.state.on_hidden();
1255                    self.applied.hide(&mut self.c).unwrap();
1256                    if timer && self.state.on_timer(g) == IdleAction::SuspendNow {
1257                        self.applied
1258                            .hide_and_suspend(&mut self.c, Instant::now())
1259                            .unwrap();
1260                    }
1261                }
1262                MinimizeStep::End => {
1263                    self.tracker.clear();
1264                    self.state.on_visible();
1265                    self.applied
1266                        .resume_and_show(&mut self.c, Instant::now())
1267                        .unwrap();
1268                }
1269                MinimizeStep::None => {}
1270            }
1271        }
1272    }
1273
1274    #[test]
1275    fn minimise_only_trigger_ignores_hides_and_always_accepts_a_minimise() {
1276        for trigger in [IdleTrigger::HiddenOrMinimised, IdleTrigger::Minimised] {
1277            assert!(source_starts_idle(trigger, IdleSource::Minimise));
1278        }
1279        assert!(source_starts_idle(
1280            IdleTrigger::HiddenOrMinimised,
1281            IdleSource::Hide
1282        ));
1283        assert!(!source_starts_idle(
1284            IdleTrigger::Minimised,
1285            IdleSource::Hide
1286        ));
1287        let o = IdleOptions::default().on_minimise(true);
1288        assert_eq!(o.trigger, IdleTrigger::Minimised);
1289        assert!(o.minimise_enabled() && !o.start_on_minimize);
1290        assert!(!IdleOptions::default().minimise_enabled());
1291        assert_eq!(o.on_minimise(false).trigger, IdleTrigger::HiddenOrMinimised);
1292    }
1293
1294    #[test]
1295    fn minimise_then_restore_suspends_and_resumes_in_order() {
1296        let mut m = Cycle::new();
1297        m.minimised(true, true);
1298        assert_eq!(m.c.calls, ["hide", "suspend"]);
1299        m.c.calls.clear();
1300        m.minimised(false, false);
1301        assert_eq!(m.c.calls, ["resume", "show"]);
1302        assert_eq!(m.state.snapshot(), (false, false));
1303    }
1304
1305    #[test]
1306    fn a_double_minimise_changes_nothing_the_second_time() {
1307        let mut m = Cycle::new();
1308        m.minimised(true, true);
1309        m.c.calls.clear();
1310        m.minimised(true, true);
1311        assert!(m.c.calls.is_empty());
1312        m.minimised(false, false);
1313        assert_eq!(m.c.calls, ["resume", "show"]);
1314    }
1315
1316    #[test]
1317    fn a_restore_before_the_timer_only_shows_and_never_resumes() {
1318        let mut m = Cycle::new();
1319        m.minimised(true, false);
1320        m.minimised(false, false);
1321        assert_eq!(m.c.calls, ["hide", "show"]);
1322    }
1323
1324    #[test]
1325    fn a_restore_with_no_minimise_makes_no_controller_call() {
1326        let mut m = Cycle::new();
1327        m.minimised(false, false);
1328        assert!(m.c.calls.is_empty());
1329    }
1330
1331    #[test]
1332    fn the_minimise_trigger_honours_a_per_hide_delay() {
1333        let host = Duration::from_secs(30);
1334        assert_eq!(
1335            minimize_action(host, MinimizeStep::Start, None),
1336            MinimizeAction::Hide { delay: host }
1337        );
1338        assert_eq!(
1339            minimize_action(host, MinimizeStep::Start, Some(Duration::from_secs(2))),
1340            MinimizeAction::Hide {
1341                delay: Duration::from_secs(2)
1342            }
1343        );
1344        assert_eq!(
1345            minimize_action(
1346                host,
1347                MinimizeStep::Start,
1348                Some(Duration::from_secs(10 * 24 * 3600))
1349            ),
1350            MinimizeAction::Hide {
1351                delay: MAX_SUSPEND_AFTER
1352            }
1353        );
1354        assert_eq!(
1355            minimize_action(host, MinimizeStep::End, Some(Duration::ZERO)),
1356            MinimizeAction::Visible
1357        );
1358        assert_eq!(
1359            minimize_action(host, MinimizeStep::None, None),
1360            MinimizeAction::None
1361        );
1362    }
1363}