Avaldatud allikas
Laadi alla allikas ZIP
CH-J Tools
Vaata katalooge ja faile konkreetse rakenduse vabastamiseks.
CH-J / Server Manager
1704 failid
0.1.2-beta
Lisatud: · Ehitatud:
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
// In order of preference. tools.ch-j.de is the address of the planned rename to CH-J TOOLS; the21
// update channels and version numbering continue there, so both stay valid for updates.22
pub const UPDATE_BASE_URLS: [&str; 2] = ["https://sm.ch-j.de/", "https://tools.ch-j.de/"];24
static NAV_ID: Lazy<Regex> = Lazy::new(|| {25
Regex::new(r"^(overview|terminal|profiles|keychain|updates|plugins|diagnostics|settings|plugin:[a-z0-9][a-z0-9.-]{2,63})$").unwrap()26
});27
static MODULE_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9][a-z0-9.-]{2,63}$").unwrap());28
static SHA512: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-f0-9]{128}$").unwrap());30
pub fn default_config() -> Value {31
json!({32
"schemaVersion": 1,33
"ui": {34
"language": DEFAULT_LANGUAGE,35
"theme": "dark",36
"terminalSuggest": true,37
"terminalAppearance": normalize_terminal_appearance(None),38
"terminalProfiles": [],39
"terminalSuggestRepair": true,40
"terminalLayout": "single",41
"terminalSplit": 0.5,42
"terminalSwapped": false,43
"sidebar": { "mode": "full", "hidden": false, "width": SIDEBAR_DEFAULT },44
"navigationOrder": []45
},46
"updates": { "channel": "beta", "autoCheck": true, "lastLaunchedRelease": null, "baseUrls": UPDATE_BASE_URLS },47
"plugins": { "channel": "alpha", "display": {} },48
"monitoring": { "enabled": true, "intervalSeconds": DEFAULT_INTERVAL_SECS },49
"security": { "autoLockMinutes": 0, "lockOnBlur": false, "requireSystemAuthentication": false }50
})51
}53
fn get<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {54
let mut current = value;55
for key in path {56
current = current.as_object()?.get(*key)?;57
}58
Some(current)59
}61
fn str_at<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {62
get(value, path).and_then(Value::as_str)63
}65
pub fn normalize_release_marker(value: Option<&Value>) -> Value {66
let Some(Value::Object(map)) = value else { return Value::Null };67
let text = |key: &str| -> String {68
match map.get(key) {69
Some(Value::String(s)) => s.trim().to_string(),70
Some(Value::Number(n)) => n.to_string(),71
_ => String::new(),72
}73
};74
let (id, version, published_at) = (text("id"), text("version"), text("publishedAt"));75
let sha512 = text("sha512").to_lowercase();76
let date_ok = chrono::DateTime::parse_from_rfc3339(&published_at).is_ok()77
|| chrono::NaiveDate::parse_from_str(&published_at, "%Y-%m-%d").is_ok();78
if id.is_empty() || version.is_empty() || !date_ok || !SHA512.is_match(&sha512) {79
return Value::Null;80
}81
json!({ "id": id, "version": version, "publishedAt": published_at, "sha512": sha512 })82
}84
const MAX_TERMINAL_PROFILES: usize = 50;85
const ANSI_COLORS: [&str; 16] = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"];87
fn is_hex_color(text: &str) -> bool {88
text.len() == 7 && text.starts_with('#') && text[1..].chars().all(|c| c.is_ascii_hexdigit())89
}91
/// Terminal look. Empty or invalid values mean the CH-J default, which itself lives in the UI and never changes.92
fn normalize_terminal_appearance(value: Option<&Value>) -> Value {93
let empty = json!({});94
let source = value.filter(|v| v.is_object()).unwrap_or(&empty);95
let font = source["fontFamily"].as_str().unwrap_or("").trim();96
let font = if font.chars().count() <= 80 && font.chars().all(|c| c.is_alphanumeric() || " -_.".contains(c)) { font } else { "" };97
let number = |key: &str, min: f64, max: f64, default: f64| source[key].as_f64().filter(|n| n.is_finite()).map_or(default, |n| n.clamp(min, max));98
let color = |key: &str| {99
let text = source[key].as_str().unwrap_or("");100
if is_hex_color(text) { text.to_ascii_lowercase() } else { String::new() }101
};102
let weight = if source["fontWeight"] == "bold" { "bold" } else { "normal" };103
let cursor = source["cursorStyle"].as_str().filter(|style| ["underline", "bar"].contains(style)).unwrap_or("block");104
let mut ansi = serde_json::Map::new();105
for name in ANSI_COLORS {106
let text = source["ansi"][name].as_str().unwrap_or("");107
if is_hex_color(text) {108
ansi.insert(name.to_string(), json!(text.to_ascii_lowercase()));109
}110
}111
let profile = source["profile"].as_str().filter(|id| id.chars().count() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')).unwrap_or("");112
json!({ "profile": profile, "fontFamily": font, "fontSize": number("fontSize", 8.0, 32.0, 13.0), "fontWeight": weight, "lineHeight": number("lineHeight", 1.0, 2.0, 1.0), "letterSpacing": number("letterSpacing", -2.0, 5.0, 0.0), "cursorStyle": cursor, "foreground": color("foreground"), "background": color("background"), "cursor": color("cursor"), "selection": color("selection"), "ansi": ansi })113
}115
/// Saved terminal looks; a broken or unnamed entry is dropped.116
fn normalize_terminal_profiles(value: Option<&Value>) -> Value {117
let mut seen = std::collections::HashSet::new();118
let items = value.and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|item| {119
let id = item["id"].as_str().filter(|id| id.starts_with("user-") && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))?;120
let name = item["name"].as_str().map(str::trim).filter(|name| !name.is_empty() && name.chars().count() <= 40 && !name.chars().any(char::is_control))?;121
seen.insert(id.to_string()).then(|| json!({ "id": id, "name": name, "appearance": normalize_terminal_appearance(item.get("appearance")) }))122
}).take(MAX_TERMINAL_PROFILES).collect();123
Value::Array(items)124
}126
fn normalize_sidebar(value: Option<&Value>) -> Value {127
let source = value.and_then(Value::as_object);128
let mode = source129
.and_then(|s| s.get("mode"))130
.and_then(Value::as_str)131
.filter(|m| SIDEBAR_MODES.contains(m))132
.unwrap_or("full");133
let hidden = source.and_then(|s| s.get("hidden")).and_then(Value::as_bool) == Some(true);134
let width = source135
.and_then(|s| s.get("width"))136
.and_then(Value::as_f64)137
.filter(|w| w.is_finite())138
.map(|w| (w.max(SIDEBAR_MIN as f64).min(SIDEBAR_MAX as f64)).round() as i64)139
.unwrap_or(SIDEBAR_DEFAULT);140
json!({ "mode": mode, "hidden": hidden, "width": width })141
}143
pub fn normalize_config(input: &Value, languages: &[String]) -> Value {144
let source = if input.is_object() { input.clone() } else { json!({}) };145
let channel_of = |path: &[&str], fallback: &str| -> String {146
str_at(&source, path).filter(|c| CATALOG_CHANNELS.contains(c)).unwrap_or(fallback).to_string()147
};148
let theme = str_at(&source, &["ui", "theme"]).filter(|t| ["system", "dark", "light"].contains(t)).unwrap_or("dark");149
let language = str_at(&source, &["ui", "language"])150
.filter(|l| languages.iter().any(|known| known == l))151
.unwrap_or(DEFAULT_LANGUAGE);152
let mut navigation = Vec::<Value>::new();153
if let Some(Value::Array(items)) = get(&source, &["ui", "navigationOrder"]) {154
for item in items {155
if let Some(id) = item.as_str() {156
if NAV_ID.is_match(id) && !navigation.iter().any(|v| v.as_str() == Some(id)) {157
navigation.push(Value::String(id.to_string()));158
}159
}160
if navigation.len() >= 200 {161
break;162
}163
}164
}165
let mut display = Map::new();166
if let Some(Value::Object(entries)) = get(&source, &["plugins", "display"]) {167
for (id, mode) in entries {168
if let Some(mode) = mode.as_str() {169
if MODULE_ID.is_match(id) && ["hidden", "visible", "navigation"].contains(&mode) {170
display.insert(id.clone(), Value::String(mode.to_string()));171
}172
}173
}174
}175
let auto_lock = get(&source, &["security", "autoLockMinutes"])176
.and_then(Value::as_f64)177
.filter(|m| [0.0, 1.0, 5.0, 10.0, 15.0, 30.0, 60.0].contains(m))178
.map(|m| m as i64)179
.unwrap_or(0);180
json!({181
"schemaVersion": 1,182
"ui": {183
"language": language,184
"theme": theme,185
"terminalAppearance": normalize_terminal_appearance(get(&source, &["ui", "terminalAppearance"])),186
"terminalProfiles": normalize_terminal_profiles(get(&source, &["ui", "terminalProfiles"])),187
"terminalSuggest": get(&source, &["ui", "terminalSuggest"]).and_then(Value::as_bool) != Some(false),188
"terminalSuggestRepair": get(&source, &["ui", "terminalSuggestRepair"]).and_then(Value::as_bool) != Some(false),189
"terminalLayout": str_at(&source, &["ui", "terminalLayout"]).filter(|l| ["split", "stacked"].contains(l)).unwrap_or("single"),190
"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),191
"terminalSwapped": get(&source, &["ui", "terminalSwapped"]).and_then(Value::as_bool) == Some(true),192
"sidebar": normalize_sidebar(get(&source, &["ui", "sidebar"])),193
"navigationOrder": navigation194
},195
"updates": {196
"channel": channel_of(&["updates", "channel"], "beta"),197
"autoCheck": get(&source, &["updates", "autoCheck"]).and_then(Value::as_bool) != Some(false),198
"lastLaunchedRelease": normalize_release_marker(get(&source, &["updates", "lastLaunchedRelease"])),199
"baseUrls": UPDATE_BASE_URLS200
},201
"plugins": { "channel": channel_of(&["plugins", "channel"], "alpha"), "display": display },202
"monitoring": {203
"enabled": get(&source, &["monitoring", "enabled"]).and_then(Value::as_bool) != Some(false),204
"intervalSeconds": get(&source, &["monitoring", "intervalSeconds"]).and_then(Value::as_u64).filter(|s| INTERVALS_SECS.contains(s)).unwrap_or(DEFAULT_INTERVAL_SECS)205
},206
"security": {207
"autoLockMinutes": auto_lock,208
"lockOnBlur": get(&source, &["security", "lockOnBlur"]).and_then(Value::as_bool) == Some(true),209
"requireSystemAuthentication": get(&source, &["security", "requireSystemAuthentication"]).and_then(Value::as_bool) == Some(true)210
}211
})212
}214
fn merge_object(base: &Value, patch: Option<&Value>) -> Value {215
let mut out = base.as_object().cloned().unwrap_or_default();216
if let Some(Value::Object(p)) = patch {217
for (k, v) in p {218
out.insert(k.clone(), v.clone());219
}220
}221
Value::Object(out)222
}224
pub struct ConfigStore {225
path: PathBuf,226
languages: Vec<String>,227
value: RwLock<Value>,228
}230
impl ConfigStore {231
pub fn new(root: &Path, languages: Vec<String>) -> Self {232
Self { path: root.join("config").join("config.json"), languages, value: RwLock::new(default_config()) }233
}235
pub fn languages(&self) -> &[String] {236
&self.languages237
}239
pub fn load(&self) -> Value {240
match read_json(&self.path) {241
Ok(raw) => *self.value.write() = normalize_config(&raw, &self.languages),242
Err(error) => {243
if error.code != "ENOENT" {244
let backup = self.path.with_file_name(format!("config.json.invalid-{}", chrono::Utc::now().timestamp_millis()));245
let _ = std::fs::rename(&self.path, backup);246
}247
*self.value.write() = default_config();248
let _ = self.save();249
}250
}251
self.get()252
}254
pub fn get(&self) -> Value {255
self.value.read().clone()256
}258
pub fn language(&self) -> String {259
str_at(&self.value.read(), &["ui", "language"]).unwrap_or(DEFAULT_LANGUAGE).to_string()260
}262
pub fn update(&self, patch: &Value) -> CoreResult<Value> {263
let current = self.get();264
let ui_patch = get(patch, &["ui"]);265
let mut ui = merge_object(¤t["ui"], ui_patch);266
ui["sidebar"] = merge_object(¤t["ui"]["sidebar"], ui_patch.and_then(|u| u.get("sidebar")));267
let mut updates = current["updates"].clone();268
if let Some(Value::Object(p)) = get(patch, &["updates"]) {269
if let Some(v) = p.get("channel").filter(|v| !v.is_null()) {270
updates["channel"] = v.clone();271
}272
if let Some(v) = p.get("autoCheck").filter(|v| !v.is_null()) {273
updates["autoCheck"] = v.clone();274
}275
}276
let next = json!({277
"schemaVersion": 1,278
"ui": ui,279
"updates": updates,280
"plugins": merge_object(¤t["plugins"], get(patch, &["plugins"])),281
"monitoring": merge_object(¤t["monitoring"], get(patch, &["monitoring"])),282
"security": merge_object(¤t["security"], get(patch, &["security"]))283
});284
*self.value.write() = normalize_config(&next, &self.languages);285
self.save()?;286
Ok(self.get())287
}289
pub fn record_launched_release(&self, release: &Value) -> CoreResult<Value> {290
let marker = normalize_release_marker(Some(release));291
if marker.is_null() {292
return Err(CoreError::plain("Invalid launched update release metadata."));293
}294
let mut next = self.get();295
next["updates"]["lastLaunchedRelease"] = marker.clone();296
*self.value.write() = normalize_config(&next, &self.languages);297
self.save()?;298
Ok(marker)299
}301
pub fn save(&self) -> CoreResult<()> {302
let value = self.get();303
atomic_write_json(&self.path, &value)304
}305
}307
#[cfg(test)]308
mod tests {309
use super::*;311
fn langs() -> Vec<String> {312
vec!["cs".into(), "de".into(), "en".into()]313
}315
#[test]316
fn plaintext_settings_drop_server_metadata_credentials_and_connection_history() {317
let root = tempfile::tempdir().unwrap(); let store = ConfigStore::new(root.path(), langs()); store.load();318
let secret = "synthetic-secret-marker"; let host = "private-server-fixture.invalid";319
store.update(&json!({"password":secret,"profiles":[{"host":host}],"history":[host],320
"ui":{"host":host,"password":secret,"connectionHistory":[host],"sidebar":{"host":host},321
"terminalProfiles":[{"id":"user-fixture","name":"Ocean","host":host,"password":secret,"appearance":{"host":host}}]},322
"plugins":{"host":host,"password":secret},"monitoring":{"servers":[host]},"security":{"password":secret}})).unwrap();323
let text = std::fs::read_to_string(root.path().join("config/config.json")).unwrap();324
assert!(!text.contains(secret)); assert!(!text.contains(host)); assert!(!text.contains("connectionHistory"));325
assert_eq!(store.get()["ui"]["terminalProfiles"][0]["name"],"Ocean");326
}328
#[test]329
fn monitoring_defaults_on_and_only_offered_intervals_stay() {330
let config = normalize_config(&json!({}), &langs());331
assert_eq!(config["monitoring"], json!({ "enabled": true, "intervalSeconds": 12 }));332
let config = normalize_config(&json!({"monitoring": {"enabled": false, "intervalSeconds": 30}}), &langs());333
assert_eq!(config["monitoring"], json!({ "enabled": false, "intervalSeconds": 30 }));334
let config = normalize_config(&json!({"monitoring": {"enabled": "no", "intervalSeconds": 1}}), &langs());335
assert_eq!(config["monitoring"], json!({ "enabled": true, "intervalSeconds": 12 }));336
}338
#[test]339
fn terminal_workspace_values_are_limited() {340
let config = normalize_config(&json!({"ui": {"terminalLayout": "stacked", "terminalSplit": 5.0, "terminalSwapped": true}}), &langs());341
assert_eq!(config["ui"]["terminalLayout"], "stacked");342
assert_eq!(config["ui"]["terminalSplit"], 0.8);343
assert_eq!(config["ui"]["terminalSwapped"], true);344
assert_eq!(normalize_config(&json!({}), &langs())["ui"]["terminalSuggest"], true);345
let look = normalize_config(&json!({"ui": {"terminalAppearance": {"fontFamily": "Times New Roman", "fontSize": 99, "lineHeight": 0.2, "foreground": "#ABCDEF", "background": "red", "cursorStyle": "bar"}}}), &langs());346
let look = &look["ui"]["terminalAppearance"];347
assert_eq!((look["fontFamily"].as_str(), look["fontSize"].as_f64(), look["lineHeight"].as_f64(), look["foreground"].as_str(), look["background"].as_str(), look["cursorStyle"].as_str()), (Some("Times New Roman"), Some(32.0), Some(1.0), Some("#abcdef"), Some(""), Some("bar")));348
let saved = normalize_config(&json!({"ui": {"terminalAppearance": {"ansi": {"red": "#FF0000", "green": "zelena", "unknown": "#000000"}}, "terminalProfiles": [{"id": "user-1", "name": " Můj ", "appearance": {"foreground": "#111111"}}, {"id": "user-1", "name": "dvojník"}, {"id": "bad id", "name": "x"}, {"id": "user-2", "name": ""}]}}), &langs());349
assert_eq!(saved["ui"]["terminalAppearance"]["ansi"], json!({"red": "#ff0000"}));350
assert_eq!(saved["ui"]["terminalProfiles"].as_array().unwrap().len(), 1);351
assert_eq!((saved["ui"]["terminalProfiles"][0]["name"].as_str(), saved["ui"]["terminalProfiles"][0]["appearance"]["foreground"].as_str()), (Some("Můj"), Some("#111111")));352
assert_eq!(normalize_config(&json!({"ui": {"terminalAppearance": {"fontFamily": "x\"; @import url(y)"}}}), &langs())["ui"]["terminalAppearance"]["fontFamily"], "", "a font name cannot carry CSS");353
assert_eq!(normalize_config(&json!({"ui": {"terminalSuggest": false}}), &langs())["ui"]["terminalSuggest"], false);354
let config = normalize_config(&json!({"ui": {"terminalLayout": "grid", "terminalSplit": "x", "terminalSwapped": 1}}), &langs());355
assert_eq!(config["ui"]["terminalLayout"], "single");356
assert_eq!(config["ui"]["terminalSplit"], 0.5);357
assert_eq!(config["ui"]["terminalSwapped"], false);358
}360
#[test]361
fn defaults_and_invalid_values() {362
let config = normalize_config(&json!({"ui": {"language": "xx", "theme": "neon", "sidebar": {"width": 9999, "mode": "split"}}, "updates": {"channel": "nightly"}}), &langs());363
assert_eq!(config["ui"]["language"], "cs");364
assert_eq!(config["ui"]["theme"], "dark");365
assert_eq!(config["ui"]["sidebar"]["width"], 420);366
assert_eq!(config["ui"]["sidebar"]["mode"], "split");367
assert_eq!(config["updates"]["channel"], "beta");368
assert_eq!(config["plugins"]["channel"], "alpha");369
assert_eq!(config["updates"]["baseUrls"][0], "https://sm.ch-j.de/");370
}372
#[test]373
fn dynamic_language_list() {374
let mut languages = langs();375
languages.push("sk".into());376
let config = normalize_config(&json!({"ui": {"language": "sk"}}), &languages);377
assert_eq!(config["ui"]["language"], "sk");378
}380
#[test]381
fn update_merges_sidebar_and_ignores_base_urls() {382
let dir = tempfile::tempdir().unwrap();383
let store = ConfigStore::new(dir.path(), langs());384
store.load();385
store.update(&json!({"ui": {"sidebar": {"hidden": true}}, "updates": {"baseUrls": ["https://evil/"]}})).unwrap();386
let config = store.update(&json!({"ui": {"sidebar": {"mode": "compact"}}})).unwrap();387
assert_eq!(config["ui"]["sidebar"]["hidden"], true);388
assert_eq!(config["ui"]["sidebar"]["mode"], "compact");389
assert_eq!(config["updates"]["baseUrls"], json!(["https://sm.ch-j.de/", "https://tools.ch-j.de/"]));390
let reloaded = ConfigStore::new(dir.path(), langs());391
assert_eq!(reloaded.load()["ui"]["sidebar"]["mode"], "compact");392
}394
#[test]395
fn invalid_file_is_backed_up() {396
let dir = tempfile::tempdir().unwrap();397
std::fs::create_dir_all(dir.path().join("config")).unwrap();398
std::fs::write(dir.path().join("config/config.json"), "{broken").unwrap();399
let store = ConfigStore::new(dir.path(), langs());400
assert_eq!(store.load()["schemaVersion"], 1);401
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();402
assert!(names.iter().any(|n| n.starts_with("config.json.invalid-")));403
}404
}SHA-256
b9e0cf29b61209dde75c609585cc00c39b0ec552410e376fe811ff7da8a4f340CH-J Proprietary Software License 1.14 · Allikas on esitatud CH-J Proprietary Software License 1.14 all. Selle kättesaadavus ei muuda litsentsitingimusi ega anna täiendavaid õigusi.
Arhiiv SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f