diff --git a/Cargo.lock b/Cargo.lock index 380679e4..81129d72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml index 177fff84..e048c4bb 100644 --- a/crates-tauri/yaak-app-client/Cargo.toml +++ b/crates-tauri/yaak-app-client/Cargo.toml @@ -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" diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 420c60f5..50962f9e 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -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"); - }; - } _ => {} }; }); diff --git a/crates-tauri/yaak-app-proxy/src/lib.rs b/crates-tauri/yaak-app-proxy/src/lib.rs index b66a78a4..80a25e49 100644 --- a/crates-tauri/yaak-app-proxy/src/lib.rs +++ b/crates-tauri/yaak-app-proxy/src/lib.rs @@ -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() }; diff --git a/crates-tauri/yaak-window/Cargo.toml b/crates-tauri/yaak-window/Cargo.toml index 9dee5062..36cd25cd 100644 --- a/crates-tauri/yaak-window/Cargo.toml +++ b/crates-tauri/yaak-window/Cargo.toml @@ -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"] } diff --git a/crates-tauri/yaak-window/src/lib.rs b/crates-tauri/yaak-window/src/lib.rs index 61b63f17..9dd96822 100644 --- a/crates-tauri/yaak-window/src/lib.rs +++ b/crates-tauri/yaak-window/src/lib.rs @@ -1 +1,3 @@ +mod window_state; + pub mod window; diff --git a/crates-tauri/yaak-window/src/window.rs b/crates-tauri/yaak-window/src/window.rs index 950ed3c7..08fbd6d4 100644 --- a/crates-tauri/yaak-window/src/window.rs +++ b/crates-tauri/yaak-window/src/window.rs @@ -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, pub inner_size: Option<(f64, f64)>, pub position: Option<(f64, f64)>, + pub restore_position: Option, pub navigation_tx: Option>, pub close_tx: Option>, pub data_dir_key: Option, - pub visible: bool, + pub hidden: bool, pub hide_titlebar: bool, pub use_native_titlebar: bool, } @@ -32,13 +36,27 @@ pub fn create_window( config: CreateWindowConfig, ) -> tauri::Result> { 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( } } - 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( } 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::() * 20.0, 100.0 + random::() * 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 { 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), diff --git a/crates-tauri/yaak-window/src/window_state.rs b/crates-tauri/yaak-window/src/window_state.rs new file mode 100644 index 00000000..682a9d57 --- /dev/null +++ b/crates-tauri/yaak-window/src/window_state.rs @@ -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( + app_handle: &AppHandle, + 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(window: &WebviewWindow, 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( + window: WebviewWindow, + state_key: String, + save_generation: Arc, +) { + 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(window: &WebviewWindow, 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::(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::(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( + app_handle: &AppHandle, + state_key: &str, +) -> Option { + 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(app_handle: &AppHandle) -> tauri::Result { + Ok(app_handle.path().app_config_dir()?.join(WINDOW_STATE_FILE)) +} + +fn read_window_states(state_path: &PathBuf) -> HashMap { + 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, +) -> 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(app_handle: &AppHandle, 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::(scale_factor); + let size = monitor.size().to_logical::(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) +}