Skip to main content

lingxia_settings/
lib.rs

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    /// User override for the product display language; `None` follows the
22    /// system locale. Applies to every host-owned UI surface (webui pages and
23    /// native chrome), not just the webui — the old stored key is kept as an
24    /// alias for files written before the rename.
25    #[serde(
26        default,
27        alias = "webuiLanguage",
28        skip_serializing_if = "Option::is_none"
29    )]
30    pub display_language: Option<String>,
31    /// Persisted lxapp-scoped appearance preferences. Values are validated by
32    /// `lingxia-lxapp`; unknown historical values fall back to the manifest.
33    #[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
43/// Serializes load-modify-save cycles so concurrent setters cannot drop each
44/// other's field.
45fn 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                // A corrupt file would otherwise fail every load forever; set
70                // it aside and recover with defaults.
71                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    // Temp-write + rename so a crash mid-write cannot truncate the file.
91    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        // The store is writable again after recovery.
243        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}