From bc9fe67abf6241db1a800708381a44cb6346ba0f Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 1 Jun 2026 16:01:20 +0800 Subject: [PATCH] fix(desktop): sanitize invalid .window-state.json before restore (#4401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.window-state.json` containing the Windows minimized sentinel (x/y = -32000) or a 0×0 size makes WebView2 reject the restored bounds with 0x80070057 ("The parameter is incorrect"), so the app fails to launch until the file is deleted by hand. Add a small `window-state-sanitizer` plugin, registered before tauri-plugin-window-state, that strips window entries with invalid geometry (non-positive size, or a position past the -16000 off-screen cutoff) from the state file before the plugin loads it. Affected windows fall back to default geometry instead of crashing. Defense-in-depth: the bundled plugin (2.4.1) already guards against writing these values, so a bad file is almost certainly stale from an older build; this self-heals it on next launch. Refs #4398 Co-authored-by: Claude Opus 4.8 (1M context) --- apps/readest-app/src-tauri/src/lib.rs | 9 ++ .../readest-app/src-tauri/src/window_state.rs | 143 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 apps/readest-app/src-tauri/src/window_state.rs diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index fe1f4cd0..409c4f2d 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -29,6 +29,8 @@ mod discord_rpc; #[cfg(target_os = "macos")] mod macos; mod transfer_file; +#[cfg(desktop)] +mod window_state; #[cfg(target_os = "windows")] use tauri::webview::ScrollBarStyle; use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window}; @@ -317,6 +319,13 @@ pub fn run() { #[cfg(desktop)] let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + // Strip invalid geometry from the saved window state before the + // window-state plugin loads it, so a bad `.window-state.json` (e.g. the + // Windows minimized `-32000` sentinel) can't crash WebView2 on launch. + // See https://github.com/readest/readest/issues/4398. + #[cfg(desktop)] + let builder = builder.plugin(window_state::init()); + #[cfg(desktop)] let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build()); diff --git a/apps/readest-app/src-tauri/src/window_state.rs b/apps/readest-app/src-tauri/src/window_state.rs new file mode 100644 index 00000000..20a707bd --- /dev/null +++ b/apps/readest-app/src-tauri/src/window_state.rs @@ -0,0 +1,143 @@ +//! Defensive sanitizer for the `.window-state.json` file written by +//! `tauri-plugin-window-state`. +//! +//! On Windows a minimized window reports its position as `(-32000, -32000)` +//! and its size as `0x0`. The plugin already guards against persisting those +//! values, but a state file written by an older build (or a future +//! regression) can still contain them, and WebView2 then rejects the restored +//! bounds with `0x80070057` ("The parameter is incorrect"), leaving the app +//! unable to launch. See https://github.com/readest/readest/issues/4398. +//! +//! This module strips any window entry with invalid geometry from the state +//! file *before* the window-state plugin loads it, so the affected window +//! falls back to its default position and size instead of crashing. + +use std::path::Path; +use tauri::{ + plugin::{Builder, TauriPlugin}, + Manager, Runtime, +}; + +/// Default filename used by `tauri-plugin-window-state`. +const STATE_FILENAME: &str = ".window-state.json"; + +/// Windows parks a minimized window at exactly `(-32000, -32000)`. Real +/// monitors sit only a few thousand pixels off the origin even in multi-display +/// setups (a 4K display left of the primary is `-3840`), so a saved coordinate +/// at or below `-16000` — roughly halfway to the sentinel and well past any +/// normal desktop — is the minimize marker rather than a real position. A +/// normal negative like `-1920` stays well above the cutoff and is kept. +const MIN_VALID_COORD: i64 = -16000; + +/// Returns a sanitized copy of the window-state JSON when one or more window +/// entries have invalid geometry, or `None` when nothing needs to change +/// (already valid, empty, or unparseable). +fn sanitize_json(content: &str) -> Option { + let mut windows: serde_json::Map = + serde_json::from_str(content).ok()?; + let before = windows.len(); + windows.retain(|_, state| has_valid_geometry(state)); + if windows.len() == before { + return None; + } + serde_json::to_string_pretty(&windows).ok() +} + +/// A window entry is only usable if it has a positive size and an on-screen +/// position. Missing fields are treated as valid so a schema change never +/// drops an otherwise-good entry. +fn has_valid_geometry(state: &serde_json::Value) -> bool { + let int = |key: &str, default: i64| { + state + .get(key) + .and_then(serde_json::Value::as_i64) + .unwrap_or(default) + }; + int("width", 1) > 0 + && int("height", 1) > 0 + && int("x", 0) > MIN_VALID_COORD + && int("y", 0) > MIN_VALID_COORD +} + +/// Reads, sanitizes, and rewrites the window-state file at `path`. Removes the +/// file entirely when sanitizing leaves no valid entries. +fn sanitize_file(path: &Path) { + let Ok(content) = std::fs::read_to_string(path) else { + return; + }; + let Some(sanitized) = sanitize_json(&content) else { + return; + }; + log::warn!("Removing invalid window geometry from {}", path.display()); + if sanitized.trim() == "{}" { + let _ = std::fs::remove_file(path); + } else { + let _ = std::fs::write(path, sanitized); + } +} + +/// Tauri plugin that sanitizes the saved window state during setup. Register it +/// immediately **before** `tauri-plugin-window-state` so the bad geometry is +/// gone before that plugin loads the file. +pub fn init() -> TauriPlugin { + Builder::new("window-state-sanitizer") + .setup(|app, _api| { + if let Ok(dir) = app.path().app_config_dir() { + sanitize_file(&dir.join(STATE_FILENAME)); + } + Ok(()) + }) + .build() +} + +#[cfg(test)] +mod tests { + use super::sanitize_json; + + const VALID: &str = r#"{"main":{"width":1280,"height":800,"x":100,"y":100,"prev_x":0,"prev_y":0,"maximized":false,"visible":true,"decorated":true,"fullscreen":false}}"#; + + #[test] + fn keeps_valid_state() { + assert!(sanitize_json(VALID).is_none()); + } + + #[test] + fn keeps_negative_multi_monitor_position() { + // A monitor to the left yields a legitimately negative x (e.g. -1920). + let json = r#"{"main":{"width":1280,"height":800,"x":-1920,"y":0}}"#; + assert!(sanitize_json(json).is_none()); + } + + #[test] + fn keeps_deep_multi_monitor_position() { + // Even a few stacked displays left of the primary stay well above the + // cutoff (three 4K monitors reach only ~ -11520). + let json = r#"{"main":{"width":1280,"height":800,"x":-11520,"y":0}}"#; + assert!(sanitize_json(json).is_none()); + } + + #[test] + fn drops_minimized_sentinel_position() { + let json = r#"{"main":{"width":800,"height":600,"x":-32000,"y":-32000}}"#; + assert_eq!(sanitize_json(json).as_deref().map(str::trim), Some("{}")); + } + + #[test] + fn drops_zero_size() { + let json = r#"{"main":{"width":0,"height":0,"x":100,"y":100}}"#; + assert!(sanitize_json(json).is_some()); + } + + #[test] + fn keeps_good_entry_drops_bad_entry() { + let json = r#"{"good":{"width":1280,"height":800,"x":0,"y":0},"bad":{"width":0,"height":0,"x":-32000,"y":-32000}}"#; + let out = sanitize_json(json).expect("file changed"); + assert!(out.contains("good")); + assert!(!out.contains("bad")); + } + + #[test] + fn ignores_unparseable_content() { + assert!(sanitize_json("not json").is_none()); + } +}