diff --git a/Cargo.toml b/Cargo.toml index a40cbb4..468dbb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,12 +9,12 @@ members = [ "llimphi-hal", "llimphi-raster", "llimphi-layout", "llimphi-text", "llimphi-ui", "llimphi-theme", "llimphi-surface", "llimphi-motion", "llimphi-icons", "llimphi-compositor", "llimphi-workspace", - "widgets/*", "modules/*", + "widgets/*", "modules/*", "shared/app-bus", ] exclude = [ "android", "llimphi-gallery", "llimphi-gpu-bench", - "widgets/gallery", "widgets/menubar", + "widgets/gallery", "modules/shuma-term", "modules/plugin-host", ] diff --git a/shared/app-bus/Cargo.toml b/shared/app-bus/Cargo.toml new file mode 100644 index 0000000..0fedc26 --- /dev/null +++ b/shared/app-bus/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "app-bus" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "app-bus — registro único de aplicaciones de gioser + protocolo de menú global (Archivo/Editar/Ayuda) + bus de eventos foco/lanzamiento + trait Launcher. Lo consultan los launchers (mirada, shuma, wawa) en vez de reimplementar el despacho cada uno. Los datos + el trait son no_std; el descubrimiento (fs/TOML), el spawn de procesos y el Bus van detrás del feature `std`." + +[features] +default = ["std"] +# `std` enciende el descubrimiento por filesystem/TOML, el spawn de +# procesos del host (ProcessLauncher) y el Bus pub/sub (std::sync::mpsc). +# Sin `std`, el crate queda en datos + trait Launcher + AppMenu, listo para +# espejar en el kernel de wawa. +std = ["serde/std", "toml", "directories"] + +[dependencies] +# serde directo (no workspace) para poder apagar default-features en el +# build no_std — la versión workspace fuerza std. +serde = { version = "1", default-features = false, features = ["derive", "alloc"] } +toml = { workspace = true, optional = true } +directories = { workspace = true, optional = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/shared/app-bus/README.md b/shared/app-bus/README.md new file mode 100644 index 0000000..361138c --- /dev/null +++ b/shared/app-bus/README.md @@ -0,0 +1,38 @@ +# app-bus — bus de eventos in-proc para apps Llimphi + +Bus de **publish/subscribe** tipado, síncrono y en memoria, para coordinar apps +dentro del mismo proceso. Cubre tres familias de eventos transversales: **foco** +(qué app/ventana lo tiene), **navegación** (abrir/cerrar/enfocar una app o ruta) +y **notificaciones** efímeras. + +## Qué expone + +- `AppBus` — handle compartible (`Clone`, internamente `Arc>`). +- `Event` — enum transversal: `FocusChanged` / `Navigate` / `CloseApp` / `Notify`. +- `NotifyLevel` — `Info` / `Warn` / `Error`. +- `Subscription` — guard RAII: al soltarlo se cancela la suscripción. +- `publish(Event)` entrega de forma síncrona a todos los suscriptores; entrega + anidada si un callback publica desde su propio handler. + +## No-objetivos + +- No es un bus interproceso ni de red (eso es Akasha / app-channel). +- No persiste eventos ni garantiza entrega tras reinicio. +- No ordena por prioridad. + +## Estado (2026-05-31) + +### Hecho +- Bus pub/sub completo: `publish`, `subscribe`, cancelación por guard RAII. +- Enum `Event` con foco/navegación/cierre/notificaciones. +- Consumido por `launcher-llimphi` (dispara navegación/lanzamiento). + +### Pendiente +- Adaptador multiproceso (hoy estrictamente in-proc). +- Entrega diferida / cola (hoy reentrancia anidada síncrona). +- Filtrado por tipo de evento en `subscribe` (hoy el callback filtra). + +## Lugar en el repo + +`shared/app-bus` — canal in-proc de grano fino. El plano de control a más alto +nivel es `shared/sandokan` (ver su SDD.md). diff --git a/shared/app-bus/assets/apps/media.toml b/shared/app-bus/assets/apps/media.toml new file mode 100644 index 0000000..f52ca63 --- /dev/null +++ b/shared/app-bus/assets/apps/media.toml @@ -0,0 +1,32 @@ +# Reproductor multimedia de la suite (02_ruway/media). +# +# Copiá este archivo a ~/.config/gioser/apps/media.toml para que los launchers +# (el menú de inicio de pata, el spotlight) y el "abrir con" del navegador lo +# descubran. El binario `media-app` debe estar en el PATH (cargo install) o poné +# su ruta absoluta en `exec`. + +id = "media" +label = "Media" +icon = "▶" +category = "ruway" + +# Los mimes que sabe abrir (open-with). Deben coincidir con los que pata deriva +# de la extensión (02_ruway/pata/pata-llimphi/src/open.rs::mime_for_path). +handles = [ + "video/mp4", + "video/x-matroska", + "video/webm", + "video/quicktime", + "video/x-msvideo", + "audio/mpeg", + "audio/flac", + "audio/wav", + "audio/ogg", + "audio/opus", +] + +[launch] +exec = "media-app" +# `%f` = la ruta del archivo a abrir (convención freedesktop). Sin placeholder, +# la ruta se agrega igual como último argumento. +args = ["%f"] diff --git a/shared/app-bus/assets/apps/nada.toml b/shared/app-bus/assets/apps/nada.toml new file mode 100644 index 0000000..5757dfc --- /dev/null +++ b/shared/app-bus/assets/apps/nada.toml @@ -0,0 +1,40 @@ +# Editor de texto/código de la suite (02_ruway/nada): file tree + text-editor +# Llimphi sobre archivos reales. Acepta una ruta (archivo o directorio) como +# argumento. +# +# Copiá este archivo a ~/.config/gioser/apps/nada.toml. El binario `nada` debe +# estar en el PATH (cargo install -p nada) o poné su ruta absoluta en `exec`. + +id = "nada" +label = "Nada" +icon = "✎" +category = "ruway" + +# Texto y código: lo que el text-editor de Llimphi sabe editar. Coincide con la +# tabla de mimes de pata (open.rs::mime_for_path). +handles = [ + "text/plain", + "text/markdown", + "text/x-rst", + "text/csv", + "text/x-rust", + "text/x-python", + "text/javascript", + "text/typescript", + "text/x-c", + "text/x-c++", + "text/x-go", + "text/x-java", + "text/x-ruby", + "application/x-shellscript", + "application/toml", + "application/json", + "application/yaml", + "application/xml", + "text/html", + "text/css", +] + +[launch] +exec = "nada" +args = ["%f"] diff --git a/shared/app-bus/src/lib.rs b/shared/app-bus/src/lib.rs new file mode 100644 index 0000000..4039556 --- /dev/null +++ b/shared/app-bus/src/lib.rs @@ -0,0 +1,795 @@ +//! `app-bus` — el cimiento del menú de aplicaciones de gioser. +//! +//! Hoy hay tres lanzadores que no comparten nada: `mirada-launcher` +//! (TOML propio, `std::process`), `shuma-module-launcher` (otro TOML, +//! `process_group`) y el launcher in-kernel de wawa (Manifiesto, WASM). +//! Cada uno reimplementa "qué apps existen y cómo se lanzan". Este crate +//! es la tabla única que todos consultan. +//! +//! Cuatro piezas, en capas: +//! +//! 1. **Registro** ([`AppRegistry`] + [`AppEntry`]): qué apps hay, cómo se +//! lanzan ([`Launch`]) y qué mimes/lentes saben abrir (open-with). +//! Se descubre de `~/.config/gioser/apps/*.toml` (feature `std`). +//! 2. **Menú global** ([`AppMenu`]/[`Menu`]/[`MenuItem`]): el clásico +//! Archivo/Editar/Ayuda que la app *declara*. Cuando hay una barra de +//! launcher presente, ésta lo *adopta* y la app deja de pintarlo en su +//! ventana — el comportamiento "menú global" de macOS. +//! 3. **Launcher** ([`Launcher`] trait + [`LaunchError`]): la *instrucción +//! de ejecución* abstracta. El host implementa con `std::process` +//! ([`ProcessLauncher`]), wawa con instanciación WASM, shuma +//! despachando `action`. El motor de launcher llama al trait y no se +//! entera de en qué entorno corre. +//! 4. **Bus** ([`Bus`] + [`BusEvent`]): pub/sub in-process de foco / +//! cambio de menú / pedido de lanzamiento / comando. La versión +//! cross-proceso montará sobre el broker de brahman más adelante. +//! +//! Los **datos** ([`AppEntry`], [`Launch`], [`AppMenu`]…) y el **trait +//! [`Launcher`]** son `no_std + alloc`. El descubrimiento por filesystem, +//! el spawn de procesos y el [`Bus`] viven detrás del feature `std`. + +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] + +extern crate alloc; + +use alloc::string::String; +use alloc::vec::Vec; + +use serde::{Deserialize, Serialize}; + +// ===================================================================== +// Registro de apps +// ===================================================================== + +/// Cómo se enciende una app. Los tres mundos de gioser: +/// `Exec` (binario del host), `Action` (acción interna del chasis que la +/// hospeda — p.ej. `focus:shell`) y `Wasm` (módulo en el almacén de wawa, +/// direccionado por hash de bytecode). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Launch { + /// Spawnear un comando/binario del host. + Exec { program: String, args: Vec }, + /// Acción interna a despachar por el host (no spawnea proceso). + Action(String), + /// App WASM de wawa, por hash de bytecode (hex) en el almacén. + Wasm { bytecode_hex: String }, +} + +/// Una app registrada — la fila de la tabla que ven los launchers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppEntry { + pub id: String, + pub label: String, + /// Glyph/emoji o ruta de ícono. Sin imponer formato — el launcher + /// decide cómo pintarlo (texto en el dock MVP). + pub icon: Option, + /// Agrupador opcional para la grilla/spotlight (p.ej. cuadrante). + pub category: Option, + pub launch: Launch, + /// Mimes/lentes que esta app sabe abrir (open-with). Vacío = no es + /// visor; el registro de visores de nahual-shell se alimenta de acá. + pub handles: Vec, +} + +impl AppEntry { + /// `true` si la app declara saber abrir `mime`. + pub fn handles_mime(&self, mime: &str) -> bool { + self.handles.iter().any(|m| m == mime) + } +} + +#[cfg(feature = "std")] +impl AppEntry { + /// Enciende la app vía `std::process`. Sólo `Exec` spawnea; `Action`/ + /// `Wasm` devuelven `Ok(None)` — los despacha el host (chasis/kernel). + pub fn spawn(&self) -> std::io::Result> { + match &self.launch { + Launch::Exec { program, args } => std::process::Command::new(program) + .args(args) + .spawn() + .map(Some), + Launch::Action(_) | Launch::Wasm { .. } => Ok(None), + } + } + + /// **Open-with out-of-process**: abre `target` con esta app. Para `Exec`, + /// spawnea el binario sustituyendo el placeholder `%f`/`%u` en los args + /// por `target`; si ningún arg lo trae, agrega `target` como último + /// argumento (semántica estilo freedesktop `Exec=app %f`). `Action`/`Wasm` + /// devuelven `Ok(None)`: el target lo despacha el host (chasis a una vista + /// in-process, o kernel de wawa a una app WASM), no un proceso del SO. + pub fn open(&self, target: &str) -> std::io::Result> { + match &self.launch { + Launch::Exec { program, args } => std::process::Command::new(program) + .args(expand_target(args, target)) + .spawn() + .map(Some), + Launch::Action(_) | Launch::Wasm { .. } => Ok(None), + } + } +} + +/// Sustituye los placeholders `%f`/`%u` por `target` en `args`. Si ninguno +/// aparece, agrega `target` como argumento final — la convención de +/// freedesktop (`Exec=app %f`) que entiende cualquier "abrir con". +#[cfg(feature = "std")] +pub fn expand_target(args: &[String], target: &str) -> Vec { + let mut sustituido = false; + let mut out: Vec = args + .iter() + .map(|a| { + if a.contains("%f") || a.contains("%u") { + sustituido = true; + a.replace("%f", target).replace("%u", target) + } else { + a.clone() + } + }) + .collect(); + if !sustituido { + out.push(target.to_string()); + } + out +} + +// ----- forma en disco (TOML) ----- + +/// Espejo serde del archivo `.toml`. La `[launch]` es una tabla con +/// campos opcionales en vez de un enum etiquetado — toml 0.8 trata los +/// enums internamente etiquetados de forma quisquillosa, así que +/// resolvemos a mano a [`Launch`]. Sólo se usa al parsear TOML (`std`). +#[cfg(feature = "std")] +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AppFile { + id: String, + label: String, + #[serde(default)] + icon: Option, + #[serde(default)] + category: Option, + #[serde(default)] + handles: Vec, + launch: LaunchFile, +} + +#[cfg(feature = "std")] +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LaunchFile { + #[serde(default)] + exec: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + action: Option, + #[serde(default)] + wasm: Option, +} + +#[cfg(feature = "std")] +impl LaunchFile { + fn resolve(self) -> Option { + if let Some(program) = self.exec { + Some(Launch::Exec { + program, + args: self.args, + }) + } else if let Some(action) = self.action { + Some(Launch::Action(action)) + } else { + self.wasm.map(|bytecode_hex| Launch::Wasm { bytecode_hex }) + } + } +} + +#[cfg(feature = "std")] +impl AppFile { + fn into_entry(self) -> Option { + Some(AppEntry { + id: self.id, + label: self.label, + icon: self.icon, + category: self.category, + handles: self.handles, + launch: self.launch.resolve()?, + }) + } +} + +/// Parsea una entrada de app desde texto TOML. Devuelve `None` si no +/// parsea o si la `[launch]` no nombra ningún modo (`exec`/`action`/`wasm`). +#[cfg(feature = "std")] +pub fn parse_entry(toml_src: &str) -> Option { + toml::from_str::(toml_src) + .ok() + .and_then(AppFile::into_entry) +} + +/// Directorio canónico del registro: `~/.config/gioser/apps/`. +#[cfg(feature = "std")] +pub fn apps_dir() -> Option { + directories::BaseDirs::new().map(|b| b.config_dir().join("gioser").join("apps")) +} + +/// La tabla de apps. Inmutable tras descubrir — recargar = volver a +/// `discover`. Barato: son pocos archivos y no es hot-path. +#[derive(Debug, Clone, Default)] +pub struct AppRegistry { + entries: Vec, +} + +impl AppRegistry { + pub fn new(mut entries: Vec) -> Self { + // sort_unstable_by para no exigir alloc extra (vive en core). + entries.sort_unstable_by(|a, b| a.label.cmp(&b.label)); + Self { entries } + } + + pub fn all(&self) -> &[AppEntry] { + &self.entries + } + + pub fn get(&self, id: &str) -> Option<&AppEntry> { + self.entries.iter().find(|e| e.id == id) + } + + /// Apps que declaran abrir `mime` — para el open-with universal. + pub fn handlers_for(&self, mime: &str) -> Vec<&AppEntry> { + self.entries.iter().filter(|e| e.handles_mime(mime)).collect() + } + + /// Apps de una categoría, en orden de label (para grilla/spotlight). + pub fn in_category(&self, category: &str) -> Vec<&AppEntry> { + self.entries + .iter() + .filter(|e| e.category.as_deref() == Some(category)) + .collect() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +#[cfg(feature = "std")] +impl AppRegistry { + /// Descubre del dir canónico. Vacío si no hay config dir o el dir no + /// existe — la app sigue, sólo sin entradas. + pub fn discover() -> Self { + apps_dir().map(Self::from_dir).unwrap_or_default() + } + + /// **Open-with universal**: elige el primer handler de `mime` (orden de + /// label) y le abre `target` out-of-process vía [`AppEntry::open`]. + /// Devuelve el `AppEntry` elegido y su `Child` (o `None` en el child si + /// la app es `Action`/`Wasm`, que despacha el host). `Ok(None)` si ninguna + /// app registrada declara abrir ese mime — el caller cae a su visor + /// in-process por defecto (p.ej. el `viewer_registry` de nahual-shell). + pub fn open_with( + &self, + mime: &str, + target: &str, + ) -> std::io::Result)>> { + match self.handlers_for(mime).into_iter().next() { + Some(entry) => Ok(Some((entry, entry.open(target)?))), + None => Ok(None), + } + } + + /// Escanea `/*.toml`. Ignora en silencio los que no parsean + /// (con una nota a stderr), igual que el resto de los loaders del repo. + pub fn from_dir(dir: impl AsRef) -> Self { + let dir = dir.as_ref(); + let mut entries = Vec::new(); + if let Ok(rd) = std::fs::read_dir(dir) { + for e in rd.flatten() { + let p = e.path(); + if p.extension().and_then(|s| s.to_str()) != Some("toml") { + continue; + } + match std::fs::read_to_string(&p) { + Ok(src) => match parse_entry(&src) { + Some(entry) => entries.push(entry), + None => eprintln!("app-bus: {p:?} no declara una app válida"), + }, + Err(err) => eprintln!("app-bus: no se pudo leer {p:?}: {err}"), + } + } + } + Self::new(entries) + } +} + +/// Siembra manifests por defecto en [`apps_dir`] si todavía no hay +/// ninguno, para que [`AppRegistry::discover`] devuelva las apps del repo +/// en una máquina recién instalada. No pisa nada si ya existe algún +/// `*.toml`. Devuelve cuántos manifests escribió. +#[cfg(feature = "std")] +pub fn seed_default_apps() -> std::io::Result { + let Some(dir) = apps_dir() else { + return Ok(0); + }; + // Si ya hay manifests, respetar la config del usuario y no tocar nada. + if let Ok(rd) = std::fs::read_dir(&dir) { + let ya_hay = rd.flatten().any(|e| { + e.path().extension().and_then(|s| s.to_str()) == Some("toml") + }); + if ya_hay { + return Ok(0); + } + } + std::fs::create_dir_all(&dir)?; + + // (id, label, icono, binario, cuadrante). Los binarios son los nombres + // de crate ejecutables del workspace; el cuadrante alimenta la grilla. + const DEFAULTS: &[(&str, &str, &str, &str, &str)] = &[ + ("cosmos", "Cosmos", "✶", "cosmos-app-llimphi", "yachay"), + ("nada", "Nada", "✎", "nada", "ruway"), + ("pluma", "Pluma", "✒", "pluma-editor-llimphi", "unanchay"), + ("nahual", "Nahual", "❖", "nahual-shell-llimphi", "ruway"), + ("dominium", "Dominium", "◉", "dominium-app-llimphi", "yachay"), + ("tinkuy", "Tinkuy", "⚛", "tinkuy-llimphi", "yachay"), + ("takiy", "Takiy", "♪", "takiy-app-llimphi", "ruway"), + ("media", "Media", "▶", "media-app", "ruway"), + ("tullpu", "Tullpu", "✦", "tullpu-app-llimphi", "ruway"), + ("supay", "Supay", "✷", "supay-app-llimphi", "ruway"), + ("sandokan-monitor", "Monitor", "❤", "sandokan-monitor", "ukupacha"), + ]; + + let mut escritos = 0; + for (id, label, icon, exec, cat) in DEFAULTS { + let toml = alloc::format!( + "id = \"{id}\"\nlabel = \"{label}\"\nicon = \"{icon}\"\ncategory = \"{cat}\"\n\n[launch]\nexec = \"{exec}\"\n" + ); + std::fs::write(dir.join(alloc::format!("{id}.toml")), toml)?; + escritos += 1; + } + Ok(escritos) +} + +// ===================================================================== +// Menú global (Archivo / Editar / Ayuda …) +// ===================================================================== + +/// Un ítem de menú. `command` es el id que la app entiende: la barra lo +/// re-emite por el [`Bus`] como [`BusEvent::Command`] y la app focuseada +/// lo ejecuta. `shortcut` es sólo para pintar (la app sigue dueña del +/// binding real). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MenuItem { + pub label: String, + pub command: String, + #[serde(default)] + pub shortcut: Option, + /// Glifo (unicode) opcional para el gutter de íconos del dropdown. + #[serde(default)] + pub icon: Option, + #[serde(default = "yes")] + pub enabled: bool, + /// Dibujar un separador *antes* de este ítem. + #[serde(default)] + pub separator_before: bool, +} + +fn yes() -> bool { + true +} + +impl MenuItem { + pub fn new(label: impl Into, command: impl Into) -> Self { + Self { + label: label.into(), + command: command.into(), + shortcut: None, + icon: None, + enabled: true, + separator_before: false, + } + } + + pub fn shortcut(mut self, s: impl Into) -> Self { + self.shortcut = Some(s.into()); + self + } + + /// Glifo del gutter izquierdo (unicode). + pub fn icon(mut self, glyph: impl Into) -> Self { + self.icon = Some(glyph.into()); + self + } + + pub fn disabled(mut self) -> Self { + self.enabled = false; + self + } + + pub fn separated(mut self) -> Self { + self.separator_before = true; + self + } +} + +/// Un menú raíz (Archivo, Editar, Ayuda…) con sus ítems. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Menu { + pub label: String, + pub items: Vec, +} + +impl Menu { + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + items: Vec::new(), + } + } + + pub fn item(mut self, it: MenuItem) -> Self { + self.items.push(it); + self + } +} + +/// El menú global completo de una app. La app lo declara; la barra de +/// launcher lo adopta (y entonces la app no lo pinta en su ventana). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppMenu { + pub menus: Vec, +} + +impl AppMenu { + pub fn new() -> Self { + Self::default() + } + + pub fn menu(mut self, m: Menu) -> Self { + self.menus.push(m); + self + } + + pub fn is_empty(&self) -> bool { + self.menus.is_empty() + } + + /// Esqueleto estándar Archivo/Editar/Ayuda — punto de partida para que + /// toda app tenga un menú base coherente sin reinventarlo. Los + /// `command` siguen la convención `menu.`; la app mapea los que + /// le sirven y deja `disabled` los que no. + pub fn standard() -> Self { + Self::new() + .menu( + Menu::new("Archivo") + .item(MenuItem::new("Nuevo", "file.new").shortcut("Ctrl+N")) + .item(MenuItem::new("Abrir…", "file.open").shortcut("Ctrl+O")) + .item(MenuItem::new("Guardar", "file.save").shortcut("Ctrl+S")) + .item(MenuItem::new("Cerrar", "file.close").shortcut("Ctrl+W").separated()), + ) + .menu( + Menu::new("Editar") + .item(MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z")) + .item(MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y")) + .item(MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated()) + .item(MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C")) + .item(MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V")), + ) + .menu( + Menu::new("Ayuda") + .item(MenuItem::new("Atajos", "help.shortcuts").shortcut("F1")) + .item(MenuItem::new("Acerca de", "help.about")), + ) + } +} + +// ===================================================================== +// Launcher — la instrucción de ejecución abstracta +// ===================================================================== + +/// Por qué no se pudo lanzar una app. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LaunchError { + /// Este `Launcher` no maneja el modo de la app (p.ej. un host que no + /// instancia WASM, o wawa que no spawnea procesos del host). + Unsupported, + /// El lanzamiento falló; mensaje libre. + Failed(String), +} + +/// La *instrucción de ejecución* abstracta. El motor de launcher +/// (`launcher-core`/`launcher-llimphi`) llama a `launch` y no sabe en qué +/// entorno corre — host, shuma o wawa cada uno trae su impl. +pub trait Launcher { + fn launch(&self, app: &AppEntry) -> Result<(), LaunchError>; +} + +/// Launcher del host: spawnea binarios vía `std::process`. No maneja +/// `Action`/`Wasm` (devuelve `Unsupported` — esos los resuelve el chasis). +#[cfg(feature = "std")] +#[derive(Debug, Clone, Copy, Default)] +pub struct ProcessLauncher; + +#[cfg(feature = "std")] +impl Launcher for ProcessLauncher { + fn launch(&self, app: &AppEntry) -> Result<(), LaunchError> { + match app.spawn() { + Ok(Some(_child)) => Ok(()), + Ok(None) => Err(LaunchError::Unsupported), + Err(e) => Err(LaunchError::Failed(alloc::string::ToString::to_string(&e))), + } + } +} + +// ===================================================================== +// Bus de eventos (pub/sub in-process) — sólo `std` +// ===================================================================== + +/// Lo que viaja por el bus. El flujo del menú global: una app toma foco +/// → `AppFocused` + `MenuChanged` → la barra adopta el menú → el usuario +/// clickea un ítem → la barra emite `Command` → la app focuseada lo +/// ejecuta. El dock/spotlight emiten `LaunchRequested` y el shell lo +/// resuelve contra el [`AppRegistry`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BusEvent { + /// Una app tomó foco — la barra debería adoptar su menú. + AppFocused { app_id: String }, + /// El menú de una app cambió (ítems habilitados/labels dinámicos). + MenuChanged { app_id: String, menu: AppMenu }, + /// Dock/spotlight pidieron lanzar una app por id. + LaunchRequested { app_id: String }, + /// La barra global disparó un comando del menú hacia la app focuseada. + Command { app_id: String, command: String }, +} + +/// Bus pub/sub mínimo y `Send + Sync`: fan-out a todos los suscriptores. +/// Un suscriptor caído (receiver dropeado) se poda en el próximo publish. +/// Clonar el `Bus` comparte el mismo conjunto de suscriptores. +#[cfg(feature = "std")] +#[derive(Clone, Default)] +pub struct Bus { + subs: std::sync::Arc>>>, +} + +#[cfg(feature = "std")] +impl Bus { + pub fn new() -> Self { + Self::default() + } + + /// Crea un canal y devuelve su extremo de recepción. El emisor queda + /// registrado para recibir cada `publish` futuro. + pub fn subscribe(&self) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + self.subs.lock().unwrap().push(tx); + rx + } + + /// Emite a todos los suscriptores vivos. Devuelve cuántos lo recibieron. + pub fn publish(&self, ev: BusEvent) -> usize { + let mut subs = self.subs.lock().unwrap(); + subs.retain(|tx| tx.send(ev.clone()).is_ok()); + subs.len() + } +} + +// ===================================================================== +// Tests (corren con default features = std) +// ===================================================================== + +#[cfg(all(test, feature = "std"))] +mod tests { + use super::*; + + #[test] + fn parse_exec_entry() { + let src = r#" + id = "cosmos" + label = "Cosmos" + icon = "✶" + category = "yachay" + handles = ["application/x-cosmos-chart"] + [launch] + exec = "cosmos-app-llimphi" + args = ["--release"] + "#; + let e = parse_entry(src).expect("parsea"); + assert_eq!(e.id, "cosmos"); + assert_eq!(e.icon.as_deref(), Some("✶")); + assert!(e.handles_mime("application/x-cosmos-chart")); + assert_eq!( + e.launch, + Launch::Exec { + program: "cosmos-app-llimphi".into(), + args: vec!["--release".into()], + } + ); + } + + #[test] + fn parse_action_and_wasm() { + let a = parse_entry("id='s'\nlabel='Shell'\n[launch]\naction='focus:shell'").unwrap(); + assert_eq!(a.launch, Launch::Action("focus:shell".into())); + let w = parse_entry("id='h'\nlabel='Hola'\n[launch]\nwasm='deadbeef'").unwrap(); + assert_eq!(w.launch, Launch::Wasm { bytecode_hex: "deadbeef".into() }); + } + + #[test] + fn launch_sin_modo_es_none() { + assert!(parse_entry("id='x'\nlabel='X'\n[launch]").is_none()); + } + + #[test] + fn registry_from_dir_y_consultas() { + let dir = + std::env::temp_dir().join(format!("app-bus-test-{}-{}", std::process::id(), "reg")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("cosmos.toml"), + "id='cosmos'\nlabel='Cosmos'\ncategory='yachay'\nhandles=['x/chart']\n[launch]\nexec='cosmos-app-llimphi'", + ) + .unwrap(); + std::fs::write( + dir.join("nada.toml"), + "id='nada'\nlabel='Nada'\ncategory='ruway'\n[launch]\nexec='nada'", + ) + .unwrap(); + std::fs::write(dir.join("roto.toml"), "no es toml válido = =").unwrap(); + + let reg = AppRegistry::from_dir(&dir); + assert_eq!(reg.len(), 2); + assert_eq!(reg.all()[0].id, "cosmos"); + assert_eq!(reg.get("nada").unwrap().label, "Nada"); + assert_eq!(reg.handlers_for("x/chart").len(), 1); + assert_eq!(reg.in_category("yachay").len(), 1); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn manifiestos_de_ejemplo_parsean_y_resuelven_handlers() { + // Los manifiestos de `assets/apps/` (las apps reales de la suite que se + // copian a ~/.config/gioser/apps/) deben parsear y declarar sus mimes. + // Canario del formato: si cambia el esquema, esto avisa. + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/apps"); + let reg = AppRegistry::from_dir(&dir); + assert_eq!(reg.len(), 2, "media + nada"); + // media abre video/audio; nada, texto/código. + assert_eq!(reg.handlers_for("video/mp4")[0].id, "media"); + assert_eq!(reg.handlers_for("text/x-rust")[0].id, "nada"); + // El exec lleva el placeholder freedesktop. + let media = reg.get("media").unwrap(); + assert_eq!( + media.launch, + Launch::Exec { + program: "media-app".into(), + args: vec!["%f".into()], + } + ); + } + + #[test] + fn menu_estandar_y_builder() { + let m = AppMenu::standard(); + assert_eq!(m.menus.len(), 3); + assert_eq!(m.menus[0].label, "Archivo"); + let custom = AppMenu::new().menu( + Menu::new("Carta").item(MenuItem::new("Duplicar", "carta.dup").shortcut("Ctrl+D")), + ); + assert_eq!(custom.menus[0].items[0].command, "carta.dup"); + } + + #[test] + fn menu_roundtrip_serde() { + let m = AppMenu::standard(); + let json = serde_json::to_string(&m).unwrap(); + let back: AppMenu = serde_json::from_str(&json).unwrap(); + assert_eq!(m, back); + } + + #[test] + fn process_launcher_unsupported_para_action() { + // Action no es del host → Unsupported (no intenta spawnear). + let app = AppEntry { + id: "s".into(), + label: "Shell".into(), + icon: None, + category: None, + launch: Launch::Action("focus:shell".into()), + handles: Vec::new(), + }; + assert_eq!(ProcessLauncher.launch(&app), Err(LaunchError::Unsupported)); + } + + #[test] + fn bus_fanout_y_poda() { + let bus = Bus::new(); + let a = bus.subscribe(); + let b = bus.subscribe(); + let n = bus.publish(BusEvent::LaunchRequested { + app_id: "cosmos".into(), + }); + assert_eq!(n, 2); + assert!(matches!(a.recv().unwrap(), BusEvent::LaunchRequested { .. })); + assert!(matches!(b.recv().unwrap(), BusEvent::LaunchRequested { .. })); + drop(a); + let n = bus.publish(BusEvent::AppFocused { + app_id: "nada".into(), + }); + assert_eq!(n, 1); + } + + // ===== open-with out-of-process ===== + + #[test] + fn expand_target_sustituye_placeholder() { + let args = vec!["--open".to_string(), "%f".to_string()]; + assert_eq!( + expand_target(&args, "/tmp/x.png"), + vec!["--open".to_string(), "/tmp/x.png".to_string()] + ); + // `%u` también; y substitución embebida en un arg compuesto. + let args = vec!["url=%u".to_string()]; + assert_eq!(expand_target(&args, "http://a"), vec!["url=http://a".to_string()]); + } + + #[test] + fn expand_target_agrega_si_no_hay_placeholder() { + let args = vec!["--flag".to_string()]; + assert_eq!( + expand_target(&args, "/tmp/x.png"), + vec!["--flag".to_string(), "/tmp/x.png".to_string()] + ); + } + + #[test] + fn open_with_sin_handler_devuelve_none() { + let reg = AppRegistry::new(vec![]); + assert!(reg.open_with("image/png", "/tmp/x.png").unwrap().is_none()); + } + + #[test] + fn open_with_spawnea_handler_y_le_pasa_el_target() { + use std::io::Read; + // Archivo donde el "handler" escribirá el target que recibió. + let out = + std::env::temp_dir().join(format!("app-bus-openwith-{}.txt", std::process::id())); + let _ = std::fs::remove_file(&out); + + // Handler = sh que escribe $1 (el target expandido en %f) al archivo. + let entry = AppEntry { + id: "writer".into(), + label: "Writer".into(), + icon: None, + category: None, + launch: Launch::Exec { + program: "sh".into(), + args: vec![ + "-c".into(), + format!("printf '%s' \"$1\" > {}", out.display()), + "_".into(), + "%f".into(), + ], + }, + handles: vec!["image/png".into()], + }; + let reg = AppRegistry::new(vec![entry]); + + let (chosen, child) = reg + .open_with("image/png", "TARGET-123") + .unwrap() + .expect("debe haber handler para image/png"); + assert_eq!(chosen.id, "writer"); + child.expect("Exec debe spawnear un Child").wait().unwrap(); + + let mut s = String::new(); + std::fs::File::open(&out) + .unwrap() + .read_to_string(&mut s) + .unwrap(); + assert_eq!(s, "TARGET-123", "el handler recibió el target en %f"); + let _ = std::fs::remove_file(&out); + } +} diff --git a/widgets/menubar/Cargo.toml b/widgets/menubar/Cargo.toml index d71f673..5663f21 100644 --- a/widgets/menubar/Cargo.toml +++ b/widgets/menubar/Cargo.toml @@ -12,4 +12,4 @@ llimphi-ui = { workspace = true } llimphi-theme = { workspace = true } llimphi-widget-button = { workspace = true } llimphi-widget-context-menu = { workspace = true } -app-bus = { path = "../../../../shared/app-bus" } +app-bus = { workspace = true }