Skip to main content

rightkit_tauri_shell/
quit.rs

1//! Opt-in quit drain.
2//!
3//! By default the shell quits at once: Quit marks the app exiting, saves window state, stops the
4//! engine and exits. A host that has work to flush first (a mail outbox, an unsaved draft) opts in
5//! with [`crate::ShellBuilder::quit_drain`]. A quit request then does not exit; it emits
6//! `rightkit-shell://quit-requested` to the renderer, prevents the exit, and waits for the renderer
7//! to call `rk_shell_finish_quit`. The host never trusts the renderer to answer: after a bound
8//! ([`DEFAULT_QUIT_DRAIN_BOUND`] unless the host sets one, never above [`MAX_QUIT_DRAIN_BOUND`]) the
9//! app exits anyway.
10//!
11//! `@rightkit/tauri-shell` drains its handlers for 10 s by default, so the 15 s default host bound leaves
12//! room for the renderer's own timeout to fire first and report.
13//!
14//! [`QuitGate`] is the whole decision and has no Tauri dependency, so the order of events is tested
15//! without an event loop; `lifecycle` only maps `RunEvent::ExitRequested` and the tray Quit item
16//! onto it.
17
18use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
19use std::sync::Arc;
20use std::time::Duration;
21
22/// The host-side bound after which the app exits even if the renderer never calls finish.
23pub const DEFAULT_QUIT_DRAIN_BOUND: Duration = Duration::from_secs(15);
24
25/// The longest bound a host may configure; larger values are clamped. An unbounded drain would let
26/// a hung renderer keep a quit-requested app alive forever.
27pub const MAX_QUIT_DRAIN_BOUND: Duration = Duration::from_secs(120);
28
29/// What a quit request should do.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum QuitStep {
32    /// Proceed with the exit now (drain not enabled, already exiting, or no renderer to drain).
33    ExitNow,
34    /// Emit `quit-requested`, prevent the exit and arm the bound.
35    Drain,
36    /// A drain is already running: prevent the exit, emit nothing.
37    AlreadyDraining,
38}
39
40/// The quit decision. One per app, in `ShellState`.
41#[derive(Debug)]
42pub struct QuitGate {
43    bound: Option<Duration>,
44    draining: AtomicBool,
45    /// Bumped by every armed bound and by `finish`, so a stale timer never fires.
46    epoch: AtomicU64,
47}
48
49impl QuitGate {
50    /// `None`: drain off (today's immediate quit). `Some(bound)`: drain on, clamped to
51    /// [`MAX_QUIT_DRAIN_BOUND`].
52    pub fn new(bound: Option<Duration>) -> Self {
53        Self {
54            bound: bound.map(|b| b.min(MAX_QUIT_DRAIN_BOUND)),
55            draining: AtomicBool::new(false),
56            epoch: AtomicU64::new(0),
57        }
58    }
59
60    pub fn enabled(&self) -> bool {
61        self.bound.is_some()
62    }
63
64    pub fn bound(&self) -> Option<Duration> {
65        self.bound
66    }
67
68    /// True while a drain is in progress.
69    pub fn draining(&self) -> bool {
70        self.draining.load(Ordering::SeqCst)
71    }
72
73    /// Decide a quit request. `exiting` is the shell's exiting flag: once set (by
74    /// `rk_shell_finish_quit`, `rk_shell_quit` or the bound) the exit path never prompts again.
75    /// `renderer_alive` is false when no webview window remains, so nothing can answer.
76    pub fn request(&self, exiting: &AtomicBool, renderer_alive: bool) -> QuitStep {
77        if exiting.load(Ordering::SeqCst) || !self.enabled() || !renderer_alive {
78            return QuitStep::ExitNow;
79        }
80        if self.draining.swap(true, Ordering::SeqCst) {
81            QuitStep::AlreadyDraining
82        } else {
83            QuitStep::Drain
84        }
85    }
86
87    /// The renderer finished (or the host is exiting): cancel the bound.
88    pub fn finish(&self) {
89        self.epoch.fetch_add(1, Ordering::SeqCst);
90        self.draining.store(false, Ordering::SeqCst);
91    }
92
93    /// Start the bound: after it elapses, `on_expiry` runs once unless [`QuitGate::finish`] ran
94    /// first. The thread is short-lived and holds no `AppHandle` beyond what `on_expiry` captures.
95    pub fn arm_bound(self: &Arc<Self>, on_expiry: impl FnOnce() + Send + 'static) {
96        let Some(bound) = self.bound else {
97            return;
98        };
99        let epoch = self.epoch.fetch_add(1, Ordering::SeqCst) + 1;
100        let gate = Arc::clone(self);
101        let spawned = std::thread::Builder::new()
102            .name("rk-shell-quit-bound".into())
103            .spawn(move || {
104                std::thread::sleep(bound);
105                if gate.epoch.load(Ordering::SeqCst) == epoch && gate.draining() {
106                    gate.draining.store(false, Ordering::SeqCst);
107                    on_expiry();
108                }
109            });
110        if spawned.is_err() {
111            // No thread, no bound: do not leave the app waiting on a renderer. The caller's
112            // closure was moved into the failed spawn, so clear the drain and let a retry exit.
113            self.draining.store(false, Ordering::SeqCst);
114        }
115    }
116}