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 <wasimysdev@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
This commit is contained in:
oobabooga 2026-06-02 13:26:15 -03:00 committed by GitHub
commit 85692f1c1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 88 additions and 21 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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<void> {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
if (!isCurrent()) return;
@ -39,35 +42,54 @@ async function showSetupWindow(isCurrent: WindowLayoutGuard): Promise<void> {
async function applyAppWindowLayout(isCurrent: WindowLayoutGuard): Promise<void> {
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<boolean>("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<void> {

View file

@ -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",

View file

@ -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"

View file

@ -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"
]
}

View file

@ -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<Box<dyn SharedLogger>> = 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"))]