Custom Tauri window state plugin (#495)

This commit is contained in:
Gregory Schier
2026-07-03 10:11:20 -07:00
committed by GitHub
parent 5db2008fae
commit 0497a54928
8 changed files with 252 additions and 37 deletions
Generated
+2 -16
View File
@@ -7863,21 +7863,6 @@ dependencies = [
"zip",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.0",
"log 0.4.29",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-runtime"
version = "2.11.1"
@@ -10105,7 +10090,6 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"thiserror 2.0.17",
"tokio",
"tokio-stream",
@@ -10532,6 +10516,8 @@ dependencies = [
"log 0.4.29",
"md5 0.8.0",
"rand 0.9.1",
"serde",
"serde_json",
"tauri",
"tokio",
]
-1
View File
@@ -59,7 +59,6 @@ tauri-plugin-os = "2.3.2"
tauri-plugin-shell = { workspace = true }
tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }
tauri-plugin-updater = "2.10.1"
tauri-plugin-window-state = "2.4.1"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-stream = "0.1.17"
-15
View File
@@ -26,7 +26,6 @@ use tauri::{Manager, WindowEvent};
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::fern::colors::ColoredLevelConfig;
use tauri_plugin_log::{Builder, Target, TargetKind, log};
use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
@@ -1677,13 +1676,6 @@ pub fn run() {
builder = builder
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_opener::init())
// Don't restore StateFlags::DECORATIONS because we want to be able to toggle them on/off on a restart
// We could* make this work if we toggled them in the frontend before the window closes, but, this is nicer.
.plugin(
tauri_plugin_window_state::Builder::new()
.with_state_flags(StateFlags::all() - StateFlags::DECORATIONS)
.build(),
)
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
@@ -1959,13 +1951,6 @@ pub fn run() {
}
});
}
RunEvent::WindowEvent { event: WindowEvent::CloseRequested { .. }, .. } => {
if let Err(e) = app_handle.save_window_state(StateFlags::all()) {
warn!("Failed to save window state {e:?}");
} else {
info!("Saved window state");
};
}
_ => {}
};
});
+1 -1
View File
@@ -92,7 +92,7 @@ pub fn run() {
label: "main_0",
title: "Yaak Proxy",
inner_size: Some((1000.0, 700.0)),
visible: false,
hidden: true,
hide_titlebar: true,
..Default::default()
};
+2
View File
@@ -8,5 +8,7 @@ publish = false
log = { workspace = true }
md5 = "0.8.0"
rand = "0.9.0"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tauri = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
+2
View File
@@ -1 +1,3 @@
mod window_state;
pub mod window;
+27 -4
View File
@@ -1,3 +1,4 @@
use crate::window_state;
use log::info;
use rand::random;
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WindowEvent};
@@ -11,18 +12,21 @@ const MIN_WINDOW_HEIGHT: f64 = 300.0;
pub const MAIN_WINDOW_PREFIX: &str = "main_";
const OTHER_WINDOW_PREFIX: &str = "other_";
const MAIN_WINDOW_STATE_KEY: &str = "main";
#[derive(Default, Debug)]
pub struct CreateWindowConfig<'s> {
pub url: &'s str,
pub label: &'s str,
pub title: &'s str,
pub state_key: Option<String>,
pub inner_size: Option<(f64, f64)>,
pub position: Option<(f64, f64)>,
pub restore_position: Option<bool>,
pub navigation_tx: Option<mpsc::Sender<String>>,
pub close_tx: Option<mpsc::Sender<()>>,
pub data_dir_key: Option<String>,
pub visible: bool,
pub hidden: bool,
pub hide_titlebar: bool,
pub use_native_titlebar: bool,
}
@@ -32,13 +36,27 @@ pub fn create_window<R: Runtime>(
config: CreateWindowConfig,
) -> tauri::Result<WebviewWindow<R>> {
info!("Create new window label={}", config.label);
let state_key = config.state_key.clone().unwrap_or_else(|| config.label.to_string());
let restore_position = config.restore_position.unwrap_or(true);
let mut inner_size = config.inner_size;
let mut position = config.position;
let mut maximized = false;
window_state::apply_saved_state(
handle,
&state_key,
&mut inner_size,
&mut position,
&mut maximized,
restore_position,
);
let mut win_builder =
tauri::WebviewWindowBuilder::new(handle, config.label, WebviewUrl::App(config.url.into()))
.title(config.title)
.resizable(true)
.visible(config.visible)
.visible(!config.hidden)
.fullscreen(false)
.maximized(maximized)
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
if let Some(key) = config.data_dir_key {
@@ -61,13 +79,13 @@ pub fn create_window<R: Runtime>(
}
}
if let Some((w, h)) = config.inner_size {
if let Some((w, h)) = inner_size {
win_builder = win_builder.inner_size(w, h);
} else {
win_builder = win_builder.inner_size(600.0, 600.0);
}
if let Some((x, y)) = config.position {
if let Some((x, y)) = position {
win_builder = win_builder.position(x, y);
} else {
win_builder = win_builder.center();
@@ -103,6 +121,7 @@ pub fn create_window<R: Runtime>(
}
let win = win_builder.build()?;
window_state::track_window(&win, &state_key);
if let Some(tx) = config.close_tx {
win.on_window_event(move |event| match event {
@@ -138,12 +157,14 @@ pub fn create_main_window(
url,
label: label.as_str(),
title: "Yaak",
state_key: Some(MAIN_WINDOW_STATE_KEY.to_string()),
inner_size: Some((DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)),
position: Some((
// Offset by random amount so it's easier to differentiate
100.0 + random::<f64>() * 20.0,
100.0 + random::<f64>() * 20.0,
)),
restore_position: Some(counter == 0),
hide_titlebar: true,
use_native_titlebar,
..Default::default()
@@ -161,6 +182,7 @@ pub fn create_child_window(
use_native_titlebar: bool,
) -> tauri::Result<WebviewWindow> {
let app_handle = parent_window.app_handle();
let state_key = label.to_string();
let label = format!("{OTHER_WINDOW_PREFIX}_{label}");
let scale_factor = parent_window.scale_factor()?;
@@ -176,6 +198,7 @@ pub fn create_child_window(
let config = CreateWindowConfig {
label: label.as_str(),
title,
state_key: Some(state_key),
url,
inner_size: Some(inner_size),
position: Some(position),
@@ -0,0 +1,218 @@
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::{AppHandle, Manager, Monitor, Runtime, WebviewWindow, WindowEvent};
const WINDOW_STATE_FILE: &str = "window-state.json";
const SAVE_DEBOUNCE: Duration = Duration::from_millis(1000);
static WINDOW_STATE_FILE_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
struct WindowState {
width: f64,
height: f64,
x: f64,
y: f64,
maximized: bool,
}
impl WindowState {
fn has_size(self) -> bool {
self.width > 0.0 && self.height > 0.0
}
fn has_position(self) -> bool {
self.x.is_finite() && self.y.is_finite()
}
}
pub fn apply_saved_state<R: Runtime>(
app_handle: &AppHandle<R>,
state_key: &str,
inner_size: &mut Option<(f64, f64)>,
position: &mut Option<(f64, f64)>,
maximized: &mut bool,
restore_position: bool,
) {
let Some(state) = read_window_state(app_handle, state_key) else {
debug!("No saved window state for {state_key}");
return;
};
debug!(
"Applying saved window state for {state_key}: width={} height={} x={} y={} maximized={} restore_position={restore_position}",
state.width, state.height, state.x, state.y, state.maximized
);
if state.has_size() {
*inner_size = Some((state.width, state.height));
}
if restore_position && state.has_position() {
if is_position_visible(app_handle, state) {
*position = Some((state.x, state.y));
} else {
debug!("Ignoring saved window position for {state_key} because it is off-screen");
}
}
*maximized = state.maximized;
}
pub fn track_window<R: Runtime>(window: &WebviewWindow<R>, state_key: &str) {
let state_key = state_key.to_string();
let save_generation = Arc::new(AtomicU64::new(0));
let tracked_window = window.clone();
window.clone().on_window_event(move |event| match event {
WindowEvent::Moved(_) | WindowEvent::Resized(_) => {
schedule_save(tracked_window.clone(), state_key.clone(), save_generation.clone());
}
WindowEvent::CloseRequested { .. } => {
save_generation.fetch_add(1, Ordering::Relaxed);
if let Err(e) = save_window_state(&tracked_window, &state_key) {
warn!("Failed to save window state for {state_key}: {e}");
}
}
_ => {}
});
}
fn schedule_save<R: Runtime>(
window: WebviewWindow<R>,
state_key: String,
save_generation: Arc<AtomicU64>,
) {
let generation = save_generation.fetch_add(1, Ordering::Relaxed) + 1;
let window_for_dispatch = window.clone();
std::thread::spawn(move || {
std::thread::sleep(SAVE_DEBOUNCE);
if save_generation.load(Ordering::Relaxed) != generation {
return;
}
let state_key_for_save = state_key.clone();
let window_for_save = window.clone();
if let Err(e) = window_for_dispatch.run_on_main_thread(move || {
if let Err(e) = save_window_state(&window_for_save, &state_key_for_save) {
warn!("Failed to save window state for {state_key_for_save}: {e}");
}
}) {
debug!("Failed to dispatch debounced window state save for {state_key}: {e}");
}
});
}
fn save_window_state<R: Runtime>(window: &WebviewWindow<R>, state_key: &str) -> tauri::Result<()> {
let app_handle = window.app_handle();
let state_path = window_state_path(&app_handle)?;
let _lock = WINDOW_STATE_FILE_LOCK.lock().unwrap();
let mut states = read_window_states(&state_path);
let mut state = states.get(state_key).copied().unwrap_or_default();
let maximized = window.is_maximized().unwrap_or(false);
let minimized = window.is_minimized().unwrap_or(false);
let scale_factor = window.scale_factor().unwrap_or(1.0);
if !minimized && (!maximized || !state.has_size()) {
let size = window.inner_size()?.to_logical::<f64>(scale_factor);
if size.width > 0.0 && size.height > 0.0 {
state.width = size.width;
state.height = size.height;
}
}
if !minimized && (!maximized || !state.has_position()) {
let position = window.outer_position()?.to_logical::<f64>(scale_factor);
state.x = position.x;
state.y = position.y;
}
state.maximized = maximized;
states.insert(state_key.to_string(), state);
write_window_states(&state_path, &states)?;
debug!(
"Saved window state for {state_key} to {}: width={} height={} x={} y={} maximized={} minimized={minimized}",
state_path.display(),
state.width,
state.height,
state.x,
state.y,
state.maximized
);
Ok(())
}
fn read_window_state<R: Runtime>(
app_handle: &AppHandle<R>,
state_key: &str,
) -> Option<WindowState> {
let state_path = window_state_path(app_handle).ok()?;
debug!("Reading window state for {state_key} from {}", state_path.display());
read_window_states(&state_path).get(state_key).copied()
}
fn window_state_path<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<PathBuf> {
Ok(app_handle.path().app_config_dir()?.join(WINDOW_STATE_FILE))
}
fn read_window_states(state_path: &PathBuf) -> HashMap<String, WindowState> {
let Ok(bytes) = fs::read(state_path) else {
return HashMap::new();
};
match serde_json::from_slice(&bytes) {
Ok(states) => states,
Err(e) => {
warn!("Failed to read window state {}: {e}", state_path.display());
HashMap::new()
}
}
}
fn write_window_states(
state_path: &PathBuf,
states: &HashMap<String, WindowState>,
) -> tauri::Result<()> {
if let Some(parent) = state_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(state_path, serde_json::to_vec_pretty(states)?)?;
Ok(())
}
fn is_position_visible<R: Runtime>(app_handle: &AppHandle<R>, state: WindowState) -> bool {
let Ok(monitors) = app_handle.available_monitors() else {
return true;
};
monitors.into_iter().any(|monitor| monitor_intersects_window(&monitor, state))
}
fn monitor_intersects_window(monitor: &Monitor, state: WindowState) -> bool {
let scale_factor = monitor.scale_factor();
let position = monitor.position().to_logical::<f64>(scale_factor);
let size = monitor.size().to_logical::<f64>(scale_factor);
let left = position.x;
let right = position.x + size.width;
let top = position.y;
let bottom = position.y + size.height;
[
(state.x, state.y),
(state.x + state.width, state.y),
(state.x, state.y + state.height),
(state.x + state.width, state.y + state.height),
]
.into_iter()
.any(|(x, y)| x >= left && x < right && y >= top && y < bottom)
}