From 12606b9e53ff55a8ed98918363880f8d78ca1744 Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Mon, 16 Feb 2026 14:05:42 +0200 Subject: [PATCH] feat(desktop): persist main window geometry across restarts (#432) Save and restore main-window bounds/state in desktop settings so reopen behavior is consistent, while keeping new windows on defaults. Add debounce, off-screen fallback, and minimum-size guards to prevent unusable or stale geometry restores. --- packages/desktop/src-tauri/src/main.rs | 207 ++++++++++++++++++++++++- 1 file changed, 203 insertions(+), 4 deletions(-) diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index f55d3055..138633ce 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -930,6 +930,11 @@ const HEALTH_TIMEOUT: Duration = Duration::from_secs(20); const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(250); const DEFAULT_DESKTOP_PORT: u16 = 57123; +const WINDOW_STATE_DEBOUNCE_MS: u64 = 300; +const MIN_WINDOW_WIDTH: u32 = 800; +const MIN_WINDOW_HEIGHT: u32 = 520; +const MIN_RESTORE_WINDOW_WIDTH: u32 = 900; +const MIN_RESTORE_WINDOW_HEIGHT: u32 = 560; const LOCAL_HOST_ID: &str = "local"; @@ -1004,6 +1009,24 @@ struct DesktopHostsConfig { default_host_id: Option, } +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DesktopWindowState { + x: i32, + y: i32, + width: u32, + height: u32, + #[serde(default)] + maximized: bool, + #[serde(default)] + fullscreen: bool, +} + +#[derive(Default)] +struct WindowGeometryDebounceState { + revisions: Mutex>, +} + fn normalize_host_url(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -1117,6 +1140,41 @@ fn read_desktop_hosts_config_from_disk() -> DesktopHostsConfig { } } +fn read_desktop_window_state_from_disk() -> Option { + let path = settings_file_path(); + let raw = fs::read_to_string(path).ok(); + let parsed = raw + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()); + + parsed + .as_ref() + .and_then(|v| v.get("desktopWindowState")) + .cloned() + .and_then(|v| serde_json::from_value::(v).ok()) +} + +fn write_desktop_window_state_to_disk(state: &DesktopWindowState) -> Result<()> { + let path = settings_file_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let mut root: serde_json::Value = if let Ok(raw) = fs::read_to_string(&path) { + serde_json::from_str(&raw).unwrap_or(serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + if !root.is_object() { + root = serde_json::json!({}); + } + + root["desktopWindowState"] = serde_json::to_value(state).unwrap_or(serde_json::Value::Null); + fs::write(&path, serde_json::to_string_pretty(&root)?)?; + Ok(()) +} + fn write_desktop_hosts_config_to_disk(config: &DesktopHostsConfig) -> Result<()> { let path = settings_file_path(); if let Some(parent) = path.parent() { @@ -1840,7 +1898,7 @@ fn desktop_new_window_at_url(app: tauri::AppHandle, url: String) -> Result<(), S .and_then(|state| state.local_origin.lock().expect("desktop local origin mutex").clone()) .ok_or_else(|| "Local origin not yet known (sidecar may still be starting)".to_string())?; - create_window(&app, &url, &local_origin).map_err(|e| e.to_string()) + create_window(&app, &url, &local_origin, false).map_err(|e| e.to_string()) } /// Read a file and return its content as base64 with mime type detection. @@ -1967,8 +2025,114 @@ fn build_init_script(local_origin: &str) -> String { init_script } +fn is_window_state_visible(app: &tauri::AppHandle, state: &DesktopWindowState) -> bool { + if state.width == 0 || state.height == 0 { + return false; + } + + let Ok(monitors) = app.available_monitors() else { + return true; + }; + if monitors.is_empty() { + return true; + } + + let left = state.x as f64; + let top = state.y as f64; + let right = left + state.width as f64; + let bottom = top + state.height as f64; + + for monitor in monitors { + let scale = monitor.scale_factor(); + if !scale.is_finite() || scale <= 0.0 { + continue; + } + + let position = monitor.position(); + let size = monitor.size(); + + let monitor_left = position.x as f64 / scale; + let monitor_top = position.y as f64 / scale; + let monitor_right = monitor_left + size.width as f64 / scale; + let monitor_bottom = monitor_top + size.height as f64 / scale; + + let overlap_width = right.min(monitor_right) - left.max(monitor_left); + let overlap_height = bottom.min(monitor_bottom) - top.max(monitor_top); + if overlap_width > 0.0 && overlap_height > 0.0 { + return true; + } + } + + false +} + +fn capture_window_state(window: &tauri::Window) -> Option { + let position = window.outer_position().ok()?; + let size = window.inner_size().ok()?; + let scale = window + .scale_factor() + .ok() + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(1.0); + + Some(DesktopWindowState { + x: (position.x as f64 / scale).round() as i32, + y: (position.y as f64 / scale).round() as i32, + width: (size.width as f64 / scale).round().max(MIN_WINDOW_WIDTH as f64) as u32, + height: (size.height as f64 / scale).round().max(MIN_WINDOW_HEIGHT as f64) as u32, + maximized: window.is_maximized().unwrap_or(false), + fullscreen: window.is_fullscreen().unwrap_or(false), + }) +} + +fn schedule_window_state_persist(window: tauri::Window, immediate: bool) { + if window.label() != "main" { + return; + } + + let app = window.app_handle().clone(); + let label = window.label().to_string(); + let revision = { + let Some(state) = app.try_state::() else { + return; + }; + let mut guard = state.revisions.lock().expect("window geometry debounce mutex"); + let next = guard.get(&label).copied().unwrap_or(0).saturating_add(1); + guard.insert(label.clone(), next); + next + }; + + tauri::async_runtime::spawn(async move { + if !immediate { + tokio::time::sleep(Duration::from_millis(WINDOW_STATE_DEBOUNCE_MS)).await; + } + + let is_latest = app + .try_state::() + .map(|state| { + state + .revisions + .lock() + .map(|guard| guard.get(&label).copied() == Some(revision)) + .unwrap_or(false) + }) + .unwrap_or(false); + if !is_latest { + return; + } + + let Some(snapshot) = capture_window_state(&window) else { + return; + }; + + if let Err(err) = write_desktop_window_state_to_disk(&snapshot) { + log::warn!("[desktop] failed to persist window geometry: {err}"); + } + }); +} + /// Create a new window with a unique label, pointing at the given URL. -fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str) -> Result<()> { +fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str, restore_geometry: bool) -> Result<()> { let parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?; let label = next_window_label(); @@ -1980,15 +2144,35 @@ fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str) -> Resul *state.local_origin.lock().expect("desktop local origin mutex") = Some(local_origin.to_string()); } + let restored_state = if restore_geometry { + read_desktop_window_state_from_disk() + } else { + None + }; + let mut builder = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(parsed)) .title("OpenChamber") .inner_size(1280.0, 800.0) + .min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64) .decorations(true) .visible(false) .initialization_script(&init_script) .background_throttling(BackgroundThrottlingPolicy::Disabled) ; + let apply_restored_state = restored_state + .as_ref() + .map(|state| is_window_state_visible(app, state)) + .unwrap_or(false); + + if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { + let restored_width = state.width.max(MIN_RESTORE_WINDOW_WIDTH); + let restored_height = state.height.max(MIN_RESTORE_WINDOW_HEIGHT); + builder = builder + .inner_size(restored_width as f64, restored_height as f64) + .position(state.x as f64, state.y as f64); + } + #[cfg(target_os = "macos")] { builder = builder @@ -1999,6 +2183,12 @@ fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str) -> Resul let window = builder.build()?; + if let Some(state) = restored_state.as_ref().filter(|_| apply_restored_state) { + if state.maximized || state.fullscreen { + let _ = window.maximize(); + } + } + let _ = window.show(); let _ = window.set_focus(); @@ -2072,7 +2262,7 @@ fn open_new_window(app: &tauri::AppHandle) { } } - if let Err(err) = create_window(app, &target_url, &local_origin) { + if let Err(err) = create_window(app, &target_url, &local_origin, false) { log::error!("[desktop] failed to create new window: {err}"); } } @@ -2090,6 +2280,7 @@ fn main() { .manage(SidecarState::default()) .manage(DesktopUiInjectionState::default()) .manage(WindowFocusState::default()) + .manage(WindowGeometryDebounceState::default()) .manage(MenuRuntimeState::default()) .manage(PendingUpdate(Mutex::new(None))) .plugin(tauri_plugin_shell::init()) @@ -2265,6 +2456,14 @@ fn main() { app.exit(0); } } + + if matches!(event, tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_)) { + schedule_window_state_persist(window.clone(), false); + } + + if matches!(event, tauri::WindowEvent::CloseRequested { .. }) { + schedule_window_state_persist(window.clone(), true); + } }) .invoke_handler(tauri::generate_handler![ desktop_notify, @@ -2381,7 +2580,7 @@ fn main() { } } - if let Err(err) = create_window(&handle, &initial_url, &local_origin) { + if let Err(err) = create_window(&handle, &initial_url, &local_origin, true) { log::error!("[desktop] failed to create window: {err}"); } });