Files
mail/src/window_controller.rs
T

611 lines
22 KiB
Rust

//! Native window and system-tray lifecycle wiring.
//!
//! Window close behavior, tray actions, background resource release, and
//! persistence of the close-to-tray, sign-in and window-frame preferences form
//! one lifecycle boundary. Keeping that boundary here makes the application
//! composition root independent of platform window policy.
use super::*;
use crate::tray_ui::FlectarTray;
#[cfg(target_os = "macos")]
mod macos;
/// How long the window must stay minimized before its rendering buffers are
/// released. Shorter interruptions keep the rendered message ready.
#[cfg(desktop)]
const MINIMIZED_RELEASE_DELAY: Duration = Duration::from_secs(10);
/// Let Slint destroy the native surface and process pending property changes
/// that may still own image references before returning memory.
pub(super) const TRIM_DELAY: Duration = Duration::from_millis(250);
/// Coalesces the burst of startup updates applied while the window is hidden.
const HIDDEN_TRIM_DELAY: Duration = Duration::from_secs(1);
thread_local! {
static LIFECYCLE: Lifecycle = Lifecycle::default();
}
#[derive(Default)]
struct Lifecycle {
minimized_release: Timer,
hidden_trim: Timer,
/// The last minimized state reported by the platform.
minimized: Cell<bool>,
/// Rendering was suspended because the window stayed minimized.
released_while_minimized: Cell<bool>,
/// Deferred until the window is first shown after a background start.
first_show: RefCell<Vec<Box<dyn FnOnce()>>>,
/// Maximized and full-screen state when the window was hidden.
hidden_state: Cell<(bool, bool)>,
/// The main window was closed while messages were being written in
/// their own windows; the application ends when the last one closes.
quit_after_composers: Cell<bool>,
}
pub(super) fn create_and_register_window_lifecycle(
app: &AppWindow,
#[cfg_attr(not(feature = "flatpak"), allow(unused_variables))] runtime: &tokio::runtime::Handle,
) -> Result<Option<FlectarTray>, slint::PlatformError> {
#[cfg(not(any(mobile, feature = "flatpak")))]
let tray = Some(FlectarTray::new()?);
#[cfg(any(mobile, feature = "flatpak"))]
let tray: Option<FlectarTray> = None;
#[cfg(mobile)]
app.set_close_to_tray(false);
// The Flatpak has no tray icon: closing keeps it running in the
// background, and Settings says so.
#[cfg(feature = "flatpak")]
app.set_tray_supported(false);
if let Some(tray) = tray.as_ref() {
tray.set_enabled(app.get_close_to_tray());
crate::app_icon::attach_tray(app, tray);
let app_for_tray = app.as_weak();
tray.on_show_window(move || {
if let Some(app) = app_for_tray.upgrade() {
activate(&app, None);
}
});
let app_for_tray_quit = app.as_weak();
tray.on_quit_app(move || match app_for_tray_quit.upgrade() {
Some(app) => quit(&app),
None => {
let _ = slint::quit_event_loop();
}
});
}
// Opening the running app from the Dock or Finder shows its window.
#[cfg(target_os = "macos")]
{
let app = app.as_weak();
slint::Timer::single_shot(Duration::ZERO, move || {
macos::on_reopen(move || {
if let Some(app) = app.upgrade() {
activate(&app, None);
}
});
});
}
let app_for_close = app.as_weak();
#[cfg(feature = "flatpak")]
let runtime = runtime.clone();
app.window().on_close_requested(move || {
let Some(app) = app_for_close.upgrade() else {
return slint::CloseRequestResponse::HideWindow;
};
if app.invoke_system_back_requested() {
return slint::CloseRequestResponse::KeepWindowShown;
}
if app.get_close_to_tray() {
prepare_for_tray(&app);
#[cfg(feature = "flatpak")]
describe_background(&app, &runtime);
slint::CloseRequestResponse::HideWindow
} else if composer::open_windows() > 0 {
LIFECYCLE.with(|lifecycle| lifecycle.quit_after_composers.set(true));
prepare_for_tray(&app);
slint::CloseRequestResponse::HideWindow
} else {
let _ = slint::quit_event_loop();
slint::CloseRequestResponse::HideWindow
}
});
Ok(tray)
}
/// End the app, as Quit in the tray menu or a desktop's background-apps list
/// asks.
pub(super) fn quit(app: &AppWindow) {
let _ = app.hide();
let _ = slint::quit_event_loop();
}
/// Tell the desktop, in the current language, what the Flatpak does while
/// its window is closed: GNOME lists it under Background Apps with this line.
#[cfg(feature = "flatpak")]
pub(super) fn describe_background(app: &AppWindow, runtime: &tokio::runtime::Handle) {
let status = app.global::<I18n>().invoke_background_status().to_string();
runtime.spawn(crate::autostart::set_background_status(status));
}
pub(super) fn prepare_for_tray(app: &AppWindow) {
let window = app.window();
let state = (window.is_maximized(), window.is_fullscreen());
LIFECYCLE.with(|lifecycle| {
lifecycle.minimized_release.stop();
lifecycle.released_while_minimized.set(false);
lifecycle.hidden_state.set(state);
});
app.invoke_suspend_rendering(true);
slint::Timer::single_shot(TRIM_DELAY, release_free_memory);
}
/// A composer window closed. Once the last one is gone, a main window that
/// was closed while they were open ends the application.
pub(super) fn composer_closed() {
if LIFECYCLE.with(|lifecycle| lifecycle.quit_after_composers.get())
&& composer::open_windows() == 0
{
let _ = slint::quit_event_loop();
}
}
pub(super) fn show_from_tray(app: &AppWindow) {
LIFECYCLE.with(|lifecycle| lifecycle.quit_after_composers.set(false));
crate::window_chrome::before_show();
if let Err(error) = app.show() {
eprintln!("could not restore window: {error}");
return;
}
let (maximized, fullscreen) = LIFECYCLE.with(|lifecycle| {
lifecycle.minimized.set(false);
lifecycle.hidden_state.take()
});
restore_window_state(app, maximized, fullscreen);
app.invoke_suspend_rendering(false);
app.window().request_redraw();
for hook in LIFECYCLE.with(|lifecycle| lifecycle.first_show.take()) {
hook();
}
}
/// The recreated native window starts out normal, while Slint still records
/// the old state and so ignores a request to set it again. Apply it to the
/// native window directly; Slint follows the resulting resize.
fn restore_window_state(app: &AppWindow, maximized: bool, fullscreen: bool) {
#[cfg(desktop)]
if maximized || fullscreen {
use slint::winit_030::{WinitWindowAccessor, winit::window::Fullscreen};
let app = app.as_weak();
// The native window is created again after `show` returns.
let _ = slint::spawn_local(async move {
let Some(app) = app.upgrade() else {
return;
};
let Ok(window) = app.window().winit_window().await else {
return;
};
if fullscreen {
window.set_fullscreen(Some(Fullscreen::Borderless(None)));
} else {
window.set_maximized(true);
}
});
}
#[cfg(mobile)]
let _ = (app, maximized, fullscreen);
}
/// Bring the window to the user from the tray or another launch: show it if
/// it is hidden, restore it if it is minimized, and ask for focus with the
/// launcher's activation token when one was forwarded.
pub(super) fn activate(app: &AppWindow, activation_token: Option<String>) {
let window = app.window();
if !window.is_visible() {
show_from_tray(app);
} else if window.is_minimized() {
window.set_minimized(false);
}
#[cfg(desktop)]
crate::window_chrome::focus(app, activation_token);
#[cfg(mobile)]
let _ = activation_token;
}
/// Return memory freed by startup work that ran while the window is hidden,
/// as after a start in the tray. Hiding the window trims on its own.
pub(super) fn trim_while_hidden(app: &AppWindow) {
if app.window().is_visible() {
return;
}
LIFECYCLE.with(|lifecycle| {
lifecycle.hidden_trim.start(
slint::TimerMode::SingleShot,
HIDDEN_TRIM_DELAY,
release_free_memory,
);
});
}
/// Run `hook` once the window is visible: now, or when a background start
/// first shows it.
pub(super) fn when_first_shown(app: &AppWindow, hook: impl FnOnce() + 'static) {
if app.window().is_visible() {
hook();
} else {
LIFECYCLE.with(|lifecycle| lifecycle.first_show.borrow_mut().push(Box::new(hook)));
}
}
/// Follow platform minimize reports. A window that stays minimized releases
/// the same rendering buffers as one closed to the tray, and renders them
/// again as soon as it is restored. Platforms that cannot report minimization
/// (Wayland) keep everything rendered.
#[cfg(desktop)]
pub(crate) fn minimized_changed(app: &AppWindow, minimized: bool) {
let changed = LIFECYCLE.with(|lifecycle| lifecycle.minimized.replace(minimized) != minimized);
if !changed {
return;
}
if minimized {
let app = app.as_weak();
LIFECYCLE.with(|lifecycle| {
lifecycle.minimized_release.start(
slint::TimerMode::SingleShot,
MINIMIZED_RELEASE_DELAY,
move || {
let Some(app) = app.upgrade() else {
return;
};
// Hiding to the tray already released everything.
if !LIFECYCLE.with(|lifecycle| lifecycle.minimized.get())
|| !app.window().is_visible()
|| app.get_render_suspended()
{
return;
}
LIFECYCLE.with(|lifecycle| lifecycle.released_while_minimized.set(true));
app.invoke_suspend_rendering(true);
slint::Timer::single_shot(TRIM_DELAY, release_free_memory);
},
);
});
return;
}
let resume = LIFECYCLE.with(|lifecycle| {
lifecycle.minimized_release.stop();
lifecycle.released_while_minimized.replace(false)
});
if resume {
app.invoke_suspend_rendering(false);
app.window().request_redraw();
}
}
/// Whether the window was last reported minimized. Lets the platform event
/// filter skip minimize queries on ordinary frames and resizes.
#[cfg(desktop)]
pub(crate) fn reported_minimized() -> bool {
LIFECYCLE.with(|lifecycle| lifecycle.minimized.get())
}
/// Whether a background start leaves the user a way back to the window. A
/// tray icon is one: Linux needs a StatusNotifier host for it, which some
/// desktops (GNOME without an extension) do not provide, while Windows and
/// macOS always have one. The Flatpak has no tray icon; opening it again
/// brings the window back, and GNOME lists it under Background Apps meanwhile.
#[cfg(desktop)]
pub(super) fn can_start_hidden(runtime: &tokio::runtime::Handle) -> bool {
if cfg!(feature = "flatpak") {
return true;
}
#[cfg(linux_desktop)]
return runtime.block_on(status_notifier_host_registered());
#[cfg(not(linux_desktop))]
{
let _ = runtime;
true
}
}
#[cfg(linux_desktop)]
async fn status_notifier_host_registered() -> bool {
use ashpd::zbus;
let query = async {
let connection = zbus::Connection::session().await.ok()?;
let watcher = zbus::Proxy::new(
&connection,
"org.kde.StatusNotifierWatcher",
"/StatusNotifierWatcher",
"org.kde.StatusNotifierWatcher",
)
.await
.ok()?;
watcher
.get_property::<bool>("IsStatusNotifierHostRegistered")
.await
.ok()
};
matches!(
tokio::time::timeout(Duration::from_secs(1), query).await,
Ok(Some(true))
)
}
/// Return memory once the window has gone to the tray or stayed minimized.
fn release_free_memory() {
crate::memory::release_free_heap();
#[cfg(target_os = "windows")]
crate::memory::trim_working_set();
}
pub(super) fn register_window_preference_callbacks(
app: &AppWindow,
tray: Option<&FlectarTray>,
state: &Rc<RefCell<InboxState>>,
runtime: &tokio::runtime::Handle,
ui_task_tx: &UiSender<UiTaskUpdate>,
) {
let app_weak = app.as_weak();
app.on_toggle_settings(move || {
if let Some(app) = app_weak.upgrade() {
app.set_settings_open(!app.get_settings_open());
}
});
// The startup entry and the default email app can also change in the
// system's own settings, so read them again whenever Settings appears:
// from the rail, a tab or a shortcut into a settings page.
#[cfg(desktop)]
{
let launch_at_login = LaunchAtLogin::register(app, runtime);
let default_email_app =
crate::default_email_app::DefaultEmailAppRow::register(app, runtime);
let app_weak = app.as_weak();
app.on_settings_shown(move || {
if let Some(app) = app_weak.upgrade() {
launch_at_login.refresh(&app, false);
if let Some(default_email_app) = &default_email_app {
default_email_app.refresh(&app);
}
}
});
}
let app_weak = app.as_weak();
let state_for_frame = Rc::clone(state);
let runtime_for_frame = runtime.clone();
app.global::<WindowChrome>().on_choose(move |integrated| {
let Some(app) = app_weak.upgrade() else {
return;
};
let frame = if integrated {
window_chrome::WindowFrame::Integrated
} else {
window_chrome::WindowFrame::System
};
window_chrome::apply_preference(&app, frame);
let Some(core) = state_for_frame.borrow().core.clone() else {
app.set_sync_status(UiMessage::plain(
"Window frame updated for this session.",
));
return;
};
match runtime_for_frame.block_on(core.set_window_frame(frame.key())) {
Ok(()) => app.set_sync_status(UiMessage::plain("Window frame preference saved.")),
Err(error) => app.set_sync_status(UiMessage::detail(
"Could not save window frame preference: {}",
error,
)),
}
});
let app_weak = app.as_weak();
let tray_for_close_setting = tray.map(FlectarTray::as_weak);
let state_for_close_setting = Rc::clone(state);
let runtime_for_close_setting = runtime.clone();
let ui_task_tx_for_close_setting = ui_task_tx.clone();
app.on_set_close_to_tray(move |enabled| {
let Some(app) = app_weak.upgrade() else {
return;
};
if cfg!(mobile) {
app.set_close_to_tray(false);
app.set_sync_status(UiMessage::plain(
"System tray behavior is unavailable in this package.",
));
return;
}
app.set_close_to_tray(enabled);
if let Some(tray) = tray_for_close_setting
.as_ref()
.and_then(slint::Weak::upgrade)
{
tray.set_enabled(enabled);
}
let core = state_for_close_setting.borrow().core.clone();
app.set_sync_status(UiMessage::plain("Saving close behavior…"));
let updates = ui_task_tx_for_close_setting.clone();
runtime_for_close_setting.spawn(async move {
// The Flatpak may run with its window closed only while the
// desktop allows it; a refusal turns the switch back off.
#[cfg(feature = "flatpak")]
if enabled && let Some(message) = background_refusal().await {
let _ = updates
.send(close_behavior_update(message, Some(false)))
.await;
return;
}
let update = match core {
None => close_behavior_update(
UiMessage::plain("Close-to-tray is active for this session."),
None,
),
Some(core) => match core.set_close_to_tray(enabled).await {
Ok(()) => close_behavior_update(close_behavior_saved(enabled), None),
// Restore both the setting and tray visibility if the
// preference could not be persisted.
Err(error) => close_behavior_update(
UiMessage::detail("Could not save close behavior: {}", error),
Some(!enabled),
),
},
};
let _ = updates.send(update).await;
});
});
}
/// Ask the desktop to let the Flatpak run with its window closed; what to
/// tell the user when it does not.
#[cfg(feature = "flatpak")]
async fn background_refusal() -> Option<UiMessage> {
match tokio::task::spawn_blocking(crate::autostart::allow_background).await {
Ok(Ok(true)) => None,
Ok(Ok(false)) => Some(UiMessage::plain(
"Your desktop did not allow Flectar Mail to run in the background.",
)),
Ok(Err(error)) => Some(UiMessage::detail(
"Could not save close behavior: {}",
error,
)),
Err(error) => Some(UiMessage::detail(
"Could not save close behavior: {}",
error,
)),
}
}
/// What closing the window does now.
fn close_behavior_saved(enabled: bool) -> UiMessage {
if !enabled {
UiMessage::plain("Closing the window will now quit Flectar Mail.")
} else if cfg!(feature = "flatpak") {
UiMessage::plain("Closing the window will now keep Flectar Mail running in the background.")
} else {
UiMessage::plain("Closing the window will now keep Flectar Mail in the tray.")
}
}
/// A close-behavior result for the window: what to tell the user, and the
/// switch to restore when the change did not take.
fn close_behavior_update(message: UiMessage, close_to_tray: Option<bool>) -> UiTaskUpdate {
UiTaskUpdate {
message,
accounts: None,
calendar_connections: None,
carddav_connections: None,
calendar_error: None,
account_removal: None,
clear_account_form: false,
finishes_oauth: false,
finishes_account_setup: false,
close_to_tray,
}
}
/// The "Start automatically" switch. The operating system's startup list is
/// read and written on the blocking pool; one lock and a request counter keep
/// overlapping toggles and refreshes from applying out of order.
#[cfg(desktop)]
#[derive(Clone)]
struct LaunchAtLogin {
supported: bool,
runtime: tokio::runtime::Handle,
lock: Arc<Mutex<()>>,
requests: Arc<std::sync::atomic::AtomicU64>,
}
#[cfg(desktop)]
impl LaunchAtLogin {
fn register(app: &AppWindow, runtime: &tokio::runtime::Handle) -> Self {
let this = Self {
supported: crate::autostart::supported(),
runtime: runtime.clone(),
lock: Arc::default(),
requests: Arc::default(),
};
app.set_launch_at_login_available(this.supported);
if !this.supported {
return this;
}
this.refresh(app, true);
let toggle = this.clone();
let app_weak = app.as_weak();
app.on_set_launch_at_login(move |enabled| {
if app_weak.upgrade().is_some() {
toggle.set(app_weak.clone(), enabled);
}
});
this
}
/// Read the entry into the switch. At startup, also re-point an entry
/// whose executable was moved or replaced.
fn refresh(&self, app: &AppWindow, repair: bool) {
if !self.supported {
return;
}
let this = self.clone();
let app = app.as_weak();
self.runtime.spawn_blocking(move || {
use std::sync::atomic::Ordering;
let _guard = this.lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let requests = this.requests.load(Ordering::SeqCst);
if repair && let Err(error) = crate::autostart::repair() {
eprintln!("could not update the sign-in entry: {error}");
}
match crate::autostart::is_enabled() {
// A toggle requested meanwhile reports its own result.
Ok(enabled) if this.requests.load(Ordering::SeqCst) == requests => {
let _ = app.upgrade_in_event_loop(move |app| app.set_launch_at_login(enabled));
}
Ok(_) => {}
Err(error) => eprintln!("could not read the sign-in entry: {error}"),
}
});
}
fn set(&self, app: slint::Weak<AppWindow>, enabled: bool) {
use std::sync::atomic::Ordering;
let request = self.requests.fetch_add(1, Ordering::SeqCst) + 1;
let this = self.clone();
self.runtime.spawn_blocking(move || {
let _guard = this.lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Only the latest request is applied; it supersedes this one.
if this.requests.load(Ordering::SeqCst) != request {
return;
}
use crate::autostart::Applied;
let result = crate::autostart::set_enabled(enabled).map_err(|error| error.to_string());
let actual = crate::autostart::is_enabled()
.unwrap_or(enabled == matches!(result, Ok(Applied::Done)));
let _ = app.upgrade_in_event_loop(move |app| {
app.set_launch_at_login(actual);
app.set_sync_status(match result {
Ok(Applied::Done) if enabled => {
UiMessage::plain("Flectar Mail will start when you sign in.")
}
Ok(Applied::Done) => UiMessage::plain(
"Flectar Mail will no longer start when you sign in.",
),
Ok(Applied::NeedsApproval) if cfg!(target_os = "windows") => UiMessage::plain(
"Turn on Flectar Mail under Startup apps in Windows Settings to finish.",
),
Ok(Applied::NeedsApproval) => UiMessage::plain(
"Allow Flectar Mail under Login Items in System Settings to finish.",
),
Err(error) => {
UiMessage::detail("Could not change startup behavior: {}", error)
}
});
});
});
}
}