Skip to main content

lingxia_settings/
lib.rs

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    /// User override for the product display language; `None` follows the
21    /// system locale. Applies to every host-owned UI surface (webui pages and
22    /// native chrome), not just the webui — the old stored key is kept as an
23    /// alias for files written before the rename.
24    #[serde(
25        default,
26        alias = "webuiLanguage",
27        skip_serializing_if = "Option::is_none"
28    )]
29    pub display_language: Option<String>,
30}
31
32static SETTINGS_CACHE: OnceLock<DashMap<String, Settings>> = OnceLock::new();
33
34fn cache() -> &'static DashMap<String, Settings> {
35    SETTINGS_CACHE.get_or_init(DashMap::new)
36}
37
38/// Serializes load-modify-save cycles so concurrent setters cannot drop each
39/// other's field.
40fn store_lock() -> &'static Mutex<()> {
41    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
42    LOCK.get_or_init(|| Mutex::new(()))
43}
44
45fn settings_key(app_data_dir: &Path) -> String {
46    app_data_dir.to_string_lossy().to_string()
47}
48
49pub fn settings_path(app_data_dir: &Path) -> PathBuf {
50    lingxia_app_context::app_state_file(app_data_dir, "settings.json")
51}
52
53pub fn load(app_data_dir: &Path) -> Result<Settings, SettingsError> {
54    let key = settings_key(app_data_dir);
55    if let Some(entry) = cache().get(&key) {
56        return Ok(entry.value().clone());
57    }
58
59    let path = settings_path(app_data_dir);
60    let settings = match std::fs::read(&path) {
61        Ok(bytes) => match serde_json::from_slice::<Settings>(&bytes) {
62            Ok(settings) => settings,
63            Err(err) => {
64                // A corrupt file would otherwise fail every load forever; set
65                // it aside and recover with defaults.
66                log::error!("corrupt {}: {err}; using defaults", path.display());
67                let _ = std::fs::rename(&path, path.with_extension("json.corrupt"));
68                Settings::default()
69            }
70        },
71        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Settings::default(),
72        Err(err) => return Err(SettingsError::Io(err)),
73    };
74
75    cache().insert(key, settings.clone());
76    Ok(settings)
77}
78
79pub fn save(app_data_dir: &Path, settings: &Settings) -> Result<(), SettingsError> {
80    let path = settings_path(app_data_dir);
81    if let Some(parent) = path.parent() {
82        std::fs::create_dir_all(parent)?;
83    }
84    let bytes = serde_json::to_vec_pretty(settings)?;
85    // Temp-write + rename so a crash mid-write cannot truncate the file.
86    let tmp = path.with_extension("json.tmp");
87    std::fs::write(&tmp, bytes)?;
88    replace_saved_file(&tmp, &path)?;
89    cache().insert(settings_key(app_data_dir), settings.clone());
90    Ok(())
91}
92
93#[cfg(not(windows))]
94fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
95    Ok(std::fs::rename(tmp, path)?)
96}
97
98#[cfg(windows)]
99fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
100    let backup = path.with_extension("json.bak");
101    if backup.exists() {
102        std::fs::remove_file(&backup)?;
103    }
104    let had_previous = path.exists();
105    if had_previous {
106        std::fs::rename(path, &backup)?;
107    }
108    if let Err(err) = std::fs::rename(tmp, path) {
109        if had_previous {
110            let _ = std::fs::rename(&backup, path);
111        }
112        return Err(SettingsError::Io(err));
113    }
114    if had_previous {
115        let _ = std::fs::remove_file(backup);
116    }
117    Ok(())
118}
119
120pub fn get_download_dir(app_data_dir: &Path) -> Result<Option<PathBuf>, SettingsError> {
121    Ok(load(app_data_dir)?
122        .download_dir
123        .filter(|value| !value.trim().is_empty())
124        .map(PathBuf::from))
125}
126
127pub fn set_download_dir(
128    app_data_dir: &Path,
129    path: Option<impl AsRef<Path>>,
130) -> Result<(), SettingsError> {
131    let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
132    let mut settings = load(app_data_dir)?;
133    settings.download_dir = path.map(|value| value.as_ref().to_string_lossy().to_string());
134    save(app_data_dir, &settings)
135}
136
137pub fn get_display_language(app_data_dir: &Path) -> Result<Option<String>, SettingsError> {
138    Ok(load(app_data_dir)?
139        .display_language
140        .filter(|value| !value.trim().is_empty()))
141}
142
143pub fn set_display_language(
144    app_data_dir: &Path,
145    language: Option<&str>,
146) -> Result<(), SettingsError> {
147    let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
148    let mut settings = load(app_data_dir)?;
149    settings.display_language = language.map(str::to_string);
150    save(app_data_dir, &settings)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn display_language_round_trips_with_other_settings() {
159        let dir = tempfile::tempdir().unwrap();
160        set_download_dir(dir.path(), Some(dir.path().join("downloads"))).unwrap();
161        set_display_language(dir.path(), Some("zh-CN")).unwrap();
162
163        assert_eq!(
164            get_display_language(dir.path()).unwrap().as_deref(),
165            Some("zh-CN")
166        );
167        assert_eq!(
168            get_download_dir(dir.path()).unwrap(),
169            Some(dir.path().join("downloads"))
170        );
171    }
172
173    #[test]
174    fn display_language_reads_the_pre_rename_stored_key() {
175        let dir = tempfile::tempdir().unwrap();
176        let path = settings_path(dir.path());
177        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
178        std::fs::write(&path, r#"{ "webuiLanguage": "zh-CN" }"#).unwrap();
179
180        assert_eq!(
181            get_display_language(dir.path()).unwrap().as_deref(),
182            Some("zh-CN")
183        );
184    }
185
186    #[test]
187    fn corrupt_settings_file_recovers_to_defaults() {
188        let dir = tempfile::tempdir().unwrap();
189        let path = settings_path(dir.path());
190        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
191        std::fs::write(&path, "{ not json").unwrap();
192
193        assert!(load(dir.path()).unwrap().download_dir.is_none());
194        assert!(!path.exists());
195        assert!(path.with_extension("json.corrupt").exists());
196
197        // The store is writable again after recovery.
198        set_display_language(dir.path(), Some("en-US")).unwrap();
199        assert_eq!(
200            get_display_language(dir.path()).unwrap().as_deref(),
201            Some("en-US")
202        );
203    }
204}