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    /// User override for the whole host's light/dark appearance; `None`
31    /// follows the system. An lxapp that pinned its own scheme in its manifest
32    /// keeps it.
33    #[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
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    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    // Temp-write + rename so a crash mid-write cannot truncate the file.
99    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    /// A file written before the product owned light/dark carries per-lxapp
222    /// entries. They must load and be ignored, not fail the read.
223    #[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        // The store is writable again after recovery.
265        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}