Skip to main content

webui_desktop/
window_state.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4use 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/// Persisted window geometry supplied to and consumed by native backends.
24#[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/// Available display work area used to reject stale off-screen state.
34#[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/// Persistence error.
42#[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/// Bounded JSON state store in the app-data directory.
56#[derive(Clone, Debug)]
57pub struct WindowStateStore {
58    path: PathBuf,
59}
60impl WindowStateStore {
61    /// Create a state store at an explicit path.
62    #[must_use]
63    pub fn new(path: PathBuf) -> Self {
64        Self { path }
65    }
66    /// Return the platform app-data state path for an app id.
67    #[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    /// Configure native persistence only with an explicit, path-safe identity.
76    #[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    /// Atomically replace state using a uniquely created sibling temporary file.
97    ///
98    /// Readers see either complete version. This guarantees atomic visibility,
99    /// not durability across power loss.
100    ///
101    /// On Windows, the state directory must support POSIX-style atomic rename
102    /// (Windows 10 version 1607 or later on a supported filesystem such as NTFS).
103    /// Unsupported filesystems return an error rather than weaken this guarantee.
104    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        // Close before rename or failure cleanup, including on Windows.
117        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    /// Load visible state, returning `None` for absent or stale geometry.
124    ///
125    /// Malformed or oversized input returns a typed error. On Windows, a
126    /// transient access denial during replacement is retried at most seven times.
127    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    /// Return the storage path.
145    #[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            // Failed writes remove only the file reserved with create_new.
184            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}