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}
36
37static SETTINGS_CACHE: OnceLock<DashMap<String, Settings>> = OnceLock::new();
38
39fn cache() -> &'static DashMap<String, Settings> {
40 SETTINGS_CACHE.get_or_init(DashMap::new)
41}
42
43fn store_lock() -> &'static Mutex<()> {
46 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
47 LOCK.get_or_init(|| Mutex::new(()))
48}
49
50fn settings_key(app_data_dir: &Path) -> String {
51 app_data_dir.to_string_lossy().to_string()
52}
53
54pub fn settings_path(app_data_dir: &Path) -> PathBuf {
55 lingxia_app_context::app_state_file(app_data_dir, "settings.json")
56}
57
58pub fn load(app_data_dir: &Path) -> Result<Settings, SettingsError> {
59 let key = settings_key(app_data_dir);
60 if let Some(entry) = cache().get(&key) {
61 return Ok(entry.value().clone());
62 }
63
64 let path = settings_path(app_data_dir);
65 let settings = match std::fs::read(&path) {
66 Ok(bytes) => match serde_json::from_slice::<Settings>(&bytes) {
67 Ok(settings) => settings,
68 Err(err) => {
69 log::error!("corrupt {}: {err}; using defaults", path.display());
72 let _ = std::fs::rename(&path, path.with_extension("json.corrupt"));
73 Settings::default()
74 }
75 },
76 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Settings::default(),
77 Err(err) => return Err(SettingsError::Io(err)),
78 };
79
80 cache().insert(key, settings.clone());
81 Ok(settings)
82}
83
84pub fn save(app_data_dir: &Path, settings: &Settings) -> Result<(), SettingsError> {
85 let path = settings_path(app_data_dir);
86 if let Some(parent) = path.parent() {
87 std::fs::create_dir_all(parent)?;
88 }
89 let bytes = serde_json::to_vec_pretty(settings)?;
90 let tmp = path.with_extension("json.tmp");
92 std::fs::write(&tmp, bytes)?;
93 replace_saved_file(&tmp, &path)?;
94 cache().insert(settings_key(app_data_dir), settings.clone());
95 Ok(())
96}
97
98#[cfg(not(windows))]
99fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
100 Ok(std::fs::rename(tmp, path)?)
101}
102
103#[cfg(windows)]
104fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
105 let backup = path.with_extension("json.bak");
106 if backup.exists() {
107 std::fs::remove_file(&backup)?;
108 }
109 let had_previous = path.exists();
110 if had_previous {
111 std::fs::rename(path, &backup)?;
112 }
113 if let Err(err) = std::fs::rename(tmp, path) {
114 if had_previous {
115 let _ = std::fs::rename(&backup, path);
116 }
117 return Err(SettingsError::Io(err));
118 }
119 if had_previous {
120 let _ = std::fs::remove_file(backup);
121 }
122 Ok(())
123}
124
125pub fn get_download_dir(app_data_dir: &Path) -> Result<Option<PathBuf>, SettingsError> {
126 Ok(load(app_data_dir)?
127 .download_dir
128 .filter(|value| !value.trim().is_empty())
129 .map(PathBuf::from))
130}
131
132pub fn set_download_dir(
133 app_data_dir: &Path,
134 path: Option<impl AsRef<Path>>,
135) -> Result<(), SettingsError> {
136 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
137 let mut settings = load(app_data_dir)?;
138 settings.download_dir = path.map(|value| value.as_ref().to_string_lossy().to_string());
139 save(app_data_dir, &settings)
140}
141
142pub fn get_display_language(app_data_dir: &Path) -> Result<Option<String>, SettingsError> {
143 Ok(load(app_data_dir)?
144 .display_language
145 .filter(|value| !value.trim().is_empty()))
146}
147
148pub fn set_display_language(
149 app_data_dir: &Path,
150 language: Option<&str>,
151) -> Result<(), SettingsError> {
152 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
153 let mut settings = load(app_data_dir)?;
154 settings.display_language = language.map(str::to_string);
155 save(app_data_dir, &settings)
156}
157
158pub fn get_lxapp_appearance(
159 app_data_dir: &Path,
160 app_id: &str,
161) -> Result<Option<String>, SettingsError> {
162 Ok(load(app_data_dir)?.lxapp_appearances.get(app_id).cloned())
163}
164
165pub fn set_lxapp_appearance(
166 app_data_dir: &Path,
167 app_id: &str,
168 preference: &str,
169) -> Result<(), SettingsError> {
170 let _guard = store_lock()
171 .lock()
172 .unwrap_or_else(|error| error.into_inner());
173 let mut settings = load(app_data_dir)?;
174 settings
175 .lxapp_appearances
176 .insert(app_id.to_string(), preference.to_string());
177 save(app_data_dir, &settings)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn display_language_round_trips_with_other_settings() {
186 let dir = tempfile::tempdir().unwrap();
187 set_download_dir(dir.path(), Some(dir.path().join("downloads"))).unwrap();
188 set_display_language(dir.path(), Some("zh-CN")).unwrap();
189
190 assert_eq!(
191 get_display_language(dir.path()).unwrap().as_deref(),
192 Some("zh-CN")
193 );
194 assert_eq!(
195 get_download_dir(dir.path()).unwrap(),
196 Some(dir.path().join("downloads"))
197 );
198 }
199
200 #[test]
201 fn lxapp_appearance_is_isolated_by_app_id() {
202 let dir = tempfile::tempdir().unwrap();
203 set_lxapp_appearance(dir.path(), "alpha", "dark").unwrap();
204 set_lxapp_appearance(dir.path(), "beta", "light").unwrap();
205
206 assert_eq!(
207 get_lxapp_appearance(dir.path(), "alpha")
208 .unwrap()
209 .as_deref(),
210 Some("dark")
211 );
212 assert_eq!(
213 get_lxapp_appearance(dir.path(), "beta").unwrap().as_deref(),
214 Some("light")
215 );
216 }
217
218 #[test]
219 fn display_language_reads_the_pre_rename_stored_key() {
220 let dir = tempfile::tempdir().unwrap();
221 let path = settings_path(dir.path());
222 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
223 std::fs::write(&path, r#"{ "webuiLanguage": "zh-CN" }"#).unwrap();
224
225 assert_eq!(
226 get_display_language(dir.path()).unwrap().as_deref(),
227 Some("zh-CN")
228 );
229 }
230
231 #[test]
232 fn corrupt_settings_file_recovers_to_defaults() {
233 let dir = tempfile::tempdir().unwrap();
234 let path = settings_path(dir.path());
235 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
236 std::fs::write(&path, "{ not json").unwrap();
237
238 assert!(load(dir.path()).unwrap().download_dir.is_none());
239 assert!(!path.exists());
240 assert!(path.with_extension("json.corrupt").exists());
241
242 set_display_language(dir.path(), Some("en-US")).unwrap();
244 assert_eq!(
245 get_display_language(dir.path()).unwrap().as_deref(),
246 Some("en-US")
247 );
248 }
249}