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