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:
@@ -930,6 +930,11 @@ const HEALTH_TIMEOUT: Duration = Duration::from_secs(20);
|
|||||||
const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
const DEFAULT_DESKTOP_PORT: u16 = 57123;
|
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";
|
const LOCAL_HOST_ID: &str = "local";
|
||||||
|
|
||||||
@@ -1004,6 +1009,24 @@ struct DesktopHostsConfig {
|
|||||||
default_host_id: Option<String>,
|
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> {
|
fn normalize_host_url(raw: &str) -> Option<String> {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if trimmed.is_empty() {
|
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<()> {
|
fn write_desktop_hosts_config_to_disk(config: &DesktopHostsConfig) -> Result<()> {
|
||||||
let path = settings_file_path();
|
let path = settings_file_path();
|
||||||
if let Some(parent) = path.parent() {
|
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())
|
.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())?;
|
.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.
|
/// 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
|
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.
|
/// 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 parsed = url::Url::parse(url).map_err(|err| anyhow!("Invalid URL: {err}"))?;
|
||||||
let label = next_window_label();
|
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());
|
*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))
|
let mut builder = WebviewWindowBuilder::new(app, &label, WebviewUrl::External(parsed))
|
||||||
.title("OpenChamber")
|
.title("OpenChamber")
|
||||||
.inner_size(1280.0, 800.0)
|
.inner_size(1280.0, 800.0)
|
||||||
|
.min_inner_size(MIN_WINDOW_WIDTH as f64, MIN_WINDOW_HEIGHT as f64)
|
||||||
.decorations(true)
|
.decorations(true)
|
||||||
.visible(false)
|
.visible(false)
|
||||||
.initialization_script(&init_script)
|
.initialization_script(&init_script)
|
||||||
.background_throttling(BackgroundThrottlingPolicy::Disabled)
|
.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")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
builder = builder
|
builder = builder
|
||||||
@@ -1999,6 +2183,12 @@ fn create_window(app: &tauri::AppHandle, url: &str, local_origin: &str) -> Resul
|
|||||||
|
|
||||||
let window = builder.build()?;
|
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.show();
|
||||||
let _ = window.set_focus();
|
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}");
|
log::error!("[desktop] failed to create new window: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2090,6 +2280,7 @@ fn main() {
|
|||||||
.manage(SidecarState::default())
|
.manage(SidecarState::default())
|
||||||
.manage(DesktopUiInjectionState::default())
|
.manage(DesktopUiInjectionState::default())
|
||||||
.manage(WindowFocusState::default())
|
.manage(WindowFocusState::default())
|
||||||
|
.manage(WindowGeometryDebounceState::default())
|
||||||
.manage(MenuRuntimeState::default())
|
.manage(MenuRuntimeState::default())
|
||||||
.manage(PendingUpdate(Mutex::new(None)))
|
.manage(PendingUpdate(Mutex::new(None)))
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
@@ -2265,6 +2456,14 @@ fn main() {
|
|||||||
app.exit(0);
|
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![
|
.invoke_handler(tauri::generate_handler![
|
||||||
desktop_notify,
|
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}");
|
log::error!("[desktop] failed to create window: {err}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user