From 8854a4583b6933b8a1cdd0ffac3f35cbc60926cd Mon Sep 17 00:00:00 2001 From: Tant Date: Mon, 24 Aug 2026 00:51:12 +0800 Subject: [PATCH 1/2] fix(desktop): restore maximized main window state on startup The tauri plugin window-state flow already persists the main window geometry, but a maximized undecorated window was saved as maximized:false together with its stretched frame stored as normal bounds. Every launch then restored that degenerate near-fullscreen normal window instead of the remembered geometry. Fix both sides on Windows: - After each explicit save, treat GetWindowPlacement as the authoritative maximized signal and flip the persisted flag when it disagrees. Geometry fields are never rewritten from the placement: rcNormalPosition is unreliable for maximized undecorated windows and previously blended the pre-restore centered origin with monitor-sized dimensions, which moved the restored window off-screen. - Skip maximizing during the hidden restore phase and re-assert the persisted maximized flag after the window becomes visible, because maximizing a hidden undecorated window is dropped on show. Save call sites now carry a reason for failure diagnostics. --- src/apps/desktop/src/api/system_api.rs | 6 +- src/apps/desktop/src/appearance.rs | 33 +- src/apps/desktop/src/lib.rs | 69 +++- src/apps/desktop/src/tray.rs | 2 +- src/apps/desktop/src/webview_recovery.rs | 6 +- src/apps/desktop/src/window_state_support.rs | 345 ++++++++++++++++++ .../startupPerformanceContract.test.ts | 4 +- 7 files changed, 441 insertions(+), 24 deletions(-) create mode 100644 src/apps/desktop/src/window_state_support.rs diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index 975c77b4d4..a1b2e8861d 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -401,7 +401,7 @@ pub struct RestartAppRequest {} pub async fn restart_app(app: AppHandle, request: RestartAppRequest) -> Result<(), String> { let _ = request; crate::crash_diagnostics::mark_clean_shutdown("restart_app"); - crate::save_main_window_state(&app); + crate::save_main_window_state(&app, "restart_app"); crate::perform_process_exit_cleanup(); app.restart(); Ok(()) @@ -660,7 +660,7 @@ pub async fn set_main_window_transient_geometry( pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { log::info!("Quit requested via quit_app command"); crate::crash_diagnostics::mark_clean_shutdown("quit_app_command"); - crate::save_main_window_state(&app); + crate::save_main_window_state(&app, "quit_app_command"); crate::perform_process_exit_cleanup(); app.exit(0); Ok(()) @@ -732,7 +732,7 @@ pub async fn startup_window_control( if behavior == "quit" { log::info!("Quit requested from startup window control"); crate::crash_diagnostics::mark_clean_shutdown("startup_window_control"); - crate::save_main_window_state(&app); + crate::save_main_window_state(&app, "startup_window_control_quit"); crate::perform_process_exit_cleanup(); app.exit(0); } else { diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index d3ca6a3c0b..55f4e32270 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -646,7 +646,7 @@ pub fn create_main_window( let build_started_at = Instant::now(); match builder.build() { Ok(window) => { - crate::restore_main_window_state(&window); + let reapply_maximized = crate::restore_main_window_state(&window); crate::webview_recovery::install(&window); startup_trace.record_elapsed_step("native_window", "webview_build", build_started_at); debug!( @@ -665,7 +665,12 @@ pub fn create_main_window( } } - show_main_window_for_startup(&window, total_started_at, startup_trace); + show_main_window_for_startup( + &window, + total_started_at, + startup_trace, + reapply_maximized, + ); } Err(e) => { error!( @@ -681,6 +686,7 @@ fn show_main_window_for_startup( window: &tauri::WebviewWindow, total_started_at: Instant, startup_trace: &DesktopStartupTrace, + reapply_maximized: bool, ) { let show_started_at = Instant::now(); if let Err(error) = window.show() { @@ -705,6 +711,29 @@ fn show_main_window_for_startup( focus_started_at.elapsed().as_millis(), total_started_at.elapsed().as_millis() ); + + // Maximize only after the window is visible: maximizing a hidden + // undecorated window on Windows is dropped on show and leaves a bogus + // normal-placement rect behind (see `main_window_restore_flags`). + if reapply_maximized { + match window.is_maximized() { + Ok(true) => {} + Ok(false) => { + if let Err(error) = window.maximize() { + log::warn!( + "Failed to re-apply persisted maximized state after main window show: {}", + error + ); + } + } + Err(error) => { + log::warn!( + "Failed to query main window maximized state after show: {}", + error + ) + } + } + } } fn app_url(path: &str) -> WebviewUrl { diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index a3f1d5757e..62b32c5ae4 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -34,6 +34,7 @@ pub mod sleep_prevention; pub mod startup_trace; pub mod tray; mod webview_recovery; +mod window_state_support; use bitfun_agent_runtime::sdk::{attach_session_event_cursor, SessionEventJournal}; use bitfun_core::agentic::tools::computer_use_capability::set_computer_use_desktop_available; @@ -318,19 +319,48 @@ fn main_window_state_flags() -> StateFlags { StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN } -fn persist_main_window_state(app: &tauri::AppHandle) -> Result<(), String> { - app.save_window_state(main_window_state_flags()) - .map_err(|error| error.to_string()) +/// Restore deliberately excludes `MAXIMIZED`: maximizing a hidden undecorated +/// window on Windows does not survive `show()` and leaves Windows tracking a +/// bogus normal-placement rect. The persisted maximized flag is re-asserted +/// after the window becomes visible instead; see `restore_main_window_state`. +fn main_window_restore_flags() -> StateFlags { + StateFlags::SIZE | StateFlags::POSITION | StateFlags::FULLSCREEN } -pub(crate) fn save_main_window_state(app: &tauri::AppHandle) { +fn persist_main_window_state(app: &tauri::AppHandle, reason: &str) -> Result<(), String> { + let result = app + .save_window_state(main_window_state_flags()) + .map_err(|error| error.to_string()); + if let Err(error) = &result { + log::warn!( + "Failed to save main window state: reason={}, error={}", + reason, + error + ); + return result; + } + + #[cfg(target_os = "windows")] + window_state_support::correct_saved_main_window_state(app); + + Ok(()) +} + +pub(crate) fn save_main_window_state(app: &tauri::AppHandle, reason: &str) { if MAIN_WINDOW_USES_TRANSIENT_GEOMETRY.load(Ordering::SeqCst) { - log::debug!("Skipped saving transient main window geometry"); + log::debug!( + "Skipped saving transient main window geometry: reason={}", + reason + ); return; } - if let Err(error) = persist_main_window_state(app) { - log::warn!("Failed to save main window state: {}", error); + if let Err(error) = persist_main_window_state(app, reason) { + log::warn!( + "Failed to save main window state: reason={}, error={}", + reason, + error + ); } } @@ -345,7 +375,7 @@ pub(crate) fn set_main_window_transient_geometry( // Capture the latest normal bounds before toolbar mode starts resizing // the shared native window. - persist_main_window_state(app).map_err(|error| { + persist_main_window_state(app, "transient_geometry_enter_capture").map_err(|error| { format!( "Failed to save main window state before transient geometry: {}", error @@ -356,7 +386,7 @@ pub(crate) fn set_main_window_transient_geometry( } MAIN_WINDOW_USES_TRANSIENT_GEOMETRY.store(false, Ordering::SeqCst); - persist_main_window_state(app).map_err(|error| { + persist_main_window_state(app, "transient_geometry_exit_persist").map_err(|error| { format!( "Failed to save restored main window state after transient geometry: {}", error @@ -368,11 +398,17 @@ fn has_standard_main_window_size(width: f64, height: f64) -> bool { width >= MAIN_WINDOW_MIN_WIDTH && height >= MAIN_WINDOW_MIN_HEIGHT } -pub(crate) fn restore_main_window_state(window: &tauri::WebviewWindow) { - if let Err(error) = window.restore_state(main_window_state_flags()) { +pub(crate) fn restore_main_window_state(window: &tauri::WebviewWindow) -> bool { + if let Err(error) = window.restore_state(main_window_restore_flags()) { log::warn!("Failed to restore main window state: {}", error); } + // The persisted maximized flag is re-asserted once the window is visible; + // maximizing while hidden is unreliable on Windows (see + // `main_window_restore_flags`). + let reapply_maximized = + window_state_support::read_persisted_main_maximized(window.app_handle()).unwrap_or(false); + let is_maximized = window.is_maximized().unwrap_or(false); let is_fullscreen = window.is_fullscreen().unwrap_or(false); if !is_maximized && !is_fullscreen { @@ -402,7 +438,10 @@ pub(crate) fn restore_main_window_state(window: &tauri::WebviewWindow) { log::warn!("Failed to center reset main window: {}", error); } if resize_succeeded { - if let Err(error) = persist_main_window_state(window.app_handle()) { + if let Err(error) = persist_main_window_state( + window.app_handle(), + "startup_geometry_repair", + ) { log::warn!("Failed to persist repaired main window state: {}", error); } } @@ -423,6 +462,8 @@ pub(crate) fn restore_main_window_state(window: &tauri::WebviewWindow) { ))) { log::warn!("Failed to set main window minimum size: {}", error); } + + reapply_maximized } #[cfg(test)] @@ -1177,7 +1218,7 @@ pub async fn run() { if window.label() == "main" && matches!(event, tauri::WindowEvent::CloseRequested { .. }) { - save_main_window_state(window.app_handle()); + save_main_window_state(window.app_handle(), "close_requested"); } if let tauri::WindowEvent::CloseRequested { api: _api, .. } = event { @@ -1907,7 +1948,7 @@ pub async fn run() { app.run(|_app_handle, event| match event { tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit => { crash_diagnostics::mark_clean_shutdown("tauri_run_exit"); - save_main_window_state(_app_handle); + save_main_window_state(_app_handle, "tauri_run_exit"); perform_process_exit_cleanup(); } #[cfg(target_os = "macos")] diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index 730b608204..3e318e090a 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -220,7 +220,7 @@ pub fn setup_tray( } else if id == "quit" { log::info!("Quit requested from tray menu"); crate::crash_diagnostics::mark_clean_shutdown("tray_quit"); - crate::save_main_window_state(app); + crate::save_main_window_state(app, "tray_quit"); crate::perform_process_exit_cleanup(); app.exit(0); } else if id == "toggle_desktop_pet" { diff --git a/src/apps/desktop/src/webview_recovery.rs b/src/apps/desktop/src/webview_recovery.rs index 3342186457..73fc2aabe9 100644 --- a/src/apps/desktop/src/webview_recovery.rs +++ b/src/apps/desktop/src/webview_recovery.rs @@ -240,7 +240,7 @@ mod windows { fn request_automatic_restart(app: &tauri::AppHandle) { log::warn!("Requesting controlled application restart for WebView2 recovery"); crate::crash_diagnostics::mark_clean_shutdown("webview_recovery_restart"); - crate::save_main_window_state(app); + crate::save_main_window_state(app, "webview_recovery_restart"); crate::perform_process_exit_cleanup(); app.request_restart(); } @@ -266,7 +266,7 @@ mod windows { } _ => { crate::crash_diagnostics::mark_clean_shutdown("webview_recovery_exit"); - crate::save_main_window_state(&app); + crate::save_main_window_state(&app, "webview_recovery_exit_dialog"); crate::perform_process_exit_cleanup(); app.exit(1); } @@ -275,7 +275,7 @@ mod windows { fn request_user_restart(app: &tauri::AppHandle) { crate::crash_diagnostics::mark_clean_shutdown("webview_recovery_user_restart"); - crate::save_main_window_state(app); + crate::save_main_window_state(app, "webview_recovery_user_restart"); crate::perform_process_exit_cleanup(); app.request_restart(); } diff --git a/src/apps/desktop/src/window_state_support.rs b/src/apps/desktop/src/window_state_support.rs new file mode 100644 index 0000000000..d94bd3cca1 --- /dev/null +++ b/src/apps/desktop/src/window_state_support.rs @@ -0,0 +1,345 @@ +//! Windows-native correction for the persisted main-window state. +//! +//! BitFun drives [tauri_plugin_window_state] explicitly (`with_state_flags` +//! empty at registration, explicit save/restore around known geometry +//! boundaries). The plugin captures geometry through generic window queries. +//! On Windows the main window is undecorated, and when a quit happens while a +//! maximized frameless window is on screen the persisted entry can degrade to +//! `maximized: false` together with the stretched maximized frame stored as +//! normal bounds. Every later launch then faithfully restores that degenerate +//! near-fullscreen normal window instead of the remembered geometry. +//! +//! This module uses [`GetWindowPlacement`] as the authoritative maximized +//! signal: after each successful save the persisted `main` entry is corrected +//! in place when the native placement reports a maximized window. Unreadable +//! or missing files are never recreated or deleted. + +use std::path::Path; + +use tauri::Manager; + +const MAIN_WINDOW_LABEL: &str = "main"; +/// Keep in sync with `tauri_plugin_window_state::DEFAULT_FILENAME`. +const WINDOW_STATE_FILENAME: &str = ".window-state.json"; + +// ─── Authoritative maximized placement ──────────────────────────────────────── + +/// Native window placement facts, mirroring the maximized-signal parts of +/// Win32 `WINDOWPLACEMENT`. +/// +/// Kept platform-independent so the correction logic is unit-testable +/// everywhere; only the query itself is Windows-specific. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct NativePlacementReport { + pub show_cmd: i32, + pub restore_to_maximized: bool, +} + +impl NativePlacementReport { + /// Whether the placement describes a window that is zoomed now or will be + /// maximized once it leaves the minimized state. + /// + /// `show_cmd` comparison targets `SW_SHOWMAXIMIZED`; the constant is + /// inlined because this type is shared across platforms. + pub(crate) fn reports_maximized(&self) -> bool { + const SW_SHOWMAXIMIZED: i32 = 3; + self.show_cmd == SW_SHOWMAXIMIZED || self.restore_to_maximized + } +} + +#[cfg(target_os = "windows")] +mod native { + use super::NativePlacementReport; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + GetWindowPlacement, WINDOWPLACEMENT, WINDOWPLACEMENT_FLAGS, WPF_RESTORETOMAXIMIZED, + }; + + pub(super) fn query(hwnd_inner: isize) -> Option { + let mut placement = WINDOWPLACEMENT::default(); + placement.length = std::mem::size_of::() as u32; + // SAFETY: the handle belongs to the live main window and the output + // buffer outlives the single call. + unsafe { GetWindowPlacement(HWND(hwnd_inner as *mut _), &mut placement) }.ok()?; + Some(NativePlacementReport { + show_cmd: placement.showCmd as i32, + restore_to_maximized: placement.flags & WPF_RESTORETOMAXIMIZED + != WINDOWPLACEMENT_FLAGS(0), + }) + } +} + +#[cfg(target_os = "windows")] +fn query_native_window_placement(window: &tauri::WebviewWindow) -> Option { + let handle = window.hwnd().ok()?; + native::query(handle.0 as isize) +} + +// ─── Persisted-state correction ─────────────────────────────────────────────── + +/// Flags the persisted `main` entry as maximized when the authoritative native +/// placement disagrees with what the plugin captured. +/// +/// Geometry fields are deliberately never rewritten: for a maximized +/// undecorated window `rcNormalPosition` is unreliable (it has been observed +/// mixing the pre-restore centered origin with monitor-sized dimensions), so +/// the last persisted normal bounds stay authoritative. +/// +/// Returns `true` when the flag flipped. Non-maximized placements and entries +/// already marked maximized never modify the document. +pub(crate) fn apply_maximized_correction( + document: &mut serde_json::Value, + report: &NativePlacementReport, +) -> bool { + if !report.reports_maximized() { + return false; + } + + let Some(entry) = document + .get_mut(MAIN_WINDOW_LABEL) + .and_then(|value| value.as_object_mut()) + else { + return false; + }; + + set_bool_if_changed(entry, "maximized", true) +} + +/// Reads the persisted `maximized` flag of the `main` entry so the restore +/// path can re-assert the maximized state after the window becomes visible. +pub(crate) fn read_persisted_main_maximized(app: &tauri::AppHandle) -> Option { + let config_dir = app.path().app_config_dir().ok()?; + let state_path = config_dir.join(WINDOW_STATE_FILENAME); + let bytes = std::fs::read(state_path).ok()?; + let document: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + document.get(MAIN_WINDOW_LABEL)?.get("maximized")?.as_bool() +} + +fn set_bool_if_changed( + entry: &mut serde_json::Map, + key: &str, + value: bool, +) -> bool { + if entry.get(key).and_then(serde_json::Value::as_bool) == Some(value) { + return false; + } + entry.insert(key.to_string(), serde_json::Value::Bool(value)); + true +} + +/// Post-corrects the saved state file after a successful plugin save. +/// +/// Skipped unless the authoritative placement says the window is maximized. +/// Existing files are never created or deleted; unparsable content is logged +/// and left untouched. +#[cfg(target_os = "windows")] +pub(crate) fn correct_saved_main_window_state(app: &tauri::AppHandle) { + let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else { + log::debug!("Saved main-window state correction skipped: main window not found"); + return; + }; + let Some(report) = query_native_window_placement(&window) else { + log::debug!("Saved main-window state correction skipped: native placement unavailable"); + return; + }; + if !report.reports_maximized() { + return; + } + + let Ok(config_dir) = app.path().app_config_dir() else { + log::warn!("Saved main-window state correction skipped: app config dir unavailable"); + return; + }; + let state_path = config_dir.join(WINDOW_STATE_FILENAME); + + match correct_saved_state_file(&state_path, &report) { + Ok(_) => {} + Err(error) => { + log::warn!("Failed to correct persisted main-window state: {}", error) + } + } +} + +fn correct_saved_state_file( + state_path: &Path, + report: &NativePlacementReport, +) -> Result { + let bytes = std::fs::read(state_path).map_err(|error| format!("read failed: {}", error))?; + let mut document: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| { + format!( + "state file is not valid JSON, keeping it untouched: {}", + error + ) + })?; + + if !apply_maximized_correction(&mut document, report) { + return Ok(false); + } + + let serialized = serde_json::to_vec_pretty(&document) + .map_err(|error| format!("serialize failed: {}", error))?; + let temporary_path = state_path.with_extension("json.tmp"); + std::fs::write(&temporary_path, serialized) + .map_err(|error| format!("temporary write failed: {}", error))?; + if state_path.exists() { + std::fs::remove_file(state_path).map_err(|error| format!("replace failed: {}", error))?; + } + std::fs::rename(&temporary_path, state_path) + .map_err(|error| format!("rename failed: {}", error))?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn maximized_report() -> NativePlacementReport { + NativePlacementReport { + show_cmd: 3, + restore_to_maximized: false, + } + } + + /// Mirrors the degraded shape observed in the wild: stretched maximized + /// frame stored as normal bounds with `maximized: false`. + fn degraded_document() -> serde_json::Value { + json!({ + "main": { + "width": 2560, + "height": 1537, + "x": -11, + "y": -11, + "prev_x": -11, + "prev_y": -11, + "maximized": false, + "visible": true, + "decorated": true, + "fullscreen": false, + } + }) + } + + fn flipped_degraded_document() -> serde_json::Value { + let mut document = degraded_document(); + document["main"]["maximized"] = json!(true); + document + } + + #[test] + fn degraded_maximized_entry_flips_flag_without_touching_geometry() { + let mut document = degraded_document(); + + let changed = apply_maximized_correction(&mut document, &maximized_report()); + + assert!(changed); + assert_eq!(document, flipped_degraded_document()); + } + + #[test] + fn correction_is_idempotent() { + let mut document = degraded_document(); + assert!(apply_maximized_correction( + &mut document, + &maximized_report() + )); + // Second pass on the already-flipped entry must be a no-op: geometry + // fields must never be rewritten from the untrustworthy placement. + assert!(!apply_maximized_correction( + &mut document, + &maximized_report() + )); + } + + #[test] + fn already_maximized_entry_is_never_rewritten() { + let mut document = flipped_degraded_document(); + + assert!(!apply_maximized_correction( + &mut document, + &maximized_report() + )); + assert_eq!(document, flipped_degraded_document()); + } + + #[test] + fn non_maximized_placement_never_modifies_the_document() { + let mut document = degraded_document(); + let mut report = maximized_report(); + report.show_cmd = 1; + + assert!(!apply_maximized_correction(&mut document, &report)); + assert_eq!(document, degraded_document()); + } + + #[test] + fn minimized_restore_to_maximized_flag_counts_as_maximized() { + let mut report = maximized_report(); + report.show_cmd = 2; + report.restore_to_maximized = true; + + assert!(report.reports_maximized()); + } + + #[test] + fn missing_main_entry_is_ignored() { + let mut document = json!({ "other_window": { "width": 5 } }); + + assert!(!apply_maximized_correction( + &mut document, + &maximized_report() + )); + assert_eq!( + document.get("other_window").unwrap().get("width"), + Some(&json!(5)) + ); + } + + #[test] + fn legacy_partial_entry_is_tolerated_and_completed() { + let mut document = json!({ "main": { "width": 100 } }); + + assert!(apply_maximized_correction( + &mut document, + &maximized_report() + )); + let entry = document.get("main").unwrap(); + assert_eq!(entry.get("maximized"), Some(&json!(true))); + assert_eq!(entry.get("width"), Some(&json!(100))); + assert!(entry.get("visible").is_none()); + } + + #[test] + fn saved_state_file_round_trip_flips_only_the_flag() { + let directory = tempfile::tempdir().expect("temporary directory"); + let state_path = directory.path().join(".window-state.json"); + std::fs::write(&state_path, degraded_document().to_string()).expect("seed state file"); + + let changed = + correct_saved_state_file(&state_path, &maximized_report()).expect("correction"); + + assert!(changed); + let corrected: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).expect("reread")) + .expect("corrected state parses"); + assert_eq!(corrected["main"]["maximized"], json!(true)); + assert_eq!(corrected["main"], flipped_degraded_document()["main"]); + assert!(!state_path.with_extension("json.tmp").exists()); + } + + #[test] + fn saved_state_file_keeps_invalid_content_untouched() { + let directory = tempfile::tempdir().expect("temporary directory"); + let state_path = directory.path().join(".window-state.json"); + std::fs::write(&state_path, "{not json").expect("seed invalid state file"); + + let error = correct_saved_state_file(&state_path, &maximized_report()) + .expect_err("invalid content must fail instead of being replaced"); + + assert!(error.contains("not valid JSON")); + assert_eq!( + std::fs::read_to_string(&state_path).expect("content preserved"), + "{not json" + ); + } +} diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index 89b464818e..4c5f7a4f8a 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -198,7 +198,9 @@ describe('startup performance contract', () => { expect(windowEventStart).toBeGreaterThan(-1); expect(invokeHandlerStart).toBeGreaterThan(windowEventStart); expect(windowEventSource).toContain('matches!(event, tauri::WindowEvent::CloseRequested { .. })'); - expect(windowEventSource).toContain('save_main_window_state(window.app_handle())'); + expect(windowEventSource).toContain( + 'save_main_window_state(window.app_handle(), "close_requested")' + ); expect(toolbarModeProviderSource).toContain( 'setMainWindowTransientGeometry(true)' ); From 9c7274f9a8f63f200fd4b13eec795666c5855747 Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 24 Aug 2026 11:30:19 +0800 Subject: [PATCH 2/2] fix(desktop): preserve window state during correction A maximized frameless main window could be persisted with stretched normal bounds or lose its maximized flag during startup geometry repair. The correction path also replaced the state file by deleting the existing file first and assumed the plugin's default filename. Keep the persisted state reliable across startup and explicit save boundaries: - Separate full-state saves from geometry-only saves so startup size repair cannot overwrite maximized. - Defer maximized restoration only on Windows, where maximizing a hidden undecorated window is not reliable. - Resolve the state path through tauri-plugin-window-state's filename API. - Replace the state file atomically with ReplaceFileW and write-through semantics on Windows, and rename it atomically on other platforms. - Add regression coverage for replacement failures, preserved state, and platform-specific state flags. Validation: cargo check -p bitfun-desktop; cargo build -p bitfun-desktop; git diff --check. Refs: #2435 --- src/apps/desktop/Cargo.toml | 1 + src/apps/desktop/src/lib.rs | 73 ++++++++++++++++---- src/apps/desktop/src/window_state_support.rs | 72 ++++++++++++++++--- 3 files changed, 124 insertions(+), 22 deletions(-) diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index ec66a94562..5f56a9513b 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -119,6 +119,7 @@ windows = { workspace = true, features = [ "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", + "Win32_Storage_FileSystem", "Win32_Storage_Xps", "Win32_System_Com", "Win32_System_Ole", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 62b32c5ae4..874dbdb636 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -316,20 +316,42 @@ fn handle_secondary_launch(app: &tauri::AppHandle) { } fn main_window_state_flags() -> StateFlags { - StateFlags::SIZE | StateFlags::POSITION | StateFlags::MAXIMIZED | StateFlags::FULLSCREEN + main_window_geometry_state_flags() | StateFlags::MAXIMIZED } -/// Restore deliberately excludes `MAXIMIZED`: maximizing a hidden undecorated -/// window on Windows does not survive `show()` and leaves Windows tracking a -/// bogus normal-placement rect. The persisted maximized flag is re-asserted -/// after the window becomes visible instead; see `restore_main_window_state`. -fn main_window_restore_flags() -> StateFlags { +fn main_window_geometry_state_flags() -> StateFlags { StateFlags::SIZE | StateFlags::POSITION | StateFlags::FULLSCREEN } +/// Restore deliberately excludes `MAXIMIZED` on Windows: maximizing a hidden +/// undecorated window does not survive `show()` and leaves Windows tracking a +/// bogus normal-placement rect. Other platforms use the plugin's complete +/// restore behavior. +#[cfg(target_os = "windows")] +fn main_window_restore_flags() -> StateFlags { + main_window_geometry_state_flags() +} + +#[cfg(not(target_os = "windows"))] +fn main_window_restore_flags() -> StateFlags { + main_window_state_flags() +} + fn persist_main_window_state(app: &tauri::AppHandle, reason: &str) -> Result<(), String> { + persist_main_window_state_with_flags(app, reason, main_window_state_flags()) +} + +fn persist_main_window_geometry_state(app: &tauri::AppHandle, reason: &str) -> Result<(), String> { + persist_main_window_state_with_flags(app, reason, main_window_geometry_state_flags()) +} + +fn persist_main_window_state_with_flags( + app: &tauri::AppHandle, + reason: &str, + flags: StateFlags, +) -> Result<(), String> { let result = app - .save_window_state(main_window_state_flags()) + .save_window_state(flags) .map_err(|error| error.to_string()); if let Err(error) = &result { log::warn!( @@ -341,7 +363,9 @@ fn persist_main_window_state(app: &tauri::AppHandle, reason: &str) -> Result<(), } #[cfg(target_os = "windows")] - window_state_support::correct_saved_main_window_state(app); + if flags.contains(StateFlags::MAXIMIZED) { + window_state_support::correct_saved_main_window_state(app); + } Ok(()) } @@ -403,12 +427,13 @@ pub(crate) fn restore_main_window_state(window: &tauri::WebviewWindow) -> bool { log::warn!("Failed to restore main window state: {}", error); } - // The persisted maximized flag is re-asserted once the window is visible; - // maximizing while hidden is unreliable on Windows (see - // `main_window_restore_flags`). + #[cfg(target_os = "windows")] let reapply_maximized = window_state_support::read_persisted_main_maximized(window.app_handle()).unwrap_or(false); + #[cfg(not(target_os = "windows"))] + let reapply_maximized = false; + let is_maximized = window.is_maximized().unwrap_or(false); let is_fullscreen = window.is_fullscreen().unwrap_or(false); if !is_maximized && !is_fullscreen { @@ -438,7 +463,7 @@ pub(crate) fn restore_main_window_state(window: &tauri::WebviewWindow) -> bool { log::warn!("Failed to center reset main window: {}", error); } if resize_succeeded { - if let Err(error) = persist_main_window_state( + if let Err(error) = persist_main_window_geometry_state( window.app_handle(), "startup_geometry_repair", ) { @@ -468,7 +493,11 @@ pub(crate) fn restore_main_window_state(window: &tauri::WebviewWindow) -> bool { #[cfg(test)] mod main_window_geometry_tests { - use super::has_standard_main_window_size; + use super::{ + has_standard_main_window_size, main_window_geometry_state_flags, main_window_restore_flags, + main_window_state_flags, + }; + use tauri_plugin_window_state::StateFlags; #[test] fn floating_toolbar_sizes_are_not_valid_main_window_sizes() { @@ -480,6 +509,24 @@ mod main_window_geometry_tests { fn default_client_size_is_a_valid_main_window_size() { assert!(has_standard_main_window_size(1200.0, 800.0)); } + + #[test] + fn geometry_saves_do_not_overwrite_maximized_state() { + assert!(!main_window_geometry_state_flags().contains(StateFlags::MAXIMIZED)); + assert!(main_window_state_flags().contains(StateFlags::MAXIMIZED)); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_restore_defers_maximized_state_until_after_show() { + assert!(!main_window_restore_flags().contains(StateFlags::MAXIMIZED)); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn non_windows_restore_keeps_plugin_maximized_behavior() { + assert!(main_window_restore_flags().contains(StateFlags::MAXIMIZED)); + } } #[tauri::command] diff --git a/src/apps/desktop/src/window_state_support.rs b/src/apps/desktop/src/window_state_support.rs index d94bd3cca1..6c0fee3b71 100644 --- a/src/apps/desktop/src/window_state_support.rs +++ b/src/apps/desktop/src/window_state_support.rs @@ -17,10 +17,9 @@ use std::path::Path; use tauri::Manager; +use tauri_plugin_window_state::AppHandleExt; const MAIN_WINDOW_LABEL: &str = "main"; -/// Keep in sync with `tauri_plugin_window_state::DEFAULT_FILENAME`. -const WINDOW_STATE_FILENAME: &str = ".window-state.json"; // ─── Authoritative maximized placement ──────────────────────────────────────── @@ -107,9 +106,10 @@ pub(crate) fn apply_maximized_correction( /// Reads the persisted `maximized` flag of the `main` entry so the restore /// path can re-assert the maximized state after the window becomes visible. +#[cfg(target_os = "windows")] pub(crate) fn read_persisted_main_maximized(app: &tauri::AppHandle) -> Option { let config_dir = app.path().app_config_dir().ok()?; - let state_path = config_dir.join(WINDOW_STATE_FILENAME); + let state_path = config_dir.join(app.filename()); let bytes = std::fs::read(state_path).ok()?; let document: serde_json::Value = serde_json::from_slice(&bytes).ok()?; document.get(MAIN_WINDOW_LABEL)?.get("maximized")?.as_bool() @@ -150,7 +150,7 @@ pub(crate) fn correct_saved_main_window_state(app: &tauri::AppHandle) { log::warn!("Saved main-window state correction skipped: app config dir unavailable"); return; }; - let state_path = config_dir.join(WINDOW_STATE_FILENAME); + let state_path = config_dir.join(app.filename()); match correct_saved_state_file(&state_path, &report) { Ok(_) => {} @@ -181,14 +181,51 @@ fn correct_saved_state_file( let temporary_path = state_path.with_extension("json.tmp"); std::fs::write(&temporary_path, serialized) .map_err(|error| format!("temporary write failed: {}", error))?; - if state_path.exists() { - std::fs::remove_file(state_path).map_err(|error| format!("replace failed: {}", error))?; - } - std::fs::rename(&temporary_path, state_path) - .map_err(|error| format!("rename failed: {}", error))?; + replace_state_file_atomically(state_path, &temporary_path)?; Ok(true) } +fn replace_state_file_atomically(state_path: &Path, temporary_path: &Path) -> Result<(), String> { + #[cfg(target_os = "windows")] + { + use std::iter::once; + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ReplaceFileW, REPLACEFILE_WRITE_THROUGH}; + + let state_path_wide: Vec = state_path + .as_os_str() + .encode_wide() + .chain(once(0)) + .collect(); + let temporary_path_wide: Vec = temporary_path + .as_os_str() + .encode_wide() + .chain(once(0)) + .collect(); + + // SAFETY: both UTF-16 buffers are NUL-terminated and live for the + // duration of the call. The backup and reserved parameters are unused. + unsafe { + ReplaceFileW( + PCWSTR::from_raw(state_path_wide.as_ptr()), + PCWSTR::from_raw(temporary_path_wide.as_ptr()), + None, + REPLACEFILE_WRITE_THROUGH, + None, + None, + ) + } + .map_err(|error| format!("atomic replace failed: {}", error))?; + } + + #[cfg(not(target_os = "windows"))] + std::fs::rename(temporary_path, state_path) + .map_err(|error| format!("atomic rename failed: {}", error))?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -342,4 +379,21 @@ mod tests { "{not json" ); } + + #[test] + fn failed_state_file_replacement_keeps_original_content() { + let directory = tempfile::tempdir().expect("temporary directory"); + let state_path = directory.path().join(".window-state.json"); + let missing_temporary_path = directory.path().join("missing.json.tmp"); + std::fs::write(&state_path, "original").expect("seed state file"); + + let error = replace_state_file_atomically(&state_path, &missing_temporary_path) + .expect_err("missing replacement must fail"); + + assert!(error.contains("replace") || error.contains("rename")); + assert_eq!( + std::fs::read_to_string(&state_path).expect("original content preserved"), + "original" + ); + } }