Skip to main content

ashlar_native/
session_end.rs

1//! Quit, logout and shutdown requests from outside the window, so they get the same "Save
2//! changes?" question and the same cleanup (settings, autosave, telemetry) as closing the window.
3//!
4//! - macOS: Dock ▸ Quit, the app menu's Quit item (`terminate:`), logout, restart and shutdown
5//!   all ask the application delegate `applicationShouldTerminate:`. winit's delegate doesn't
6//!   answer it, so [`listen`] adds the method to winit's delegate class at run time. It answers
7//!   "later" and sends an [`EndRequest`]; the app answers with [`reply`] once the user has chosen.
8//! - Windows: a hidden top-level window on its own thread receives `WM_QUERYENDSESSION` /
9//!   `WM_ENDSESSION` (message-only windows don't get these broadcasts). Windows doesn't wait for
10//!   a dialog, so the app saves recovery data and allows the logout ([`CAN_WAIT`] is `false`);
11//!   `WM_ENDSESSION` waits a few seconds for [`reply`] so the flush finishes first.
12//! - Elsewhere [`listen`] returns `Unsupported`; SIGTERM goes through [`crate::terminate`].
13//!
14//! What the app does with a request is the pure state machine [`step`].
15
16use std::sync::mpsc::{Receiver, channel};
17
18use crate::{Result, Waker};
19
20/// Why the OS asks the app to end.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum EndKind {
23    /// Dock ▸ Quit or another app asking this one to quit.
24    Quit,
25    /// The user logs out, restarts or shuts down.
26    Logout,
27}
28
29/// One request to end the app. Answer it with [`reply`].
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct EndRequest {
32    pub kind: EndKind,
33}
34
35/// Whether the OS waits while the app asks "Save changes?" (macOS does; Windows ends the session
36/// after a few seconds whatever the app answers).
37pub const CAN_WAIT: bool = cfg!(target_os = "macos");
38
39/// Start receiving end requests; `waker` runs after each one is sent. macOS: call on the main
40/// thread once the event loop runs (winit's delegate must exist). Calling again replaces the
41/// previous receiver.
42pub fn listen(waker: Waker) -> Result<Receiver<EndRequest>> {
43    let (tx, rx) = channel();
44    imp::listen(tx, waker)?;
45    Ok(rx)
46}
47
48/// Answer the pending request: `true` lets the app end (after its cleanup), `false` cancels
49/// (the user chose Cancel, or a save failed). Without a pending request it only records the
50/// answer for later requests (`true`: answer at once from now on).
51pub fn reply(allow: bool) {
52    imp::reply(allow);
53}
54
55// ------------------------------------------------------------------ the quit state machine
56
57/// Where the app is in answering an end request.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum State {
60    /// No request pending.
61    #[default]
62    Idle,
63    /// The "Save changes?" question is up.
64    Asking(EndKind),
65    /// The user chose Save; the save (maybe through a Save As dialog) runs.
66    Saving(EndKind),
67    /// Cleaned up and allowed: the process is ending.
68    Done,
69}
70
71/// What happened.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum Event {
74    /// The OS asks to end. `dirty`: the document has unsaved changes. `can_wait`: the OS waits
75    /// for a dialog ([`CAN_WAIT`]).
76    Requested {
77        kind: EndKind,
78        dirty: bool,
79        can_wait: bool,
80    },
81    /// Answers to the question.
82    Save,
83    Discard,
84    Cancel,
85    /// The save finished, or failed / its Save As dialog was cancelled.
86    Saved,
87    SaveFailed,
88}
89
90/// What the app must do, in order.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Effect {
93    /// Show "Save changes?".
94    Ask,
95    /// Save the document.
96    Save,
97    /// Stop jobs, flush settings and telemetry, end autosave. `keep_recovery`: the changes are
98    /// unsaved and the user wasn't asked, so autosave keeps them for the next launch.
99    Shutdown { keep_recovery: bool },
100    /// `reply(true)`: let the app end.
101    Allow,
102    /// `reply(false)`: stay open.
103    Refuse,
104}
105
106/// One step of the quit flow. Pure: the caller performs the effects.
107pub fn step(state: State, event: Event) -> (State, Vec<Effect>) {
108    use Effect::*;
109    match (state, event) {
110        // already cleaned up: let any later ask through at once
111        (State::Done, Event::Requested { .. }) => (State::Done, vec![Allow]),
112        (State::Idle, Event::Requested { dirty: false, .. }) => (State::Done, vec![Shutdown { keep_recovery: false }, Allow]),
113        (State::Idle, Event::Requested { kind, dirty: true, can_wait: true }) => (State::Asking(kind), vec![Ask]),
114        (State::Idle, Event::Requested { dirty: true, can_wait: false, .. }) => (State::Done, vec![Shutdown { keep_recovery: true }, Allow]),
115        (State::Asking(kind), Event::Save) => (State::Saving(kind), vec![Save]),
116        (State::Asking(_), Event::Discard) | (State::Saving(_), Event::Saved) => (State::Done, vec![Shutdown { keep_recovery: false }, Allow]),
117        (State::Asking(_) | State::Saving(_), Event::Cancel) | (State::Saving(_), Event::SaveFailed) => (State::Idle, vec![Refuse]),
118        // a second request while answering the first, or an answer nobody asked for
119        (s, _) => (s, vec![]),
120    }
121}
122
123// ------------------------------------------------------------------ macOS
124
125#[cfg(all(target_os = "macos", feature = "session-end"))]
126#[allow(unsafe_code)]
127mod imp {
128    use std::ffi::CStr;
129    use std::sync::atomic::{AtomicBool, Ordering};
130    use std::sync::mpsc::Sender;
131    use std::sync::{Mutex, PoisonError};
132
133    use block2::RcBlock;
134    use objc2::runtime::{AnyClass, AnyObject, Imp, ProtocolObject, Sel};
135    use objc2::{MainThreadMarker, sel};
136    use objc2_app_kit::NSApplication;
137    use objc2_foundation::{NSAppleEventManager, NSRunLoop};
138
139    use super::*;
140    use crate::NativeError;
141
142    // NSApplicationTerminateReply
143    const TERMINATE_NOW: usize = 1;
144    const TERMINATE_LATER: usize = 2;
145
146    const fn four(c: &[u8; 4]) -> u32 {
147        u32::from_be_bytes(*c)
148    }
149    /// `kEventParamReason`, the quit event's attribute that says why.
150    const QUIT_REASON: u32 = four(b"why?");
151
152    static SINK: Mutex<Option<(Sender<EndRequest>, Waker)>> = Mutex::new(None);
153    /// We answered "later" and owe AppKit a reply.
154    static PENDING: AtomicBool = AtomicBool::new(false);
155    /// The app has cleaned up: answer "now" from here on.
156    static ALLOWED: AtomicBool = AtomicBool::new(false);
157
158    /// The quit Apple event's reason: logout, restart and shutdown carry one; Dock ▸ Quit doesn't.
159    fn kind() -> EndKind {
160        let event = NSAppleEventManager::sharedAppleEventManager().currentAppleEvent();
161        let reason = event.and_then(|e| e.attributeDescriptorForKeyword(QUIT_REASON)).map(|d| d.enumCodeValue()).unwrap_or(0);
162        if reason == 0 { EndKind::Quit } else { EndKind::Logout }
163    }
164
165    fn answer() -> usize {
166        if ALLOWED.load(Ordering::SeqCst) {
167            return TERMINATE_NOW;
168        }
169        let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).clone();
170        let Some((tx, wake)) = sink else { return TERMINATE_NOW };
171        if PENDING.load(Ordering::SeqCst) {
172            return TERMINATE_LATER;
173        }
174        // the app is gone (its receiver dropped): nothing to ask
175        if tx.send(EndRequest { kind: kind() }).is_err() {
176            return TERMINATE_NOW;
177        }
178        PENDING.store(true, Ordering::SeqCst);
179        wake();
180        TERMINATE_LATER
181    }
182
183    /// `-[delegate applicationShouldTerminate:]`. Never unwinds into AppKit: a panic lets the
184    /// quit go on, as AppKit would without the method.
185    extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _cmd: Sel, _sender: *mut AnyObject) -> usize {
186        std::panic::catch_unwind(answer).unwrap_or(TERMINATE_NOW)
187    }
188
189    pub fn listen(tx: Sender<EndRequest>, waker: Waker) -> Result<()> {
190        let Some(mtm) = MainThreadMarker::new() else {
191            return Err(NativeError::Os("quit requests must be set up on the main thread".into()));
192        };
193        let app = NSApplication::sharedApplication(mtm);
194        let Some(delegate) = app.delegate() else {
195            return Err(NativeError::Os("the application has no delegate yet; set up quit requests once the event loop runs".into()));
196        };
197        let obj: &AnyObject = ProtocolObject::as_ref(&*delegate);
198        let cls: &AnyClass = obj.class();
199        let name = sel!(applicationShouldTerminate:);
200        let ours = should_terminate as extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize;
201        if let Some(m) = cls.instance_method(name) {
202            let current = m.implementation();
203            if current as usize != ours as usize {
204                return Err(NativeError::Os("the application delegate already answers quit requests".into()));
205            }
206        } else {
207            const TYPES: &CStr = c"Q@:@"; // NSUInteger (id self, SEL _cmd, id sender)
208            // SAFETY: `Imp` is the untyped method pointer type; the runtime calls it with the
209            // signature `TYPES` declares, which is `should_terminate`'s (`Q` = NSUInteger =
210            // usize on the 64-bit targets macOS supports).
211            let imp: Imp = unsafe { std::mem::transmute::<extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize, Imp>(ours) };
212            // SAFETY: `cls` is a registered class (the delegate's), `name` a valid selector and
213            // `TYPES` a NUL-terminated encoding matching `imp`. Adding a method the class lacks
214            // is how the runtime extends classes; it only fails if the method exists.
215            let added = unsafe { objc2::ffi::class_addMethod(cls as *const AnyClass as *mut AnyClass, name, imp, TYPES.as_ptr()) };
216            if !added.as_bool() {
217                return Err(NativeError::Os("could not add the quit handler to the application delegate".into()));
218            }
219            // NSApplication may cache what its delegate answers when the delegate is set: set
220            // the same delegate again so it looks again.
221            app.setDelegate(Some(&delegate));
222        }
223        *SINK.lock().unwrap_or_else(PoisonError::into_inner) = Some((tx, waker));
224        Ok(())
225    }
226
227    pub fn reply(allow: bool) {
228        if allow {
229            ALLOWED.store(true, Ordering::SeqCst);
230        }
231        if !PENDING.swap(false, Ordering::SeqCst) {
232            return;
233        }
234        // Not from inside the app's frame: a yes ends the process through winit's
235        // `applicationWillTerminate:`, which must not run while winit handles an event. The
236        // block runs from the run loop (common modes, which include the modal mode AppKit
237        // waits in after "later").
238        let block = RcBlock::new(move || {
239            if let Some(mtm) = MainThreadMarker::new() {
240                NSApplication::sharedApplication(mtm).replyToApplicationShouldTerminate(allow);
241            }
242        });
243        // SAFETY: the main run loop runs the block on the main thread; it captures only a
244        // `bool` and checks for the main thread again before touching AppKit.
245        unsafe { NSRunLoop::mainRunLoop().performBlock(&block) };
246    }
247}
248
249// ------------------------------------------------------------------ Windows
250
251#[cfg(all(target_os = "windows", feature = "session-end"))]
252#[allow(unsafe_code)]
253mod imp {
254    use std::sync::mpsc::Sender;
255    use std::sync::{Condvar, Mutex, Once, PoisonError};
256    use std::time::Duration;
257
258    use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
259    use windows::Win32::System::LibraryLoader::GetModuleHandleW;
260    use windows::Win32::UI::WindowsAndMessaging::{
261        CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, MSG, RegisterClassW, TranslateMessage, WINDOW_EX_STYLE, WM_ENDSESSION,
262        WM_QUERYENDSESSION, WNDCLASSW, WS_OVERLAPPED,
263    };
264    use windows::core::w;
265
266    use super::*;
267    use crate::NativeError;
268
269    /// How long `WM_ENDSESSION` waits for the app's cleanup. Windows shows "this app is
270    /// preventing shutdown" after about five seconds.
271    const FLUSH_WAIT: Duration = Duration::from_secs(4);
272
273    static SINK: Mutex<Option<(Sender<EndRequest>, Waker)>> = Mutex::new(None);
274    /// (a request is pending, the app answered)
275    static REPLY: (Mutex<(bool, bool)>, Condvar) = (Mutex::new((false, false)), Condvar::new());
276
277    fn request() {
278        let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).clone();
279        let Some((tx, wake)) = sink else { return };
280        let mut r = REPLY.0.lock().unwrap_or_else(PoisonError::into_inner);
281        if r.1 || r.0 {
282            return;
283        }
284        if tx.send(EndRequest { kind: EndKind::Logout }).is_ok() {
285            r.0 = true;
286            drop(r);
287            wake();
288        }
289    }
290
291    fn wait_for_reply() {
292        let r = REPLY.0.lock().unwrap_or_else(PoisonError::into_inner);
293        if !r.0 {
294            return;
295        }
296        let _ = REPLY.1.wait_timeout_while(r, FLUSH_WAIT, |r| !r.1);
297    }
298
299    extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
300        let handled = std::panic::catch_unwind(|| match msg {
301            WM_QUERYENDSESSION => {
302                request();
303                // never block the logout: the app keeps unsaved work for recovery instead
304                Some(LRESULT(1))
305            }
306            WM_ENDSESSION => {
307                if wparam.0 != 0 {
308                    wait_for_reply();
309                }
310                Some(LRESULT(0))
311            }
312            _ => None,
313        });
314        match handled {
315            Ok(Some(r)) => r,
316            // SAFETY: the arguments are the ones this window procedure was called with.
317            _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
318        }
319    }
320
321    fn run() {
322        // SAFETY: a null module name asks for this executable's handle.
323        let Ok(module) = (unsafe { GetModuleHandleW(None) }) else { return };
324        let class = WNDCLASSW { lpfnWndProc: Some(wndproc), hInstance: module.into(), lpszClassName: w!("AshlarSessionEnd"), ..Default::default() };
325        // SAFETY: `class` is fully initialised and its name a static wide string.
326        if unsafe { RegisterClassW(&class) } == 0 {
327            log::warn!("session end: the window class could not be registered");
328            return;
329        }
330        // A top-level window that is never shown: message-only windows don't receive the
331        // session-end broadcasts.
332        // SAFETY: the class was registered above; no parent, menu or creation data.
333        let hwnd = unsafe {
334            CreateWindowExW(WINDOW_EX_STYLE(0), w!("AshlarSessionEnd"), w!(""), WS_OVERLAPPED, 0, 0, 0, 0, None, None, Some(module.into()), None)
335        };
336        if hwnd.is_err() {
337            log::warn!("session end: the window could not be created");
338            return;
339        }
340        let mut msg = MSG::default();
341        // SAFETY: `msg` is a valid out-pointer; the loop ends when GetMessageW returns 0 or -1.
342        while unsafe { GetMessageW(&mut msg, None, 0, 0) }.0 > 0 {
343            // SAFETY: `msg` came from GetMessageW.
344            unsafe {
345                let _ = TranslateMessage(&msg);
346                DispatchMessageW(&msg);
347            }
348        }
349    }
350
351    pub fn listen(tx: Sender<EndRequest>, waker: Waker) -> Result<()> {
352        *SINK.lock().unwrap_or_else(PoisonError::into_inner) = Some((tx, waker));
353        static START: Once = Once::new();
354        let mut spawned = Ok(());
355        START.call_once(|| {
356            spawned = std::thread::Builder::new().name("ashlar-session-end".into()).spawn(run).map(drop);
357        });
358        spawned.map_err(|e| NativeError::Os(crate::os_text(e)))
359    }
360
361    pub fn reply(_allow: bool) {
362        let mut r = REPLY.0.lock().unwrap_or_else(PoisonError::into_inner);
363        r.1 = true;
364        REPLY.1.notify_all();
365    }
366}
367
368#[cfg(not(all(any(target_os = "macos", target_os = "windows"), feature = "session-end")))]
369mod imp {
370    use std::sync::mpsc::Sender;
371
372    use super::*;
373
374    pub fn listen(_tx: Sender<EndRequest>, _waker: Waker) -> Result<()> {
375        Err(crate::NativeError::Unsupported)
376    }
377    pub fn reply(_allow: bool) {}
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use Effect::*;
384
385    fn req(kind: EndKind, dirty: bool, can_wait: bool) -> Event {
386        Event::Requested { kind, dirty, can_wait }
387    }
388
389    /// Run events from `Idle`, collecting every effect.
390    fn run(events: &[Event]) -> (State, Vec<Effect>) {
391        events.iter().fold((State::Idle, vec![]), |(s, mut all), e| {
392            let (s, fx) = step(s, *e);
393            all.extend(fx);
394            (s, all)
395        })
396    }
397
398    #[test]
399    fn a_clean_quit_cleans_up_and_ends_at_once() {
400        for kind in [EndKind::Quit, EndKind::Logout] {
401            for can_wait in [true, false] {
402                assert_eq!(run(&[req(kind, false, can_wait)]), (State::Done, vec![Shutdown { keep_recovery: false }, Allow]));
403            }
404        }
405    }
406
407    #[test]
408    fn a_dirty_quit_asks_first() {
409        assert_eq!(run(&[req(EndKind::Quit, true, true)]), (State::Asking(EndKind::Quit), vec![Ask]));
410        assert_eq!(run(&[req(EndKind::Logout, true, true)]), (State::Asking(EndKind::Logout), vec![Ask]));
411    }
412
413    #[test]
414    fn save_then_saved_ends() {
415        let (s, fx) = run(&[req(EndKind::Quit, true, true), Event::Save, Event::Saved]);
416        assert_eq!(s, State::Done);
417        assert_eq!(fx, vec![Ask, Save, Shutdown { keep_recovery: false }, Allow]);
418    }
419
420    #[test]
421    fn a_failed_or_cancelled_save_stays_open() {
422        assert_eq!(run(&[req(EndKind::Quit, true, true), Event::Save, Event::SaveFailed]), (State::Idle, vec![Ask, Save, Refuse]));
423        assert_eq!(run(&[req(EndKind::Quit, true, true), Event::Save, Event::Cancel]), (State::Idle, vec![Ask, Save, Refuse]));
424    }
425
426    #[test]
427    fn discard_ends_without_keeping_recovery_data() {
428        assert_eq!(run(&[req(EndKind::Quit, true, true), Event::Discard]), (State::Done, vec![Ask, Shutdown { keep_recovery: false }, Allow]));
429    }
430
431    #[test]
432    fn cancel_stays_open_and_a_later_quit_asks_again() {
433        let (s, fx) = run(&[req(EndKind::Logout, true, true), Event::Cancel]);
434        assert_eq!((s, fx), (State::Idle, vec![Ask, Refuse]));
435        assert_eq!(step(s, req(EndKind::Quit, true, true)), (State::Asking(EndKind::Quit), vec![Ask]));
436    }
437
438    #[test]
439    fn a_logout_that_cant_wait_keeps_the_unsaved_work_for_recovery() {
440        assert_eq!(run(&[req(EndKind::Logout, true, false)]), (State::Done, vec![Shutdown { keep_recovery: true }, Allow]));
441    }
442
443    #[test]
444    fn repeated_requests_dont_stack_dialogs_and_stray_answers_do_nothing() {
445        let (s, fx) = run(&[req(EndKind::Quit, true, true), req(EndKind::Quit, true, true), req(EndKind::Logout, true, true)]);
446        assert_eq!((s, fx), (State::Asking(EndKind::Quit), vec![Ask]));
447        for e in [Event::Save, Event::Discard, Event::Cancel, Event::Saved, Event::SaveFailed] {
448            assert_eq!(step(State::Idle, e), (State::Idle, vec![]), "{e:?}");
449        }
450        assert_eq!(step(State::Saving(EndKind::Quit), req(EndKind::Quit, true, true)), (State::Saving(EndKind::Quit), vec![]));
451    }
452
453    #[test]
454    fn once_done_every_later_ask_is_allowed() {
455        assert_eq!(step(State::Done, req(EndKind::Quit, true, true)), (State::Done, vec![Allow]));
456        assert_eq!(step(State::Done, Event::Cancel), (State::Done, vec![]));
457    }
458
459    #[test]
460    fn listening_off_the_main_thread_or_unsupported_is_an_error_not_a_crash() {
461        let r = std::thread::spawn(|| listen(crate::no_waker()).map(drop)).join();
462        if cfg!(all(target_os = "windows", feature = "session-end")) {
463            // Windows listens on its own thread, so any thread may start it
464            assert!(matches!(r, Ok(Ok(()))));
465        } else {
466            assert!(matches!(r, Ok(Err(_))));
467        }
468        reply(false); // nothing pending: a no-op
469    }
470}