fix(desktop): sanitize invalid .window-state.json before restore (#4401)

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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-01 16:01:20 +08:00
committed by GitHub
parent 45ef5f7515
commit bc9fe67abf
2 changed files with 152 additions and 0 deletions
+9
View File
@@ -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());
@@ -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<String> {
let mut windows: serde_json::Map<String, serde_json::Value> =
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<R: Runtime>() -> TauriPlugin<R> {
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());
}
}