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.
This commit is contained in:
Iuliia Ivashko
2026-02-16 14:05:42 +02:00
committed by GitHub
parent 243abe9d16
commit 12606b9e53
+203 -4
View File
@@ -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<String>,
}
#[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<HashMap<String, u64>>,
}
fn normalize_host_url(raw: &str) -> Option<String> {
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<DesktopWindowState> {
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::<serde_json::Value>(s).ok());
parsed
.as_ref()
.and_then(|v| v.get("desktopWindowState"))
.cloned()
.and_then(|v| serde_json::from_value::<DesktopWindowState>(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<DesktopWindowState> {
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::<WindowGeometryDebounceState>() 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::<WindowGeometryDebounceState>()
.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}");
}
});