//! Configuration store (port of `config/configStore.js`, schema 1, same file //! `config/config.json`). Supported UI languages are no longer hard-coded: they come //! from the language manifest `i18n/languages.json` shipped with the UI. use crate::core::availability::{DEFAULT_INTERVAL_SECS, INTERVALS_SECS}; use crate::core::error::{CoreError, CoreResult}; use crate::core::storage::{atomic_write_json, read_json}; use once_cell::sync::Lazy; use parking_lot::RwLock; use regex::Regex; use serde_json::{json, Map, Value}; use std::path::{Path, PathBuf}; pub const CATALOG_CHANNELS: [&str; 4] = ["alpha", "beta", "stable", "all"]; pub const SIDEBAR_MODES: [&str; 3] = ["full", "compact", "split"]; pub const SIDEBAR_MIN: i64 = 220; pub const SIDEBAR_MAX: i64 = 420; pub const SIDEBAR_DEFAULT: i64 = 248; pub const DEFAULT_LANGUAGE: &str = "cs"; pub const UPDATE_BASE_URLS: [&str; 1] = ["https://sm.ch-j.de/"]; static NAV_ID: Lazy = Lazy::new(|| { Regex::new(r"^(overview|terminal|profiles|updates|plugins|diagnostics|settings|plugin:[a-z0-9][a-z0-9.-]{2,63})$").unwrap() }); static MODULE_ID: Lazy = Lazy::new(|| Regex::new(r"^[a-z0-9][a-z0-9.-]{2,63}$").unwrap()); static SHA512: Lazy = Lazy::new(|| Regex::new(r"^[a-f0-9]{128}$").unwrap()); pub fn default_config() -> Value { json!({ "schemaVersion": 1, "ui": { "language": DEFAULT_LANGUAGE, "theme": "dark", "terminalLayout": "single", "terminalSplit": 0.5, "terminalSwapped": false, "sidebar": { "mode": "full", "hidden": false, "width": SIDEBAR_DEFAULT }, "navigationOrder": [] }, "updates": { "channel": "beta", "autoCheck": true, "lastLaunchedRelease": null, "baseUrls": UPDATE_BASE_URLS }, "plugins": { "channel": "alpha", "display": {} }, "monitoring": { "enabled": true, "intervalSeconds": DEFAULT_INTERVAL_SECS }, "security": { "autoLockMinutes": 0, "lockOnBlur": false, "requireSystemAuthentication": false } }) } fn get<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { let mut current = value; for key in path { current = current.as_object()?.get(*key)?; } Some(current) } fn str_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> { get(value, path).and_then(Value::as_str) } pub fn normalize_release_marker(value: Option<&Value>) -> Value { let Some(Value::Object(map)) = value else { return Value::Null }; let text = |key: &str| -> String { match map.get(key) { Some(Value::String(s)) => s.trim().to_string(), Some(Value::Number(n)) => n.to_string(), _ => String::new(), } }; let (id, version, published_at) = (text("id"), text("version"), text("publishedAt")); let sha512 = text("sha512").to_lowercase(); let date_ok = chrono::DateTime::parse_from_rfc3339(&published_at).is_ok() || chrono::NaiveDate::parse_from_str(&published_at, "%Y-%m-%d").is_ok(); if id.is_empty() || version.is_empty() || !date_ok || !SHA512.is_match(&sha512) { return Value::Null; } json!({ "id": id, "version": version, "publishedAt": published_at, "sha512": sha512 }) } fn normalize_sidebar(value: Option<&Value>) -> Value { let source = value.and_then(Value::as_object); let mode = source .and_then(|s| s.get("mode")) .and_then(Value::as_str) .filter(|m| SIDEBAR_MODES.contains(m)) .unwrap_or("full"); let hidden = source.and_then(|s| s.get("hidden")).and_then(Value::as_bool) == Some(true); let width = source .and_then(|s| s.get("width")) .and_then(Value::as_f64) .filter(|w| w.is_finite()) .map(|w| (w.max(SIDEBAR_MIN as f64).min(SIDEBAR_MAX as f64)).round() as i64) .unwrap_or(SIDEBAR_DEFAULT); json!({ "mode": mode, "hidden": hidden, "width": width }) } pub fn normalize_config(input: &Value, languages: &[String]) -> Value { let source = if input.is_object() { input.clone() } else { json!({}) }; let channel_of = |path: &[&str], fallback: &str| -> String { str_at(&source, path).filter(|c| CATALOG_CHANNELS.contains(c)).unwrap_or(fallback).to_string() }; let theme = str_at(&source, &["ui", "theme"]).filter(|t| ["system", "dark", "light"].contains(t)).unwrap_or("dark"); let language = str_at(&source, &["ui", "language"]) .filter(|l| languages.iter().any(|known| known == l)) .unwrap_or(DEFAULT_LANGUAGE); let mut navigation = Vec::::new(); if let Some(Value::Array(items)) = get(&source, &["ui", "navigationOrder"]) { for item in items { if let Some(id) = item.as_str() { if NAV_ID.is_match(id) && !navigation.iter().any(|v| v.as_str() == Some(id)) { navigation.push(Value::String(id.to_string())); } } if navigation.len() >= 200 { break; } } } let mut display = Map::new(); if let Some(Value::Object(entries)) = get(&source, &["plugins", "display"]) { for (id, mode) in entries { if let Some(mode) = mode.as_str() { if MODULE_ID.is_match(id) && ["hidden", "visible", "navigation"].contains(&mode) { display.insert(id.clone(), Value::String(mode.to_string())); } } } } let auto_lock = get(&source, &["security", "autoLockMinutes"]) .and_then(Value::as_f64) .filter(|m| [0.0, 1.0, 5.0, 10.0, 15.0, 30.0, 60.0].contains(m)) .map(|m| m as i64) .unwrap_or(0); json!({ "schemaVersion": 1, "ui": { "language": language, "theme": theme, "terminalLayout": str_at(&source, &["ui", "terminalLayout"]).filter(|l| ["split", "stacked"].contains(l)).unwrap_or("single"), "terminalSplit": get(&source, &["ui", "terminalSplit"]).and_then(Value::as_f64).filter(|r| r.is_finite()).map(|r| r.clamp(0.2, 0.8)).unwrap_or(0.5), "terminalSwapped": get(&source, &["ui", "terminalSwapped"]).and_then(Value::as_bool) == Some(true), "sidebar": normalize_sidebar(get(&source, &["ui", "sidebar"])), "navigationOrder": navigation }, "updates": { "channel": channel_of(&["updates", "channel"], "beta"), "autoCheck": get(&source, &["updates", "autoCheck"]).and_then(Value::as_bool) != Some(false), "lastLaunchedRelease": normalize_release_marker(get(&source, &["updates", "lastLaunchedRelease"])), "baseUrls": UPDATE_BASE_URLS }, "plugins": { "channel": channel_of(&["plugins", "channel"], "alpha"), "display": display }, "monitoring": { "enabled": get(&source, &["monitoring", "enabled"]).and_then(Value::as_bool) != Some(false), "intervalSeconds": get(&source, &["monitoring", "intervalSeconds"]).and_then(Value::as_u64).filter(|s| INTERVALS_SECS.contains(s)).unwrap_or(DEFAULT_INTERVAL_SECS) }, "security": { "autoLockMinutes": auto_lock, "lockOnBlur": get(&source, &["security", "lockOnBlur"]).and_then(Value::as_bool) == Some(true), "requireSystemAuthentication": get(&source, &["security", "requireSystemAuthentication"]).and_then(Value::as_bool) == Some(true) } }) } fn merge_object(base: &Value, patch: Option<&Value>) -> Value { let mut out = base.as_object().cloned().unwrap_or_default(); if let Some(Value::Object(p)) = patch { for (k, v) in p { out.insert(k.clone(), v.clone()); } } Value::Object(out) } pub struct ConfigStore { path: PathBuf, languages: Vec, value: RwLock, } impl ConfigStore { pub fn new(root: &Path, languages: Vec) -> Self { Self { path: root.join("config").join("config.json"), languages, value: RwLock::new(default_config()) } } pub fn languages(&self) -> &[String] { &self.languages } pub fn load(&self) -> Value { match read_json(&self.path) { Ok(raw) => *self.value.write() = normalize_config(&raw, &self.languages), Err(error) => { if error.code != "ENOENT" { let backup = self.path.with_file_name(format!("config.json.invalid-{}", chrono::Utc::now().timestamp_millis())); let _ = std::fs::rename(&self.path, backup); } *self.value.write() = default_config(); let _ = self.save(); } } self.get() } pub fn get(&self) -> Value { self.value.read().clone() } pub fn language(&self) -> String { str_at(&self.value.read(), &["ui", "language"]).unwrap_or(DEFAULT_LANGUAGE).to_string() } pub fn update(&self, patch: &Value) -> CoreResult { let current = self.get(); let ui_patch = get(patch, &["ui"]); let mut ui = merge_object(¤t["ui"], ui_patch); ui["sidebar"] = merge_object(¤t["ui"]["sidebar"], ui_patch.and_then(|u| u.get("sidebar"))); let mut updates = current["updates"].clone(); if let Some(Value::Object(p)) = get(patch, &["updates"]) { if let Some(v) = p.get("channel").filter(|v| !v.is_null()) { updates["channel"] = v.clone(); } if let Some(v) = p.get("autoCheck").filter(|v| !v.is_null()) { updates["autoCheck"] = v.clone(); } } let next = json!({ "schemaVersion": 1, "ui": ui, "updates": updates, "plugins": merge_object(¤t["plugins"], get(patch, &["plugins"])), "monitoring": merge_object(¤t["monitoring"], get(patch, &["monitoring"])), "security": merge_object(¤t["security"], get(patch, &["security"])) }); *self.value.write() = normalize_config(&next, &self.languages); self.save()?; Ok(self.get()) } pub fn record_launched_release(&self, release: &Value) -> CoreResult { let marker = normalize_release_marker(Some(release)); if marker.is_null() { return Err(CoreError::plain("Invalid launched update release metadata.")); } let mut next = self.get(); next["updates"]["lastLaunchedRelease"] = marker.clone(); *self.value.write() = normalize_config(&next, &self.languages); self.save()?; Ok(marker) } pub fn save(&self) -> CoreResult<()> { let value = self.get(); atomic_write_json(&self.path, &value) } } #[cfg(test)] mod tests { use super::*; fn langs() -> Vec { vec!["cs".into(), "de".into(), "en".into()] } #[test] fn monitoring_defaults_on_and_only_offered_intervals_stay() { let config = normalize_config(&json!({}), &langs()); assert_eq!(config["monitoring"], json!({ "enabled": true, "intervalSeconds": 12 })); let config = normalize_config(&json!({"monitoring": {"enabled": false, "intervalSeconds": 30}}), &langs()); assert_eq!(config["monitoring"], json!({ "enabled": false, "intervalSeconds": 30 })); let config = normalize_config(&json!({"monitoring": {"enabled": "no", "intervalSeconds": 1}}), &langs()); assert_eq!(config["monitoring"], json!({ "enabled": true, "intervalSeconds": 12 })); } #[test] fn terminal_workspace_values_are_limited() { let config = normalize_config(&json!({"ui": {"terminalLayout": "stacked", "terminalSplit": 5.0, "terminalSwapped": true}}), &langs()); assert_eq!(config["ui"]["terminalLayout"], "stacked"); assert_eq!(config["ui"]["terminalSplit"], 0.8); assert_eq!(config["ui"]["terminalSwapped"], true); let config = normalize_config(&json!({"ui": {"terminalLayout": "grid", "terminalSplit": "x", "terminalSwapped": 1}}), &langs()); assert_eq!(config["ui"]["terminalLayout"], "single"); assert_eq!(config["ui"]["terminalSplit"], 0.5); assert_eq!(config["ui"]["terminalSwapped"], false); } #[test] fn defaults_and_invalid_values() { let config = normalize_config(&json!({"ui": {"language": "xx", "theme": "neon", "sidebar": {"width": 9999, "mode": "split"}}, "updates": {"channel": "nightly"}}), &langs()); assert_eq!(config["ui"]["language"], "cs"); assert_eq!(config["ui"]["theme"], "dark"); assert_eq!(config["ui"]["sidebar"]["width"], 420); assert_eq!(config["ui"]["sidebar"]["mode"], "split"); assert_eq!(config["updates"]["channel"], "beta"); assert_eq!(config["plugins"]["channel"], "alpha"); assert_eq!(config["updates"]["baseUrls"][0], "https://sm.ch-j.de/"); } #[test] fn dynamic_language_list() { let mut languages = langs(); languages.push("sk".into()); let config = normalize_config(&json!({"ui": {"language": "sk"}}), &languages); assert_eq!(config["ui"]["language"], "sk"); } #[test] fn update_merges_sidebar_and_ignores_base_urls() { let dir = tempfile::tempdir().unwrap(); let store = ConfigStore::new(dir.path(), langs()); store.load(); store.update(&json!({"ui": {"sidebar": {"hidden": true}}, "updates": {"baseUrls": ["https://evil/"]}})).unwrap(); let config = store.update(&json!({"ui": {"sidebar": {"mode": "compact"}}})).unwrap(); assert_eq!(config["ui"]["sidebar"]["hidden"], true); assert_eq!(config["ui"]["sidebar"]["mode"], "compact"); assert_eq!(config["updates"]["baseUrls"][0], "https://sm.ch-j.de/"); let reloaded = ConfigStore::new(dir.path(), langs()); assert_eq!(reloaded.load()["ui"]["sidebar"]["mode"], "compact"); } #[test] fn invalid_file_is_backed_up() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("config")).unwrap(); std::fs::write(dir.path().join("config/config.json"), "{broken").unwrap(); let store = ConfigStore::new(dir.path(), langs()); assert_eq!(store.load()["schemaVersion"], 1); let names: Vec = std::fs::read_dir(dir.path().join("config")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); assert!(names.iter().any(|n| n.starts_with("config.json.invalid-"))); } }