diff --git a/Cargo.lock b/Cargo.lock index 91b16275b1..07fc074d3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6025,7 +6025,6 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-updater", - "tauri-plugin-window-state", "tempfile", "thiserror 2.0.19", "time", @@ -10222,21 +10221,6 @@ dependencies = [ "zip 4.6.1", ] -[[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.1", - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.19", -] - [[package]] name = "tauri-runtime" version = "2.11.3" diff --git a/Cargo.toml b/Cargo.toml index bb26e7d069..54bd7dd60e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -214,7 +214,6 @@ tauri-plugin-autostart = "2.5" tauri-plugin-notification = "2.3" tauri-plugin-updater = "2.10" tauri-plugin-single-instance = "2.4" -tauri-plugin-window-state = "2.4" notify-rust = { version = "4.18", default-features = false } tauri-build = { version = "2.6", features = [] } keepawake = "0.6.0" diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 2da33d07c0..8c13e198e9 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -35,6 +35,8 @@ crate;`src/crates/assembly/core` 只保留产品装配与兼容桥接。 - 桌面端专属集成留在这里,不要下沉到共享 core - 窗口 lifecycle 行为(包括 close/minimize-to-tray 默认值)属于桌面端 surface;修改时必须保留用户已保存偏好。 +- `window_state_support` 负责主窗口布局校验,并沿用旧 `.window-state.json` 格式原子保存。 + 不要同时注册 window-state 插件,其退出时写入的缓存可能覆盖修复结果。 ## 命令 @@ -78,6 +80,13 @@ pnpm run prepare:dsh-profile # 可选:本地 DeepSeek Harness 会话 cargo check -p openbitfun-desktop && cargo test -p openbitfun-desktop ``` +窗口布局恢复、旧状态兼容和快照保存使用: + +```bash +cargo test -p openbitfun-desktop --lib window_state_support::tests +pnpm --dir src/web-ui run test:run src/app/startup/startupPerformanceContract.test.ts +``` + 如果改动影响启动、WebDriver、browser/computer-use 或打包行为,还需要运行: ```bash diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 88ba74e292..24e33576dc 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -44,6 +44,9 @@ product wiring and compatibility bridges in `src/crates/assembly/core`. - Keep desktop-only integrations here; do not move them into shared core - Window lifecycle behavior, including close/minimize-to-tray defaults, is a desktop surface concern. Preserve saved user preferences when changing it. +- `window_state_support` owns main-window geometry validation and atomic + persistence in the legacy `.window-state.json` format. Do not reinstall the + window-state plugin alongside it: the plugin's exit cache can overwrite repairs. ## Commands @@ -105,6 +108,10 @@ For staged application-update cache and signature behavior, use `cargo test -p openbitfun-desktop --lib api::update_api::tests`. For peer system-info response compatibility, run `cargo test -p openbitfun-desktop --lib system_info_home_contract`. +For window geometry recovery, legacy state compatibility, and snapshot persistence, +run `cargo test -p openbitfun-desktop --lib window_state_support::tests`. +For the matching startup wiring contract, run +`pnpm --dir src/web-ui run test:run src/app/startup/startupPerformanceContract.test.ts`. After changing updater command registration, also run `cargo test -p openbitfun-desktop --lib remote_workspace_policy`. diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 6189fda773..70270a6007 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -42,7 +42,6 @@ tauri-plugin-autostart = { workspace = true } tauri-plugin-notification = { workspace = true } tauri-plugin-updater = { workspace = true } tauri-plugin-single-instance = { workspace = true } -tauri-plugin-window-state = { workspace = true } keepawake = { workspace = true } # Keep Tauri's transitive time resolution on the known-good release. time = { workspace = true } diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index 1833aa1f99..f1ad8edb3d 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -683,18 +683,18 @@ fn show_main_window_for_startup( let focus_started_at = Instant::now(); if let Err(error) = window.set_focus() { warn!("Failed to focus main window during startup: {}", error); - return; + } else { + startup_trace.record_elapsed_step("native_window", "focus_window", focus_started_at); + debug!( + "Main window startup show step completed: step=focus duration_ms={} since_create_start_ms={}", + focus_started_at.elapsed().as_millis(), + total_started_at.elapsed().as_millis() + ); } - startup_trace.record_elapsed_step("native_window", "focus_window", focus_started_at); - debug!( - "Main window startup show step completed: step=focus duration_ms={} since_create_start_ms={}", - 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`). + // normal-placement rect behind (see `window_state_support`). if reapply_maximized { match window.is_maximized() { Ok(true) => {} @@ -1024,6 +1024,15 @@ pub async fn hide_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { let total_started_at = Instant::now(); if let Some(main_window) = app.get_webview_window("main") { + main_window + .unminimize() + .map_err(|error| error.to_string())?; + if let Err(error) = crate::window_state_support::repair_for_activation(&main_window) { + warn!( + "Failed to repair main window geometry during activation: {}", + error + ); + } let step_started_at = Instant::now(); main_window.show().map_err(|e| { error!("Failed to show main window: {}", e); diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index f83fb1f480..7f4202200c 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -58,7 +58,6 @@ use std::sync::{ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::Emitter; use tauri::Manager; -use tauri_plugin_window_state::{AppHandleExt, StateFlags, WindowExt}; // Re-export API pub use api::*; @@ -288,6 +287,12 @@ fn show_main_window_for_secondary_launch( main_window .unminimize() .map_err(|error| format!("failed to unminimize main window: {}", error))?; + if let Err(error) = window_state_support::repair_for_activation(&main_window) { + log::warn!( + "Failed to repair main window geometry from secondary launch: {}", + error + ); + } main_window .show() .map_err(|error| format!("failed to show main window: {}", error))?; @@ -329,59 +334,8 @@ pub(crate) fn e2e_storage_guard_enabled() -> bool { .is_some_and(|value| value == "1" || value.eq_ignore_ascii_case("true")) } -fn main_window_state_flags() -> StateFlags { - main_window_geometry_state_flags() | StateFlags::MAXIMIZED -} - -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(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(()) + window_state_support::save(app, reason) } pub(crate) fn save_main_window_state(app: &tauri::AppHandle, reason: &str) { @@ -432,115 +386,8 @@ pub(crate) fn set_main_window_transient_geometry( }) } -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) -> 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 { - match (window.inner_size(), window.scale_factor()) { - (Ok(size), Ok(scale_factor)) => { - let logical_size = size.to_logical::(scale_factor); - if !has_standard_main_window_size(logical_size.width, logical_size.height) { - log::info!( - "Resetting undersized main window state: width={}, height={}", - logical_size.width, - logical_size.height - ); - - let resize_result = window.set_size(tauri::LogicalSize::new( - MAIN_WINDOW_DEFAULT_WIDTH, - MAIN_WINDOW_DEFAULT_HEIGHT, - )); - let center_result = window.center(); - let resize_succeeded = match resize_result { - Ok(()) => true, - Err(error) => { - log::warn!("Failed to reset main window size: {}", error); - false - } - }; - if let Err(error) = center_result { - log::warn!("Failed to center reset main window: {}", error); - } - if resize_succeeded { - 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); - } - } - } - } - (Err(error), _) => { - log::warn!("Failed to read restored main window size: {}", error); - } - (_, Err(error)) => { - log::warn!("Failed to read main window scale factor: {}", error); - } - } - } - - if let Err(error) = window.set_min_size(Some(tauri::LogicalSize::new( - MAIN_WINDOW_MIN_WIDTH, - MAIN_WINDOW_MIN_HEIGHT, - ))) { - 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, 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() { - assert!(!has_standard_main_window_size(440.0, 680.0)); - assert!(!has_standard_main_window_size(700.0, 140.0)); - } - - #[test] - 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)); - } + window_state_support::restore(window) } #[tauri::command] @@ -860,17 +707,9 @@ pub async fn run() { ) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin( - tauri_plugin_window_state::Builder::default() - // Restore explicitly after the main window is built, and save - // explicitly at normal-geometry boundaries. Empty automatic - // flags keep toolbar-mode resize/move events out of the - // plugin cache and prevent its exit hook from overwriting the - // last normal main-window geometry. - .with_state_flags(StateFlags::empty()) - .with_filter(|label| label == "main") - .build(), - ) + // The desktop owns validated snapshots and atomic writes. Do not install + // window-state: its exit hook can overwrite repairs with stale cached data. + .manage(window_state_support::MainWindowState::default()) .manage(app_state) .manage(sleep_prevention::SleepPreventionState::default()) .manage(desktop_runtime) @@ -1344,6 +1183,12 @@ pub async fn run() { }) .on_window_event({ move |window, event| { + if window.label() == "main" + && !MAIN_WINDOW_USES_TRANSIENT_GEOMETRY.load(Ordering::SeqCst) + && matches!(event, tauri::WindowEvent::Moved(_) | tauri::WindowEvent::Resized(_)) + { + window_state_support::remember_normal(window); + } if window.label() == "main" && matches!(event, tauri::WindowEvent::CloseRequested { .. }) { diff --git a/src/apps/desktop/src/tray.rs b/src/apps/desktop/src/tray.rs index 9a1490074e..c9c5be9964 100644 --- a/src/apps/desktop/src/tray.rs +++ b/src/apps/desktop/src/tray.rs @@ -272,6 +272,9 @@ pub fn show_main_window(app: &tauri::AppHandle) { log::warn!("Failed to unminimize main window via tray: {}", error); return; } + if let Err(error) = crate::window_state_support::repair_for_activation(&window) { + log::warn!("Failed to repair main window geometry via tray: {}", error); + } if let Err(error) = window.show() { log::warn!("Failed to show main window via tray: {}", error); return; diff --git a/src/apps/desktop/src/window_state_support.rs b/src/apps/desktop/src/window_state_support.rs index 61ea0ba7c2..1caf4f7f31 100644 --- a/src/apps/desktop/src/window_state_support.rs +++ b/src/apps/desktop/src/window_state_support.rs @@ -1,399 +1,377 @@ -//! Windows-native correction for the persisted main-window state. -//! -//! OpenBitFun 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; +//! Validated desktop window persistence using the legacy window-state JSON shape. +//! This module owns the sample and writer: the old plugin re-sampled on save and +//! wrote its cache on exit even when automatic tracking was disabled. +mod geometry; +#[cfg(test)] +mod tests; + +use geometry::{Desktop, Geometry}; +use serde_json::{json, Value}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use tauri::Manager; -use tauri_plugin_window_state::AppHandleExt; - -const MAIN_WINDOW_LABEL: &str = "main"; -// ─── Authoritative maximized placement ──────────────────────────────────────── +const STATE_FILE: &str = ".window-state.json"; -/// 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, +#[derive(Default)] +pub(crate) struct MainWindowState { + ready: AtomicBool, + // Drag/resize only updates memory; disk writes stay at lifecycle boundaries. + // Never hold either lock across native window calls. + normal: Mutex>, + writes: Mutex<()>, } -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 - } +#[derive(Debug, Clone, Copy)] +struct Snapshot { + geometry: Geometry, + maximized: bool, + minimized: bool, + fullscreen: bool, + visible: bool, } -#[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), - }) +impl Snapshot { + fn is_normal(self) -> bool { + self.visible && !self.maximized && !self.minimized && !self.fullscreen } } -#[cfg(target_os = "windows")] -fn query_native_window_placement(window: &tauri::WebviewWindow) -> Option { - let handle = window.hwnd().ok()?; - native::query(handle.0 as isize) +#[derive(Debug)] +struct RestorePlan { + geometry: Geometry, + maximized: bool, + fullscreen: bool, + repair: bool, } -// ─── 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; +fn restore_plan(document: &Value, desktop: &Desktop) -> RestorePlan { + let entry = &document["main"]; + let maximized = entry["maximized"].as_bool().unwrap_or(false); + let fullscreen = entry["fullscreen"].as_bool().unwrap_or(false); + let saved = Geometry::read(entry, maximized); + let valid = saved.filter(|geometry| desktop.valid(*geometry)); + RestorePlan { + geometry: valid.unwrap_or_else(|| desktop.default_geometry(saved)), + maximized, + fullscreen, + repair: entry.is_object() && valid.is_none(), } - - 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 state_path(app: &tauri::AppHandle) -> Result { + app.path() + .app_config_dir() + .map(|directory| directory.join(STATE_FILE)) + .map_err(|error| error.to_string()) } -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; +fn read_document(path: &Path) -> Result<(Value, Option>), String> { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((json!({}), None)), + Err(error) => return Err(format!("Failed to read window state: {error}")), + }; + let document: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("Invalid window state JSON; keeping original: {error}"))?; + if !document.is_object() || document.get("main").is_some_and(|entry| !entry.is_object()) { + return Err("Unrecognized window state shape; keeping original".into()); } - entry.insert(key.to_string(), serde_json::Value::Bool(value)); - true + Ok((document, Some(bytes))) } -/// 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; +fn update_document(document: &mut Value, geometry: Geometry, flags: Option<(bool, bool)>) { + let entry = document + .as_object_mut() + .expect("validated document") + .entry("main") + .or_insert_with(|| json!({})); + geometry.write(entry); + let entry = entry.as_object_mut().expect("validated main entry"); + // Only fill missing legacy fields. Visibility and decorations remain surface + // defaults, not startup instructions. Unknown fields/windows survive. + for (key, value) in [ + ("visible", true), + ("decorated", true), + ("maximized", false), + ("fullscreen", false), + ] { + entry.entry(key).or_insert(json!(value)); } - - 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) - } + if let Some((maximized, fullscreen)) = flags { + entry.insert("maximized".into(), json!(maximized)); + entry.insert("fullscreen".into(), json!(fullscreen)); } } -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); +fn write_document(path: &Path, document: &Value, backup: Option<&[u8]>) -> Result<(), String> { + let directory = path.parent().ok_or("Window state path has no parent")?; + std::fs::create_dir_all(directory).map_err(|error| error.to_string())?; + if let Some(original) = backup { + let backup_path = directory.join(format!( + ".window-state.invalid-{}.json", + uuid::Uuid::new_v4() + )); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&backup_path) + .map_err(|error| format!("Failed to preserve invalid window state: {error}"))?; + file.write_all(original) + .and_then(|_| file.sync_all()) + .map_err(|error| error.to_string())?; + log::warn!( + "Preserved invalid window geometry before repair: backup={}", + backup_path.display() + ); } - - 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) + // persist replaces atomically on Windows and Unix; a failure keeps the old file. + let mut file = tempfile::NamedTempFile::new_in(directory).map_err(|error| error.to_string())?; + serde_json::to_writer_pretty(&mut file, document).map_err(|error| error.to_string())?; + file.flush() + .and_then(|_| file.as_file().sync_all()) + .map_err(|error| error.to_string())?; + file.persist(path) + .map_err(|error| format!("Failed to replace window state: {error}"))?; + Ok(()) } -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, - ) +pub(crate) fn restore(window: &tauri::WebviewWindow) -> bool { + match restore_inner(&window.as_ref().window()) { + Ok(maximized) => maximized, + Err(error) => { + // The builder already supplied a safe centered default. + log::warn!("Failed to restore main window state; using startup defaults: {error}"); + false } - .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, +fn restore_inner(window: &tauri::Window) -> Result { + let app = window.app_handle(); + let state = app.state::(); + let desktop = Desktop::read(window)?; + let path = state_path(app)?; + let (document, _) = read_document(&path)?; + let plan = restore_plan(&document, &desktop); + if plan.repair { + log::warn!( + "Repairing persisted main window geometry: saved_width={} saved_height={} restored={:?}", + document["main"]["width"], + document["main"]["height"], + plan.geometry + ); + } + apply_geometry(window, plan.geometry, &desktop)?; + *state.normal.lock().map_err(|error| error.to_string())? = Some(plan.geometry); + if plan.repair { + let _write = state.writes.lock().map_err(|error| error.to_string())?; + let (mut document, original) = read_document(&path)?; + update_document(&mut document, plan.geometry, None); + if let Err(error) = write_document(&path, &document, original.as_deref()) { + log::warn!( + "Main window geometry repaired in memory but could not be persisted: {error}" + ); } } + window + .set_fullscreen(plan.fullscreen) + .map_err(|error| error.to_string())?; + state.ready.store(true, Ordering::Release); + // Windows must not maximize a hidden undecorated window. Fullscreen takes + // precedence for this launch; its saved maximize preference is kept on disk. + Ok(plan.maximized && !plan.fullscreen) +} - /// 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 apply_geometry( + window: &tauri::Window, + geometry: Geometry, + desktop: &Desktop, +) -> Result<(), String> { + window + .set_size(tauri::PhysicalSize::new(geometry.width, geometry.height)) + .map_err(|error| error.to_string())?; + window + .set_position(tauri::PhysicalPosition::new(geometry.x, geometry.y)) + .map_err(|error| error.to_string())?; + let (width, height) = desktop.minimum_size(geometry); + window + .set_min_size(Some(tauri::PhysicalSize::new(width, height))) + .map_err(|error| error.to_string()) +} - fn flipped_degraded_document() -> serde_json::Value { - let mut document = degraded_document(); - document["main"]["maximized"] = json!(true); - document +/// Retain normal bounds before maximize/minimize hides them. The caller excludes +/// toolbar mode; initialization/repair suppresses partial programmatic rectangles. +pub(crate) fn remember_normal(window: &tauri::Window) { + let state = window.app_handle().state::(); + if !state.ready.load(Ordering::Acquire) { + return; } - - #[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()); + let Ok(snapshot) = capture(window) else { + return; + }; + if !snapshot.is_normal() { + return; } - - #[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() - )); + let Ok(desktop) = Desktop::read(window) else { + return; + }; + if desktop.valid(snapshot.geometry) { + if let Ok(mut normal) = state.normal.lock() { + *normal = Some(snapshot.geometry); + } } +} - #[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()); +pub(crate) fn save(app: &tauri::AppHandle, reason: &str) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or("Main window not found")?; + let window = window.as_ref().window(); + let snapshot = capture(&window)?; + let desktop = Desktop::read(&window)?; + let state = app.state::(); + let mut normal = state.normal.lock().map_err(|error| error.to_string())?; + if snapshot.is_normal() { + if !desktop.valid(snapshot.geometry) { + return Err(format!( + "Rejected main window snapshot: reason={reason}, snapshot={snapshot:?}" + )); + } + *normal = Some(snapshot.geometry); } + let geometry = *normal; + drop(normal); + // No native calls under the writer lock. Persist exactly the sample validated. + let path = state_path(app)?; + let _write = state.writes.lock().map_err(|error| error.to_string())?; + persist_snapshot(&path, snapshot, geometry, &desktop) +} - #[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()); +fn persist_snapshot( + path: &Path, + snapshot: Snapshot, + normal: Option, + desktop: &Desktop, +) -> Result<(), String> { + if snapshot.is_normal() && !desktop.valid(snapshot.geometry) { + return Err("Rejected invalid normal window geometry".into()); } + let (mut document, original) = read_document(path)?; + let plan = restore_plan(&document, desktop); + let geometry = if snapshot.is_normal() { + snapshot.geometry + } else { + normal + .filter(|geometry| desktop.valid(*geometry)) + .unwrap_or(plan.geometry) + }; + update_document( + &mut document, + geometry, + // Hiding to tray may change the native show command. The close boundary + // already saved the visible window's preferences; keep those on exit. + snapshot + .visible + .then_some((snapshot.maximized, snapshot.fullscreen)), + ); + write_document(path, &document, original.as_deref().filter(|_| plan.repair)) +} - #[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()); +pub(crate) fn repair_for_activation(window: &tauri::WebviewWindow) -> Result<(), String> { + if crate::MAIN_WINDOW_USES_TRANSIENT_GEOMETRY.load(Ordering::SeqCst) { + return Ok(()); } - - #[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)) - ); + let window = window.as_ref().window(); + let snapshot = capture(&window)?; + if snapshot.minimized || snapshot.maximized || snapshot.fullscreen { + return Ok(()); } - - #[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()); + let desktop = Desktop::read(&window)?; + if desktop.valid(snapshot.geometry) { + return Ok(()); } + log::warn!("Repairing main window geometry during activation: snapshot={snapshot:?}"); + let state = window.app_handle().state::(); + let normal = *state.normal.lock().map_err(|error| error.to_string())?; + let geometry = normal + .filter(|geometry| desktop.valid(*geometry)) + .unwrap_or_else(|| desktop.default_geometry(Some(snapshot.geometry))); + state.ready.store(false, Ordering::Release); + let result = apply_geometry(&window, geometry, &desktop); + state.ready.store(true, Ordering::Release); + result?; + *state.normal.lock().map_err(|error| error.to_string())? = Some(geometry); + Ok(()) +} - #[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()); +#[cfg(target_os = "windows")] +fn capture(window: &tauri::Window) -> Result { + use windows::Win32::Foundation::{HWND, RECT}; + use windows::Win32::UI::WindowsAndMessaging::{ + GetClientRect, GetWindowPlacement, GetWindowRect, IsWindowVisible, WINDOWPLACEMENT, + WINDOWPLACEMENT_FLAGS, WPF_RESTORETOMAXIMIZED, + }; + let handle = window.hwnd().map_err(|error| error.to_string())?; + let hwnd = HWND(handle.0); + let mut before = WINDOWPLACEMENT::default(); + before.length = std::mem::size_of::() as u32; + let mut after = before; + let mut client = RECT::default(); + let mut outer = RECT::default(); + // SAFETY: hwnd belongs to the live main window and output buffers outlive + // the calls. Avoid Tao's cached maximized flag and unreliable rcNormalPosition + // geometry. Refuse a sample spanning a native placement transition. + unsafe { + GetWindowPlacement(hwnd, &mut before).map_err(|error| error.to_string())?; + GetClientRect(hwnd, &mut client).map_err(|error| error.to_string())?; + GetWindowRect(hwnd, &mut outer).map_err(|error| error.to_string())?; + GetWindowPlacement(hwnd, &mut after).map_err(|error| error.to_string())?; } - - #[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" + if before.showCmd != after.showCmd + || before.flags != after.flags + || before.rcNormalPosition != after.rcNormalPosition + { + return Err( + "Main window placement changed during snapshot; keeping last normal geometry".into(), ); } + let minimized = matches!(after.showCmd, 2 | 6 | 7 | 11); + let maximized = after.showCmd == 3 + || (minimized && after.flags & WPF_RESTORETOMAXIMIZED != WINDOWPLACEMENT_FLAGS(0)); + Ok(Snapshot { + geometry: Geometry { + width: u32::try_from(i64::from(client.right) - i64::from(client.left)) + .map_err(|_| "Negative window client width")?, + height: u32::try_from(i64::from(client.bottom) - i64::from(client.top)) + .map_err(|_| "Negative window client height")?, + x: outer.left, + y: outer.top, + }, + maximized, + minimized, + fullscreen: window.is_fullscreen().map_err(|error| error.to_string())?, + visible: unsafe { IsWindowVisible(hwnd) }.as_bool(), + }) +} - #[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" - ); - } +#[cfg(not(target_os = "windows"))] +fn capture(window: &tauri::Window) -> Result { + let size = window.inner_size().map_err(|error| error.to_string())?; + let position = window.outer_position().map_err(|error| error.to_string())?; + Ok(Snapshot { + geometry: Geometry { + width: size.width, + height: size.height, + x: position.x, + y: position.y, + }, + maximized: window.is_maximized().map_err(|error| error.to_string())?, + minimized: window.is_minimized().map_err(|error| error.to_string())?, + fullscreen: window.is_fullscreen().map_err(|error| error.to_string())?, + visible: window.is_visible().map_err(|error| error.to_string())?, + }) } diff --git a/src/apps/desktop/src/window_state_support/geometry.rs b/src/apps/desktop/src/window_state_support/geometry.rs new file mode 100644 index 0000000000..f16476694d --- /dev/null +++ b/src/apps/desktop/src/window_state_support/geometry.rs @@ -0,0 +1,172 @@ +use serde_json::{json, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct Geometry { + pub width: u32, + pub height: u32, + pub x: i32, + pub y: i32, +} + +impl Geometry { + pub(super) fn read(entry: &Value, maximized: bool) -> Option { + let position = |current: &str, previous: &str| { + let value = if maximized { + entry.get(previous).or_else(|| entry.get(current)) + } else { + entry.get(current) + }; + i32::try_from(value?.as_i64()?).ok() + }; + Some(Self { + width: u32::try_from(entry["width"].as_u64()?).ok()?, + height: u32::try_from(entry["height"].as_u64()?).ok()?, + x: position("x", "prev_x")?, + y: position("y", "prev_y")?, + }) + } + + pub(super) fn write(self, entry: &mut Value) { + for (key, value) in [ + ("width", json!(self.width)), + ("height", json!(self.height)), + ("x", json!(self.x)), + ("y", json!(self.y)), + ("prev_x", json!(self.x)), + ("prev_y", json!(self.y)), + ] { + entry[key] = value; + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct Display { + pub bounds: Geometry, + pub scale: f64, +} + +pub(super) struct Desktop(pub Vec); + +impl Desktop { + pub(super) fn read(window: &tauri::Window) -> Result { + let primary = window + .primary_monitor() + .map_err(|error| error.to_string())?; + let mut monitors = window + .available_monitors() + .map_err(|error| error.to_string())?; + if let Some(primary) = primary { + monitors.sort_by_key(|monitor| monitor.position() != primary.position()); + } + let displays: Vec<_> = monitors + .into_iter() + .filter_map(|monitor| { + let area = monitor.work_area(); + let scale = monitor.scale_factor(); + (area.size.width > 0 && area.size.height > 0 && scale.is_finite() && scale > 0.0) + .then_some(Display { + bounds: Geometry { + width: area.size.width, + height: area.size.height, + x: area.position.x, + y: area.position.y, + }, + scale, + }) + }) + .collect(); + if displays.is_empty() { + return Err("No usable monitor bounds; keeping saved window state".into()); + } + Ok(Self(displays)) + } + + fn display(&self, geometry: Option) -> Display { + geometry + .and_then(|geometry| { + self.0.iter().find(|display| { + let bounds = display.bounds; + i64::from(geometry.x) >= i64::from(bounds.x) + && i64::from(geometry.x) < i64::from(bounds.x) + i64::from(bounds.width) + && i64::from(geometry.y) >= i64::from(bounds.y) + && i64::from(geometry.y) < i64::from(bounds.y) + i64::from(bounds.height) + }) + }) + .copied() + .unwrap_or(self.0[0]) + } + + pub(super) fn minimum_size(&self, geometry: Geometry) -> (u32, u32) { + let display = self.display(Some(geometry)); + ( + ((crate::MAIN_WINDOW_MIN_WIDTH * display.scale).ceil() as u32) + .min(display.bounds.width), + ((crate::MAIN_WINDOW_MIN_HEIGHT * display.scale).ceil() as u32) + .min(display.bounds.height), + ) + } + + pub(super) fn valid(&self, geometry: Geometry) -> bool { + let (minimum_width, minimum_height) = self.minimum_size(geometry); + if geometry.width < minimum_width || geometry.height < minimum_height { + return false; + } + // Bound dimensions by the whole desktop, not just the primary monitor. + // Allow native frame/shadow overhang (128 logical pixels). + let left = self.0.iter().map(|d| i64::from(d.bounds.x)).min().unwrap(); + let top = self.0.iter().map(|d| i64::from(d.bounds.y)).min().unwrap(); + let right = self + .0 + .iter() + .map(|d| i64::from(d.bounds.x) + i64::from(d.bounds.width)) + .max() + .unwrap(); + let bottom = self + .0 + .iter() + .map(|d| i64::from(d.bounds.y) + i64::from(d.bounds.height)) + .max() + .unwrap(); + let margin = self + .0 + .iter() + .map(|d| (128.0 * d.scale).ceil() as i64) + .max() + .unwrap(); + if i64::from(geometry.width) > right - left + margin + || i64::from(geometry.height) > bottom - top + margin + { + return false; + } + // A usable part of the top bar must intersect a real display. Intersecting + // only the virtual desktop bounding box can land in a gap between screens. + self.0.iter().any(|display| { + let bounds = display.bounds; + let grip_width = (64.0 * display.scale).ceil() as i64; + let grip_height = (16.0 * display.scale).ceil() as i64; + let bar_height = (48.0 * display.scale).ceil() as i64; + let overlap_width = (i64::from(geometry.x) + i64::from(geometry.width)) + .min(i64::from(bounds.x) + i64::from(bounds.width)) + - i64::from(geometry.x).max(i64::from(bounds.x)); + let overlap_height = (i64::from(geometry.y) + bar_height) + .min(i64::from(bounds.y) + i64::from(bounds.height)) + - i64::from(geometry.y).max(i64::from(bounds.y)); + overlap_width >= grip_width && overlap_height >= grip_height + }) + } + + pub(super) fn default_geometry(&self, previous: Option) -> Geometry { + let display = self.display(previous); + let width = ((crate::MAIN_WINDOW_DEFAULT_WIDTH * display.scale).round() as u32) + .min(display.bounds.width); + let height = ((crate::MAIN_WINDOW_DEFAULT_HEIGHT * display.scale).round() as u32) + .min(display.bounds.height); + Geometry { + width, + height, + x: (i64::from(display.bounds.x) + i64::from(display.bounds.width - width) / 2) as i32, + y: (i64::from(display.bounds.y) + i64::from(display.bounds.height - height) / 2) as i32, + } + } +} diff --git a/src/apps/desktop/src/window_state_support/tests.rs b/src/apps/desktop/src/window_state_support/tests.rs new file mode 100644 index 0000000000..d3f16c8c3e --- /dev/null +++ b/src/apps/desktop/src/window_state_support/tests.rs @@ -0,0 +1,330 @@ +use super::geometry::Display; +use super::*; + +fn desktop() -> Desktop { + Desktop(vec![Display { + bounds: Geometry { + width: 1920, + height: 1040, + x: 0, + y: 0, + }, + scale: 1.0, + }]) +} + +fn good_geometry() -> Geometry { + Geometry { + width: 1200, + height: 800, + x: 100, + y: 80, + } +} + +fn normal_snapshot(geometry: Geometry) -> Snapshot { + Snapshot { + geometry, + maximized: false, + minimized: false, + fullscreen: false, + visible: true, + } +} + +fn reported_legacy_document() -> Value { + json!({"main": { + "width": 65519, "height": 65526, "x": 0, "y": 0, "prev_x": 0, "prev_y": 0, + "maximized": false, "visible": true, "decorated": true, "fullscreen": false, + }}) +} + +#[test] +fn reported_legacy_dimensions_are_repaired_before_any_native_restore() { + let plan = restore_plan(&reported_legacy_document(), &desktop()); + assert!(plan.repair); + assert_eq!( + plan.geometry, + Geometry { + width: 1200, + height: 800, + x: 360, + y: 120 + } + ); + assert!(!plan.maximized); + assert!(desktop().valid(plan.geometry)); +} + +#[test] +fn invalid_legacy_numeric_shapes_fall_back_without_casting_or_overflow() { + for width in [json!(-17), json!(1.5), json!(u64::MAX), json!("65519")] { + let mut document = reported_legacy_document(); + document["main"]["width"] = width; + let plan = restore_plan(&document, &desktop()); + assert!(plan.repair); + assert!(desktop().valid(plan.geometry)); + } +} + +#[test] +fn rejects_invalid_dimensions_and_inaccessible_positions_without_magic_values() { + for geometry in [ + Geometry { + width: 65519, + height: 65526, + ..good_geometry() + }, + Geometry { + width: u32::MAX, + ..good_geometry() + }, + Geometry { + height: u32::MAX, + ..good_geometry() + }, + Geometry { + width: 0, + ..good_geometry() + }, + Geometry { + width: 440, + height: 680, + ..good_geometry() + }, + Geometry { + x: i32::MAX, + ..good_geometry() + }, + Geometry { + y: -32000, + ..good_geometry() + }, + ] { + assert!(!desktop().valid(geometry), "{geometry:?}"); + } +} + +#[test] +fn supports_negative_coordinates_spanning_screens_and_high_dpi() { + let mut desktop = desktop(); + desktop.0.push(Display { + bounds: Geometry { + width: 3840, + height: 2080, + x: -3840, + y: 0, + }, + scale: 2.0, + }); + let high_dpi = Geometry { + width: 2400, + height: 1600, + x: -3600, + y: 80, + }; + assert!(desktop.valid(high_dpi)); + assert!(desktop.valid(Geometry { + width: 4800, + ..high_dpi + })); + let mut document = json!({}); + update_document(&mut document, high_dpi, Some((false, false))); + assert_eq!(restore_plan(&document, &desktop).geometry, high_dpi); + assert!(!restore_plan(&document, &desktop).repair); +} + +#[test] +fn disconnected_display_and_display_gaps_recover_to_primary() { + let mut desktop = desktop(); + desktop.0.push(Display { + bounds: Geometry { + width: 1920, + height: 1040, + x: 6000, + y: 0, + }, + scale: 1.0, + }); + assert!(!desktop.valid(Geometry { + x: 3000, + ..good_geometry() + })); + let mut document = json!({}); + update_document( + &mut document, + Geometry { + x: -1800, + ..good_geometry() + }, + None, + ); + let plan = restore_plan(&document, &desktop); + assert!(plan.repair); + assert_eq!(plan.geometry.x, 360); +} + +#[test] +fn defaults_fit_small_high_dpi_work_area() { + let desktop = Desktop(vec![Display { + bounds: Geometry { + width: 1024, + height: 700, + x: 0, + y: 40, + }, + scale: 2.0, + }]); + let geometry = desktop.default_geometry(None); + assert_eq!(geometry.width, 1024); + assert_eq!(geometry.height, 700); + assert!(desktop.valid(geometry)); +} + +#[test] +fn validates_the_actual_previous_position_used_by_maximized_legacy_windows() { + let mut document = json!({}); + update_document(&mut document, good_geometry(), Some((true, false))); + document["main"]["prev_x"] = json!(-32000); + let plan = restore_plan(&document, &desktop()); + assert!(plan.maximized); + assert!(plan.repair); + assert!(desktop().valid(plan.geometry)); +} + +#[test] +fn partial_legacy_shape_keeps_preferences_and_unknown_fields() { + let mut document = json!({ + "main": {"width": 100, "maximized": true, "fullscreen": true, "future": {"value": 9}}, + "other-window": {"data": "untouched"}, + }); + let plan = restore_plan(&document, &desktop()); + assert!(plan.maximized && plan.fullscreen && plan.repair); + update_document(&mut document, plan.geometry, None); + let reloaded: Value = serde_json::from_slice(&serde_json::to_vec(&document).unwrap()).unwrap(); + assert_eq!(reloaded["main"]["future"]["value"], 9); + assert_eq!(reloaded["other-window"]["data"], "untouched"); + assert!(restore_plan(&reloaded, &desktop()).maximized); + assert!(!restore_plan(&reloaded, &desktop()).repair); +} + +#[test] +fn old_payload_repair_save_restart_preserves_original_and_does_not_reintroduce_bad_dimensions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(STATE_FILE); + let original = serde_json::to_vec_pretty(&reported_legacy_document()).unwrap(); + std::fs::write(&path, &original).unwrap(); + let (mut document, bytes) = read_document(&path).unwrap(); + let plan = restore_plan(&document, &desktop()); + update_document(&mut document, plan.geometry, None); + write_document(&path, &document, bytes.as_deref()).unwrap(); + // Exit while minimized/maximized: invalid native client bounds must not + // overwrite the validated normal rectangle retained during startup. + let mut snapshot = normal_snapshot(Geometry { + width: 65519, + height: 65526, + ..good_geometry() + }); + snapshot.minimized = true; + snapshot.maximized = true; + persist_snapshot(&path, snapshot, Some(plan.geometry), &desktop()).unwrap(); + let restarted = restore_plan(&read_document(&path).unwrap().0, &desktop()); + assert_eq!(restarted.geometry, plan.geometry); + assert!(restarted.maximized); + assert!(!restarted.repair); + let backups: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(Result::unwrap) + .filter(|file| { + file.file_name() + .to_string_lossy() + .starts_with(".window-state.invalid-") + }) + .collect(); + assert_eq!(backups.len(), 1); + assert_eq!(std::fs::read(backups[0].path()).unwrap(), original); +} + +#[test] +fn invalid_sample_does_not_overwrite_last_good_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(STATE_FILE); + persist_snapshot(&path, normal_snapshot(good_geometry()), None, &desktop()).unwrap(); + let before = std::fs::read(&path).unwrap(); + let bad = normal_snapshot(Geometry { + width: 65519, + height: 65526, + ..good_geometry() + }); + assert!(persist_snapshot(&path, bad, Some(good_geometry()), &desktop()).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[test] +fn quitting_from_tray_preserves_pre_hide_maximize_and_fullscreen_preferences() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(STATE_FILE); + let mut document = json!({}); + update_document(&mut document, good_geometry(), Some((true, true))); + write_document(&path, &document, None).unwrap(); + let mut hidden = normal_snapshot(good_geometry()); + hidden.visible = false; + persist_snapshot(&path, hidden, Some(good_geometry()), &desktop()).unwrap(); + let plan = restore_plan(&read_document(&path).unwrap().0, &desktop()); + assert!(plan.maximized && plan.fullscreen); + assert_eq!(plan.geometry, good_geometry()); +} + +#[test] +fn maximized_fullscreen_and_hidden_samples_keep_last_normal_geometry() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(STATE_FILE); + let mut document = json!({}); + update_document(&mut document, good_geometry(), None); + document["main"]["future"] = json!("keep"); + write_document(&path, &document, None).unwrap(); + for flags in [ + (true, false, true), + (false, true, true), + (false, false, false), + ] { + let mut sample = normal_snapshot(Geometry { + width: 65519, + height: 65526, + ..good_geometry() + }); + (sample.maximized, sample.fullscreen, sample.visible) = flags; + persist_snapshot(&path, sample, Some(good_geometry()), &desktop()).unwrap(); + let document = read_document(&path).unwrap().0; + assert_eq!( + Geometry::read(&document["main"], false), + Some(good_geometry()) + ); + assert_eq!(document["main"]["future"], "keep"); + } +} + +#[test] +fn unreadable_shapes_are_never_overwritten_even_on_exit() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(STATE_FILE); + for original in ["{not json", "[]", "{\"main\":null}"] { + std::fs::write(&path, original).unwrap(); + assert!( + persist_snapshot(&path, normal_snapshot(good_geometry()), None, &desktop()).is_err() + ); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + } +} + +#[test] +fn failed_atomic_replacement_leaves_existing_target_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(STATE_FILE); + std::fs::create_dir(&path).unwrap(); + let marker = path.join("original"); + std::fs::write(&marker, "preserve").unwrap(); + assert!(write_document(&path, &json!({"main": {}}), None).is_err()); + assert_eq!(std::fs::read_to_string(marker).unwrap(), "preserve"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); +} diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index 719acdb732..51b68eb444 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -195,10 +195,10 @@ describe('startup performance contract', () => { expect(desktopLibSource).toContain('MAIN_WINDOW_DEFAULT_WIDTH: f64 = 1200.0'); expect(desktopLibSource).toContain('MAIN_WINDOW_DEFAULT_HEIGHT: f64 = 800.0'); expect(desktopAppearanceSource).not.toContain('windows_maximize_show_wait_action'); - expect(desktopLibSource).toContain('tauri_plugin_window_state::Builder::default()'); - expect(desktopLibSource).toContain('.with_state_flags(StateFlags::empty())'); - expect(desktopLibSource).toContain('.with_filter(|label| label == "main")'); - expect(desktopLibSource).toContain('Resetting undersized main window state'); + expect(desktopLibSource).toContain('.manage(window_state_support::MainWindowState::default())'); + expect(desktopLibSource).not.toContain('tauri_plugin_window_state::Builder'); + expect(desktopLibSource).toContain('window_state_support::restore(window)'); + expect(desktopLibSource).toContain('window_state_support::save(app, reason)'); expect(desktopLibSource).toContain('MAIN_WINDOW_USES_TRANSIENT_GEOMETRY'); expect(toolbarModeProviderSource).not.toContain( "import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'"