Vydané zdroje
Stáhnout zdroje ZIP
CH-J Tools
Procházejte adresáře a soubory konkrétního vydání aplikace.
CH-J / Server Manager
872 souborů
core-20261030T220507Z-61e4558d889f
Přidáno: · Sestaveno:
1
//! Configuration store (port of `config/configStore.js`, schema 1, same file2
//! `config/config.json`). Supported UI languages are no longer hard-coded: they come3
//! from the language manifest `i18n/languages.json` shipped with the UI.5
use crate::core::availability::{DEFAULT_INTERVAL_SECS, INTERVALS_SECS};6
use crate::core::error::{CoreError, CoreResult};7
use crate::core::storage::{atomic_write_json, read_json};8
use once_cell::sync::Lazy;9
use parking_lot::RwLock;10
use regex::Regex;11
use serde_json::{json, Map, Value};12
use std::path::{Path, PathBuf};14
pub const CATALOG_CHANNELS: [&str; 4] = ["alpha", "beta", "stable", "all"];15
pub const SIDEBAR_MODES: [&str; 3] = ["full", "compact", "split"];16
pub const SIDEBAR_MIN: i64 = 220;17
pub const SIDEBAR_MAX: i64 = 420;18
pub const SIDEBAR_DEFAULT: i64 = 248;19
pub const DEFAULT_LANGUAGE: &str = "cs";20
pub const UPDATE_BASE_URLS: [&str; 1] = ["https://sm.ch-j.de/"];22
static NAV_ID: Lazy<Regex> = Lazy::new(|| {23
Regex::new(r"^(overview|terminal|profiles|updates|plugins|diagnostics|settings|plugin:[a-z0-9][a-z0-9.-]{2,63})$").unwrap()24
});25
static MODULE_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9][a-z0-9.-]{2,63}$").unwrap());26
static SHA512: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-f0-9]{128}$").unwrap());28
pub fn default_config() -> Value {29
json!({30
"schemaVersion": 1,31
"ui": {32
"language": DEFAULT_LANGUAGE,33
"theme": "dark",34
"terminalLayout": "single",35
"terminalSplit": 0.5,36
"terminalSwapped": false,37
"sidebar": { "mode": "full", "hidden": false, "width": SIDEBAR_DEFAULT },38
"navigationOrder": []39
},40
"updates": { "channel": "beta", "autoCheck": true, "lastLaunchedRelease": null, "baseUrls": UPDATE_BASE_URLS },41
"plugins": { "channel": "alpha", "display": {} },42
"monitoring": { "enabled": true, "intervalSeconds": DEFAULT_INTERVAL_SECS },43
"security": { "autoLockMinutes": 0, "lockOnBlur": false, "requireSystemAuthentication": false }44
})45
}47
fn get<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {48
let mut current = value;49
for key in path {50
current = current.as_object()?.get(*key)?;51
}52
Some(current)53
}55
fn str_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {56
get(value, path).and_then(Value::as_str)57
}59
pub fn normalize_release_marker(value: Option<&Value>) -> Value {60
let Some(Value::Object(map)) = value else { return Value::Null };61
let text = |key: &str| -> String {62
match map.get(key) {63
Some(Value::String(s)) => s.trim().to_string(),64
Some(Value::Number(n)) => n.to_string(),65
_ => String::new(),66
}67
};68
let (id, version, published_at) = (text("id"), text("version"), text("publishedAt"));69
let sha512 = text("sha512").to_lowercase();70
let date_ok = chrono::DateTime::parse_from_rfc3339(&published_at).is_ok()71
|| chrono::NaiveDate::parse_from_str(&published_at, "%Y-%m-%d").is_ok();72
if id.is_empty() || version.is_empty() || !date_ok || !SHA512.is_match(&sha512) {73
return Value::Null;74
}75
json!({ "id": id, "version": version, "publishedAt": published_at, "sha512": sha512 })76
}78
fn normalize_sidebar(value: Option<&Value>) -> Value {79
let source = value.and_then(Value::as_object);80
let mode = source81
.and_then(|s| s.get("mode"))82
.and_then(Value::as_str)83
.filter(|m| SIDEBAR_MODES.contains(m))84
.unwrap_or("full");85
let hidden = source.and_then(|s| s.get("hidden")).and_then(Value::as_bool) == Some(true);86
let width = source87
.and_then(|s| s.get("width"))88
.and_then(Value::as_f64)89
.filter(|w| w.is_finite())90
.map(|w| (w.max(SIDEBAR_MIN as f64).min(SIDEBAR_MAX as f64)).round() as i64)91
.unwrap_or(SIDEBAR_DEFAULT);92
json!({ "mode": mode, "hidden": hidden, "width": width })93
}95
pub fn normalize_config(input: &Value, languages: &[String]) -> Value {96
let source = if input.is_object() { input.clone() } else { json!({}) };97
let channel_of = |path: &[&str], fallback: &str| -> String {98
str_at(&source, path).filter(|c| CATALOG_CHANNELS.contains(c)).unwrap_or(fallback).to_string()99
};100
let theme = str_at(&source, &["ui", "theme"]).filter(|t| ["system", "dark", "light"].contains(t)).unwrap_or("dark");101
let language = str_at(&source, &["ui", "language"])102
.filter(|l| languages.iter().any(|known| known == l))103
.unwrap_or(DEFAULT_LANGUAGE);104
let mut navigation = Vec::<Value>::new();105
if let Some(Value::Array(items)) = get(&source, &["ui", "navigationOrder"]) {106
for item in items {107
if let Some(id) = item.as_str() {108
if NAV_ID.is_match(id) && !navigation.iter().any(|v| v.as_str() == Some(id)) {109
navigation.push(Value::String(id.to_string()));110
}111
}112
if navigation.len() >= 200 {113
break;114
}115
}116
}117
let mut display = Map::new();118
if let Some(Value::Object(entries)) = get(&source, &["plugins", "display"]) {119
for (id, mode) in entries {120
if let Some(mode) = mode.as_str() {121
if MODULE_ID.is_match(id) && ["hidden", "visible", "navigation"].contains(&mode) {122
display.insert(id.clone(), Value::String(mode.to_string()));123
}124
}125
}126
}127
let auto_lock = get(&source, &["security", "autoLockMinutes"])128
.and_then(Value::as_f64)129
.filter(|m| [0.0, 1.0, 5.0, 10.0, 15.0, 30.0, 60.0].contains(m))130
.map(|m| m as i64)131
.unwrap_or(0);132
json!({133
"schemaVersion": 1,134
"ui": {135
"language": language,136
"theme": theme,137
"terminalLayout": str_at(&source, &["ui", "terminalLayout"]).filter(|l| ["split", "stacked"].contains(l)).unwrap_or("single"),138
"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),139
"terminalSwapped": get(&source, &["ui", "terminalSwapped"]).and_then(Value::as_bool) == Some(true),140
"sidebar": normalize_sidebar(get(&source, &["ui", "sidebar"])),141
"navigationOrder": navigation142
},143
"updates": {144
"channel": channel_of(&["updates", "channel"], "beta"),145
"autoCheck": get(&source, &["updates", "autoCheck"]).and_then(Value::as_bool) != Some(false),146
"lastLaunchedRelease": normalize_release_marker(get(&source, &["updates", "lastLaunchedRelease"])),147
"baseUrls": UPDATE_BASE_URLS148
},149
"plugins": { "channel": channel_of(&["plugins", "channel"], "alpha"), "display": display },150
"monitoring": {151
"enabled": get(&source, &["monitoring", "enabled"]).and_then(Value::as_bool) != Some(false),152
"intervalSeconds": get(&source, &["monitoring", "intervalSeconds"]).and_then(Value::as_u64).filter(|s| INTERVALS_SECS.contains(s)).unwrap_or(DEFAULT_INTERVAL_SECS)153
},154
"security": {155
"autoLockMinutes": auto_lock,156
"lockOnBlur": get(&source, &["security", "lockOnBlur"]).and_then(Value::as_bool) == Some(true),157
"requireSystemAuthentication": get(&source, &["security", "requireSystemAuthentication"]).and_then(Value::as_bool) == Some(true)158
}159
})160
}162
fn merge_object(base: &Value, patch: Option<&Value>) -> Value {163
let mut out = base.as_object().cloned().unwrap_or_default();164
if let Some(Value::Object(p)) = patch {165
for (k, v) in p {166
out.insert(k.clone(), v.clone());167
}168
}169
Value::Object(out)170
}172
pub struct ConfigStore {173
path: PathBuf,174
languages: Vec<String>,175
value: RwLock<Value>,176
}178
impl ConfigStore {179
pub fn new(root: &Path, languages: Vec<String>) -> Self {180
Self { path: root.join("config").join("config.json"), languages, value: RwLock::new(default_config()) }181
}183
pub fn languages(&self) -> &[String] {184
&self.languages185
}187
pub fn load(&self) -> Value {188
match read_json(&self.path) {189
Ok(raw) => *self.value.write() = normalize_config(&raw, &self.languages),190
Err(error) => {191
if error.code != "ENOENT" {192
let backup = self.path.with_file_name(format!("config.json.invalid-{}", chrono::Utc::now().timestamp_millis()));193
let _ = std::fs::rename(&self.path, backup);194
}195
*self.value.write() = default_config();196
let _ = self.save();197
}198
}199
self.get()200
}202
pub fn get(&self) -> Value {203
self.value.read().clone()204
}206
pub fn language(&self) -> String {207
str_at(&self.value.read(), &["ui", "language"]).unwrap_or(DEFAULT_LANGUAGE).to_string()208
}210
pub fn update(&self, patch: &Value) -> CoreResult<Value> {211
let current = self.get();212
let ui_patch = get(patch, &["ui"]);213
let mut ui = merge_object(¤t["ui"], ui_patch);214
ui["sidebar"] = merge_object(¤t["ui"]["sidebar"], ui_patch.and_then(|u| u.get("sidebar")));215
let mut updates = current["updates"].clone();216
if let Some(Value::Object(p)) = get(patch, &["updates"]) {217
if let Some(v) = p.get("channel").filter(|v| !v.is_null()) {218
updates["channel"] = v.clone();219
}220
if let Some(v) = p.get("autoCheck").filter(|v| !v.is_null()) {221
updates["autoCheck"] = v.clone();222
}223
}224
let next = json!({225
"schemaVersion": 1,226
"ui": ui,227
"updates": updates,228
"plugins": merge_object(¤t["plugins"], get(patch, &["plugins"])),229
"monitoring": merge_object(¤t["monitoring"], get(patch, &["monitoring"])),230
"security": merge_object(¤t["security"], get(patch, &["security"]))231
});232
*self.value.write() = normalize_config(&next, &self.languages);233
self.save()?;234
Ok(self.get())235
}237
pub fn record_launched_release(&self, release: &Value) -> CoreResult<Value> {238
let marker = normalize_release_marker(Some(release));239
if marker.is_null() {240
return Err(CoreError::plain("Invalid launched update release metadata."));241
}242
let mut next = self.get();243
next["updates"]["lastLaunchedRelease"] = marker.clone();244
*self.value.write() = normalize_config(&next, &self.languages);245
self.save()?;246
Ok(marker)247
}249
pub fn save(&self) -> CoreResult<()> {250
let value = self.get();251
atomic_write_json(&self.path, &value)252
}253
}255
#[cfg(test)]256
mod tests {257
use super::*;259
fn langs() -> Vec<String> {260
vec!["cs".into(), "de".into(), "en".into()]261
}263
#[test]264
fn monitoring_defaults_on_and_only_offered_intervals_stay() {265
let config = normalize_config(&json!({}), &langs());266
assert_eq!(config["monitoring"], json!({ "enabled": true, "intervalSeconds": 12 }));267
let config = normalize_config(&json!({"monitoring": {"enabled": false, "intervalSeconds": 30}}), &langs());268
assert_eq!(config["monitoring"], json!({ "enabled": false, "intervalSeconds": 30 }));269
let config = normalize_config(&json!({"monitoring": {"enabled": "no", "intervalSeconds": 1}}), &langs());270
assert_eq!(config["monitoring"], json!({ "enabled": true, "intervalSeconds": 12 }));271
}273
#[test]274
fn terminal_workspace_values_are_limited() {275
let config = normalize_config(&json!({"ui": {"terminalLayout": "stacked", "terminalSplit": 5.0, "terminalSwapped": true}}), &langs());276
assert_eq!(config["ui"]["terminalLayout"], "stacked");277
assert_eq!(config["ui"]["terminalSplit"], 0.8);278
assert_eq!(config["ui"]["terminalSwapped"], true);279
let config = normalize_config(&json!({"ui": {"terminalLayout": "grid", "terminalSplit": "x", "terminalSwapped": 1}}), &langs());280
assert_eq!(config["ui"]["terminalLayout"], "single");281
assert_eq!(config["ui"]["terminalSplit"], 0.5);282
assert_eq!(config["ui"]["terminalSwapped"], false);283
}285
#[test]286
fn defaults_and_invalid_values() {287
let config = normalize_config(&json!({"ui": {"language": "xx", "theme": "neon", "sidebar": {"width": 9999, "mode": "split"}}, "updates": {"channel": "nightly"}}), &langs());288
assert_eq!(config["ui"]["language"], "cs");289
assert_eq!(config["ui"]["theme"], "dark");290
assert_eq!(config["ui"]["sidebar"]["width"], 420);291
assert_eq!(config["ui"]["sidebar"]["mode"], "split");292
assert_eq!(config["updates"]["channel"], "beta");293
assert_eq!(config["plugins"]["channel"], "alpha");294
assert_eq!(config["updates"]["baseUrls"][0], "https://sm.ch-j.de/");295
}297
#[test]298
fn dynamic_language_list() {299
let mut languages = langs();300
languages.push("sk".into());301
let config = normalize_config(&json!({"ui": {"language": "sk"}}), &languages);302
assert_eq!(config["ui"]["language"], "sk");303
}305
#[test]306
fn update_merges_sidebar_and_ignores_base_urls() {307
let dir = tempfile::tempdir().unwrap();308
let store = ConfigStore::new(dir.path(), langs());309
store.load();310
store.update(&json!({"ui": {"sidebar": {"hidden": true}}, "updates": {"baseUrls": ["https://evil/"]}})).unwrap();311
let config = store.update(&json!({"ui": {"sidebar": {"mode": "compact"}}})).unwrap();312
assert_eq!(config["ui"]["sidebar"]["hidden"], true);313
assert_eq!(config["ui"]["sidebar"]["mode"], "compact");314
assert_eq!(config["updates"]["baseUrls"][0], "https://sm.ch-j.de/");315
let reloaded = ConfigStore::new(dir.path(), langs());316
assert_eq!(reloaded.load()["ui"]["sidebar"]["mode"], "compact");317
}319
#[test]320
fn invalid_file_is_backed_up() {321
let dir = tempfile::tempdir().unwrap();322
std::fs::create_dir_all(dir.path().join("config")).unwrap();323
std::fs::write(dir.path().join("config/config.json"), "{broken").unwrap();324
let store = ConfigStore::new(dir.path(), langs());325
assert_eq!(store.load()["schemaVersion"], 1);326
let names: Vec<String> = std::fs::read_dir(dir.path().join("config")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();327
assert!(names.iter().any(|n| n.starts_with("config.json.invalid-")));328
}329
}SHA-256
9cf2f568b774016cff13b72a471f3bf0ab92450a0b2e116522409c7da55f4101CH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
SHA-256 archivu
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df