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}