Skip to main content

lingxia_shell/
store.rs

1use crate::{PinCollection, ShellError, ShellResult, ShellWindowState};
2use serde::Serialize;
3use serde::de::DeserializeOwned;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7pub const PIN_STORE_FILE: &str = "shell-pins-v1.json";
8pub const SHELL_WINDOW_FILE: &str = "shell-window-v1.json";
9
10#[derive(Debug, Clone)]
11pub struct ShellStore {
12    root: PathBuf,
13}
14
15impl ShellStore {
16    pub fn new(root: impl Into<PathBuf>) -> Self {
17        Self { root: root.into() }
18    }
19
20    pub fn load_pins(&self) -> ShellResult<PinCollection> {
21        let Some(value) = self.load_optional::<PinCollection>(PIN_STORE_FILE)? else {
22            return Ok(PinCollection::default());
23        };
24        value.restore()
25    }
26
27    pub(crate) fn load_pins_recovering(&self) -> PinCollection {
28        match self.load_pins() {
29            Ok(pins) => pins,
30            Err(_) => {
31                let _ = self.quarantine(PIN_STORE_FILE);
32                PinCollection::default()
33            }
34        }
35    }
36
37    pub fn save_pins(&self, pins: &PinCollection) -> ShellResult<()> {
38        self.save(PIN_STORE_FILE, pins)
39    }
40
41    /// A missing or unreadable file is the ordinary first-launch case.
42    pub fn load_window_state(&self) -> ShellWindowState {
43        match self.load_optional::<ShellWindowState>(SHELL_WINDOW_FILE) {
44            Ok(Some(state)) => state.normalized(),
45            Ok(None) => ShellWindowState::default(),
46            Err(_) => {
47                let _ = self.quarantine(SHELL_WINDOW_FILE);
48                ShellWindowState::default()
49            }
50        }
51    }
52
53    pub fn save_window_state(&self, state: &ShellWindowState) -> ShellResult<()> {
54        self.save(SHELL_WINDOW_FILE, state)
55    }
56
57    fn load_optional<T: DeserializeOwned>(&self, name: &str) -> ShellResult<Option<T>> {
58        let path = self.root.join(name);
59        #[cfg(windows)]
60        restore_backup_if_needed(&path)?;
61        if !path.is_file() {
62            return Ok(None);
63        }
64        let raw = fs::read_to_string(&path)
65            .map_err(|error| ShellError::Io(format!("read {}: {error}", path.display())))?;
66        serde_json::from_str(&raw)
67            .map(Some)
68            .map_err(|error| ShellError::InvalidState(format!("{}: {error}", path.display())))
69    }
70
71    fn quarantine(&self, name: &str) -> ShellResult<Option<PathBuf>> {
72        let path = self.root.join(name);
73        if !path.exists() {
74            return Ok(None);
75        }
76        for index in 0..=u16::MAX {
77            let suffix = if index == 0 {
78                "invalid".to_string()
79            } else {
80                format!("invalid.{index}")
81            };
82            let target = self.root.join(format!("{name}.{suffix}"));
83            if !target.exists() {
84                fs::rename(&path, &target).map_err(|error| {
85                    ShellError::Io(format!(
86                        "quarantine {} as {}: {error}",
87                        path.display(),
88                        target.display()
89                    ))
90                })?;
91                return Ok(Some(target));
92            }
93        }
94        Err(ShellError::Io(format!(
95            "no quarantine filename available for {}",
96            path.display()
97        )))
98    }
99
100    fn save<T: Serialize>(&self, name: &str, value: &T) -> ShellResult<()> {
101        fs::create_dir_all(&self.root)
102            .map_err(|error| ShellError::Io(format!("create {}: {error}", self.root.display())))?;
103        let path = self.root.join(name);
104        let tmp = self.root.join(format!("{name}.tmp"));
105        let raw = serde_json::to_vec_pretty(value)?;
106        fs::write(&tmp, raw)
107            .map_err(|error| ShellError::Io(format!("write {}: {error}", tmp.display())))?;
108        replace_file(&tmp, &path)
109    }
110}
111
112#[cfg(not(windows))]
113fn replace_file(tmp: &Path, path: &Path) -> ShellResult<()> {
114    fs::rename(tmp, path)
115        .map_err(|error| ShellError::Io(format!("replace {}: {error}", path.display())))
116}
117
118#[cfg(windows)]
119fn restore_backup_if_needed(path: &Path) -> ShellResult<()> {
120    if path.exists() {
121        return Ok(());
122    }
123    let backup = path.with_extension("json.bak");
124    if backup.exists() {
125        fs::rename(&backup, path)
126            .map_err(|error| ShellError::Io(format!("restore {}: {error}", path.display())))?;
127    }
128    Ok(())
129}
130
131#[cfg(windows)]
132fn replace_file(tmp: &Path, path: &Path) -> ShellResult<()> {
133    let backup = path.with_extension("json.bak");
134    if backup.exists() {
135        fs::remove_file(&backup)
136            .map_err(|error| ShellError::Io(format!("remove {}: {error}", backup.display())))?;
137    }
138    let had_previous = path.exists();
139    if had_previous {
140        fs::rename(path, &backup)
141            .map_err(|error| ShellError::Io(format!("backup {}: {error}", path.display())))?;
142    }
143    if let Err(error) = fs::rename(tmp, path) {
144        if had_previous {
145            let _ = fs::rename(&backup, path);
146        }
147        return Err(ShellError::Io(format!(
148            "replace {}: {error}",
149            path.display()
150        )));
151    }
152    if had_previous {
153        let _ = fs::remove_file(backup);
154    }
155    Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::ShellPinTarget;
162
163    #[test]
164    fn stores_mixed_pins() {
165        let dir = tempfile::tempdir().unwrap();
166        let store = ShellStore::new(dir.path());
167        let mut pins = PinCollection::default();
168        pins.pin(ShellPinTarget::Lxapp {
169            key: "app.chat".to_string(),
170        })
171        .unwrap();
172        pins.pin(ShellPinTarget::Bookmark {
173            key: "bookmark-a".to_string(),
174        })
175        .unwrap();
176        store.save_pins(&pins).unwrap();
177
178        let restored_pins = store.load_pins().unwrap();
179        assert_eq!(restored_pins, pins);
180    }
181
182    #[test]
183    fn stores_sidebar_and_window_in_the_renamed_file() {
184        let dir = tempfile::tempdir().unwrap();
185        let store = ShellStore::new(dir.path());
186        let state = ShellWindowState {
187            sidebar: crate::SidebarChrome::with_expanded(false, 260.0),
188            window: crate::WindowFrame::new(20.0, 30.0, 1280.0, 720.0),
189        };
190
191        store.save_window_state(&state).unwrap();
192
193        assert_eq!(store.load_window_state(), state);
194        assert!(dir.path().join(SHELL_WINDOW_FILE).is_file());
195        assert!(!dir.path().join("shell-sidebar-v1.json").exists());
196    }
197
198    #[test]
199    fn old_sidebar_file_is_not_loaded() {
200        let dir = tempfile::tempdir().unwrap();
201        fs::write(
202            dir.path().join("shell-sidebar-v1.json"),
203            r#"{"mode":"rail"}"#,
204        )
205        .unwrap();
206
207        assert_eq!(
208            ShellStore::new(dir.path()).load_window_state(),
209            ShellWindowState::default()
210        );
211    }
212
213    #[test]
214    fn invalid_pin_store_is_quarantined_without_blocking_manager_open() {
215        let overflow = serde_json::json!({
216            "version": 1,
217            "items": (0..=crate::MAX_SHELL_PINS)
218                .map(|index| serde_json::json!({ "kind": "lxapp", "key": format!("app.{index}") }))
219                .collect::<Vec<_>>()
220        })
221        .to_string();
222        for invalid in [
223            "{".to_string(),
224            r#"{"version":2,"items":[]}"#.to_string(),
225            r#"{"version":1,"items":[{"kind":"lxapp","key":"chat"},{"kind":"lxapp","key":"chat"}]}"#.to_string(),
226            overflow,
227        ] {
228            let dir = tempfile::tempdir().unwrap();
229            let path = dir.path().join(PIN_STORE_FILE);
230            fs::write(&path, invalid).unwrap();
231
232            let manager = crate::ShellManager::open(dir.path()).unwrap();
233
234            assert!(manager.snapshot().pins.items.is_empty());
235            assert!(!path.exists());
236            assert!(
237                dir.path()
238                    .join(format!("{PIN_STORE_FILE}.invalid"))
239                    .is_file()
240            );
241        }
242    }
243
244    #[cfg(windows)]
245    #[test]
246    fn interrupted_windows_replace_restores_the_backup() {
247        let dir = tempfile::tempdir().unwrap();
248        let path = dir.path().join(PIN_STORE_FILE);
249        let backup = path.with_extension("json.bak");
250        fs::write(
251            &backup,
252            r#"{"version":1,"items":[{"kind":"lxapp","key":"chat"}]}"#,
253        )
254        .unwrap();
255
256        let manager = crate::ShellManager::open(dir.path()).unwrap();
257
258        assert_eq!(manager.snapshot().pins.items.len(), 1);
259        assert!(path.is_file());
260        assert!(!backup.exists());
261    }
262}