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    /// Whether the local control socket is listening. Absent means off: a
36    /// product declaring the capability ships the ability, not the decision —
37    /// this endpoint hands any local process the product's full automation
38    /// surface, so it waits for the user to say yes.
39    #[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
49/// Serializes load-modify-save cycles so concurrent setters cannot drop each
50/// other's field.
51fn 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                // A corrupt file would otherwise fail every load forever; set
76                // it aside and recover with defaults.
77                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    // Temp-write + rename so a crash mid-write cannot truncate the file.
97    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
164/// Whether the user has turned the control socket on. Off unless they have.
165pub 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        // The store is writable again after recovery.
264        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}