1use dashmap::DashMap;
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum SettingsError {
9 #[error("I/O error: {0}")]
10 Io(#[from] std::io::Error),
11 #[error("JSON error: {0}")]
12 Json(#[from] serde_json::Error),
13}
14
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct Settings {
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub download_dir: Option<String>,
20 #[serde(
25 default,
26 alias = "webuiLanguage",
27 skip_serializing_if = "Option::is_none"
28 )]
29 pub display_language: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub host_appearance: Option<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 save_with_replace(app_data_dir, settings, replace_saved_file)
86}
87
88fn save_with_replace(
89 app_data_dir: &Path,
90 settings: &Settings,
91 replace: impl FnOnce(&Path, &Path) -> Result<(), SettingsError>,
92) -> Result<(), SettingsError> {
93 let path = settings_path(app_data_dir);
94 if let Some(parent) = path.parent() {
95 std::fs::create_dir_all(parent)?;
96 }
97 let bytes = serde_json::to_vec_pretty(settings)?;
98 let tmp = path.with_extension("json.tmp");
100 std::fs::write(&tmp, bytes)?;
101 replace(&tmp, &path)?;
102 cache().insert(settings_key(app_data_dir), settings.clone());
103 Ok(())
104}
105
106#[cfg(not(windows))]
107fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
108 Ok(std::fs::rename(tmp, path)?)
109}
110
111#[cfg(windows)]
112fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
113 use std::os::windows::ffi::OsStrExt;
114 use windows::Win32::Storage::FileSystem::{
115 MOVEFILE_WRITE_THROUGH, MoveFileExW, REPLACEFILE_WRITE_THROUGH, ReplaceFileW,
116 };
117 use windows::core::PCWSTR;
118
119 let wide = |value: &Path| {
120 value
121 .as_os_str()
122 .encode_wide()
123 .chain(std::iter::once(0))
124 .collect::<Vec<_>>()
125 };
126 let target_exists = path.exists();
127 let tmp = wide(tmp);
128 let path = wide(path);
129 let result = unsafe {
130 if target_exists {
131 ReplaceFileW(
132 PCWSTR(path.as_ptr()),
133 PCWSTR(tmp.as_ptr()),
134 PCWSTR::null(),
135 REPLACEFILE_WRITE_THROUGH,
136 None,
137 None,
138 )
139 } else {
140 MoveFileExW(
141 PCWSTR(tmp.as_ptr()),
142 PCWSTR(path.as_ptr()),
143 MOVEFILE_WRITE_THROUGH,
144 )
145 }
146 };
147 result.map_err(|error| SettingsError::Io(std::io::Error::other(error.to_string())))
148}
149
150pub fn get_download_dir(app_data_dir: &Path) -> Result<Option<PathBuf>, SettingsError> {
151 Ok(load(app_data_dir)?
152 .download_dir
153 .filter(|value| !value.trim().is_empty())
154 .map(PathBuf::from))
155}
156
157pub fn set_download_dir(
158 app_data_dir: &Path,
159 path: Option<impl AsRef<Path>>,
160) -> Result<(), SettingsError> {
161 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
162 let mut settings = load(app_data_dir)?;
163 settings.download_dir = path.map(|value| value.as_ref().to_string_lossy().to_string());
164 save(app_data_dir, &settings)
165}
166
167pub fn get_display_language(app_data_dir: &Path) -> Result<Option<String>, SettingsError> {
168 Ok(load(app_data_dir)?
169 .display_language
170 .filter(|value| !value.trim().is_empty()))
171}
172
173pub fn set_display_language(
174 app_data_dir: &Path,
175 language: Option<&str>,
176) -> Result<(), SettingsError> {
177 let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
178 let mut settings = load(app_data_dir)?;
179 settings.display_language = language.map(str::to_string);
180 save(app_data_dir, &settings)
181}
182
183pub fn get_host_appearance(app_data_dir: &Path) -> Result<Option<String>, SettingsError> {
184 Ok(load(app_data_dir)?
185 .host_appearance
186 .filter(|value| !value.trim().is_empty()))
187}
188
189pub fn set_host_appearance(
190 app_data_dir: &Path,
191 preference: Option<&str>,
192) -> Result<(), SettingsError> {
193 let _guard = store_lock()
194 .lock()
195 .unwrap_or_else(|error| error.into_inner());
196 let mut settings = load(app_data_dir)?;
197 settings.host_appearance = preference.map(str::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]
224 fn a_file_with_retired_per_lxapp_appearances_still_loads() {
225 let dir = tempfile::tempdir().unwrap();
226 let path = settings_path(dir.path());
227 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
228 std::fs::write(
229 &path,
230 r#"{ "lxappAppearances": { "alpha": "dark" }, "hostAppearance": "light" }"#,
231 )
232 .unwrap();
233
234 assert_eq!(
235 get_host_appearance(dir.path()).unwrap().as_deref(),
236 Some("light")
237 );
238 }
239
240 #[test]
241 fn display_language_reads_the_pre_rename_stored_key() {
242 let dir = tempfile::tempdir().unwrap();
243 let path = settings_path(dir.path());
244 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
245 std::fs::write(&path, r#"{ "webuiLanguage": "zh-CN" }"#).unwrap();
246
247 assert_eq!(
248 get_display_language(dir.path()).unwrap().as_deref(),
249 Some("zh-CN")
250 );
251 }
252
253 #[test]
254 fn corrupt_settings_file_recovers_to_defaults() {
255 let dir = tempfile::tempdir().unwrap();
256 let path = settings_path(dir.path());
257 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
258 std::fs::write(&path, "{ not json").unwrap();
259
260 assert!(load(dir.path()).unwrap().download_dir.is_none());
261 assert!(!path.exists());
262 assert!(path.with_extension("json.corrupt").exists());
263
264 set_display_language(dir.path(), Some("en-US")).unwrap();
266 assert_eq!(
267 get_display_language(dir.path()).unwrap().as_deref(),
268 Some("en-US")
269 );
270 }
271
272 #[test]
273 fn failed_replacement_preserves_file_and_cache() {
274 let dir = tempfile::tempdir().unwrap();
275 let original = Settings {
276 display_language: Some("en-US".to_string()),
277 ..Settings::default()
278 };
279 save(dir.path(), &original).unwrap();
280 let original_bytes = std::fs::read(settings_path(dir.path())).unwrap();
281
282 let replacement = Settings {
283 display_language: Some("ja-JP".to_string()),
284 ..Settings::default()
285 };
286 let result = save_with_replace(dir.path(), &replacement, |_, _| {
287 Err(SettingsError::Io(std::io::Error::other(
288 "injected replacement failure",
289 )))
290 });
291
292 assert!(result.is_err());
293 assert_eq!(
294 std::fs::read(settings_path(dir.path())).unwrap(),
295 original_bytes
296 );
297 assert_eq!(
298 load(dir.path()).unwrap().display_language.as_deref(),
299 Some("en-US")
300 );
301 }
302}