1use serde::{Deserialize, Serialize};
5use std::fs::{self, File, OpenOptions};
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9use thiserror::Error;
10
11#[cfg(windows)]
12#[allow(unsafe_code)]
13mod windows;
14
15const MAX_STATE_BYTES: u16 = 4096;
16const MAX_TEMP_ATTEMPTS: usize = 16;
17#[cfg(any(test, windows))]
18const MAX_WINDOWS_OPEN_ATTEMPTS: usize = 8;
19#[cfg(any(test, windows))]
20const WINDOWS_ACCESS_DENIED: i32 = 5;
21static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0);
22
23#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
25pub struct WindowState {
26 pub x: i32,
27 pub y: i32,
28 pub width: u32,
29 pub height: u32,
30 #[serde(default)]
31 pub maximized: bool,
32}
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub struct DisplayBounds {
36 pub x: i32,
37 pub y: i32,
38 pub width: u32,
39 pub height: u32,
40}
41#[derive(Debug, Error)]
43pub enum WindowStateError {
44 #[error("failed while {context}: {source}")]
45 Io {
46 context: String,
47 #[source]
48 source: std::io::Error,
49 },
50 #[error("failed to parse saved window state: {0}")]
51 Json(serde_json::Error),
52 #[error("saved window state exceeds 4096 bytes (observed at least {size}); help: remove the state file and relaunch")]
53 TooLarge { size: u64 },
54}
55#[derive(Clone, Debug)]
57pub struct WindowStateStore {
58 path: PathBuf,
59}
60impl WindowStateStore {
61 #[must_use]
63 pub fn new(path: PathBuf) -> Self {
64 Self { path }
65 }
66 #[must_use]
68 pub fn for_app_id(app_id: &str) -> Self {
69 let base = std::env::var_os("WEBUI_APP_DATA_DIR")
70 .map(PathBuf::from)
71 .or_else(platform_app_data_root)
72 .unwrap_or_else(std::env::temp_dir);
73 Self::new(base.join(app_id).join("window-state.json"))
74 }
75 #[cfg(any(feature = "native", test))]
77 pub(crate) fn for_window(
78 remember_state: bool,
79 app_id: Option<&str>,
80 ) -> Result<Option<Self>, WindowStateError> {
81 if !remember_state {
82 return Ok(None);
83 }
84 let Some(app_id) = app_id.filter(|id| crate::app_identity::valid_app_id(id)) else {
85 return Err(WindowStateError::Io {
86 context: "configuring window-state identity".to_string(),
87 source: std::io::Error::new(
88 std::io::ErrorKind::InvalidInput,
89 "remember_state requires a path-safe app id; set DesktopFrame::with_app_id with at most 255 ASCII letters, digits, dots, underscores, or hyphens (no trailing dot)",
90 ),
91 });
92 };
93 Ok(Some(Self::for_app_id(app_id)))
94 }
95
96 pub fn save(&self, state: &WindowState) -> Result<(), WindowStateError> {
105 if let Some(parent) = self
106 .path
107 .parent()
108 .filter(|path| !path.as_os_str().is_empty())
109 {
110 fs::create_dir_all(parent).map_err(|source| io_error("creating", parent, source))?;
111 }
112 let data = serde_json::to_vec(state).map_err(WindowStateError::Json)?;
113 let (mut file, mut temporary) = create_temporary(&self.path)
114 .map_err(|source| io_error("creating temporary state for", &self.path, source))?;
115 let result = file.write_all(&data);
116 drop(file);
118 result.map_err(|source| io_error("writing", &temporary.path, source))?;
119 replace_state(&temporary.path, &self.path)?;
120 temporary.committed = true;
121 Ok(())
122 }
123 pub fn load_valid(
128 &self,
129 displays: &[DisplayBounds],
130 ) -> Result<Option<WindowState>, WindowStateError> {
131 #[cfg(windows)]
132 let opened = open_state_during_replacement(&self.path, |path| File::open(path));
133 #[cfg(not(windows))]
134 let opened = File::open(&self.path);
135 let file = match opened {
136 Ok(file) => file,
137 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
138 Err(source) => return Err(io_error("opening", &self.path, source)),
139 };
140 let data = read_bounded(file, &self.path)?;
141 let state = serde_json::from_slice(&data).map_err(WindowStateError::Json)?;
142 Ok(is_visible(&state, displays).then_some(state))
143 }
144 #[must_use]
146 pub fn path(&self) -> &Path {
147 &self.path
148 }
149}
150
151#[cfg(any(test, windows))]
152fn open_state_during_replacement(
153 path: &Path,
154 mut open: impl FnMut(&Path) -> std::io::Result<File>,
155) -> std::io::Result<File> {
156 for _ in 1..MAX_WINDOWS_OPEN_ATTEMPTS {
157 match open(path) {
158 Err(error) if error.raw_os_error() == Some(WINDOWS_ACCESS_DENIED) => {
159 std::thread::sleep(std::time::Duration::from_millis(2));
160 }
161 result => return result,
162 }
163 }
164 open(path)
165}
166
167fn replace_state(source: &Path, destination: &Path) -> Result<(), WindowStateError> {
168 #[cfg(windows)]
169 let result = windows::replace(source, destination);
170 #[cfg(not(windows))]
171 let result = fs::rename(source, destination);
172 result.map_err(|source| io_error("replacing", destination, source))
173}
174
175struct TemporaryState {
176 path: PathBuf,
177 committed: bool,
178}
179
180impl Drop for TemporaryState {
181 fn drop(&mut self) {
182 if !self.committed {
183 let _ = fs::remove_file(&self.path);
185 }
186 }
187}
188
189fn create_temporary(path: &Path) -> std::io::Result<(File, TemporaryState)> {
190 let parent = path.parent().unwrap_or_else(|| Path::new("."));
191 if path.file_name().is_none() {
192 return Err(std::io::Error::new(
193 std::io::ErrorKind::InvalidInput,
194 "window state path must name a file",
195 ));
196 }
197 for _ in 0..MAX_TEMP_ATTEMPTS {
198 let sequence = NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed);
199 let name = format!(".webui-window-state.{}.{sequence}.tmp", std::process::id());
200 let path = parent.join(name);
201 match OpenOptions::new().write(true).create_new(true).open(&path) {
202 Ok(file) => {
203 return Ok((
204 file,
205 TemporaryState {
206 path,
207 committed: false,
208 },
209 ))
210 }
211 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
212 Err(error) => return Err(error),
213 }
214 }
215 Err(std::io::Error::new(
216 std::io::ErrorKind::AlreadyExists,
217 "could not reserve a unique temporary window state file",
218 ))
219}
220
221#[cfg(target_os = "macos")]
222fn platform_app_data_root() -> Option<PathBuf> {
223 std::env::var_os("HOME").map(|home| {
224 PathBuf::from(home)
225 .join("Library")
226 .join("Application Support")
227 })
228}
229
230#[cfg(windows)]
231fn platform_app_data_root() -> Option<PathBuf> {
232 std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
233}
234
235#[cfg(not(any(target_os = "macos", windows)))]
236fn platform_app_data_root() -> Option<PathBuf> {
237 std::env::var_os("XDG_DATA_HOME")
238 .map(PathBuf::from)
239 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))
240}
241
242fn read_bounded(reader: impl Read, path: &Path) -> Result<Vec<u8>, WindowStateError> {
243 let limit = u64::from(MAX_STATE_BYTES) + 1;
244 let mut data = Vec::with_capacity(usize::from(MAX_STATE_BYTES) + 1);
245 reader
246 .take(limit)
247 .read_to_end(&mut data)
248 .map_err(|source| io_error("reading", path, source))?;
249 if data.len() > usize::from(MAX_STATE_BYTES) {
250 return Err(WindowStateError::TooLarge { size: limit });
251 }
252 Ok(data)
253}
254
255#[cold]
256#[inline(never)]
257fn io_error(context: &str, path: &Path, source: std::io::Error) -> WindowStateError {
258 WindowStateError::Io {
259 context: format!("{context} {}", path.display()),
260 source,
261 }
262}
263
264fn is_visible(state: &WindowState, displays: &[DisplayBounds]) -> bool {
265 if state.width < 64 || state.height < 64 || state.width > 16_384 || state.height > 16_384 {
266 return false;
267 }
268 displays.iter().any(|display| {
269 let right = i64::from(state.x) + i64::from(state.width);
270 let bottom = i64::from(state.y) + i64::from(state.height);
271 right > i64::from(display.x)
272 && bottom > i64::from(display.y)
273 && i64::from(state.x) < i64::from(display.x) + i64::from(display.width)
274 && i64::from(state.y) < i64::from(display.y) + i64::from(display.height)
275 })
276}
277#[cfg(test)]
278#[allow(clippy::disallowed_methods)]
279mod tests {
280 use super::*;
281 use std::cell::Cell;
282
283 #[test]
284 fn windows_transient_denial_during_state_open_is_retried() {
285 let dir = tempfile::TempDir::new().unwrap();
286 let store = WindowStateStore::new(dir.path().join("state.json"));
287 let state = WindowState {
288 x: 10,
289 y: 10,
290 width: 800,
291 height: 600,
292 maximized: false,
293 };
294 store.save(&state).unwrap();
295 let attempts = Cell::new(0);
296 let file = open_state_during_replacement(store.path(), |path| {
297 attempts.set(attempts.get() + 1);
298 if attempts.get() == 1 {
299 Err(std::io::Error::from_raw_os_error(5))
300 } else {
301 File::open(path)
302 }
303 })
304 .unwrap();
305 assert_eq!(attempts.get(), 2);
306 assert_eq!(
307 serde_json::from_reader::<_, WindowState>(file).unwrap(),
308 state
309 );
310 }
311
312 #[test]
313 fn windows_persistent_denial_and_other_open_errors_are_reported() {
314 let path = Path::new("state.json");
315 let attempts = Cell::new(0);
316 let denied = open_state_during_replacement(path, |_| {
317 attempts.set(attempts.get() + 1);
318 Err(std::io::Error::from_raw_os_error(WINDOWS_ACCESS_DENIED))
319 })
320 .unwrap_err();
321 assert_eq!(denied.raw_os_error(), Some(WINDOWS_ACCESS_DENIED));
322 assert_eq!(attempts.get(), MAX_WINDOWS_OPEN_ATTEMPTS);
323
324 for code in [2, 32] {
325 attempts.set(0);
326 let error = open_state_during_replacement(path, |_| {
327 attempts.set(attempts.get() + 1);
328 Err(std::io::Error::from_raw_os_error(code))
329 })
330 .unwrap_err();
331 assert_eq!(error.raw_os_error(), Some(code));
332 assert_eq!(attempts.get(), 1);
333 }
334 }
335
336 #[test]
337 fn round_trip_and_reject_stale_geometry() {
338 let dir = tempfile::TempDir::new().unwrap();
339 let store = WindowStateStore::new(dir.path().join("state.json"));
340 let state = WindowState {
341 x: 10,
342 y: 10,
343 width: 800,
344 height: 600,
345 maximized: true,
346 };
347 store.save(&state).unwrap();
348 assert_eq!(
349 store
350 .load_valid(&[DisplayBounds {
351 x: 0,
352 y: 0,
353 width: 1920,
354 height: 1080
355 }])
356 .unwrap(),
357 Some(state)
358 );
359 assert_eq!(
360 store
361 .load_valid(&[DisplayBounds {
362 x: 2000,
363 y: 0,
364 width: 100,
365 height: 100
366 }])
367 .unwrap(),
368 None
369 );
370 }
371
372 #[test]
373 fn read_limit_applies_to_the_stream_not_a_metadata_snapshot() {
374 let mut reader = std::io::Cursor::new(vec![b' '; usize::from(MAX_STATE_BYTES) * 2]);
375 assert!(matches!(
376 read_bounded(&mut reader, Path::new("state.json")),
377 Err(WindowStateError::TooLarge { .. })
378 ));
379 assert_eq!(reader.position(), u64::from(MAX_STATE_BYTES) + 1);
380 }
381
382 #[test]
383 fn native_persistence_requires_and_isolates_explicit_identities() {
384 assert!(WindowStateStore::for_window(false, None).unwrap().is_none());
385 for id in [
386 None,
387 Some(""),
388 Some("../other"),
389 Some("/absolute"),
390 Some("app."),
391 ] {
392 assert!(WindowStateStore::for_window(true, id).is_err());
393 }
394 let first = WindowStateStore::for_window(true, Some("com.example.first"))
395 .unwrap()
396 .unwrap();
397 let second = WindowStateStore::for_window(true, Some("com.example.second"))
398 .unwrap()
399 .unwrap();
400 assert_ne!(first.path(), second.path());
401 assert!(first
402 .path()
403 .ends_with("com.example.first/window-state.json"));
404 }
405
406 #[test]
407 fn concurrent_replacement_never_exposes_partial_json() {
408 let dir = tempfile::TempDir::new().unwrap();
409 let store = WindowStateStore::new(dir.path().join("state.json"));
410 let state = WindowState {
411 x: 10,
412 y: 10,
413 width: 800,
414 height: 600,
415 maximized: false,
416 };
417 store.save(&state).unwrap();
418 let displays = [DisplayBounds {
419 x: 0,
420 y: 0,
421 width: 1920,
422 height: 1080,
423 }];
424 std::thread::scope(|scope| {
425 for index in 0..4 {
426 let store = &store;
427 let state = &state;
428 scope.spawn(move || {
429 for _ in 0..32 {
430 store
431 .save(&WindowState {
432 x: index,
433 ..state.clone()
434 })
435 .unwrap();
436 }
437 });
438 }
439 for _ in 0..128 {
440 let saved = store.load_valid(&displays).unwrap().unwrap();
441 assert_eq!(saved.width, state.width);
442 assert_eq!(saved.height, state.height);
443 }
444 });
445 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
446 }
447
448 #[test]
449 fn replacement_keeps_existing_readers_and_new_opens_valid() {
450 let dir = tempfile::TempDir::new().unwrap();
451 let store = WindowStateStore::new(dir.path().join("state.json"));
452 let previous = WindowState {
453 x: 10,
454 y: 20,
455 width: 800,
456 height: 600,
457 maximized: false,
458 };
459 store.save(&previous).unwrap();
460 let reader = File::open(store.path()).unwrap();
461 let replacement = WindowState {
462 width: 1200,
463 maximized: true,
464 ..previous
465 };
466 store.save(&replacement).unwrap();
467 let visible: WindowState =
468 serde_json::from_slice(&fs::read(store.path()).unwrap()).unwrap();
469 assert_eq!(visible, replacement);
470 let original: WindowState = serde_json::from_reader(reader).unwrap();
471 assert_eq!(original, previous);
472 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
473 }
474
475 #[test]
476 fn failed_replacement_keeps_existing_target_and_cleans_temporary_file() {
477 let dir = tempfile::TempDir::new().unwrap();
478 let target = dir.path().join("occupied");
479 fs::create_dir(&target).unwrap();
480 fs::write(target.join("keep"), b"existing").unwrap();
481 let store = WindowStateStore::new(target.clone());
482 let state = WindowState {
483 x: 0,
484 y: 0,
485 width: 800,
486 height: 600,
487 maximized: false,
488 };
489 assert!(store.save(&state).is_err());
490 assert_eq!(fs::read(target.join("keep")).unwrap(), b"existing");
491 assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1);
492 }
493
494 #[test]
495 fn committed_temporary_guard_does_not_delete_a_reused_name() {
496 let dir = tempfile::TempDir::new().unwrap();
497 let target = dir.path().join("state.json");
498 let (file, mut temporary) = create_temporary(&target).unwrap();
499 drop(file);
500 fs::rename(&temporary.path, &target).unwrap();
501 temporary.committed = true;
502 let reused = temporary.path.clone();
503 fs::write(&reused, b"another writer").unwrap();
504 drop(temporary);
505 assert_eq!(fs::read(reused).unwrap(), b"another writer");
506 }
507}