Skip to main content

lumen_runtime/
window_state.rs

1//! Per-app window-state persistence.
2//!
3//! Opt-in via `[window] remember_state = true` in `lumen.toml`.
4//! Stores `(position, size, maximized)` at
5//! `<state_dir>/<app-id>/window-state.toml` between runs (`state_dir`
6//! follows the XDG / macOS / Windows conventions:
7//! `$XDG_STATE_HOME` -> `~/.local/state` on Linux,
8//! `~/Library/Application Support` on macOS,
9//! `%LOCALAPPDATA%` on Windows).
10//!
11//! Save happens on `LoopExiting` so a normal window close persists,
12//! and a `SIGTERM` / crash doesn't (acceptable - apps usually want a
13//! clean state if they crashed). Save also runs whenever `Resized` or
14//! a future `Moved` event fires so kill-9 still keeps the most-recent
15//! committed state.
16
17use serde::{Deserialize, Serialize};
18use std::path::PathBuf;
19
20/// On-disk shape. Logical pixels for `size` so HiDPI machines don't
21/// see the app come back at half-size; `position` is in physical
22/// pixels (winit's `outer_position` units).
23#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
24pub struct WindowState {
25    /// Outer window position in physical pixels. `None` = let the OS
26    /// pick (matches the bare-launch behaviour).
27    pub position: Option<[i32; 2]>,
28    /// Inner window size in logical pixels.
29    pub size: Option<[u32; 2]>,
30    /// Last maximized state.
31    pub maximized: bool,
32}
33
34/// Resolve the path Lumen uses to persist a given app's window state.
35/// Returns `None` when no per-user state dir is available (CI
36/// containers, sandboxed runs without `$HOME`); callers should treat
37/// that as "remember_state silently no-ops".
38pub fn state_path(app_id: &str) -> Option<PathBuf> {
39    let base = state_dir()?;
40    Some(base.join("lumen").join(app_id).join("window-state.toml"))
41}
42
43fn state_dir() -> Option<PathBuf> {
44    #[cfg(target_os = "linux")]
45    {
46        if let Ok(d) = std::env::var("XDG_STATE_HOME")
47            && !d.is_empty()
48        {
49            return Some(PathBuf::from(d));
50        }
51        std::env::var("HOME").ok().map(|h| {
52            let mut p = PathBuf::from(h);
53            p.push(".local");
54            p.push("state");
55            p
56        })
57    }
58    #[cfg(target_os = "macos")]
59    {
60        std::env::var("HOME").ok().map(|h| {
61            let mut p = PathBuf::from(h);
62            p.push("Library");
63            p.push("Application Support");
64            p
65        })
66    }
67    #[cfg(target_os = "windows")]
68    {
69        std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)
70    }
71    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
72    {
73        None
74    }
75}
76
77/// Load the persisted state for `app_id`, returning a fresh default
78/// when the file doesn't exist or fails to parse (corrupted state
79/// shouldn't brick the app - just discard).
80pub fn load(app_id: &str) -> WindowState {
81    let Some(path) = state_path(app_id) else {
82        return WindowState::default();
83    };
84    match std::fs::read_to_string(&path) {
85        Ok(src) => toml::from_str(&src).unwrap_or_default(),
86        Err(_) => WindowState::default(),
87    }
88}
89
90/// Save `state` for `app_id`. Best-effort: creates parent directories
91/// on the fly; logs to stderr on failure and returns.
92pub fn save(app_id: &str, state: &WindowState) {
93    let Some(path) = state_path(app_id) else {
94        return;
95    };
96    if let Some(parent) = path.parent()
97        && let Err(e) = std::fs::create_dir_all(parent)
98    {
99        eprintln!("lumenc: window-state mkdir failed: {e}");
100        return;
101    }
102    let body = match toml::to_string_pretty(state) {
103        Ok(s) => s,
104        Err(e) => {
105            eprintln!("lumenc: window-state serialize failed: {e}");
106            return;
107        }
108    };
109    if let Err(e) = std::fs::write(&path, body) {
110        eprintln!("lumenc: window-state write failed: {e}");
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn save_then_load_round_trips() {
120        let app_id = "lumenc-window-state-test";
121        let state = WindowState {
122            position: Some([100, 200]),
123            size: Some([1024, 768]),
124            maximized: false,
125        };
126        save(app_id, &state);
127        let loaded = load(app_id);
128        assert_eq!(loaded.position, Some([100, 200]));
129        assert_eq!(loaded.size, Some([1024, 768]));
130        assert!(!loaded.maximized);
131    }
132}