use std::sync::mpsc::{Receiver, channel};
use crate::{Result, Waker};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndKind {
Quit,
Logout,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EndRequest {
pub kind: EndKind,
}
pub const CAN_WAIT: bool = cfg!(target_os = "macos");
pub fn listen(waker: Waker) -> Result<Receiver<EndRequest>> {
let (tx, rx) = channel();
imp::listen(tx, waker)?;
Ok(rx)
}
pub fn reply(allow: bool) {
imp::reply(allow);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum State {
#[default]
Idle,
Asking(EndKind),
Saving(EndKind),
Done,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event {
Requested {
kind: EndKind,
dirty: bool,
can_wait: bool,
},
Save,
Discard,
Cancel,
Saved,
SaveFailed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Effect {
Ask,
Save,
Shutdown { keep_recovery: bool },
Allow,
Refuse,
}
pub fn step(state: State, event: Event) -> (State, Vec<Effect>) {
use Effect::*;
match (state, event) {
(State::Done, Event::Requested { .. }) => (State::Done, vec![Allow]),
(State::Idle, Event::Requested { dirty: false, .. }) => (State::Done, vec![Shutdown { keep_recovery: false }, Allow]),
(State::Idle, Event::Requested { kind, dirty: true, can_wait: true }) => (State::Asking(kind), vec![Ask]),
(State::Idle, Event::Requested { dirty: true, can_wait: false, .. }) => (State::Done, vec![Shutdown { keep_recovery: true }, Allow]),
(State::Asking(kind), Event::Save) => (State::Saving(kind), vec![Save]),
(State::Asking(_), Event::Discard) | (State::Saving(_), Event::Saved) => (State::Done, vec![Shutdown { keep_recovery: false }, Allow]),
(State::Asking(_) | State::Saving(_), Event::Cancel) | (State::Saving(_), Event::SaveFailed) => (State::Idle, vec![Refuse]),
(s, _) => (s, vec![]),
}
}
#[cfg(all(target_os = "macos", feature = "session-end"))]
#[allow(unsafe_code)]
mod imp {
use std::ffi::CStr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::sync::{Mutex, PoisonError};
use block2::RcBlock;
use objc2::runtime::{AnyClass, AnyObject, Imp, ProtocolObject, Sel};
use objc2::{MainThreadMarker, sel};
use objc2_app_kit::NSApplication;
use objc2_foundation::{NSAppleEventManager, NSRunLoop};
use super::*;
use crate::NativeError;
const TERMINATE_NOW: usize = 1;
const TERMINATE_LATER: usize = 2;
const fn four(c: &[u8; 4]) -> u32 {
u32::from_be_bytes(*c)
}
const QUIT_REASON: u32 = four(b"why?");
static SINK: Mutex<Option<(Sender<EndRequest>, Waker)>> = Mutex::new(None);
static PENDING: AtomicBool = AtomicBool::new(false);
static ALLOWED: AtomicBool = AtomicBool::new(false);
fn kind() -> EndKind {
let event = NSAppleEventManager::sharedAppleEventManager().currentAppleEvent();
let reason = event.and_then(|e| e.attributeDescriptorForKeyword(QUIT_REASON)).map(|d| d.enumCodeValue()).unwrap_or(0);
if reason == 0 { EndKind::Quit } else { EndKind::Logout }
}
fn answer() -> usize {
if ALLOWED.load(Ordering::SeqCst) {
return TERMINATE_NOW;
}
let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).clone();
let Some((tx, wake)) = sink else { return TERMINATE_NOW };
if PENDING.load(Ordering::SeqCst) {
return TERMINATE_LATER;
}
if tx.send(EndRequest { kind: kind() }).is_err() {
return TERMINATE_NOW;
}
PENDING.store(true, Ordering::SeqCst);
wake();
TERMINATE_LATER
}
extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _cmd: Sel, _sender: *mut AnyObject) -> usize {
std::panic::catch_unwind(answer).unwrap_or(TERMINATE_NOW)
}
pub fn listen(tx: Sender<EndRequest>, waker: Waker) -> Result<()> {
let Some(mtm) = MainThreadMarker::new() else {
return Err(NativeError::Os("quit requests must be set up on the main thread".into()));
};
let app = NSApplication::sharedApplication(mtm);
let Some(delegate) = app.delegate() else {
return Err(NativeError::Os("the application has no delegate yet; set up quit requests once the event loop runs".into()));
};
let obj: &AnyObject = ProtocolObject::as_ref(&*delegate);
let cls: &AnyClass = obj.class();
let name = sel!(applicationShouldTerminate:);
let ours = should_terminate as extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize;
if let Some(m) = cls.instance_method(name) {
let current = m.implementation();
if current as usize != ours as usize {
return Err(NativeError::Os("the application delegate already answers quit requests".into()));
}
} else {
const TYPES: &CStr = c"Q@:@"; let imp: Imp = unsafe { std::mem::transmute::<extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize, Imp>(ours) };
let added = unsafe { objc2::ffi::class_addMethod(cls as *const AnyClass as *mut AnyClass, name, imp, TYPES.as_ptr()) };
if !added.as_bool() {
return Err(NativeError::Os("could not add the quit handler to the application delegate".into()));
}
app.setDelegate(Some(&delegate));
}
*SINK.lock().unwrap_or_else(PoisonError::into_inner) = Some((tx, waker));
Ok(())
}
pub fn reply(allow: bool) {
if allow {
ALLOWED.store(true, Ordering::SeqCst);
}
if !PENDING.swap(false, Ordering::SeqCst) {
return;
}
let block = RcBlock::new(move || {
if let Some(mtm) = MainThreadMarker::new() {
NSApplication::sharedApplication(mtm).replyToApplicationShouldTerminate(allow);
}
});
unsafe { NSRunLoop::mainRunLoop().performBlock(&block) };
}
}
#[cfg(all(target_os = "windows", feature = "session-end"))]
#[allow(unsafe_code)]
mod imp {
use std::sync::mpsc::Sender;
use std::sync::{Condvar, Mutex, Once, PoisonError};
use std::time::Duration;
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::WindowsAndMessaging::{
CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, MSG, RegisterClassW, TranslateMessage, WINDOW_EX_STYLE, WM_ENDSESSION,
WM_QUERYENDSESSION, WNDCLASSW, WS_OVERLAPPED,
};
use windows::core::w;
use super::*;
use crate::NativeError;
const FLUSH_WAIT: Duration = Duration::from_secs(4);
static SINK: Mutex<Option<(Sender<EndRequest>, Waker)>> = Mutex::new(None);
static REPLY: (Mutex<(bool, bool)>, Condvar) = (Mutex::new((false, false)), Condvar::new());
fn request() {
let sink = SINK.lock().unwrap_or_else(PoisonError::into_inner).clone();
let Some((tx, wake)) = sink else { return };
let mut r = REPLY.0.lock().unwrap_or_else(PoisonError::into_inner);
if r.1 || r.0 {
return;
}
if tx.send(EndRequest { kind: EndKind::Logout }).is_ok() {
r.0 = true;
drop(r);
wake();
}
}
fn wait_for_reply() {
let r = REPLY.0.lock().unwrap_or_else(PoisonError::into_inner);
if !r.0 {
return;
}
let _ = REPLY.1.wait_timeout_while(r, FLUSH_WAIT, |r| !r.1);
}
extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
let handled = std::panic::catch_unwind(|| match msg {
WM_QUERYENDSESSION => {
request();
Some(LRESULT(1))
}
WM_ENDSESSION => {
if wparam.0 != 0 {
wait_for_reply();
}
Some(LRESULT(0))
}
_ => None,
});
match handled {
Ok(Some(r)) => r,
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
}
}
fn run() {
let Ok(module) = (unsafe { GetModuleHandleW(None) }) else { return };
let class = WNDCLASSW { lpfnWndProc: Some(wndproc), hInstance: module.into(), lpszClassName: w!("AshlarSessionEnd"), ..Default::default() };
if unsafe { RegisterClassW(&class) } == 0 {
log::warn!("session end: the window class could not be registered");
return;
}
let hwnd = unsafe {
CreateWindowExW(WINDOW_EX_STYLE(0), w!("AshlarSessionEnd"), w!(""), WS_OVERLAPPED, 0, 0, 0, 0, None, None, Some(module.into()), None)
};
if hwnd.is_err() {
log::warn!("session end: the window could not be created");
return;
}
let mut msg = MSG::default();
while unsafe { GetMessageW(&mut msg, None, 0, 0) }.0 > 0 {
unsafe {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
pub fn listen(tx: Sender<EndRequest>, waker: Waker) -> Result<()> {
*SINK.lock().unwrap_or_else(PoisonError::into_inner) = Some((tx, waker));
static START: Once = Once::new();
let mut spawned = Ok(());
START.call_once(|| {
spawned = std::thread::Builder::new().name("ashlar-session-end".into()).spawn(run).map(drop);
});
spawned.map_err(|e| NativeError::Os(crate::os_text(e)))
}
pub fn reply(_allow: bool) {
let mut r = REPLY.0.lock().unwrap_or_else(PoisonError::into_inner);
r.1 = true;
REPLY.1.notify_all();
}
}
#[cfg(not(all(any(target_os = "macos", target_os = "windows"), feature = "session-end")))]
mod imp {
use std::sync::mpsc::Sender;
use super::*;
pub fn listen(_tx: Sender<EndRequest>, _waker: Waker) -> Result<()> {
Err(crate::NativeError::Unsupported)
}
pub fn reply(_allow: bool) {}
}
#[cfg(test)]
mod tests {
use super::*;
use Effect::*;
fn req(kind: EndKind, dirty: bool, can_wait: bool) -> Event {
Event::Requested { kind, dirty, can_wait }
}
fn run(events: &[Event]) -> (State, Vec<Effect>) {
events.iter().fold((State::Idle, vec![]), |(s, mut all), e| {
let (s, fx) = step(s, *e);
all.extend(fx);
(s, all)
})
}
#[test]
fn a_clean_quit_cleans_up_and_ends_at_once() {
for kind in [EndKind::Quit, EndKind::Logout] {
for can_wait in [true, false] {
assert_eq!(run(&[req(kind, false, can_wait)]), (State::Done, vec![Shutdown { keep_recovery: false }, Allow]));
}
}
}
#[test]
fn a_dirty_quit_asks_first() {
assert_eq!(run(&[req(EndKind::Quit, true, true)]), (State::Asking(EndKind::Quit), vec![Ask]));
assert_eq!(run(&[req(EndKind::Logout, true, true)]), (State::Asking(EndKind::Logout), vec![Ask]));
}
#[test]
fn save_then_saved_ends() {
let (s, fx) = run(&[req(EndKind::Quit, true, true), Event::Save, Event::Saved]);
assert_eq!(s, State::Done);
assert_eq!(fx, vec![Ask, Save, Shutdown { keep_recovery: false }, Allow]);
}
#[test]
fn a_failed_or_cancelled_save_stays_open() {
assert_eq!(run(&[req(EndKind::Quit, true, true), Event::Save, Event::SaveFailed]), (State::Idle, vec![Ask, Save, Refuse]));
assert_eq!(run(&[req(EndKind::Quit, true, true), Event::Save, Event::Cancel]), (State::Idle, vec![Ask, Save, Refuse]));
}
#[test]
fn discard_ends_without_keeping_recovery_data() {
assert_eq!(run(&[req(EndKind::Quit, true, true), Event::Discard]), (State::Done, vec![Ask, Shutdown { keep_recovery: false }, Allow]));
}
#[test]
fn cancel_stays_open_and_a_later_quit_asks_again() {
let (s, fx) = run(&[req(EndKind::Logout, true, true), Event::Cancel]);
assert_eq!((s, fx), (State::Idle, vec![Ask, Refuse]));
assert_eq!(step(s, req(EndKind::Quit, true, true)), (State::Asking(EndKind::Quit), vec![Ask]));
}
#[test]
fn a_logout_that_cant_wait_keeps_the_unsaved_work_for_recovery() {
assert_eq!(run(&[req(EndKind::Logout, true, false)]), (State::Done, vec![Shutdown { keep_recovery: true }, Allow]));
}
#[test]
fn repeated_requests_dont_stack_dialogs_and_stray_answers_do_nothing() {
let (s, fx) = run(&[req(EndKind::Quit, true, true), req(EndKind::Quit, true, true), req(EndKind::Logout, true, true)]);
assert_eq!((s, fx), (State::Asking(EndKind::Quit), vec![Ask]));
for e in [Event::Save, Event::Discard, Event::Cancel, Event::Saved, Event::SaveFailed] {
assert_eq!(step(State::Idle, e), (State::Idle, vec![]), "{e:?}");
}
assert_eq!(step(State::Saving(EndKind::Quit), req(EndKind::Quit, true, true)), (State::Saving(EndKind::Quit), vec![]));
}
#[test]
fn once_done_every_later_ask_is_allowed() {
assert_eq!(step(State::Done, req(EndKind::Quit, true, true)), (State::Done, vec![Allow]));
assert_eq!(step(State::Done, Event::Cancel), (State::Done, vec![]));
}
#[test]
fn listening_off_the_main_thread_or_unsupported_is_an_error_not_a_crash() {
let r = std::thread::spawn(|| listen(crate::no_waker()).map(drop)).join();
if cfg!(all(target_os = "windows", feature = "session-end")) {
assert!(matches!(r, Ok(Ok(()))));
} else {
assert!(matches!(r, Ok(Err(_))));
}
reply(false); }
}