use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SettingsError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub download_dir: Option<String>,
#[serde(
default,
alias = "webuiLanguage",
skip_serializing_if = "Option::is_none"
)]
pub display_language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_appearance: Option<String>,
}
static SETTINGS_CACHE: OnceLock<DashMap<String, Settings>> = OnceLock::new();
fn cache() -> &'static DashMap<String, Settings> {
SETTINGS_CACHE.get_or_init(DashMap::new)
}
fn store_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn settings_key(app_data_dir: &Path) -> String {
app_data_dir.to_string_lossy().to_string()
}
pub fn settings_path(app_data_dir: &Path) -> PathBuf {
lingxia_app_context::app_state_file(app_data_dir, "settings.json")
}
pub fn load(app_data_dir: &Path) -> Result<Settings, SettingsError> {
let key = settings_key(app_data_dir);
if let Some(entry) = cache().get(&key) {
return Ok(entry.value().clone());
}
let path = settings_path(app_data_dir);
let settings = match std::fs::read(&path) {
Ok(bytes) => match serde_json::from_slice::<Settings>(&bytes) {
Ok(settings) => settings,
Err(err) => {
log::error!("corrupt {}: {err}; using defaults", path.display());
let _ = std::fs::rename(&path, path.with_extension("json.corrupt"));
Settings::default()
}
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Settings::default(),
Err(err) => return Err(SettingsError::Io(err)),
};
cache().insert(key, settings.clone());
Ok(settings)
}
pub fn save(app_data_dir: &Path, settings: &Settings) -> Result<(), SettingsError> {
save_with_replace(app_data_dir, settings, replace_saved_file)
}
fn save_with_replace(
app_data_dir: &Path,
settings: &Settings,
replace: impl FnOnce(&Path, &Path) -> Result<(), SettingsError>,
) -> Result<(), SettingsError> {
let path = settings_path(app_data_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let bytes = serde_json::to_vec_pretty(settings)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, bytes)?;
replace(&tmp, &path)?;
cache().insert(settings_key(app_data_dir), settings.clone());
Ok(())
}
#[cfg(not(windows))]
fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
Ok(std::fs::rename(tmp, path)?)
}
#[cfg(windows)]
fn replace_saved_file(tmp: &Path, path: &Path) -> Result<(), SettingsError> {
use std::os::windows::ffi::OsStrExt;
use windows::Win32::Storage::FileSystem::{
MOVEFILE_WRITE_THROUGH, MoveFileExW, REPLACEFILE_WRITE_THROUGH, ReplaceFileW,
};
use windows::core::PCWSTR;
let wide = |value: &Path| {
value
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect::<Vec<_>>()
};
let target_exists = path.exists();
let tmp = wide(tmp);
let path = wide(path);
let result = unsafe {
if target_exists {
ReplaceFileW(
PCWSTR(path.as_ptr()),
PCWSTR(tmp.as_ptr()),
PCWSTR::null(),
REPLACEFILE_WRITE_THROUGH,
None,
None,
)
} else {
MoveFileExW(
PCWSTR(tmp.as_ptr()),
PCWSTR(path.as_ptr()),
MOVEFILE_WRITE_THROUGH,
)
}
};
result.map_err(|error| SettingsError::Io(std::io::Error::other(error.to_string())))
}
pub fn get_download_dir(app_data_dir: &Path) -> Result<Option<PathBuf>, SettingsError> {
Ok(load(app_data_dir)?
.download_dir
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from))
}
pub fn set_download_dir(
app_data_dir: &Path,
path: Option<impl AsRef<Path>>,
) -> Result<(), SettingsError> {
let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
let mut settings = load(app_data_dir)?;
settings.download_dir = path.map(|value| value.as_ref().to_string_lossy().to_string());
save(app_data_dir, &settings)
}
pub fn get_display_language(app_data_dir: &Path) -> Result<Option<String>, SettingsError> {
Ok(load(app_data_dir)?
.display_language
.filter(|value| !value.trim().is_empty()))
}
pub fn set_display_language(
app_data_dir: &Path,
language: Option<&str>,
) -> Result<(), SettingsError> {
let _guard = store_lock().lock().unwrap_or_else(|e| e.into_inner());
let mut settings = load(app_data_dir)?;
settings.display_language = language.map(str::to_string);
save(app_data_dir, &settings)
}
pub fn get_host_appearance(app_data_dir: &Path) -> Result<Option<String>, SettingsError> {
Ok(load(app_data_dir)?
.host_appearance
.filter(|value| !value.trim().is_empty()))
}
pub fn set_host_appearance(
app_data_dir: &Path,
preference: Option<&str>,
) -> Result<(), SettingsError> {
let _guard = store_lock()
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut settings = load(app_data_dir)?;
settings.host_appearance = preference.map(str::to_string);
save(app_data_dir, &settings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_language_round_trips_with_other_settings() {
let dir = tempfile::tempdir().unwrap();
set_download_dir(dir.path(), Some(dir.path().join("downloads"))).unwrap();
set_display_language(dir.path(), Some("zh-CN")).unwrap();
assert_eq!(
get_display_language(dir.path()).unwrap().as_deref(),
Some("zh-CN")
);
assert_eq!(
get_download_dir(dir.path()).unwrap(),
Some(dir.path().join("downloads"))
);
}
#[test]
fn a_file_with_retired_per_lxapp_appearances_still_loads() {
let dir = tempfile::tempdir().unwrap();
let path = settings_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"{ "lxappAppearances": { "alpha": "dark" }, "hostAppearance": "light" }"#,
)
.unwrap();
assert_eq!(
get_host_appearance(dir.path()).unwrap().as_deref(),
Some("light")
);
}
#[test]
fn display_language_reads_the_pre_rename_stored_key() {
let dir = tempfile::tempdir().unwrap();
let path = settings_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, r#"{ "webuiLanguage": "zh-CN" }"#).unwrap();
assert_eq!(
get_display_language(dir.path()).unwrap().as_deref(),
Some("zh-CN")
);
}
#[test]
fn corrupt_settings_file_recovers_to_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = settings_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{ not json").unwrap();
assert!(load(dir.path()).unwrap().download_dir.is_none());
assert!(!path.exists());
assert!(path.with_extension("json.corrupt").exists());
set_display_language(dir.path(), Some("en-US")).unwrap();
assert_eq!(
get_display_language(dir.path()).unwrap().as_deref(),
Some("en-US")
);
}
#[test]
fn failed_replacement_preserves_file_and_cache() {
let dir = tempfile::tempdir().unwrap();
let original = Settings {
display_language: Some("en-US".to_string()),
..Settings::default()
};
save(dir.path(), &original).unwrap();
let original_bytes = std::fs::read(settings_path(dir.path())).unwrap();
let replacement = Settings {
display_language: Some("ja-JP".to_string()),
..Settings::default()
};
let result = save_with_replace(dir.path(), &replacement, |_, _| {
Err(SettingsError::Io(std::io::Error::other(
"injected replacement failure",
)))
});
assert!(result.is_err());
assert_eq!(
std::fs::read(settings_path(dir.path())).unwrap(),
original_bytes
);
assert_eq!(
load(dir.path()).unwrap().display_language.as_deref(),
Some("en-US")
);
}
}