gioser

tawasuyu/pata

Declarative desktop frame (bars/dock/tray/widgets), portable Linux/Wawa, on Llimphi.

Rust 281Markdown 34TOML 15JSON 16602 Sutichaykuna

⎇ 95bc028eeab35a55ae8bef41f820de73348d5baa 02_ruway/pata/pata-llimphi/src/tray.rs Rust · 452 siq'ikuna · 16.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat: pata standalone — marco de escritorio declarativo (barras/dock/tray/widgets) portable Linux/Wawa (front-door, git-dep al monorepo) 95bc028ee · Sergio ·

1//! Bandeja del sistema (`tray`) vía **StatusNotifierItem**, el protocolo D-Bus de
2//! KDE/freedesktop que usan los applets modernos (nm-applet, blueman, clientes de
3//! chat…).
4//!
5//! pata actúa como **watcher + host**: posee el nombre well-known
6//! `org.kde.StatusNotifierWatcher` y atiende a las apps que registran su item.
7//! Como el bucle de pata es bloqueante (sctk) y zbus es async, el tray corre en su
8//! **propio hilo** con un runtime tokio current-thread (el workspace fija zbus con
9//! la feature `tokio`, no la blocking — mismo patrón que `mirada-portal`). Comparte
10//! el snapshot de items con el bucle por `Arc<Mutex>` y recibe los clicks por un
11//! canal (como el exec asíncrono del Quake).
12//!
13//! Alcance del MVP (todo runtime, no verificable sin un Hyprland real):
14//! - **Enumera** los items y los **activa** al click (`Activate(0,0)`).
15//! - **Íconos**: resuelve el `IconPixmap` (ARGB32 que manda la app por D-Bus) y,
16//!   si no hay, busca el `IconName` como PNG en los directorios estándar de íconos
17//!   (búsqueda acotada: hicolor + pixmaps, sólo PNG, sin parsear `index.theme` ni
18//!   SVG). Si nada resuelve, cae a una etiqueta de texto (título o id).
19//! - **No** emite las señales del watcher (sólo le importan a *otros* hosts) ni
20//!   provee fallback si ya hay un watcher corriendo: si el nombre está tomado, el
21//!   tray queda vacío y se loguea (el caso de pata como barra única no lo tiene).
22
23use std::path::PathBuf;
24use std::sync::{Arc, Mutex};
25use std::time::Duration;
26
27use tokio::sync::mpsc;
28use zbus::message::Header;
29use zbus::{interface, proxy};
30
31/// Un ícono ya decodificado a RGBA8, listo para que el render lo envuelva en una
32/// `peniko::Image`. El render no toca D-Bus ni decodifica; sólo pinta.
33#[derive(Clone, Debug)]
34pub struct TrayIcon {
35    /// Ancho en píxeles.
36    pub width: u32,
37    /// Alto en píxeles.
38    pub height: u32,
39    /// Píxeles RGBA8 (`width*height*4` bytes).
40    pub rgba: Vec<u8>,
41}
42
43/// Lo que el render necesita de cada item del tray. `key` (`"bus|path"`) rutea la
44/// activación de vuelta al hilo del tray.
45#[derive(Clone, Debug)]
46pub struct TrayItem {
47    /// Clave estable `"bus|path"` para la activación.
48    pub key: String,
49    /// Texto a pintar (título, o id si no hay título).
50    pub label: String,
51    /// Estado SNI (`Active` / `Passive` / `NeedsAttention`).
52    pub status: String,
53    /// Ícono ya decodificado, o `None` si no se pudo resolver (cae a texto).
54    pub icon: Option<TrayIcon>,
55}
56
57/// Estado compartido con la interfaz del watcher: los items registrados como
58/// `(key, bus, path)`. Lo escribe la interfaz (en el runtime del tray) y lo lee el
59/// bucle de refresco; de ahí el `Mutex`.
60#[derive(Default)]
61struct WatcherState {
62    items: Vec<(String, String, String)>,
63}
64
65/// Órdenes del bucle de pata hacia el hilo del tray. (Soltar el [`TrayHandle`]
66/// cierra el canal, lo que termina el hilo: no hace falta una variante de parada.)
67enum TrayCmd {
68    /// Activar el item con esa `key` (click).
69    Activate(String),
70}
71
72/// El asa que el bucle de pata conserva: lee el snapshot de items y manda clicks.
73pub struct TrayHandle {
74    items: Arc<Mutex<Vec<TrayItem>>>,
75    tx: mpsc::UnboundedSender<TrayCmd>,
76}
77
78impl TrayHandle {
79    /// Arranca el hilo del tray. Devuelve `None` sólo si no se pudo lanzar el hilo;
80    /// la conexión D-Bus se intenta dentro (si falla, el hilo termina y el tray
81    /// queda vacío, sin romper la barra).
82    pub fn spawn() -> Option<Self> {
83        let items: Arc<Mutex<Vec<TrayItem>>> = Arc::new(Mutex::new(Vec::new()));
84        let (tx, rx) = mpsc::unbounded_channel::<TrayCmd>();
85        let items_hilo = items.clone();
86        std::thread::Builder::new()
87            .name("pata-tray".into())
88            .spawn(move || run_tray(items_hilo, rx))
89            .ok()?;
90        Some(Self { items, tx })
91    }
92
93    /// El snapshot actual de items para el render.
94    pub fn items(&self) -> Vec<TrayItem> {
95        self.items.lock().map(|g| g.clone()).unwrap_or_default()
96    }
97
98    /// Pide activar el item `key` (no bloquea; el hilo del tray hace la llamada).
99    pub fn activate(&self, key: String) {
100        let _ = self.tx.send(TrayCmd::Activate(key));
101    }
102}
103
104/// La interfaz `org.kde.StatusNotifierWatcher` que pata expone. Las apps llaman a
105/// `RegisterStatusNotifierItem`; guardamos el item normalizado en el estado
106/// compartido. Métodos síncronos: zbus los atiende en el runtime del tray.
107struct Watcher {
108    state: Arc<Mutex<WatcherState>>,
109}
110
111#[interface(name = "org.kde.StatusNotifierWatcher")]
112impl Watcher {
113    /// Una app registra su item. `service` puede ser un nombre de bus, una ruta de
114    /// objeto (con el bus = remitente) o la forma combinada `"bus/path"`.
115    fn register_status_notifier_item(&self, service: &str, #[zbus(header)] hdr: Header<'_>) {
116        let sender = hdr.sender().map(|s| s.to_string());
117        if let Some((bus, path)) = split_service(service, sender.as_deref()) {
118            let key = format!("{bus}|{path}");
119            let mut st = self.state.lock().unwrap();
120            if !st.items.iter().any(|(k, _, _)| *k == key) {
121                st.items.push((key, bus, path));
122            }
123        }
124    }
125
126    /// Otro host se registra. pata es su propio host, así que no hace falta nada.
127    fn register_status_notifier_host(&self, _service: &str) {}
128
129    /// La lista de items registrados, en la forma `"bus/path"` que esperan los
130    /// hosts que consulten el watcher.
131    #[zbus(property)]
132    fn registered_status_notifier_items(&self) -> Vec<String> {
133        self.state
134            .lock()
135            .unwrap()
136            .items
137            .iter()
138            .map(|(_, b, p)| format!("{b}{p}"))
139            .collect()
140    }
141
142    /// Siempre `true`: pata provee el host, así que las apps deben usar SNI.
143    #[zbus(property)]
144    fn is_status_notifier_host_registered(&self) -> bool {
145        true
146    }
147
148    /// Versión del protocolo (0, como la implementación de referencia).
149    #[zbus(property)]
150    fn protocol_version(&self) -> i32 {
151        0
152    }
153}
154
155/// Cliente del item de una app: leemos sus atributos para pintarlo y lo activamos
156/// al click.
157#[proxy(interface = "org.kde.StatusNotifierItem", assume_defaults = false)]
158trait StatusNotifierItem {
159    #[zbus(property)]
160    fn id(&self) -> zbus::Result<String>;
161    #[zbus(property)]
162    fn title(&self) -> zbus::Result<String>;
163    #[zbus(property)]
164    fn icon_name(&self) -> zbus::Result<String>;
165    /// Íconos embebidos: lista de `(ancho, alto, ARGB32)`. La app los manda por el
166    /// bus; no requieren buscar en el tema.
167    #[zbus(property)]
168    fn icon_pixmap(&self) -> zbus::Result<Vec<(i32, i32, Vec<u8>)>>;
169    #[zbus(property)]
170    fn status(&self) -> zbus::Result<String>;
171    /// Click primario sobre el item.
172    fn activate(&self, x: i32, y: i32) -> zbus::Result<()>;
173}
174
175/// El hilo del tray: levanta un runtime tokio current-thread y corre el bucle
176/// async. Si no hay runtime o D-Bus, termina (tray vacío).
177fn run_tray(items: Arc<Mutex<Vec<TrayItem>>>, rx: mpsc::UnboundedReceiver<TrayCmd>) {
178    let Ok(rt) = tokio::runtime::Builder::new_current_thread()
179        .enable_all()
180        .build()
181    else {
182        return;
183    };
184    rt.block_on(bucle_tray(items, rx));
185}
186
187/// El bucle async: arma el watcher y, hasta que cierren el canal, atiende los
188/// clicks (respuesta inmediata) o refresca el snapshot de items cada ~1s.
189async fn bucle_tray(items: Arc<Mutex<Vec<TrayItem>>>, mut rx: mpsc::UnboundedReceiver<TrayCmd>) {
190    let state = Arc::new(Mutex::new(WatcherState::default()));
191    let Some(conn) = build_watcher(state.clone()).await else {
192        return; // sin D-Bus o nombre tomado: tray vacío
193    };
194    let mut tick = tokio::time::interval(Duration::from_secs(1));
195    loop {
196        tokio::select! {
197            cmd = rx.recv() => match cmd {
198                Some(TrayCmd::Activate(key)) => activar(&conn, &state, &key).await,
199                None => break, // se soltó el TrayHandle
200            },
201            _ = tick.tick() => {}
202        }
203        refrescar(&conn, &state, &items).await;
204    }
205}
206
207/// Construye la conexión de sesión sirviendo el watcher y tomando su nombre.
208/// `None` si no hay bus de sesión o el nombre ya está tomado por otro watcher.
209async fn build_watcher(state: Arc<Mutex<WatcherState>>) -> Option<zbus::Connection> {
210    let res = zbus::connection::Builder::session()
211        .ok()?
212        .serve_at("/StatusNotifierWatcher", Watcher { state })
213        .ok()?
214        .name("org.kde.StatusNotifierWatcher")
215        .ok()?
216        .build()
217        .await;
218    match res {
219        Ok(c) => Some(c),
220        Err(e) => {
221            eprintln!("pata tray · no se pudo ser StatusNotifierWatcher ({e}); ¿ya hay uno? tray vacío");
222            None
223        }
224    }
225}
226
227/// Reconstruye el snapshot de items leyendo cada uno por D-Bus; poda los que ya no
228/// responden (su app se cerró).
229async fn refrescar(
230    conn: &zbus::Connection,
231    state: &Arc<Mutex<WatcherState>>,
232    items_out: &Arc<Mutex<Vec<TrayItem>>>,
233) {
234    let registrados = state.lock().unwrap().items.clone();
235    let mut snapshot = Vec::new();
236    let mut vivos = Vec::new();
237    for (key, bus, path) in registrados {
238        if let Some((label, status, icon)) = leer_item(conn, &bus, &path).await {
239            snapshot.push(TrayItem {
240                key: key.clone(),
241                label,
242                status,
243                icon,
244            });
245            vivos.push((key, bus, path));
246        }
247    }
248    state.lock().unwrap().items = vivos;
249    *items_out.lock().unwrap() = snapshot;
250}
251
252/// Lee `(label, status, icon)` de un item. La etiqueta es el título, o el id, o el
253/// nombre del ícono —lo primero no vacío—. El ícono sale del `IconPixmap` o, si no,
254/// del `IconName` resuelto a PNG. `None` si no se puede leer ni el label ni el id:
255/// la app se fue y hay que podar el item.
256async fn leer_item(
257    conn: &zbus::Connection,
258    bus: &str,
259    path: &str,
260) -> Option<(String, String, Option<TrayIcon>)> {
261    let proxy = item_proxy(conn, bus, path).await?;
262    let title = proxy.title().await.ok().filter(|s| !s.is_empty());
263    let id = proxy.id().await.ok().filter(|s| !s.is_empty());
264    let icon_name = proxy.icon_name().await.ok().filter(|s| !s.is_empty());
265    let label = title.or(id).or_else(|| icon_name.clone())?;
266    let status = proxy
267        .status()
268        .await
269        .ok()
270        .unwrap_or_else(|| "Active".to_string());
271
272    // Ícono: primero el pixmap embebido (no necesita tema); si no, el nombre.
273    let icon = proxy
274        .icon_pixmap()
275        .await
276        .ok()
277        .and_then(pixmap_a_icono)
278        .or_else(|| icon_name.as_deref().and_then(buscar_icono_png));
279
280    Some((label, status, icon))
281}
282
283/// Activa (click primario) el item con esa `key`, si sigue registrado.
284async fn activar(conn: &zbus::Connection, state: &Arc<Mutex<WatcherState>>, key: &str) {
285    let reg = state
286        .lock()
287        .unwrap()
288        .items
289        .iter()
290        .find(|(k, _, _)| k == key)
291        .cloned();
292    if let Some((_, bus, path)) = reg {
293        if let Some(proxy) = item_proxy(conn, &bus, &path).await {
294            let _ = proxy.activate(0, 0).await;
295        }
296    }
297}
298
299/// Arma un proxy al item de `bus`/`path`.
300async fn item_proxy<'a>(
301    conn: &zbus::Connection,
302    bus: &str,
303    path: &str,
304) -> Option<StatusNotifierItemProxy<'a>> {
305    StatusNotifierItemProxy::builder(conn)
306        .destination(bus.to_string())
307        .ok()?
308        .path(path.to_string())
309        .ok()?
310        .build()
311        .await
312        .ok()
313}
314
315/// Tamaño de ícono que preferimos para la barra (px). Elegimos el pixmap más
316/// cercano a esto para no clonar mapas de 256px en cada frame.
317const ICONO_OBJETIVO: i64 = 24;
318
319/// Convierte la lista de `IconPixmap` (ARGB32) en un [`TrayIcon`] RGBA8. Elige el
320/// pixmap de tamaño más cercano a [`ICONO_OBJETIVO`] entre los válidos. `None` si
321/// la lista viene vacía o ningún entry tiene datos consistentes.
322fn pixmap_a_icono(pixmaps: Vec<(i32, i32, Vec<u8>)>) -> Option<TrayIcon> {
323    let (w, h, data) = pixmaps
324        .into_iter()
325        .filter(|(w, h, d)| {
326            *w > 0 && *h > 0 && d.len() >= (*w as usize) * (*h as usize) * 4
327        })
328        .min_by_key(|(w, _, _)| (*w as i64 - ICONO_OBJETIVO).abs())?;
329    let (w, h) = (w as u32, h as u32);
330    let mut rgba = Vec::with_capacity((w * h * 4) as usize);
331    // ARGB32 en orden de red (big-endian) → en memoria los bytes son [A,R,G,B];
332    // peniko espera RGBA8, así que reordenamos a [R,G,B,A].
333    for px in data.chunks_exact(4) {
334        rgba.extend_from_slice(&[px[1], px[2], px[3], px[0]]);
335    }
336    Some(TrayIcon {
337        width: w,
338        height: h,
339        rgba,
340    })
341}
342
343/// Busca el `IconName` como PNG en los directorios estándar y lo decodifica. Si el
344/// nombre ya es una ruta absoluta, la usa directo. Búsqueda acotada: hicolor (por
345/// tamaños) + `/usr/share/pixmaps`, sólo PNG, sin `index.theme` ni SVG.
346fn buscar_icono_png(name: &str) -> Option<TrayIcon> {
347    if name.is_empty() {
348        return None;
349    }
350    if name.starts_with('/') {
351        return decodificar_png(&PathBuf::from(name));
352    }
353    for ruta in rutas_candidatas(name) {
354        if let Some(ic) = decodificar_png(&ruta) {
355            return Some(ic);
356        }
357    }
358    None
359}
360
361/// Las rutas PNG donde puede vivir un ícono temático `name`, en orden de
362/// preferencia (tamaños chicos primero, para una barra).
363fn rutas_candidatas(name: &str) -> Vec<PathBuf> {
364    let mut bases: Vec<PathBuf> = Vec::new();
365    if let Ok(home) = std::env::var("HOME") {
366        bases.push(PathBuf::from(&home).join(".local/share"));
367        bases.push(PathBuf::from(&home).join(".icons"));
368    }
369    let datadirs =
370        std::env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
371    bases.extend(datadirs.split(':').filter(|s| !s.is_empty()).map(PathBuf::from));
372
373    let sizes = ["32x32", "24x24", "22x22", "16x16", "48x48"];
374    let mut rutas = Vec::new();
375    for base in &bases {
376        for size in sizes {
377            rutas.push(
378                base.join("icons/hicolor")
379                    .join(size)
380                    .join("apps")
381                    .join(format!("{name}.png")),
382            );
383        }
384    }
385    // Último recurso: el cajón plano de pixmaps.
386    rutas.push(PathBuf::from(format!("/usr/share/pixmaps/{name}.png")));
387    rutas
388}
389
390/// Decodifica un PNG a [`TrayIcon`] RGBA8, o `None` si no existe / no es válido.
391fn decodificar_png(ruta: &std::path::Path) -> Option<TrayIcon> {
392    let img = image::open(ruta).ok()?.to_rgba8();
393    let (width, height) = (img.width(), img.height());
394    Some(TrayIcon {
395        width,
396        height,
397        rgba: img.into_raw(),
398    })
399}
400
401/// Normaliza el argumento de `RegisterStatusNotifierItem` a `(bus, path)`:
402/// - empieza con `/` → es una ruta de objeto, el bus es el remitente (Ayatana);
403/// - tiene un `/` interno → forma combinada `"bus/path"`;
404/// - si no → es un nombre de bus, con la ruta por defecto `/StatusNotifierItem` (KDE).
405fn split_service(service: &str, sender: Option<&str>) -> Option<(String, String)> {
406    if service.starts_with('/') {
407        Some((sender?.to_string(), service.to_string()))
408    } else if let Some(idx) = service.find('/') {
409        Some((service[..idx].to_string(), service[idx..].to_string()))
410    } else {
411        Some((service.to_string(), "/StatusNotifierItem".to_string()))
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn split_service_ruta_usa_el_remitente_como_bus() {
421        // Ayatana/AppIndicator: registra la ruta, el bus es el remitente.
422        assert_eq!(
423            split_service("/org/ayatana/NotificationItem/app", Some(":1.42")),
424            Some((
425                ":1.42".to_string(),
426                "/org/ayatana/NotificationItem/app".to_string()
427            ))
428        );
429        // Ruta sin remitente conocido: no se puede ubicar.
430        assert_eq!(split_service("/foo", None), None);
431    }
432
433    #[test]
434    fn split_service_nombre_de_bus_usa_ruta_por_defecto() {
435        // KDE: registra el nombre de bus, ruta por defecto.
436        assert_eq!(
437            split_service("org.kde.StatusNotifierItem-1234-1", Some(":1.9")),
438            Some((
439                "org.kde.StatusNotifierItem-1234-1".to_string(),
440                "/StatusNotifierItem".to_string()
441            ))
442        );
443    }
444
445    #[test]
446    fn split_service_forma_combinada_se_parte() {
447        assert_eq!(
448            split_service(":1.9/StatusNotifierItem", None),
449            Some((":1.9".to_string(), "/StatusNotifierItem".to_string()))
450        );
451    }
452}