feat: mirada standalone — compositor Wayland + WM sobre Llimphi (build magro)
Stack de display extraído del monorepo: compositor teselante (Cuerpo smithay + Cerebro WM agnóstico), greeter PAM, portal XDG, CLI de control. Llimphi se consume por git desde su repo publicado; las hojas compartidas (format, auth-core, rimay-localize, wawa-config, app-bus) y el widget menubar van vendorizados. Sin el asistente IA (pluma-llm) ni la barra web wasm — el compositor no los necesita. cargo check --workspace pasa (18 crates, 0 warn). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "wawa-config"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
directories = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -0,0 +1,35 @@
|
||||
# wawa-config — bus de configuración del SO
|
||||
|
||||
El **bus de configuración** del escritorio/SO: un archivo TOML canónico
|
||||
(`~/.config/wawa/config.toml`) + un watcher (`notify`) que reemite cambios en
|
||||
vivo, sobre una capa de sistema (`/etc/wawa/config.toml`) que el usuario puede
|
||||
override-ar. Los consumidores (apps Llimphi del escritorio) se suscriben y
|
||||
reaccionan al vuelo: cambiar theme/acento se propaga **sin reiniciar**.
|
||||
|
||||
UI-agnóstico: **no depende de `llimphi`**. El adaptador que ensambla un `Theme`
|
||||
efectivo a partir del `WawaConfig` vive en `wawa-config-llimphi`.
|
||||
|
||||
## Qué expone
|
||||
|
||||
- `WawaConfig` — la configuración (variant de theme, accent override, …).
|
||||
- Carga con merge `/etc/wawa` (sistema) bajo override de usuario.
|
||||
- Watcher (`notify`) que reemite el config al cambiar el archivo.
|
||||
|
||||
## Estado (2026-05-31)
|
||||
|
||||
### Hecho
|
||||
- Archivo canónico TOML + watcher `notify` (live reload).
|
||||
- Capa de sistema `/etc/wawa/config.toml` mergeada bajo el override de usuario.
|
||||
- Auto-apply del acento al theme global; ≈10 tests.
|
||||
- Consumido por nada, cosmos, nakui, dominium, shuma, nahual, minga, arje,
|
||||
wawa-panel y `wawactl` (CLI).
|
||||
|
||||
### Pendiente
|
||||
- Esquema de config más amplio (más que theme/acento).
|
||||
- Validación/migración de versiones del TOML.
|
||||
- Consumo desde el SO wawa bare-metal (hoy es el escritorio host).
|
||||
|
||||
## Lugar en el repo
|
||||
|
||||
`shared/wawa-config` — fuente de verdad UI-agnóstica. Adaptador de theme:
|
||||
`wawa-config-llimphi`. CLI: `wawactl`.
|
||||
@@ -0,0 +1,762 @@
|
||||
//! `wawa-config` — bus de configuración del SO wawa.
|
||||
//!
|
||||
//! Dos capas de archivos JSON canónicos actúan como medio:
|
||||
//!
|
||||
//! 1. **Sistema** — `/etc/wawa/config.json` (Linux). Defaults
|
||||
//! machine-wide; lo escribe el admin con `wawactl --system set …`
|
||||
//! (requiere root) o un instalador.
|
||||
//! 2. **Usuario** — `$XDG_CONFIG_HOME/wawa/config.json` (Linux:
|
||||
//! `~/.config/wawa/config.json`). Lo que escribe el panel y las
|
||||
//! apps; **sobreescribe** campo por campo a la capa de sistema.
|
||||
//!
|
||||
//! El panel de control y los daemons escriben; las apps Llimphi leen y
|
||||
//! se suscriben a cambios vía [`notify::RecommendedWatcher`] sobre
|
||||
//! **ambos** paths.
|
||||
//!
|
||||
//! ## Nota sobre `/etc/wawa`
|
||||
//!
|
||||
//! `/etc/` es una convención de Unix/Linux. Cuando wawa sea su propio
|
||||
//! SO (no un userland sobre Linux), esta capa se reemplazará por el
|
||||
//! mecanismo nativo de "config de sistema" que defina arje — la API
|
||||
//! pública (`load`, `system_path`, `user_path`) se mantiene; sólo
|
||||
//! cambia lo que devuelve `system_path()` adentro.
|
||||
//!
|
||||
//! Por qué archivo + `notify` y no un daemon pub-sub:
|
||||
//!
|
||||
//! * **Cero dependencias en runtime**: ninguna app necesita que un
|
||||
//! daemon esté arrancado para leer la config; basta con que el
|
||||
//! archivo exista (y si no existe, devuelve defaults).
|
||||
//! * **Auditable y editable a mano**: el archivo es JSON con `pretty`,
|
||||
//! el admin lo abre con cualquier editor o lo edita por sed/jq.
|
||||
//! * **Atomicidad simple**: `save()` escribe a `config.json.tmp` y
|
||||
//! `rename()` — los watchers ven un único evento de creación que
|
||||
//! contiene la versión completa.
|
||||
//! * **Compatible con apps existentes**: el modelo de Llimphi ya
|
||||
//! reentra al `update` vía `Handle::dispatch`; el watcher dispara
|
||||
//! un Msg del consumidor cuando llega el evento.
|
||||
//!
|
||||
//! ## Forma del archivo
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "theme_variant": "dark",
|
||||
//! "accent": "default",
|
||||
//! "lang": "es-PE",
|
||||
//! "timefmt_24h": true,
|
||||
//! "modules": {
|
||||
//! "mirada": true,
|
||||
//! "shuma": true,
|
||||
//! "chasqui": true,
|
||||
//! "akasha": true,
|
||||
//! "minga": true,
|
||||
//! "agora": true
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! Campos desconocidos se ignoran al deserializar; campos ausentes
|
||||
//! caen al default. Esto permite agregar nuevas keys sin romper
|
||||
//! consumidores antiguos.
|
||||
//!
|
||||
//! ## Productor
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use wawa_config::WawaConfig;
|
||||
//!
|
||||
//! let mut cfg = WawaConfig::load();
|
||||
//! cfg.theme_variant = "aurora".into();
|
||||
//! cfg.save()?;
|
||||
//! ```
|
||||
//!
|
||||
//! ## Consumidor (app Llimphi)
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use wawa_config::{WawaConfig, ConfigWatcher};
|
||||
//!
|
||||
//! // En `App::init`:
|
||||
//! let handle = handle.clone();
|
||||
//! let watcher = ConfigWatcher::spawn(move |cfg| {
|
||||
//! handle.dispatch(Msg::ConfigChanged(cfg));
|
||||
//! })?;
|
||||
//! // Guardar `watcher` en el Model para que viva todo lo que vive la app.
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
||||
/// Nombre del subdirectorio dentro de XDG_CONFIG_HOME donde vive el
|
||||
/// archivo. Exporto la constante para que tests y herramientas
|
||||
/// externas lo puedan inspeccionar.
|
||||
pub const CONFIG_DIR: &str = "wawa";
|
||||
/// Nombre del archivo canónico.
|
||||
pub const CONFIG_FILE: &str = "config.json";
|
||||
/// Directorio de la capa de sistema en Linux. Cuando wawa sea su
|
||||
/// propio SO esta ruta se reemplaza por lo que defina arje; la API
|
||||
/// pública (`system_config_path`) se mantiene.
|
||||
pub const SYSTEM_CONFIG_DIR_LINUX: &str = "/etc/wawa";
|
||||
|
||||
/// Capa de la cual se cargó/escribió una config. Útil para herramientas
|
||||
/// que necesiten distinguir explícitamente entre sistema y usuario.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Layer {
|
||||
/// `/etc/wawa/config.json` — defaults machine-wide, requiere root.
|
||||
System,
|
||||
/// `$XDG_CONFIG_HOME/wawa/config.json` — override por usuario.
|
||||
User,
|
||||
}
|
||||
|
||||
/// Mapea el `theme_variant` de la config (lowercase, libre) al nombre
|
||||
/// canónico que reconoce `llimphi_theme::Theme::by_name` (capitalizado).
|
||||
/// Devuelve `None` si el variant no es uno de los presets conocidos —
|
||||
/// el consumidor decide qué hacer (fallback a dark, error, etc.).
|
||||
///
|
||||
/// Los presets de Llimphi tienen `name: &'static str` capitalizado;
|
||||
/// los users del CLI y el panel escriben en lowercase. Este shim
|
||||
/// mantiene a `wawa-config` UI-agnóstico (no depende de
|
||||
/// `llimphi-theme`) y a la vez evita que cada consumidor reimplemente
|
||||
/// el casing.
|
||||
pub fn canonical_theme_name(variant: &str) -> Option<&'static str> {
|
||||
match variant.to_ascii_lowercase().as_str() {
|
||||
"dark" => Some("Dark"),
|
||||
"light" => Some("Light"),
|
||||
"aurora" => Some("Aurora"),
|
||||
"sunset" => Some("Sunset"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Devuelve el color RGB de un acento por id. `default` retorna `None`
|
||||
/// para que el consumidor no toque el accent del theme base. La paleta
|
||||
/// es la misma del web (`gioser-web/styles.css`): tinte por cuadrante
|
||||
/// + accent gioser por default.
|
||||
///
|
||||
/// Es un trio RGB (no un tipo de `peniko`) para no obligar a depender
|
||||
/// de `llimphi-raster` desde acá. Los consumidores Llimphi hacen:
|
||||
///
|
||||
/// ```ignore
|
||||
/// if let Some([r,g,b]) = wawa_config::accent_rgb(&cfg.accent) {
|
||||
/// let c = llimphi_theme::Color::from_rgba8(r, g, b, 255);
|
||||
/// theme.accent = c;
|
||||
/// theme.border_focus = c;
|
||||
/// }
|
||||
/// ```
|
||||
pub fn accent_rgb(accent: &str) -> Option<[u8; 3]> {
|
||||
match accent {
|
||||
"default" => None,
|
||||
"gioser" => Some([0x6E, 0x8C, 0xDC]),
|
||||
"unanchay" => Some([0xB9, 0xC9, 0xE8]),
|
||||
"yachay" => Some([0xE8, 0xC9, 0x7A]),
|
||||
"ruway" => Some([0xE8, 0x9B, 0x6E]),
|
||||
"ukupacha" => Some([0x8F, 0xB5, 0x8C]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lista de variants de theme reconocidas — útil para validadores y
|
||||
/// generadores de docs/UI. Orden estable.
|
||||
pub const THEME_VARIANTS: &[&str] = &["dark", "light", "aurora", "sunset"];
|
||||
|
||||
/// Lista de acentos reconocidos. `"default"` significa "no override".
|
||||
pub const ACCENTS: &[&str] = &["default", "gioser", "unanchay", "yachay", "ruway", "ukupacha"];
|
||||
|
||||
/// Identificadores estables de los módulos del SO conocidos. Las apps
|
||||
/// son libres de leer/escribir otros, pero estos son los que el panel
|
||||
/// expone por default — mantenerlos como `const` ayuda a no escribir
|
||||
/// el string mal en sitios distintos.
|
||||
pub mod modules {
|
||||
pub const MIRADA: &str = "mirada";
|
||||
pub const SHUMA: &str = "shuma";
|
||||
pub const CHASQUI: &str = "chasqui";
|
||||
pub const AKASHA: &str = "akasha";
|
||||
pub const MINGA: &str = "minga";
|
||||
pub const AGORA: &str = "agora";
|
||||
|
||||
pub fn defaults() -> [(&'static str, bool); 6] {
|
||||
[
|
||||
(MIRADA, true),
|
||||
(SHUMA, true),
|
||||
(CHASQUI, true),
|
||||
(AKASHA, true),
|
||||
(MINGA, true),
|
||||
(AGORA, true),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuración del sistema operativo wawa. Serializada como el JSON
|
||||
/// del módulo. Campos nuevos se agregan con `#[serde(default = "…")]`
|
||||
/// para preservar compatibilidad hacia atrás.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WawaConfig {
|
||||
/// Variante del theme global. Coincide con
|
||||
/// `llimphi_theme::Theme::name`: `"dark"`, `"light"`, `"aurora"`,
|
||||
/// `"sunset"`.
|
||||
#[serde(default = "default_theme_variant")]
|
||||
pub theme_variant: String,
|
||||
|
||||
/// Acento. `"default"` deja el accent del theme; cualquier otro
|
||||
/// id (gioser/unanchay/yachay/ruway/ukupacha) lo sobreescribe.
|
||||
#[serde(default = "default_accent")]
|
||||
pub accent: String,
|
||||
|
||||
/// Locale activo. Acepta lo mismo que `rimay_localize::set_locale`:
|
||||
/// `"es-PE"`, `"en-US"`, `"qu-PE"`.
|
||||
#[serde(default = "default_lang")]
|
||||
pub lang: String,
|
||||
|
||||
/// Formato del reloj (true = 24h, false = 12h con am/pm).
|
||||
#[serde(default = "default_timefmt")]
|
||||
pub timefmt_24h: bool,
|
||||
|
||||
/// Estado on/off de los módulos del SO. Usa los ids de
|
||||
/// [`modules`]. BTreeMap → serializa con orden estable y diffs
|
||||
/// limpios en git.
|
||||
#[serde(default = "default_modules")]
|
||||
pub modules: BTreeMap<String, bool>,
|
||||
}
|
||||
|
||||
fn default_theme_variant() -> String {
|
||||
"dark".into()
|
||||
}
|
||||
fn default_accent() -> String {
|
||||
"default".into()
|
||||
}
|
||||
fn default_lang() -> String {
|
||||
"es-PE".into()
|
||||
}
|
||||
fn default_timefmt() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_modules() -> BTreeMap<String, bool> {
|
||||
modules::defaults()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Default for WawaConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme_variant: default_theme_variant(),
|
||||
accent: default_accent(),
|
||||
lang: default_lang(),
|
||||
timefmt_24h: default_timefmt(),
|
||||
modules: default_modules(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WawaConfig {
|
||||
/// `true` si el módulo `id` está activo (default: activo si no se
|
||||
/// conoce — convención conservadora).
|
||||
pub fn module_enabled(&self, id: &str) -> bool {
|
||||
self.modules.get(id).copied().unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Conmuta el módulo `id`. Si no existía, lo agrega con `false`.
|
||||
pub fn toggle_module(&mut self, id: &str) {
|
||||
let v = self.modules.entry(id.to_string()).or_insert(true);
|
||||
*v = !*v;
|
||||
}
|
||||
|
||||
/// Path canónico del archivo de usuario (alias de [`user_config_path`]).
|
||||
/// `None` si la plataforma no expone un config dir (extremadamente
|
||||
/// raro fuera de embebidos).
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
user_config_path()
|
||||
}
|
||||
|
||||
/// Path canónico del archivo de la capa indicada. `None` si la
|
||||
/// capa no aplica en esta plataforma (p. ej. `Layer::System` fuera
|
||||
/// de Linux).
|
||||
pub fn path_for(layer: Layer) -> Option<PathBuf> {
|
||||
match layer {
|
||||
Layer::System => system_config_path(),
|
||||
Layer::User => user_config_path(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Carga la config efectiva: defaults → capa de sistema → capa de
|
||||
/// usuario. Cada capa **sobreescribe campo por campo** lo que
|
||||
/// definió la anterior; campos ausentes preservan el valor
|
||||
/// previo. Para `modules`, el merge es key-by-key (no reemplazo
|
||||
/// total del mapa).
|
||||
///
|
||||
/// Si ningún archivo existe, o están corruptos, devuelve defaults
|
||||
/// — nunca falla. Los errores se loggean a `tracing::warn`.
|
||||
pub fn load() -> Self {
|
||||
let mut acc = serde_json::to_value(Self::default())
|
||||
.expect("WawaConfig::default siempre serializa");
|
||||
for layer in [Layer::System, Layer::User] {
|
||||
if let Some(v) = load_layer_value(layer) {
|
||||
merge_json(&mut acc, v);
|
||||
}
|
||||
}
|
||||
serde_json::from_value(acc).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Carga **sólo** la capa indicada, sin mergear con la otra. Útil
|
||||
/// para herramientas como `wawactl --system show` que necesitan
|
||||
/// inspeccionar una capa concreta. Si el archivo no existe,
|
||||
/// devuelve `None` (no defaults — distingue "ausente" de
|
||||
/// "presente con defaults"). Errores de parseo loggean warn y
|
||||
/// también devuelven `None`.
|
||||
pub fn load_layer(layer: Layer) -> Option<Self> {
|
||||
let path = Self::path_for(layer)?;
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
|
||||
Err(e) => {
|
||||
warn!(?path, error = %e, "wawa-config: read failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<WawaConfig>(&bytes) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
warn!(?path, error = %e, "wawa-config: parse failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persiste atómicamente en la capa de **usuario** (compat con
|
||||
/// callers existentes): serializa a `config.json.tmp` y renombra
|
||||
/// sobre `config.json`. Crea el directorio padre si no existe.
|
||||
pub fn save(&self) -> Result<PathBuf, ConfigError> {
|
||||
self.save_to(Layer::User)
|
||||
}
|
||||
|
||||
/// Persiste en la capa indicada. `Layer::System` apunta a
|
||||
/// `/etc/wawa/config.json` y típicamente requiere root — devuelve
|
||||
/// `ConfigError::Io` con `PermissionDenied` si no.
|
||||
pub fn save_to(&self, layer: Layer) -> Result<PathBuf, ConfigError> {
|
||||
let path = Self::path_for(layer).ok_or(ConfigError::NoProjectDirs)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self)?;
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lee y parsea una capa como `serde_json::Value`. Devuelve `None`
|
||||
/// (no defaults) si el archivo no existe o falla el parse — esto es
|
||||
/// distinto del `WawaConfig::load_layer` que también devuelve Option,
|
||||
/// pero acá trabajamos con Value para mergear sin perder "campo
|
||||
/// ausente vs explícito".
|
||||
fn load_layer_value(layer: Layer) -> Option<serde_json::Value> {
|
||||
let path = WawaConfig::path_for(layer)?;
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
|
||||
Err(e) => {
|
||||
warn!(?path, error = %e, "wawa-config: read failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
Ok(v) => Some(v),
|
||||
Err(e) => {
|
||||
warn!(?path, error = %e, "wawa-config: parse failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge profundo: `over` sobreescribe `base` hoja por hoja, recursivo
|
||||
/// sobre objetos JSON. Para arrays y escalares, `over` reemplaza
|
||||
/// completamente. Esto preserva la semántica "campo ausente → no
|
||||
/// modifica la capa inferior" y permite que un user override sólo
|
||||
/// algunas keys de `modules`.
|
||||
fn merge_json(base: &mut serde_json::Value, over: serde_json::Value) {
|
||||
use serde_json::Value;
|
||||
match (base, over) {
|
||||
(Value::Object(b), Value::Object(o)) => {
|
||||
for (k, v) in o {
|
||||
match b.get_mut(&k) {
|
||||
Some(existing) => merge_json(existing, v),
|
||||
None => {
|
||||
b.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(slot, v) => *slot = v,
|
||||
}
|
||||
}
|
||||
|
||||
/// Errores de IO o serialización al persistir la config. La carga
|
||||
/// nunca falla — devuelve defaults en su lugar.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("no hay ProjectDirs en esta plataforma")]
|
||||
NoProjectDirs,
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("serde: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
#[error("notify: {0}")]
|
||||
Notify(#[from] notify::Error),
|
||||
}
|
||||
|
||||
/// Path del archivo de **usuario**. El qualifier "" + organization ""
|
||||
/// se mapea a `$XDG_CONFIG_HOME/wawa/` en Linux (típicamente
|
||||
/// `~/.config/wawa/`), `~/Library/Application Support/wawa/` en
|
||||
/// macOS, `%APPDATA%/wawa/` en Windows. `None` si la plataforma no
|
||||
/// expone un config dir.
|
||||
pub fn user_config_path() -> Option<PathBuf> {
|
||||
directories::ProjectDirs::from("", "", CONFIG_DIR)
|
||||
.map(|d| d.config_dir().join(CONFIG_FILE))
|
||||
}
|
||||
|
||||
/// Path del archivo de **sistema**. `Some("/etc/wawa/config.json")`
|
||||
/// en Linux; `None` en otras plataformas (no hay convención
|
||||
/// equivalente y no vale la pena inventarla). Cuando wawa sea su
|
||||
/// propio SO, esta función devolverá el equivalente nativo y la API
|
||||
/// pública no cambia.
|
||||
pub fn system_config_path() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Some(PathBuf::from(SYSTEM_CONFIG_DIR_LINUX).join(CONFIG_FILE))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Watcher
|
||||
// =====================================================================
|
||||
|
||||
/// Suscripción al bus. Mantenelo vivo (guardalo en el Model de tu app)
|
||||
/// para seguir recibiendo notificaciones; al dropearlo, los callbacks
|
||||
/// dejan de dispararse.
|
||||
///
|
||||
/// Observa **ambas capas** (sistema y usuario): un cambio en
|
||||
/// cualquiera dispara `on_change` con la config efectiva ya mergeada.
|
||||
/// Cada capa escucha el directorio padre con
|
||||
/// `RecursiveMode::NonRecursive` y filtra por `config.json` — así
|
||||
/// detecta tanto modificaciones in-place como reemplazos atómicos por
|
||||
/// `rename`. Si la capa de sistema no aplica en la plataforma (no
|
||||
/// Linux), o no se puede crear/observar (p. ej. `/etc/wawa` sin
|
||||
/// permisos de lectura — improbable porque `/etc/` es world-readable
|
||||
/// por convención), se ignora con un warn y el watcher sigue activo
|
||||
/// sólo sobre la capa de usuario.
|
||||
///
|
||||
/// Para evitar disparar dos veces seguidas cuando un editor escribe
|
||||
/// con la secuencia `truncate → write → close`, el watcher debouncea
|
||||
/// internamente con un timeout de ~200 ms: agrupa eventos consecutivos
|
||||
/// y emite un único callback con la última versión leída.
|
||||
pub struct ConfigWatcher {
|
||||
_watchers: Vec<RecommendedWatcher>,
|
||||
_debounce_thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ConfigWatcher {
|
||||
/// Arranca el watcher. `on_change` se llama cada vez que **alguna**
|
||||
/// de las capas cambia, ya con la nueva config efectiva mergeada
|
||||
/// (sistema ← usuario). Si el parseo falla, no se invoca (se
|
||||
/// loggea como warn y se ignora hasta el próximo cambio).
|
||||
///
|
||||
/// `on_change` corre en un thread propio del watcher — para
|
||||
/// reentrar al loop de Llimphi, capturá un `Handle<Msg>` clonado
|
||||
/// y llamá `handle.dispatch(...)` dentro de la closure.
|
||||
pub fn spawn<F>(on_change: F) -> Result<Self, ConfigError>
|
||||
where
|
||||
F: FnMut(WawaConfig) + Send + 'static,
|
||||
{
|
||||
let (tx, rx) = mpsc::channel::<()>();
|
||||
let mut watchers = Vec::with_capacity(2);
|
||||
|
||||
// Capa de usuario es obligatoria; si no hay ProjectDirs es un
|
||||
// entorno raro y devolvemos error como antes.
|
||||
let user_path = user_config_path().ok_or(ConfigError::NoProjectDirs)?;
|
||||
watchers.push(spawn_layer_watcher(&user_path, tx.clone(), /*must_exist=*/ true)?);
|
||||
|
||||
// Capa de sistema es best-effort: si no aplica (no Linux), o
|
||||
// no se puede observar (sin permiso de lectura de `/etc/wawa`,
|
||||
// que es muy raro pero posible si el admin la chmodea), no
|
||||
// rompe — sólo no se entera de cambios de sistema.
|
||||
if let Some(sys_path) = system_config_path() {
|
||||
match spawn_layer_watcher(&sys_path, tx.clone(), /*must_exist=*/ false) {
|
||||
Ok(w) => watchers.push(w),
|
||||
Err(e) => warn!(?sys_path, error = %e, "wawa-config: system layer watch skipped"),
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce: junta señales durante ~200 ms y al cierre llama
|
||||
// `on_change` con la lectura más reciente (mergeada). Acepta
|
||||
// que perdamos ráfagas intermedias — solo importa el estado
|
||||
// final.
|
||||
let debounce = thread::Builder::new()
|
||||
.name("wawa-config-debounce".into())
|
||||
.spawn(move || debounce_loop(rx, Box::new(on_change)))
|
||||
.ok();
|
||||
|
||||
Ok(Self {
|
||||
_watchers: watchers,
|
||||
_debounce_thread: debounce,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_layer_watcher(
|
||||
path: &Path,
|
||||
tx: mpsc::Sender<()>,
|
||||
must_exist: bool,
|
||||
) -> Result<RecommendedWatcher, ConfigError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| {
|
||||
ConfigError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"config path sin parent",
|
||||
))
|
||||
})?
|
||||
.to_path_buf();
|
||||
|
||||
// Para la capa de usuario creamos el dir si falta — notify puede
|
||||
// watchear un dir vacío. Para la de sistema no lo creamos: si
|
||||
// `/etc/wawa` no existe, probablemente esta máquina no usa la capa
|
||||
// de sistema y mejor no requerir permisos de root para correr una
|
||||
// app de usuario.
|
||||
if must_exist {
|
||||
std::fs::create_dir_all(&parent)?;
|
||||
} else if !parent.exists() {
|
||||
return Err(ConfigError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"directorio de capa de sistema ausente",
|
||||
)));
|
||||
}
|
||||
|
||||
let target_name = path.file_name().map(|n| n.to_owned());
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
let event = match res {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "wawa-config: watcher error");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let is_target = match &target_name {
|
||||
Some(name) => event
|
||||
.paths
|
||||
.iter()
|
||||
.any(|p| p.file_name().map(|f| f == name.as_os_str()).unwrap_or(false)),
|
||||
None => true,
|
||||
};
|
||||
if !is_target {
|
||||
return;
|
||||
}
|
||||
if !matches!(
|
||||
event.kind,
|
||||
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let _ = tx.send(());
|
||||
})?;
|
||||
watcher.watch(&parent, RecursiveMode::NonRecursive)?;
|
||||
Ok(watcher)
|
||||
}
|
||||
|
||||
fn debounce_loop(rx: mpsc::Receiver<()>, mut on_change: Box<dyn FnMut(WawaConfig) + Send>) {
|
||||
const QUIET: Duration = Duration::from_millis(200);
|
||||
loop {
|
||||
// Esperar al primer evento sin timeout.
|
||||
if rx.recv().is_err() {
|
||||
return;
|
||||
}
|
||||
// Drenar lo que se acumule en la ventana de quiet.
|
||||
loop {
|
||||
match rx.recv_timeout(QUIET) {
|
||||
Ok(()) => continue,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => break,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
}
|
||||
let cfg = WawaConfig::load();
|
||||
on_change(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tests
|
||||
// =====================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_round_trip() {
|
||||
let c = WawaConfig::default();
|
||||
let s = serde_json::to_string(&c).unwrap();
|
||||
let back: WawaConfig = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(c, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_json_uses_defaults() {
|
||||
// Sólo se aporta theme; el resto debe caer al default.
|
||||
let s = r#"{"theme_variant":"aurora"}"#;
|
||||
let c: WawaConfig = serde_json::from_str(s).unwrap();
|
||||
assert_eq!(c.theme_variant, "aurora");
|
||||
assert_eq!(c.accent, "default");
|
||||
assert_eq!(c.lang, "es-PE");
|
||||
assert!(c.timefmt_24h);
|
||||
assert!(c.module_enabled(modules::MIRADA));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_ignored() {
|
||||
// Un campo extra no rompe la deserialización.
|
||||
let s = r#"{"theme_variant":"dark","unknown":42}"#;
|
||||
let _c: WawaConfig = serde_json::from_str(s).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_module_persists_value() {
|
||||
let mut c = WawaConfig::default();
|
||||
assert!(c.module_enabled(modules::MIRADA));
|
||||
c.toggle_module(modules::MIRADA);
|
||||
assert!(!c.module_enabled(modules::MIRADA));
|
||||
c.toggle_module("inexistente");
|
||||
assert!(!c.module_enabled("inexistente"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_theme_maps_variants() {
|
||||
assert_eq!(canonical_theme_name("dark"), Some("Dark"));
|
||||
assert_eq!(canonical_theme_name("LIGHT"), Some("Light"));
|
||||
assert_eq!(canonical_theme_name("Aurora"), Some("Aurora"));
|
||||
assert_eq!(canonical_theme_name("sunset"), Some("Sunset"));
|
||||
assert_eq!(canonical_theme_name("hyperdark"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accent_rgb_default_is_none() {
|
||||
assert_eq!(accent_rgb("default"), None);
|
||||
assert_eq!(accent_rgb("gioser"), Some([0x6E, 0x8C, 0xDC]));
|
||||
assert_eq!(accent_rgb("ukupacha"), Some([0x8F, 0xB5, 0x8C]));
|
||||
assert_eq!(accent_rgb("desconocido"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_user_over_system_overrides_field_by_field() {
|
||||
// Sistema define theme aurora y lang qu-PE; usuario sólo
|
||||
// sobreescribe lang. Resultado: theme aurora (de sistema),
|
||||
// lang en-US (de usuario).
|
||||
let mut base = serde_json::to_value(WawaConfig::default()).unwrap();
|
||||
let system: serde_json::Value =
|
||||
serde_json::from_str(r#"{"theme_variant":"aurora","lang":"qu-PE"}"#).unwrap();
|
||||
let user: serde_json::Value =
|
||||
serde_json::from_str(r#"{"lang":"en-US"}"#).unwrap();
|
||||
merge_json(&mut base, system);
|
||||
merge_json(&mut base, user);
|
||||
let final_cfg: WawaConfig = serde_json::from_value(base).unwrap();
|
||||
assert_eq!(final_cfg.theme_variant, "aurora");
|
||||
assert_eq!(final_cfg.lang, "en-US");
|
||||
// El resto cae al default.
|
||||
assert_eq!(final_cfg.accent, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_modules_is_deep_per_key() {
|
||||
// Sistema apaga mirada; usuario apaga shuma. Esperado: ambos
|
||||
// off, el resto en su default true.
|
||||
let mut base = serde_json::to_value(WawaConfig::default()).unwrap();
|
||||
let system: serde_json::Value =
|
||||
serde_json::from_str(r#"{"modules":{"mirada":false}}"#).unwrap();
|
||||
let user: serde_json::Value =
|
||||
serde_json::from_str(r#"{"modules":{"shuma":false}}"#).unwrap();
|
||||
merge_json(&mut base, system);
|
||||
merge_json(&mut base, user);
|
||||
let final_cfg: WawaConfig = serde_json::from_value(base).unwrap();
|
||||
assert!(!final_cfg.module_enabled(modules::MIRADA));
|
||||
assert!(!final_cfg.module_enabled(modules::SHUMA));
|
||||
assert!(final_cfg.module_enabled(modules::CHASQUI));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_path_only_on_linux() {
|
||||
let p = system_config_path();
|
||||
if cfg!(target_os = "linux") {
|
||||
assert_eq!(p, Some(PathBuf::from("/etc/wawa/config.json")));
|
||||
} else {
|
||||
assert!(p.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constants_match_helpers() {
|
||||
// THEME_VARIANTS y ACCENTS deben coincidir con lo que aceptan
|
||||
// los helpers — guarda contra agregar uno y olvidar el otro.
|
||||
for v in THEME_VARIANTS {
|
||||
assert!(canonical_theme_name(v).is_some(), "variant {v} sin mapeo");
|
||||
}
|
||||
for a in ACCENTS {
|
||||
// accent_rgb("default") es None por diseño; el resto debe
|
||||
// tener color asignado.
|
||||
if *a == "default" {
|
||||
assert_eq!(accent_rgb(a), None);
|
||||
} else {
|
||||
assert!(accent_rgb(a).is_some(), "accent {a} sin color");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Path absoluto del config dir de **usuario** — alias por compat.
|
||||
/// `None` si no hay ProjectDirs disponibles.
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
directories::ProjectDirs::from("", "", CONFIG_DIR).map(|d| d.config_dir().to_path_buf())
|
||||
}
|
||||
|
||||
/// Path absoluto del config dir de **sistema** (`/etc/wawa` en Linux).
|
||||
/// `None` en otras plataformas.
|
||||
pub fn system_config_dir() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Some(PathBuf::from(SYSTEM_CONFIG_DIR_LINUX))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper opcional: agrega el `path` provisto a una lista de
|
||||
/// watchers. No es parte del flujo normal — está expuesto para
|
||||
/// herramientas que quieran observar un directorio externo (p. ej.
|
||||
/// `/etc/wawa/` para configuración del sistema vs el del usuario).
|
||||
/// El default (`spawn`) ya cubre el caso típico.
|
||||
pub fn watch_path(
|
||||
p: &Path,
|
||||
on_event: impl FnMut(notify::Event) + Send + 'static,
|
||||
) -> Result<RecommendedWatcher, ConfigError> {
|
||||
let mut on_event = on_event;
|
||||
let mut w =
|
||||
notify::recommended_watcher(move |res: notify::Result<notify::Event>| match res {
|
||||
Ok(ev) => on_event(ev),
|
||||
Err(e) => warn!(error = %e, "wawa-config: external watch error"),
|
||||
})?;
|
||||
w.watch(p, RecursiveMode::NonRecursive)?;
|
||||
Ok(w)
|
||||
}
|
||||
Reference in New Issue
Block a user