feat(mirada): mirada-portal — backend de tema org.freedesktop.appearance

Backend de xdg-desktop-portal para carmen: implementa
org.freedesktop.impl.portal.Settings y publica color-scheme,
accent-color y contrast desde el tema activo de nahual. GTK4, Qt6,
Firefox y Chromium voltean claro/oscuro + acento por protocolo, sin
tocar sus configs. Watcher con notify del archivo de nahual-theme →
emite SettingChanged en vivo. 13 tests; smoke verificado sobre un bus
de sesión efímero.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-21 16:05:27 +00:00
parent 2bd6aaad02
commit 5369c307e4
10 changed files with 789 additions and 0 deletions
+430
View File
@@ -0,0 +1,430 @@
//! `mirada-portal` — backend de `xdg-desktop-portal` para el escritorio
//! carmen.
//!
//! Implementa la interfaz `org.freedesktop.impl.portal.Settings` y
//! publica un único namespace: `org.freedesktop.appearance`. Con eso,
//! GTK4/libadwaita, Qt6, Firefox y Chromium leen del sistema —
//! **por protocolo, sin tocar sus archivos de config** — si el
//! escritorio está en modo claro u oscuro, su color de acento y si pide
//! contraste alto. Cuando el tema de `nahual` cambia, el portal emite
//! `SettingChanged` y todas esas apps voltean en vivo.
//!
//! Fuente del tema: el archivo que persiste `nahual-theme`
//! (`$XDG_CONFIG_HOME/nahual/theme`, contiene el nombre del preset
//! activo). El portal lo vigila con `notify` y reexpone sus hechos —
//! ver [`theme_facts`].
//!
//! Este crate es el **backend** del portal: el frontend genérico
//! `xdg-desktop-portal` lo enruta vía el archivo `mirada.portal`. Ver
//! el README para la instalación de los archivos de `data/`.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::signal::unix::{signal, SignalKind};
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
use zbus::zvariant::{OwnedValue, Value};
use zbus::{fdo, interface, SignalContext};
mod theme_facts;
use theme_facts::ThemeFacts;
/// Nombre de bus del backend. El patrón `org.freedesktop.impl.portal.
/// desktop.<id>` es el que espera el frontend `xdg-desktop-portal`; el
/// `<id>` (`mirada`) tiene que coincidir con el `DBusName` del archivo
/// `mirada.portal`.
const BUS_NAME: &str = "org.freedesktop.impl.portal.desktop.mirada";
/// Ruta de objeto canónica de los portales del escritorio.
const OBJ_PATH: &str = "/org/freedesktop/portal/desktop";
/// Único namespace que servimos. El estándar moderno que leen GTK, Qt,
/// Firefox y Chromium para claro/oscuro + acento.
const APPEARANCE_NS: &str = "org.freedesktop.appearance";
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
init_tracing();
info!("mirada-portal: arrancando backend org.freedesktop.impl.portal.Settings");
let theme_path = theme_config_path();
let initial = read_facts(theme_path.as_deref());
info!(
?theme_path,
color_scheme = initial.color_scheme(),
contrast = initial.contrast(),
"tema inicial resuelto"
);
let facts = Arc::new(Mutex::new(initial));
let portal = SettingsPortal {
facts: Arc::clone(&facts),
};
// El portal vive en el bus de **sesión** (no el de sistema): es un
// servicio del escritorio del usuario, no del sistema.
let conn_result = zbus::connection::Builder::session()
.and_then(|b| b.name(BUS_NAME))
.and_then(|b| b.serve_at(OBJ_PATH, portal));
match conn_result {
Ok(builder) => match builder.build().await {
Ok(conn) => {
info!(name = BUS_NAME, "name adquirido en el bus de sesión");
run(conn, facts, theme_path).await
}
Err(e) => {
warn!(?e, "no se pudo construir la conexión D-Bus — modo idle");
wait_for_term().await
}
},
Err(e) => {
warn!(?e, "builder D-Bus falló (¿hay bus de sesión?) — modo idle");
wait_for_term().await
}
}
}
/// Conectado al bus: monta el watcher del tema y espera la señal de
/// término. El watcher se guarda en `_watcher` para que no se dropee
/// (al dropearse dejaría de vigilar).
async fn run(
conn: zbus::Connection,
facts: Arc<Mutex<ThemeFacts>>,
theme_path: Option<PathBuf>,
) -> anyhow::Result<()> {
let _watcher = match &theme_path {
Some(path) => match spawn_theme_watcher(conn.clone(), Arc::clone(&facts), path.clone()) {
Ok(w) => Some(w),
Err(e) => {
warn!(
?e,
"watcher del tema no disponible — el portal no actualizará en vivo"
);
None
}
},
None => {
warn!("sin ruta de config de tema — el portal sirve un valor fijo");
None
}
};
wait_for_term().await
}
// ============================================================================
// Interfaz D-Bus: org.freedesktop.impl.portal.Settings
// ============================================================================
struct SettingsPortal {
/// Hechos del tema activo. El watcher los reescribe cuando cambia.
facts: Arc<Mutex<ThemeFacts>>,
}
#[interface(name = "org.freedesktop.impl.portal.Settings")]
impl SettingsPortal {
/// Versión de la interfaz impl. `ReadOne` se agregó en la 2.
#[zbus(property, name = "version")]
fn version(&self) -> u32 {
2
}
/// `ReadAll(namespaces) -> a{sa{sv}}`. Los `namespaces` son patrones
/// (sufijo `*` = prefijo); lista vacía = todos. Sólo respondemos
/// `org.freedesktop.appearance`.
async fn read_all(
&self,
namespaces: Vec<String>,
) -> fdo::Result<HashMap<String, HashMap<String, OwnedValue>>> {
let mut out = HashMap::new();
if namespace_requested(&namespaces, APPEARANCE_NS) {
let facts = *self.facts.lock().unwrap();
out.insert(APPEARANCE_NS.to_string(), appearance_map(&facts)?);
}
Ok(out)
}
/// `ReadOne(namespace, key) -> v`. Lee un único valor.
async fn read_one(&self, namespace: String, key: String) -> fdo::Result<OwnedValue> {
let facts = *self.facts.lock().unwrap();
lookup(&facts, &namespace, &key)
}
/// `Read(namespace, key) -> v`. Deprecado a favor de `ReadOne` desde
/// la versión 2 del portal, pero apps viejas lo siguen llamando.
async fn read(&self, namespace: String, key: String) -> fdo::Result<OwnedValue> {
let facts = *self.facts.lock().unwrap();
lookup(&facts, &namespace, &key)
}
/// `SettingChanged(namespace, key, value)`. Lo emite el watcher
/// cuando el tema persistido cambia.
#[zbus(signal)]
async fn setting_changed(
ctxt: &SignalContext<'_>,
namespace: &str,
key: &str,
value: Value<'_>,
) -> zbus::Result<()>;
}
// ============================================================================
// Mapeo tema → valores del portal
// ============================================================================
/// Construye el mapa `a{sv}` del namespace `org.freedesktop.appearance`.
fn appearance_map(facts: &ThemeFacts) -> fdo::Result<HashMap<String, OwnedValue>> {
Ok(HashMap::from([
(
"color-scheme".to_string(),
into_owned(Value::U32(facts.color_scheme()))?,
),
(
"contrast".to_string(),
into_owned(Value::U32(facts.contrast()))?,
),
("accent-color".to_string(), into_owned(accent_value(facts))?),
]))
}
/// Resuelve una clave concreta dentro de `org.freedesktop.appearance`.
fn lookup(facts: &ThemeFacts, namespace: &str, key: &str) -> fdo::Result<OwnedValue> {
if namespace != APPEARANCE_NS {
return Err(fdo::Error::Failed(format!(
"namespace no servido por mirada-portal: {namespace}"
)));
}
let value = match key {
"color-scheme" => Value::U32(facts.color_scheme()),
"contrast" => Value::U32(facts.contrast()),
"accent-color" => accent_value(facts),
other => {
return Err(fdo::Error::Failed(format!(
"clave desconocida en {APPEARANCE_NS}: {other}"
)));
}
};
into_owned(value)
}
/// El acento como structure `(ddd)` — tres dobles RGB en 0..1.
fn accent_value(facts: &ThemeFacts) -> Value<'static> {
let (r, g, b) = facts.accent_rgb();
Value::from((r, g, b))
}
/// `Value` → `OwnedValue`. Sólo falla con valores que llevan fds; los
/// nuestros (enteros y dobles) nunca lo hacen.
fn into_owned(value: Value<'_>) -> fdo::Result<OwnedValue> {
OwnedValue::try_from(value).map_err(|e| fdo::Error::Failed(format!("zvariant: {e}")))
}
/// ¿El patrón de namespaces de un `ReadAll` pide `ns`? Lista vacía =
/// todos. Un patrón con sufijo `*` matchea por prefijo; sino, exacto.
fn namespace_requested(patterns: &[String], ns: &str) -> bool {
if patterns.is_empty() {
return true;
}
patterns.iter().any(|p| match p.strip_suffix('*') {
Some(prefix) => ns.starts_with(prefix),
None => p == ns,
})
}
// ============================================================================
// Watcher del tema persistido
// ============================================================================
/// Vigila el archivo de tema de `nahual`; cuando cambia, recomputa los
/// hechos y emite `SettingChanged`. Devuelve el watcher, que el caller
/// debe mantener vivo.
fn spawn_theme_watcher(
conn: zbus::Connection,
facts: Arc<Mutex<ThemeFacts>>,
path: PathBuf,
) -> notify::Result<notify::RecommendedWatcher> {
use notify::{RecursiveMode, Watcher};
// Canal acotado: el callback de notify (en su propio hilo) sólo
// hace `try_send`; si el buffer está lleno ya hay un evento
// pendiente y da igual perder éste — coalescencia natural.
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(8);
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if res.is_ok() {
let _ = tx.try_send(());
}
})?;
// Vigilamos el **directorio padre**: así captamos también la
// creación del archivo si aún no existe.
let watch_target = path.parent().unwrap_or(&path).to_path_buf();
watcher.watch(&watch_target, RecursiveMode::NonRecursive)?;
info!(dir = ?watch_target, "vigilando el directorio del tema");
tokio::spawn(async move {
while rx.recv().await.is_some() {
let fresh = read_facts(Some(&path));
let changed = {
let mut guard = facts.lock().unwrap();
let differs = *guard != fresh;
*guard = fresh;
differs
};
if changed {
info!(
color_scheme = fresh.color_scheme(),
contrast = fresh.contrast(),
"el tema cambió — emitiendo SettingChanged"
);
if let Err(e) = emit_appearance_changed(&conn, &fresh).await {
warn!(?e, "no se pudo emitir SettingChanged");
}
}
}
});
Ok(watcher)
}
/// Emite `SettingChanged` para las tres claves de `appearance`.
async fn emit_appearance_changed(conn: &zbus::Connection, facts: &ThemeFacts) -> zbus::Result<()> {
let ctxt = SignalContext::new(conn, OBJ_PATH)?;
SettingsPortal::setting_changed(
&ctxt,
APPEARANCE_NS,
"color-scheme",
Value::U32(facts.color_scheme()),
)
.await?;
SettingsPortal::setting_changed(
&ctxt,
APPEARANCE_NS,
"contrast",
Value::U32(facts.contrast()),
)
.await?;
SettingsPortal::setting_changed(&ctxt, APPEARANCE_NS, "accent-color", accent_value(facts))
.await?;
Ok(())
}
// ============================================================================
// Lectura del tema persistido
// ============================================================================
/// Lee el nombre de tema del archivo y resuelve sus hechos. Si el
/// archivo falta o está vacío, asume `Nebula` — el default de
/// `nahual_theme::install_default`.
fn read_facts(path: Option<&Path>) -> ThemeFacts {
let name = path
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Nebula".to_string());
theme_facts::facts_for(&name)
}
/// Ruta del archivo donde `nahual-theme` persiste el nombre del tema
/// activo. Réplica de `nahual_theme::config_path()` — `mirada-portal`
/// no enlaza GPUI, así que no puede llamarla directamente. Convención
/// XDG: `$XDG_CONFIG_HOME/nahual/theme`, sino `$HOME/.config/...`.
fn theme_config_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CONFIG_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var("HOME")
.ok()
.filter(|s| !s.is_empty())
.map(|h| PathBuf::from(h).join(".config"))
})?;
Some(base.join("nahual").join("theme"))
}
// ============================================================================
// Plomería
// ============================================================================
async fn wait_for_term() -> anyhow::Result<()> {
let mut term = signal(SignalKind::terminate())?;
let mut int_ = signal(SignalKind::interrupt())?;
tokio::select! {
_ = term.recv() => info!("SIGTERM"),
_ = int_.recv() => info!("SIGINT"),
}
Ok(())
}
fn init_tracing() {
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("mirada_portal=info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(true)
.init();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn namespace_empty_matches_all() {
assert!(namespace_requested(&[], APPEARANCE_NS));
}
#[test]
fn namespace_exact_match() {
assert!(namespace_requested(
&[APPEARANCE_NS.to_string()],
APPEARANCE_NS
));
assert!(!namespace_requested(
&["org.example".to_string()],
APPEARANCE_NS
));
}
#[test]
fn namespace_wildcard_prefix() {
assert!(namespace_requested(
&["org.freedesktop.*".to_string()],
APPEARANCE_NS
));
assert!(!namespace_requested(
&["org.gnome.*".to_string()],
APPEARANCE_NS
));
}
#[test]
fn appearance_map_has_three_keys() {
let facts = theme_facts::facts_for("Nebula");
let m = appearance_map(&facts).unwrap();
assert_eq!(m.len(), 3);
assert!(m.contains_key("color-scheme"));
assert!(m.contains_key("accent-color"));
assert!(m.contains_key("contrast"));
}
#[test]
fn lookup_unknown_namespace_errs() {
let facts = theme_facts::facts_for("Nebula");
assert!(lookup(&facts, "org.gnome.desktop.interface", "color-scheme").is_err());
}
#[test]
fn lookup_unknown_key_errs() {
let facts = theme_facts::facts_for("Nebula");
assert!(lookup(&facts, APPEARANCE_NS, "no-such-key").is_err());
}
#[test]
fn lookup_color_scheme_ok() {
let facts = theme_facts::facts_for("Solarized Light");
assert!(lookup(&facts, APPEARANCE_NS, "color-scheme").is_ok());
}
}