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/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..874dbdb636 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; @@ -315,22 +316,75 @@ 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 } -fn persist_main_window_state(app: &tauri::AppHandle) -> Result<(), String> { - app.save_window_state(main_window_state_flags()) - .map_err(|error| error.to_string()) +fn main_window_geometry_state_flags() -> StateFlags { + StateFlags::SIZE | StateFlags::POSITION | StateFlags::FULLSCREEN } -pub(crate) fn save_main_window_state(app: &tauri::AppHandle) { +/// 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(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")] + if flags.contains(StateFlags::MAXIMIZED) { + 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 +399,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 +410,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 +422,18 @@ 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); } + #[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 { @@ -402,7 +463,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_geometry_state( + window.app_handle(), + "startup_geometry_repair", + ) { log::warn!("Failed to persist repaired main window state: {}", error); } } @@ -423,11 +487,17 @@ pub(crate) fn restore_main_window_state(window: &tauri::WebviewWindow) { ))) { log::warn!("Failed to set main window minimum size: {}", error); } + + reapply_maximized } #[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() { @@ -439,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] @@ -1177,7 +1265,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 +1995,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..6c0fee3b71 --- /dev/null +++ b/src/apps/desktop/src/window_state_support.rs @@ -0,0 +1,399 @@ +//! 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; +use tauri_plugin_window_state::AppHandleExt; + +const MAIN_WINDOW_LABEL: &str = "main"; + +// ─── 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. +#[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(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() +} + +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(app.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))?; + 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::*; + 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" + ); + } + + #[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" + ); + } +} 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)' );