Removes the "Use as theme" checkbox/is_theme distinction from NavMenu.tsx's CSS import and link forms — every imported/linked stylesheet is now always validated against the canonical --c-* variable set and becomes a selectable theme, same as today's is_theme:true path. Drops is_theme entirely from the backend (CssEntry, ImportCssRequest, LinkCssRequest, CssEntryDto) and from the frontend CssEntry type; an existing settings.json with a stored is_theme:false entry still deserializes fine (the field is simply unknown/ignored) and that entry becomes selectable like any other, so no migration step is needed. Also drops the now-dead always-on-overlay CSS injection path from useSettingsPanel.ts, and changes the CSS filename placeholder from "name.css" to "enter name". Implemented directly against main rather than merging the dispatched worktree branch for this ticket, since that branch's base predated ticket 012 (the custom-themes/imported-CSS consolidation) and its NavMenu structure no longer matched current main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
462 lines
17 KiB
Rust
462 lines
17 KiB
Rust
//! Process-wide settings model: colour scheme, CSS entries, and profile pointers, persisted as JSON.
|
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
use std::fmt;
|
|
use std::fs;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use directories::ProjectDirs;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::alerts::AlertRule;
|
|
|
|
/// Shared, process-wide settings handle. Cheap to clone.
|
|
pub type SettingsHandle = Arc<Mutex<Settings>>;
|
|
|
|
/// User's preferred colour scheme for the webapp.
|
|
///
|
|
/// `Dark` accepts the legacy `"grey"` serde value (ticket 004: "grey" was a
|
|
/// near-duplicate of "dark" and has been removed as a selectable option) so a
|
|
/// settings file saved before that change still loads cleanly instead of
|
|
/// falling back to `System`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum ColorScheme {
|
|
Light,
|
|
#[serde(alias = "grey")]
|
|
Dark,
|
|
Red,
|
|
Green,
|
|
Blue,
|
|
Yellow,
|
|
Pink,
|
|
Orange,
|
|
#[default]
|
|
System,
|
|
}
|
|
|
|
/// A single CSS entry: content ottyl stores itself ("imported") or a reference to an external
|
|
/// path/URL ("linked", not deletable via the API). Every entry is always a selectable
|
|
/// colour-scheme theme (ticket 020 removed the "use as theme"/always-on-overlay distinction);
|
|
/// an entry persisted before that change with a stored `is_theme: false` still loads fine (the
|
|
/// field is simply ignored) and becomes selectable like any other entry.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
|
pub enum CssEntry {
|
|
Imported { id: String, filename: String },
|
|
Linked { path_or_url: String },
|
|
}
|
|
|
|
/// Tracks known profile names and which one is active; the profile files themselves live elsewhere.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
|
pub struct Profiles {
|
|
/// Names of known profiles.
|
|
pub names: Vec<String>,
|
|
/// Name of the currently active profile, if any.
|
|
pub active_profile: Option<String>,
|
|
/// Directory (relative to the settings file's directory, or absolute)
|
|
/// where individual profile files are expected to live.
|
|
pub profiles_dir: String,
|
|
}
|
|
|
|
/// The single, process-wide settings instance. Only `PartialEq` (not `Eq`): `alert_rules` carries `f64` fields, which aren't `Eq`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct Settings {
|
|
pub color_scheme: ColorScheme,
|
|
pub css_entries: Vec<CssEntry>,
|
|
/// Id of the selected custom theme entry, if any; `None` means `color_scheme` alone determines the active palette.
|
|
#[serde(default)]
|
|
pub custom_theme_id: Option<String>,
|
|
pub profiles: Profiles,
|
|
/// Maximum number of ingested logs kept in memory; `None` means unlimited.
|
|
#[serde(default)]
|
|
pub max_logs: Option<usize>,
|
|
/// Alert rule definitions.
|
|
#[serde(default)]
|
|
pub alert_rules: Vec<AlertRule>,
|
|
/// Ntfy topic every alert rule delivers to; `None` until configured in Settings.
|
|
#[serde(default)]
|
|
pub ntfy_topic: Option<String>,
|
|
/// Ntfy server override; `None` means `https://ntfy.sh`.
|
|
#[serde(default)]
|
|
pub ntfy_server: Option<String>,
|
|
}
|
|
|
|
impl Default for Settings {
|
|
fn default() -> Self {
|
|
Settings {
|
|
color_scheme: ColorScheme::default(),
|
|
css_entries: Vec::new(),
|
|
custom_theme_id: None,
|
|
profiles: Profiles {
|
|
names: Vec::new(),
|
|
active_profile: None,
|
|
profiles_dir: "profiles".to_string(),
|
|
},
|
|
max_logs: None,
|
|
alert_rules: Vec::new(),
|
|
ntfy_topic: None,
|
|
ntfy_server: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Computes the default settings file path (`<config_dir>/settings.json`) from OS-conventional config directories; `None` if none could be determined.
|
|
pub fn default_settings_path() -> Option<PathBuf> {
|
|
ProjectDirs::from("", "", "ottyl").map(|dirs| dirs.config_dir().join("settings.json"))
|
|
}
|
|
|
|
impl Settings {
|
|
/// Loads settings from `path`, creating and persisting defaults if missing. Any I/O or parse error on an existing file is logged and treated as defaults, without overwriting the unreadable file.
|
|
pub fn load(path: &Path) -> Settings {
|
|
match fs::read_to_string(path) {
|
|
Ok(contents) => match serde_json::from_str(&contents) {
|
|
Ok(settings) => settings,
|
|
Err(err) => {
|
|
eprintln!(
|
|
"warning: failed to parse settings file at {}: {err}; using defaults",
|
|
path.display()
|
|
);
|
|
Settings::default()
|
|
}
|
|
},
|
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
|
let settings = Settings::default();
|
|
if let Err(save_err) = settings.save(path) {
|
|
eprintln!(
|
|
"warning: failed to create default settings file at {}: {save_err}",
|
|
path.display()
|
|
);
|
|
}
|
|
settings
|
|
}
|
|
Err(err) => {
|
|
eprintln!(
|
|
"warning: failed to read settings file at {}: {err}; using defaults",
|
|
path.display()
|
|
);
|
|
Settings::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Persists these settings to `path` as pretty-printed JSON, creating any missing parent directories first.
|
|
pub fn save(&self, path: &Path) -> io::Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let contents = serde_json::to_string_pretty(self)
|
|
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
|
fs::write(path, contents)
|
|
}
|
|
}
|
|
|
|
/// Error returned by CSS-entry mutations and lookups.
|
|
#[derive(Debug)]
|
|
pub enum CssError {
|
|
/// No entry with the given id exists.
|
|
NotFound,
|
|
/// The entry is a `Linked` entry, not `Imported` — ottyl doesn't own a linked entry's content, so there's nothing to delete or serve back.
|
|
NotImported,
|
|
/// Underlying I/O failure: writing/reading/removing the CSS file, or persisting `settings.json`.
|
|
Io(io::Error),
|
|
}
|
|
|
|
impl fmt::Display for CssError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
CssError::NotFound => write!(f, "no CSS entry with that id"),
|
|
CssError::NotImported => write!(f, "CSS entry is linked, not imported"),
|
|
CssError::Io(err) => write!(f, "I/O error: {err}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for CssError {}
|
|
|
|
impl From<io::Error> for CssError {
|
|
fn from(err: io::Error) -> Self {
|
|
CssError::Io(err)
|
|
}
|
|
}
|
|
|
|
/// Computes the id used to reference a `CssEntry` over the API: the stored id for `Imported`, or a hash of `path_or_url` for `Linked` (which has no stored id), so the frontend can key both the same way.
|
|
pub fn css_entry_id(entry: &CssEntry) -> String {
|
|
match entry {
|
|
CssEntry::Imported { id, .. } => id.clone(),
|
|
CssEntry::Linked { path_or_url, .. } => linked_entry_id(path_or_url),
|
|
}
|
|
}
|
|
|
|
fn linked_entry_id(path_or_url: &str) -> String {
|
|
let mut hasher = DefaultHasher::new();
|
|
path_or_url.hash(&mut hasher);
|
|
format!("linked-{:016x}", hasher.finish())
|
|
}
|
|
|
|
/// Generates an id for a newly-imported CSS entry by hashing filename, content, and time, avoiding a `uuid` dependency for what's otherwise an opaque string.
|
|
fn generate_imported_css_id(filename: &str, content: &str) -> String {
|
|
let mut hasher = DefaultHasher::new();
|
|
filename.hash(&mut hasher);
|
|
content.hash(&mut hasher);
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
nanos.hash(&mut hasher);
|
|
format!("{:016x}", hasher.finish())
|
|
}
|
|
|
|
fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf {
|
|
css_dir.join(format!("{id}.css"))
|
|
}
|
|
|
|
impl Settings {
|
|
/// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css`, records a `CssEntry::Imported` in `css_entries`, and persists settings. Returns the generated id. `css_dir` is expected to be the settings file's parent directory.
|
|
pub fn import_css(
|
|
&mut self,
|
|
settings_path: &Path,
|
|
css_dir: &Path,
|
|
filename: String,
|
|
content: &str,
|
|
) -> Result<String, CssError> {
|
|
fs::create_dir_all(css_dir)?;
|
|
let id = generate_imported_css_id(&filename, content);
|
|
fs::write(imported_css_file_path(css_dir, &id), content)?;
|
|
self.css_entries.push(CssEntry::Imported {
|
|
id: id.clone(),
|
|
filename,
|
|
});
|
|
self.save(settings_path)?;
|
|
Ok(id)
|
|
}
|
|
|
|
/// Registers a reference to an external CSS path/URL without copying its content, and persists settings.
|
|
pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> {
|
|
self.css_entries.push(CssEntry::Linked { path_or_url });
|
|
self.save(settings_path)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Removes an imported CSS entry identified by `id`: deletes its file under `css_dir`, drops it from `css_entries`, and persists settings. Fails with `NotFound` or `NotImported` (linked entries aren't deletable this way).
|
|
pub fn remove_css(
|
|
&mut self,
|
|
settings_path: &Path,
|
|
css_dir: &Path,
|
|
id: &str,
|
|
) -> Result<(), CssError> {
|
|
let index = self
|
|
.css_entries
|
|
.iter()
|
|
.position(|entry| css_entry_id(entry) == id);
|
|
match index.map(|i| &self.css_entries[i]) {
|
|
None => Err(CssError::NotFound),
|
|
Some(CssEntry::Linked { .. }) => Err(CssError::NotImported),
|
|
Some(CssEntry::Imported { .. }) => {
|
|
let idx = index.unwrap();
|
|
self.css_entries.remove(idx);
|
|
// Best-effort: the settings entry is already removed even if the file is gone.
|
|
let _ = fs::remove_file(imported_css_file_path(css_dir, id));
|
|
self.save(settings_path)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reads back the raw content of an imported CSS entry identified by `id`. Fails with `NotFound` or `NotImported` (linked entries have no local content — the frontend fetches `path_or_url` directly).
|
|
pub fn css_content(&self, css_dir: &Path, id: &str) -> Result<String, CssError> {
|
|
match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) {
|
|
None => Err(CssError::NotFound),
|
|
Some(CssEntry::Linked { .. }) => Err(CssError::NotImported),
|
|
Some(CssEntry::Imported { .. }) => {
|
|
Ok(fs::read_to_string(imported_css_file_path(css_dir, id))?)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn default_settings_path_ends_with_settings_json() {
|
|
// Can't assert the exact OS path without touching real config dirs, so just check the shape.
|
|
if let Some(path) = default_settings_path() {
|
|
assert_eq!(path.file_name().unwrap(), "settings.json");
|
|
assert!(path.parent().is_some());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn load_creates_default_when_missing() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("nested").join("settings.json");
|
|
assert!(!path.exists());
|
|
|
|
let settings = Settings::load(&path);
|
|
assert_eq!(settings, Settings::default());
|
|
assert!(path.exists(), "load() should persist a default file");
|
|
|
|
let reloaded = Settings::load(&path);
|
|
assert_eq!(reloaded, settings);
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_grey_color_scheme_deserializes_as_dark() {
|
|
let settings: Settings = serde_json::from_str(
|
|
r#"{"color_scheme":"grey","css_entries":[],"custom_theme_id":null,"profiles":{"names":[],"active_profile":null,"profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(settings.color_scheme, ColorScheme::Dark);
|
|
}
|
|
|
|
#[test]
|
|
fn round_trips_through_save_and_load() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("settings.json");
|
|
|
|
let mut settings = Settings::default();
|
|
settings.color_scheme = ColorScheme::Dark;
|
|
settings.css_entries.push(CssEntry::Imported {
|
|
id: "abc123".to_string(),
|
|
filename: "theme.css".to_string(),
|
|
});
|
|
settings.css_entries.push(CssEntry::Linked {
|
|
path_or_url: "https://example.com/style.css".to_string(),
|
|
});
|
|
settings.profiles.names.push("default".to_string());
|
|
settings.profiles.active_profile = Some("default".to_string());
|
|
settings.alert_rules.push(crate::alerts::AlertRule {
|
|
id: "rule1".to_string(),
|
|
name: "error spike".to_string(),
|
|
enabled: true,
|
|
condition: crate::alerts::AlertCondition::Immediate {
|
|
query: r#"regex("error")"#.to_string(),
|
|
},
|
|
});
|
|
settings.ntfy_topic = Some("alerts".to_string());
|
|
|
|
settings.save(&path).unwrap();
|
|
|
|
let loaded = Settings::load(&path);
|
|
assert_eq!(loaded, settings);
|
|
}
|
|
|
|
#[test]
|
|
fn save_creates_parent_directories() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("a").join("b").join("settings.json");
|
|
|
|
let settings = Settings::default();
|
|
settings.save(&path).unwrap();
|
|
|
|
assert!(path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn import_css_persists_file_and_entry_across_reload() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let settings_path = dir.path().join("settings.json");
|
|
let css_dir = dir.path().to_path_buf();
|
|
|
|
let mut settings = Settings::load(&settings_path);
|
|
let id = settings
|
|
.import_css(
|
|
&settings_path,
|
|
&css_dir,
|
|
"theme.css".to_string(),
|
|
"body { color: red; }",
|
|
)
|
|
.unwrap();
|
|
|
|
let css_file = css_dir.join(format!("{id}.css"));
|
|
assert!(css_file.exists());
|
|
assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }");
|
|
|
|
// Drop and reload settings from disk (simulates a restart).
|
|
drop(settings);
|
|
let reloaded = Settings::load(&settings_path);
|
|
assert!(reloaded.css_entries.iter().any(|entry| matches!(
|
|
entry,
|
|
CssEntry::Imported { id: entry_id, filename, .. } if entry_id == &id && filename == "theme.css"
|
|
)));
|
|
assert!(css_file.exists());
|
|
assert_eq!(
|
|
reloaded.css_content(&css_dir, &id).unwrap(),
|
|
"body { color: red; }"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_css_deletes_file_and_entry() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let settings_path = dir.path().join("settings.json");
|
|
let css_dir = dir.path().to_path_buf();
|
|
|
|
let mut settings = Settings::load(&settings_path);
|
|
let id = settings
|
|
.import_css(&settings_path, &css_dir, "a.css".to_string(), "a{}")
|
|
.unwrap();
|
|
let css_file = css_dir.join(format!("{id}.css"));
|
|
assert!(css_file.exists());
|
|
|
|
settings.remove_css(&settings_path, &css_dir, &id).unwrap();
|
|
|
|
assert!(!css_file.exists());
|
|
assert!(!settings.css_entries.iter().any(|e| css_entry_id(e) == id));
|
|
|
|
let reloaded = Settings::load(&settings_path);
|
|
assert!(!reloaded.css_entries.iter().any(|e| css_entry_id(e) == id));
|
|
}
|
|
|
|
#[test]
|
|
fn remove_css_rejects_linked_entry() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let settings_path = dir.path().join("settings.json");
|
|
let css_dir = dir.path().to_path_buf();
|
|
|
|
let mut settings = Settings::load(&settings_path);
|
|
settings
|
|
.link_css(&settings_path, "https://example.com/style.css".to_string())
|
|
.unwrap();
|
|
let linked_id = css_entry_id(&settings.css_entries[0]);
|
|
|
|
let result = settings.remove_css(&settings_path, &css_dir, &linked_id);
|
|
assert!(matches!(result, Err(CssError::NotImported)));
|
|
// Entry must still be there — rejection shouldn't silently drop it.
|
|
assert_eq!(settings.css_entries.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_css_rejects_unknown_id() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let settings_path = dir.path().join("settings.json");
|
|
let css_dir = dir.path().to_path_buf();
|
|
|
|
let mut settings = Settings::load(&settings_path);
|
|
let result = settings.remove_css(&settings_path, &css_dir, "does-not-exist");
|
|
assert!(matches!(result, Err(CssError::NotFound)));
|
|
}
|
|
|
|
#[test]
|
|
fn css_content_rejects_linked_entry() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let settings_path = dir.path().join("settings.json");
|
|
let css_dir = dir.path().to_path_buf();
|
|
|
|
let mut settings = Settings::load(&settings_path);
|
|
settings
|
|
.link_css(&settings_path, "https://example.com/style.css".to_string())
|
|
.unwrap();
|
|
let linked_id = css_entry_id(&settings.css_entries[0]);
|
|
|
|
let result = settings.css_content(&css_dir, &linked_id);
|
|
assert!(matches!(result, Err(CssError::NotImported)));
|
|
}
|
|
}
|