diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4fe7f19c..eb7602fb 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6459,7 +6459,7 @@ dependencies = [ [[package]] name = "tabularis" -version = "0.18.0" +version = "0.19.0" dependencies = [ "aes-gcm", "argon2", diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 76b1d5ec..69509aa6 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -156,6 +156,18 @@ pub struct AppConfig { pub last_active_connection_id: Option, /// Ids of all connections that were open when the app was last closed. pub last_open_connection_ids: Option>, + + // ----- Window State ----- + /// Window width in pixels when the app was last closed. + pub window_width: Option, + /// Window height in pixels when the app was last closed. + pub window_height: Option, + /// Window X position when the app was last closed. + pub window_x: Option, + /// Window Y position when the app was last closed. + pub window_y: Option, + /// Whether the window was maximized when the app was last closed. + pub window_maximized: Option, } static CONFIG_CACHE: Lazy> = Lazy::new(|| RwLock::new(AppConfig::default())); @@ -458,6 +470,21 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> { if config.last_open_connection_ids.is_some() { existing_config.last_open_connection_ids = config.last_open_connection_ids; } + if config.window_width.is_some() { + existing_config.window_width = config.window_width; + } + if config.window_height.is_some() { + existing_config.window_height = config.window_height; + } + if config.window_x.is_some() { + existing_config.window_x = config.window_x; + } + if config.window_y.is_some() { + existing_config.window_y = config.window_y; + } + if config.window_maximized.is_some() { + existing_config.window_maximized = config.window_maximized; + } let content = serde_json::to_string_pretty(&existing_config).map_err(|e| e.to_string())?; fs::write(config_path, content).map_err(|e| e.to_string())?; @@ -849,6 +876,63 @@ pub fn save_config_json(app: AppHandle, json: String) -> Result<(), String> { } } +/// Returns true when running under a Wayland compositor. +pub fn is_wayland() -> bool { + // Wrap in a closure: passing `std::env::var` as a bare fn item fails + // the higher-ranked lifetime bound on the injected lookup. + is_wayland_with_env(|key| std::env::var(key)) +} + +/// Wayland detection with an injectable env lookup, so tests do not depend +/// on the host session (`WAYLAND_DISPLAY` may or may not be set on dev +/// machines and CI runners). +fn is_wayland_with_env(env_lookup: F) -> bool +where + F: FnOnce(&str) -> Result, +{ + env_lookup("WAYLAND_DISPLAY").is_ok() +} + +/// Save the main window's size and position to config. +/// +/// When `maximized` is true, only the maximized flag is persisted — size/position +/// are intentionally skipped because a maximized window's dimensions are the +/// screen size, not the pre-maximize geometry. Restoring from those dimensions +/// would open the window fullscreen on the next launch. +/// +/// On Wayland, position is always ignored (the compositor decides placement) and +/// size is skipped because `inner_size()` includes CSD margins, causing the +/// window to inflate by ~50×100 px each session. +pub fn save_window_state(app: &AppHandle, width: u32, height: u32, x: i32, y: i32, maximized: bool) -> Result<(), String> { + if let Some(config_dir) = get_config_dir(app) { + if !config_dir.exists() { + fs::create_dir_all(&config_dir).map_err(|e| e.to_string())?; + } + let config_path = config_dir.join("config.json"); + let mut config = load_config_internal(app); + // Only persist size/position when the window is not maximized. + if !maximized { + config.window_maximized = Some(false); + // On Wayland skip position (compositor-controlled) and size + // (GTK includes CSD margins in inner_size, inflating the window). + if !is_wayland() { + config.window_width = Some(width); + config.window_height = Some(height); + config.window_x = Some(x); + config.window_y = Some(y); + } + } else { + config.window_maximized = Some(true); + } + let content = serde_json::to_string_pretty(&config).map_err(|e| e.to_string())?; + fs::write(config_path, content).map_err(|e| e.to_string())?; + cache_config(&config); + Ok(()) + } else { + Err("Could not resolve config directory".to_string()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1104,4 +1188,70 @@ mod tests { // path is exercised indirectly via parse failures + missing file). let _ = load_config_from_disk(); } + + #[test] + fn window_state_fields_default_to_none() { + let config = AppConfig::default(); + assert!(config.window_width.is_none()); + assert!(config.window_height.is_none()); + assert!(config.window_x.is_none()); + assert!(config.window_y.is_none()); + assert!(config.window_maximized.is_none()); + } + + #[test] + fn window_state_fields_serialize_with_camel_case() { + let mut config = AppConfig::default(); + config.window_width = Some(1920); + config.window_height = Some(1080); + config.window_x = Some(100); + config.window_y = Some(200); + config.window_maximized = Some(true); + + let json = serde_json::to_string(&config).unwrap(); + assert!(json.contains("windowWidth")); + assert!(json.contains("windowHeight")); + assert!(json.contains("windowX")); + assert!(json.contains("windowY")); + assert!(json.contains("windowMaximized")); + // snake_case must not appear in JSON + assert!(!json.contains("window_width")); + assert!(!json.contains("window_maximized")); + } + + #[test] + fn window_state_fields_round_trip() { + let json = r#"{ + "windowWidth": 1920, + "windowHeight": 1080, + "windowX": 100, + "windowY": 200, + "windowMaximized": true + }"#; + + let config: AppConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.window_width, Some(1920)); + assert_eq!(config.window_height, Some(1080)); + assert_eq!(config.window_x, Some(100)); + assert_eq!(config.window_y, Some(200)); + assert_eq!(config.window_maximized, Some(true)); + + // Re-serialize and parse again + let serialized = serde_json::to_string(&config).unwrap(); + let reparsed: AppConfig = serde_json::from_str(&serialized).unwrap(); + assert_eq!(reparsed.window_width, Some(1920)); + assert_eq!(reparsed.window_height, Some(1080)); + assert_eq!(reparsed.window_x, Some(100)); + assert_eq!(reparsed.window_y, Some(200)); + assert_eq!(reparsed.window_maximized, Some(true)); + } + + #[test] + fn is_wayland_detects_wayland_display_from_env_lookup() { + // Deterministic regardless of the host session: the env lookup is + // injected, so the test never assumes WAYLAND_DISPLAY is (or isn't) + // set on the machine running it. + assert!(is_wayland_with_env(|_| Ok("wayland-0".to_string()))); + assert!(!is_wayland_with_env(|_| Err(std::env::VarError::NotPresent))); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 99d5eac3..77c8fb75 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -325,16 +325,51 @@ pub fn run() { // queries instead of waiting for the full approval timeout. heartbeat::spawn(); - // Maximize the window on startup if the user enabled it. - if crate::config::load_config_internal(&app.handle()) - .start_maximized - .unwrap_or(false) - { - if let Some(window) = app.get_webview_window("main") { + // Restore window size and position if saved + let config = crate::config::load_config_internal(&app.handle()); + if let Some(window) = app.get_webview_window("main") { + // Restore position and size first + if let (Some(x), Some(y), Some(width), Some(height)) = ( + config.window_x, + config.window_y, + config.window_width, + config.window_height, + ) { + if let Err(e) = window.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x, y })) { + log::warn!("Failed to restore window position: {e}"); + } + if let Err(e) = window.set_size(tauri::Size::Physical(tauri::PhysicalSize { width, height })) { + log::warn!("Failed to restore window size: {e}"); + } + } + + // Then maximize if needed + if config.window_maximized.unwrap_or(false) || config.start_maximized.unwrap_or(false) { if let Err(e) = window.maximize() { log::warn!("Failed to maximize window on startup: {e}"); } } + + // Save window state on close + let close_app = app.handle().clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + let window = close_app.get_webview_window("main"); + if let Some(window) = window { + let maximized = window.is_maximized().unwrap_or(false); + if let (Ok(pos), Ok(size)) = (window.outer_position(), window.inner_size()) { + let _ = crate::config::save_window_state( + &close_app, + size.width, + size.height, + pos.x, + pos.y, + maximized, + ); + } + } + } + }); } // Open devtools automatically in debug mode