1use dashmap::DashMap;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum SettingsError {
10 #[error("I/O error: {0}")]
11 Io(#[from] std::io::Error),
12 #[error("JSON error: {0}")]
13 Json(#[from] serde_json::Error),
14}
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct Settings {
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub download_dir: Option<String>,
21 #[serde(
26 default,
27 alias = "webuiLanguage",
28 skip_serializing_if = "Option::is_none"
29 )]
30 pub display_language: Option<String>,
31 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
34 pub lxapp_appearances: BTreeMap<String, String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub control_enabled: Option<bool>,
41}
42
43static SETTINGS_CACHE: OnceLock<DashMap<String, Settings>> = OnceLock::new();
44
45fn cache() -> &'static DashMap<String, Settings> {
46 SETTINGS_CACHE.get_or_init(DashMap::new)
47}
48
49fn store_lock() -> &'static Mutex<()> {
52 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
53 LOCK.get_or_init(|| Mutex::new(()))
54}
55
56fn settings_key(app_data_dir: &Path) -> String {
57 app_data_dir.to_string_lossy().to_string()
58}
59
60pub fn settings_path(app_data_dir: &Path) -> PathBuf {
61 lingxia_app_context::app_state_file(app_data_dir, "settings.json")
62}
63
64pub fn load(app_data_dir: &Path) -> Result<Settings, SettingsError> {
65 let key = settings_key(app_data_dir);
66 if let Some(entry) = cache().get(&key) {
67 return Ok(entry.value().clone());
68 }
69
70 let path = settings_path(app_data_dir);
71 let settings = match std::fs::read(&path) {
72 Ok(bytes) => match serde_json::from_slice::<Settings>(&bytes) {
73 Ok(settings) => settings,
74 Err(err) => {
75 log::error!("corrupt {}: {err}; using defaults", path.display());
78 let _ = std::fs::rename(&path, path.with_extension("json.corrupt"));
79 Settings::default()
80 }
81 },
82 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Settings::default(),
83 Err(err) => return Err(SettingsError::Io(err)),
84 };
85
86 cache().insert(key, settings.clone());
87 Ok(settings)
88}
89
90pub fn save(app_data_dir: &Path, settings: &Settings) -> Result<(), SettingsError> {
91 let path = settings_path(app_data_dir);
92 if let Some(parent) = path.parent() {
93 std::fs::create_dir_all(parent)?;
94 }
95 let bytes = serde_json::to_vec_pretty(settings)?;
96 let tmp = path.with_extension("json.tmp");
98 std::fs::write(&tmp, bytes)?;
99 replace_saved_file(&tmp, &path)?;
100 cache().insert(settings_key(app_data_dir), settings.clone());
101 Ok(())
102}
103
104#[cfg(not(windows))]
105fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
106 Ok(std::fs::rename(tmp, path)?)
107}
108
109#[cfg(windows)]
110fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
111 let backup = path.with_extension("json.bak");
112 if backup.exists() {
113 std::fs::remove_file(&backup)?;
114 }
115 let had_previous = path.exists();
116 if had_previous {
117 std::fs::rename(path, &backup)?;
118 }
119 if let Err(err) = std::fs::rename(tmp, path) {
120 if had_previous {
121 let _ = std::fs::rename(&backup, path);
122 }
123 return Err(SettingsError::Io(err));
124 }
125 if had_previous {
126 let _ = std::fs::remove_file(backup);
127 }
128 Ok(())
129}
130
131pub fn get_download_dir(app_data_dir: &Path) -> Result<Option<PathBuf>, SettingsError> {
132 Ok(load(app_data_dir)?
133 .download_dir
134 .filter(|value| !value.trim().is_empty())
135 .map(PathBuf::from))
136}
137
138pub fn set_download_dir(
139 app_data_dir: &Path,
140 path: Option<impl AsRef<Path>>,
141) -> Result<(), SettingsError> {
142 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
143 let mut settings = load(app_data_dir)?;
144 settings.download_dir = path.map(|value| value.as_ref().to_string_lossy().to_string());
145 save(app_data_dir, &settings)
146}
147
148pub fn get_display_language(app_data_dir: &Path) -> Result<Option<String>, SettingsError> {
149 Ok(load(app_data_dir)?
150 .display_language
151 .filter(|value| !value.trim().is_empty()))
152}
153
154pub fn set_display_language(
155 app_data_dir: &Path,
156 language: Option<&str>,
157) -> Result<(), SettingsError> {
158 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
159 let mut settings = load(app_data_dir)?;
160 settings.display_language = language.map(str::to_string);
161 save(app_data_dir, &settings)
162}
163
164pub fn control_enabled(app_data_dir: &Path) -> bool {
166 load(app_data_dir)
167 .ok()
168 .and_then(|settings| settings.control_enabled)
169 .unwrap_or(false)
170}
171
172pub fn set_control_enabled(app_data_dir: &Path, enabled: bool) -> Result<(), SettingsError> {
173 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
174 let mut settings = load(app_data_dir)?;
175 settings.control_enabled = enabled.then_some(true);
176 save(app_data_dir, &settings)
177}
178
179pub fn get_lxapp_appearance(
180 app_data_dir: &Path,
181 app_id: &str,
182) -> Result<Option<String>, SettingsError> {
183 Ok(load(app_data_dir)?.lxapp_appearances.get(app_id).cloned())
184}
185
186pub fn set_lxapp_appearance(
187 app_data_dir: &Path,
188 app_id: &str,
189 preference: &str,
190) -> Result<(), SettingsError> {
191 let _guard = store_lock()
192 .lock()
193 .unwrap_or_else(|error| error.into_inner());
194 let mut settings = load(app_data_dir)?;
195 settings
196 .lxapp_appearances
197 .insert(app_id.to_string(), preference.to_string());
198 save(app_data_dir, &settings)
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn display_language_round_trips_with_other_settings() {
207 let dir = tempfile::tempdir().unwrap();
208 set_download_dir(dir.path(), Some(dir.path().join("downloads"))).unwrap();
209 set_display_language(dir.path(), Some("zh-CN")).unwrap();
210
211 assert_eq!(
212 get_display_language(dir.path()).unwrap().as_deref(),
213 Some("zh-CN")
214 );
215 assert_eq!(
216 get_download_dir(dir.path()).unwrap(),
217 Some(dir.path().join("downloads"))
218 );
219 }
220
221 #[test]
222 fn lxapp_appearance_is_isolated_by_app_id() {
223 let dir = tempfile::tempdir().unwrap();
224 set_lxapp_appearance(dir.path(), "alpha", "dark").unwrap();
225 set_lxapp_appearance(dir.path(), "beta", "light").unwrap();
226
227 assert_eq!(
228 get_lxapp_appearance(dir.path(), "alpha")
229 .unwrap()
230 .as_deref(),
231 Some("dark")
232 );
233 assert_eq!(
234 get_lxapp_appearance(dir.path(), "beta").unwrap().as_deref(),
235 Some("light")
236 );
237 }
238
239 #[test]
240 fn display_language_reads_the_pre_rename_stored_key() {
241 let dir = tempfile::tempdir().unwrap();
242 let path = settings_path(dir.path());
243 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
244 std::fs::write(&path, r#"{ "webuiLanguage": "zh-CN" }"#).unwrap();
245
246 assert_eq!(
247 get_display_language(dir.path()).unwrap().as_deref(),
248 Some("zh-CN")
249 );
250 }
251
252 #[test]
253 fn corrupt_settings_file_recovers_to_defaults() {
254 let dir = tempfile::tempdir().unwrap();
255 let path = settings_path(dir.path());
256 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
257 std::fs::write(&path, "{ not json").unwrap();
258
259 assert!(load(dir.path()).unwrap().download_dir.is_none());
260 assert!(!path.exists());
261 assert!(path.with_extension("json.corrupt").exists());
262
263 set_display_language(dir.path(), Some("en-US")).unwrap();
265 assert_eq!(
266 get_display_language(dir.path()).unwrap().as_deref(),
267 Some("en-US")
268 );
269 }
270}