feat: completar llimphi.git con el widget menubar + app-bus
menubar (barra de menú in-window que cualquier app monta desde un AppMenu)
estaba excluido por su dep a app-bus. Vendorizo app-bus (hoja) y reincluyo
menubar, así llimphi.git pasa a ser la dependencia UI completa — los apps
git-dependen un solo repo liviano para todo Llimphi, sin clonar el monorepo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
c1b11b2c01d7623a4f346519b9d1ec435be30b95 · por Sergio · 2026-06-04T11:45:02Z (hace 4 meses) · padre dd126bad6 · ver el árbol en este commit
Delta semántico · 63
shared/app-bus/src/lib.rs
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<RwLock<…>>`).
+- `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<String> },
+ /// 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<String>,
+ /// Agrupador opcional para la grilla/spotlight (p.ej. cuadrante).
+ pub category: Option<String>,
+ 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<String>,
+}
+
+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<Option<std::process::Child>> {
+ 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<Option<std::process::Child>> {
+ 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<String> {
+ let mut sustituido = false;
+ let mut out: Vec<String> = 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 `<id>.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<String>,
+ #[serde(default)]
+ category: Option<String>,
+ #[serde(default)]
+ handles: Vec<String>,
+ launch: LaunchFile,
+}
+
+#[cfg(feature = "std")]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct LaunchFile {
+ #[serde(default)]
+ exec: Option<String>,
+ #[serde(default)]
+ args: Vec<String>,
+ #[serde(default)]
+ action: Option<String>,
+ #[serde(default)]
+ wasm: Option<String>,
+}
+
+#[cfg(feature = "std")]
+impl LaunchFile {
+ fn resolve(self) -> Option<Launch> {
+ 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<AppEntry> {
+ 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<AppEntry> {
+ toml::from_str::<AppFile>(toml_src)
+ .ok()
+ .and_then(AppFile::into_entry)
+}
+
+/// Directorio canónico del registro: `~/.config/gioser/apps/`.
+#[cfg(feature = "std")]
+pub fn apps_dir() -> Option<std::path::PathBuf> {
+ 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<AppEntry>,
+}
+
+impl AppRegistry {
+ pub fn new(mut entries: Vec<AppEntry>) -> 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<Option<(&AppEntry, Option<std::process::Child>)>> {
+ match self.handlers_for(mime).into_iter().next() {
+ Some(entry) => Ok(Some((entry, entry.open(target)?))),
+ None => Ok(None),
+ }
+ }
+
+ /// Escanea `<dir>/*.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<std::path::Path>) -> 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<usize> {
+ 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<String>,
+ /// Glifo (unicode) opcional para el gutter de íconos del dropdown.
+ #[serde(default)]
+ pub icon: Option<String>,
+ #[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<String>, command: impl Into<String>) -> 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<String>) -> Self {
+ self.shortcut = Some(s.into());
+ self
+ }
+
+ /// Glifo del gutter izquierdo (unicode).
+ pub fn icon(mut self, glyph: impl Into<String>) -> 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<MenuItem>,
+}
+
+impl Menu {
+ pub fn new(label: impl Into<String>) -> 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<Menu>,
+}
+
+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.<verbo>`; 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<std::sync::Mutex<Vec<std::sync::mpsc::Sender<BusEvent>>>>,
+}
+
+#[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<BusEvent> {
+ 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 }