From 85692f1c1c0c19792b27f9e41d603bff776342ff Mon Sep 17 00:00:00 2001 From: oobabooga Date: Tue, 2 Jun 2026 13:26:15 -0300 Subject: [PATCH] Studio: persist Tauri window size and maximized state across launches (#5799) * Studio: persist Tauri window size and maximized state across launches * Studio: keep window state under the app home dir, not ~/.config * Undo an unnecessary change * Address Gemini's feedback * Revert to tauri-plugin-window-state implementation * fix(Studio): restore saved window size before default layout * Fix cross-platform window-state restore * fix(Studio): avoid clobbering saved window size --------- Co-authored-by: Wasim Yousef Said Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> --- studio/frontend/package-lock.json | 10 ++++ studio/frontend/package.json | 1 + studio/frontend/src/app/provider.tsx | 62 +++++++++++++++------- studio/src-tauri/Cargo.lock | 16 ++++++ studio/src-tauri/Cargo.toml | 1 + studio/src-tauri/capabilities/default.json | 3 +- studio/src-tauri/src/main.rs | 16 ++++++ 7 files changed, 88 insertions(+), 21 deletions(-) diff --git a/studio/frontend/package-lock.json b/studio/frontend/package-lock.json index 4afee8a916..e66f62d2f2 100644 --- a/studio/frontend/package-lock.json +++ b/studio/frontend/package-lock.json @@ -37,6 +37,7 @@ "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@tauri-apps/plugin-window-state": "^2.4.1", "@toolwind/corner-shape": "^0.0.8-3", "@xyflow/react": "^12.10.0", "assistant-stream": "0.3.12", @@ -6239,6 +6240,15 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@tauri-apps/plugin-window-state": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-window-state/-/plugin-window-state-2.4.1.tgz", + "integrity": "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@toolwind/corner-shape": { "version": "0.0.8-3", "resolved": "https://registry.npmjs.org/@toolwind/corner-shape/-/corner-shape-0.0.8-3.tgz", diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 66b821d6c7..40e9458d93 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -46,6 +46,7 @@ "@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.1", + "@tauri-apps/plugin-window-state": "^2.4.1", "@toolwind/corner-shape": "^0.0.8-3", "@xyflow/react": "^12.10.0", "assistant-stream": "0.3.12", diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx index 83238dadf0..6f3c7618be 100644 --- a/studio/frontend/src/app/provider.tsx +++ b/studio/frontend/src/app/provider.tsx @@ -26,6 +26,9 @@ interface AppProviderProps { type TauriWindowMode = "setup" | "app"; type WindowLayoutGuard = () => boolean; +const MIN_WINDOW_WIDTH = 900; +const MIN_WINDOW_HEIGHT = 600; + async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise { const { getCurrentWindow } = await import("@tauri-apps/api/window"); if (!isCurrent()) return; @@ -39,35 +42,54 @@ async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise { async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise { const { getCurrentWindow, currentMonitor, LogicalSize } = await import("@tauri-apps/api/window"); + const { invoke } = await import("@tauri-apps/api/core"); + const { restoreStateCurrent, StateFlags } = await import("@tauri-apps/plugin-window-state"); if (!isCurrent()) return; const win = getCurrentWindow(); - const monitor = await currentMonitor(); + // Decide first-launch vs restore from the on-disk state file BEFORE touching the + // window. Probing the window itself after restoreStateCurrent is unreliable: + // on GTK, set_size against a hidden window is deferred until show(), so + // innerSize() reads a stale value and any baseline fallback would overwrite the + // queued restore. On macOS the same probe works, hence the inconsistency + // between previous iterations of this code. + const hasSavedState = await invoke("has_saved_window_state"); if (!isCurrent()) return; - let finalW = 900; - let finalH = 600; - - if (monitor) { - const scale = monitor.scaleFactor; - const screenW = monitor.size.width / scale; - const screenH = monitor.size.height / scale; - - finalW = Math.max(900, Math.round(screenW * 0.75)); - const targetH = Math.max(600, Math.round(finalW / 1.618)); - finalH = Math.min(targetH, Math.round(screenH * 0.85)); - } - - if (!isCurrent()) return; - await win.setSize(new LogicalSize(finalW, finalH)); - if (!isCurrent()) return; - await win.setSizeConstraints({ minWidth: 900, minHeight: 600 }); - if (!isCurrent()) return; await win.setResizable(true); if (!isCurrent()) return; - await win.center(); + + if (hasSavedState) { + // Subsequent launch: the plugin handles size, position, and maximized, + // with built-in off-screen protection (monitor-intersection check) for + // positions saved on a now-disconnected display. + await restoreStateCurrent( + StateFlags.SIZE | StateFlags.POSITION | StateFlags.MAXIMIZED, + ); + } else { + // First launch: fit to the current monitor and center. + const monitor = await currentMonitor(); + if (!isCurrent()) return; + let finalW = MIN_WINDOW_WIDTH; + let finalH = MIN_WINDOW_HEIGHT; + if (monitor) { + const scale = monitor.scaleFactor; + const screenW = monitor.size.width / scale; + const screenH = monitor.size.height / scale; + finalW = Math.max(MIN_WINDOW_WIDTH, Math.round(screenW * 0.75)); + const targetH = Math.max(MIN_WINDOW_HEIGHT, Math.round(finalW / 1.618)); + finalH = Math.min(targetH, Math.round(screenH * 0.85)); + } + await win.setSize(new LogicalSize(finalW, finalH)); + if (!isCurrent()) return; + await win.center(); + } if (!isCurrent()) return; await win.show(); + if (!isCurrent()) return; + // Apply constraints after restore/show. Setting constraints before plugin restore + // can emit a Resized event and overwrite the plugin's cached saved size. + await win.setSizeConstraints({ minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT }); } async function showWindowFallback(): Promise { diff --git a/studio/src-tauri/Cargo.lock b/studio/src-tauri/Cargo.lock index df350e4011..b2398b9932 100644 --- a/studio/src-tauri/Cargo.lock +++ b/studio/src-tauri/Cargo.lock @@ -4751,6 +4751,21 @@ 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", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -5395,6 +5410,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tauri-plugin-window-state", "tokio", "windows 0.62.2", "windows-sys 0.59.0", diff --git a/studio/src-tauri/Cargo.toml b/studio/src-tauri/Cargo.toml index 4002ab420d..2250f2774f 100644 --- a/studio/src-tauri/Cargo.toml +++ b/studio/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ tauri-plugin-clipboard-manager = "2" tauri-plugin-dialog = "2" rand = "0.10.0" tauri-plugin-notification = "2.3.3" +tauri-plugin-window-state = "2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/studio/src-tauri/capabilities/default.json b/studio/src-tauri/capabilities/default.json index 413fa14cc1..232472d6db 100644 --- a/studio/src-tauri/capabilities/default.json +++ b/studio/src-tauri/capabilities/default.json @@ -28,6 +28,7 @@ "allow": [{ "url": "https://*" }, { "url": "http://*" }, { "url": "mailto:*" }] }, "updater:default", - "clipboard-manager:allow-write-text" + "clipboard-manager:allow-write-text", + "window-state:default" ] } diff --git a/studio/src-tauri/src/main.rs b/studio/src-tauri/src/main.rs index d07ff5e9d6..498cd81579 100644 --- a/studio/src-tauri/src/main.rs +++ b/studio/src-tauri/src/main.rs @@ -23,6 +23,15 @@ use std::fs; use tauri::menu::{MenuBuilder, MenuItemBuilder}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{Emitter, Manager}; +use tauri_plugin_window_state::{AppHandleExt, StateFlags}; + +#[tauri::command] +fn has_saved_window_state(app: tauri::AppHandle) -> bool { + let Ok(dir) = app.path().app_config_dir() else { + return false; + }; + dir.join(app.filename()).is_file() +} fn setup_logging() { let mut loggers: Vec> = vec![]; @@ -173,6 +182,12 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_clipboard_manager::init()) + .plugin( + tauri_plugin_window_state::Builder::new() + .with_state_flags(StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED) + .skip_initial_state("main") + .build(), + ) .manage(diagnostics::new_diagnostics_state()) .manage(install::new_install_state()) .manage(native_intents::new_native_intake_state()) @@ -204,6 +219,7 @@ fn main() { native_intents::register_artifact_path, native_intents::reveal_path_token, native_intents::open_path_token, + has_saved_window_state, ]) .setup(|app| { #[cfg(any(target_os = "windows", target_os = "linux"))]