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 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}