feat: implement debounced window state saving for shutdown resilience

- Add create_debounced_window_state_saver() function in window.rs
- Extract WINDOW_STATE_FLAGS_TO_SAVE and WINDOW_STATE_SAVE_TIMEOUT constants
- Listen to tauri://move and tauri://resize events with 5-second debouncing
- Automatically save window state after positioning changes settle
- Prevent window position loss during system shutdowns/restarts

The debouncing mechanism avoids excessive disk I/O during active window
manipulation while ensuring recent position changes are preserved even
when the application doesn't close normally.

Fixes window position being lost when system restarts before user
manually closes the application.
https://github.com/safing/portmaster/issues/2011
This commit is contained in:
Alexandr Stelnykovych
2025-09-04 23:36:01 +03:00
parent 74f549e562
commit 8dbd61215b
2 changed files with 74 additions and 3 deletions

View File

@@ -24,14 +24,22 @@ use log::{debug, error, info};
use portmaster::PortmasterExt;
use tauri_plugin_log::RotationStrategy;
use traymenu::setup_tray_menu;
use window::{close_splash_window, create_main_window, hide_splash_window};
use tauri_plugin_window_state::StateFlags;
use window::{close_splash_window, create_main_window, hide_splash_window, create_debounced_window_state_saver};
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
#[macro_use]
extern crate lazy_static;
const FALLBACK_TO_OLD_UI_EXIT_CODE: i32 = 77;
// Window state configuration: save all properties except visibility to avoid
// interference with the "--background" command line argument
const WINDOW_STATE_FLAGS_TO_SAVE: StateFlags = StateFlags::from_bits_truncate(
StateFlags::all().bits() & !StateFlags::VISIBLE.bits()
);
// Default timeout for debounced window state saving
const WINDOW_STATE_SAVE_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Clone, serde::Serialize)]
struct Payload {
args: Vec<String>,
@@ -177,7 +185,7 @@ fn main() {
// Initialize save windows state plugin.
.plugin(tauri_plugin_window_state::Builder::default()
// Don't save visibility state, so it will not interfere with "--background" command line argument
.with_state_flags(StateFlags::all() & !StateFlags::VISIBLE)
.with_state_flags(WINDOW_STATE_FLAGS_TO_SAVE)
// Don't save splash window state
.with_denylist(&["splash",])
.build())
@@ -228,6 +236,15 @@ fn main() {
// register the custom handler
app.portmaster().register_handler(handler);
// Setup window state saving on move/resize events with debouncing
let save_trigger = create_debounced_window_state_saver(app,
WINDOW_STATE_FLAGS_TO_SAVE,
WINDOW_STATE_SAVE_TIMEOUT);
let tx_move = save_trigger.clone();
let tx_resize = save_trigger;
app.listen_any("tauri://move", move |_event| { let _ = tx_move.try_send(()); });
app.listen_any("tauri://resize", move |_event| { let _ = tx_resize.try_send(()); });
Ok(())
})
.any_thread()
@@ -255,6 +272,10 @@ fn main() {
label
);
// Manually save the window state on close attempt.
// This ensures the state is saved since we prevent the close event.
let _ = handle.save_window_state(WINDOW_STATE_FLAGS_TO_SAVE);
api.prevent_close();
if let Some(window) = handle.get_webview_window(label.as_str()) {
let result = window.emit("exit-requested", "");

View File

@@ -4,6 +4,7 @@ use tauri::{
WebviewWindow, WebviewWindowBuilder,
};
use std::sync::{atomic::{AtomicBool, Ordering}};
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use crate::{portmaster::PortmasterExt, traymenu};
@@ -262,3 +263,52 @@ pub fn may_navigate_to_ui(win: &mut WebviewWindow, force: bool) {
);
}
}
/// Creates a debounced window state saver that waits for a quiet period before saving.
///
/// Returns a sender that can be used to trigger save events. Multiple rapid events
/// will be debounced - only saving after the specified timeout with no new events.
///
/// # Example
/// ```rust
/// let save_trigger = create_debounced_window_state_saver(app, state_flags, Duration::from_secs(5));
/// let _ = save_trigger.try_send(()); // Trigger a save (will be debounced)
/// ```
pub fn create_debounced_window_state_saver(
app: &tauri::App,
state_flags: StateFlags,
debounce_timeout: std::time::Duration,
) -> tokio::sync::mpsc::Sender<()> {
let app_handle = app.handle().clone();
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(10);
// Spawn debouncer task - saves state after the specified timeout following the last event
tauri::async_runtime::spawn(async move {
loop {
if rx.recv().await.is_none() {
break; // Channel closed
}
loop {
match tokio::time::timeout(debounce_timeout, rx.recv()).await {
Ok(Some(_)) => {
continue; // Received another event within timeout period, restart the timer
}
Ok(None) => {
return; // Channel closed
}
Err(_) => {
// Timeout: specified duration passed without new events, save state
if let Err(e) = app_handle.save_window_state(state_flags) {
debug!("Failed to save window state: {}", e);
}
break; // Exit inner loop and wait for next event
}
}
}
}
});
tx
}