feat: mirada standalone — compositor Wayland + WM sobre Llimphi (build magro)
1//! `app-bus` — el cimiento del menú de aplicaciones de gioser.
2//!
3//! Hoy hay tres lanzadores que no comparten nada: `mirada-launcher`
4//! (TOML propio, `std::process`), `shuma-module-launcher` (otro TOML,
5//! `process_group`) y el launcher in-kernel de wawa (Manifiesto, WASM).
6//! Cada uno reimplementa "qué apps existen y cómo se lanzan". Este crate
7//! es la tabla única que todos consultan.
8//!
9//! Cuatro piezas, en capas:
10//!
11//! 1. **Registro** ([`AppRegistry`] + [`AppEntry`]): qué apps hay, cómo se
12//! lanzan ([`Launch`]) y qué mimes/lentes saben abrir (open-with).
13//! Se descubre de `~/.config/gioser/apps/*.toml` (feature `std`).
14//! 2. **Menú global** ([`AppMenu`]/[`Menu`]/[`MenuItem`]): el clásico
15//! Archivo/Editar/Ayuda que la app *declara*. Cuando hay una barra de
16//! launcher presente, ésta lo *adopta* y la app deja de pintarlo en su
17//! ventana — el comportamiento "menú global" de macOS.
18//! 3. **Launcher** ([`Launcher`] trait + [`LaunchError`]): la *instrucción
19//! de ejecución* abstracta. El host implementa con `std::process`
20//! ([`ProcessLauncher`]), wawa con instanciación WASM, shuma
21//! despachando `action`. El motor de launcher llama al trait y no se
22//! entera de en qué entorno corre.
23//! 4. **Bus** ([`Bus`] + [`BusEvent`]): pub/sub in-process de foco /
24//! cambio de menú / pedido de lanzamiento / comando. La versión
25//! cross-proceso montará sobre el broker de brahman más adelante.
26//!
27//! Los **datos** ([`AppEntry`], [`Launch`], [`AppMenu`]…) y el **trait
28//! [`Launcher`]** son `no_std + alloc`. El descubrimiento por filesystem,
29//! el spawn de procesos y el [`Bus`] viven detrás del feature `std`.
30
31#![cfg_attr(not(feature = "std"), no_std)]
32#![forbid(unsafe_code)]
33
34extern crate alloc;
35
36use alloc::string::String;
37use alloc::vec::Vec;
38
39use serde::{Deserialize, Serialize};
40
41// =====================================================================
42// Registro de apps
43// =====================================================================
44
45/// Cómo se enciende una app. Los tres mundos de gioser:
46/// `Exec` (binario del host), `Action` (acción interna del chasis que la
47/// hospeda — p.ej. `focus:shell`) y `Wasm` (módulo en el almacén de wawa,
48/// direccionado por hash de bytecode).
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Launch {
51 /// Spawnear un comando/binario del host.
52 Exec { program: String, args: Vec<String> },
53 /// Acción interna a despachar por el host (no spawnea proceso).
54 Action(String),
55 /// App WASM de wawa, por hash de bytecode (hex) en el almacén.
56 Wasm { bytecode_hex: String },
57}
58
59/// Una app registrada — la fila de la tabla que ven los launchers.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct AppEntry {
62 pub id: String,
63 pub label: String,
64 /// Glyph/emoji o ruta de ícono. Sin imponer formato — el launcher
65 /// decide cómo pintarlo (texto en el dock MVP).
66 pub icon: Option<String>,
67 /// Agrupador opcional para la grilla/spotlight (p.ej. cuadrante).
68 pub category: Option<String>,
69 pub launch: Launch,
70 /// Mimes/lentes que esta app sabe abrir (open-with). Vacío = no es
71 /// visor; el registro de visores de nahual-shell se alimenta de acá.
72 pub handles: Vec<String>,
73}
74
75impl AppEntry {
76 /// `true` si la app declara saber abrir `mime`.
77 pub fn handles_mime(&self, mime: &str) -> bool {
78 self.handles.iter().any(|m| m == mime)
79 }
80}
81
82#[cfg(feature = "std")]
83impl AppEntry {
84 /// Enciende la app vía `std::process`. Sólo `Exec` spawnea; `Action`/
85 /// `Wasm` devuelven `Ok(None)` — los despacha el host (chasis/kernel).
86 pub fn spawn(&self) -> std::io::Result<Option<std::process::Child>> {
87 match &self.launch {
88 Launch::Exec { program, args } => std::process::Command::new(program)
89 .args(args)
90 .spawn()
91 .map(Some),
92 Launch::Action(_) | Launch::Wasm { .. } => Ok(None),
93 }
94 }
95
96 /// **Open-with out-of-process**: abre `target` con esta app. Para `Exec`,
97 /// spawnea el binario sustituyendo el placeholder `%f`/`%u` en los args
98 /// por `target`; si ningún arg lo trae, agrega `target` como último
99 /// argumento (semántica estilo freedesktop `Exec=app %f`). `Action`/`Wasm`
100 /// devuelven `Ok(None)`: el target lo despacha el host (chasis a una vista
101 /// in-process, o kernel de wawa a una app WASM), no un proceso del SO.
102 pub fn open(&self, target: &str) -> std::io::Result<Option<std::process::Child>> {
103 match &self.launch {
104 Launch::Exec { program, args } => std::process::Command::new(program)
105 .args(expand_target(args, target))
106 .spawn()
107 .map(Some),
108 Launch::Action(_) | Launch::Wasm { .. } => Ok(None),
109 }
110 }
111}
112
113/// Sustituye los placeholders `%f`/`%u` por `target` en `args`. Si ninguno
114/// aparece, agrega `target` como argumento final — la convención de
115/// freedesktop (`Exec=app %f`) que entiende cualquier "abrir con".
116#[cfg(feature = "std")]
117pub fn expand_target(args: &[String], target: &str) -> Vec<String> {
118 let mut sustituido = false;
119 let mut out: Vec<String> = args
120 .iter()
121 .map(|a| {
122 if a.contains("%f") || a.contains("%u") {
123 sustituido = true;
124 a.replace("%f", target).replace("%u", target)
125 } else {
126 a.clone()
127 }
128 })
129 .collect();
130 if !sustituido {
131 out.push(target.to_string());
132 }
133 out
134}
135
136// ----- forma en disco (TOML) -----
137
138/// Espejo serde del archivo `<id>.toml`. La `[launch]` es una tabla con
139/// campos opcionales en vez de un enum etiquetado — toml 0.8 trata los
140/// enums internamente etiquetados de forma quisquillosa, así que
141/// resolvemos a mano a [`Launch`]. Sólo se usa al parsear TOML (`std`).
142#[cfg(feature = "std")]
143#[derive(Debug, Clone, Serialize, Deserialize)]
144struct AppFile {
145 id: String,
146 label: String,
147 #[serde(default)]
148 icon: Option<String>,
149 #[serde(default)]
150 category: Option<String>,
151 #[serde(default)]
152 handles: Vec<String>,
153 launch: LaunchFile,
154}
155
156#[cfg(feature = "std")]
157#[derive(Debug, Clone, Serialize, Deserialize)]
158struct LaunchFile {
159 #[serde(default)]
160 exec: Option<String>,
161 #[serde(default)]
162 args: Vec<String>,
163 #[serde(default)]
164 action: Option<String>,
165 #[serde(default)]
166 wasm: Option<String>,
167}
168
169#[cfg(feature = "std")]
170impl LaunchFile {
171 fn resolve(self) -> Option<Launch> {
172 if let Some(program) = self.exec {
173 Some(Launch::Exec {
174 program,
175 args: self.args,
176 })
177 } else if let Some(action) = self.action {
178 Some(Launch::Action(action))
179 } else {
180 self.wasm.map(|bytecode_hex| Launch::Wasm { bytecode_hex })
181 }
182 }
183}
184
185#[cfg(feature = "std")]
186impl AppFile {
187 fn into_entry(self) -> Option<AppEntry> {
188 Some(AppEntry {
189 id: self.id,
190 label: self.label,
191 icon: self.icon,
192 category: self.category,
193 handles: self.handles,
194 launch: self.launch.resolve()?,
195 })
196 }
197}
198
199/// Parsea una entrada de app desde texto TOML. Devuelve `None` si no
200/// parsea o si la `[launch]` no nombra ningún modo (`exec`/`action`/`wasm`).
201#[cfg(feature = "std")]
202pub fn parse_entry(toml_src: &str) -> Option<AppEntry> {
203 toml::from_str::<AppFile>(toml_src)
204 .ok()
205 .and_then(AppFile::into_entry)
206}
207
208/// Directorio canónico del registro: `~/.config/gioser/apps/`.
209#[cfg(feature = "std")]
210pub fn apps_dir() -> Option<std::path::PathBuf> {
211 directories::BaseDirs::new().map(|b| b.config_dir().join("gioser").join("apps"))
212}
213
214/// La tabla de apps. Inmutable tras descubrir — recargar = volver a
215/// `discover`. Barato: son pocos archivos y no es hot-path.
216#[derive(Debug, Clone, Default)]
217pub struct AppRegistry {
218 entries: Vec<AppEntry>,
219}
220
221impl AppRegistry {
222 pub fn new(mut entries: Vec<AppEntry>) -> Self {
223 // sort_unstable_by para no exigir alloc extra (vive en core).
224 entries.sort_unstable_by(|a, b| a.label.cmp(&b.label));
225 Self { entries }
226 }
227
228 pub fn all(&self) -> &[AppEntry] {
229 &self.entries
230 }
231
232 pub fn get(&self, id: &str) -> Option<&AppEntry> {
233 self.entries.iter().find(|e| e.id == id)
234 }
235
236 /// Apps que declaran abrir `mime` — para el open-with universal.
237 pub fn handlers_for(&self, mime: &str) -> Vec<&AppEntry> {
238 self.entries.iter().filter(|e| e.handles_mime(mime)).collect()
239 }
240
241 /// Apps de una categoría, en orden de label (para grilla/spotlight).
242 pub fn in_category(&self, category: &str) -> Vec<&AppEntry> {
243 self.entries
244 .iter()
245 .filter(|e| e.category.as_deref() == Some(category))
246 .collect()
247 }
248
249 pub fn len(&self) -> usize {
250 self.entries.len()
251 }
252
253 pub fn is_empty(&self) -> bool {
254 self.entries.is_empty()
255 }
256}
257
258#[cfg(feature = "std")]
259impl AppRegistry {
260 /// Descubre del dir canónico. Vacío si no hay config dir o el dir no
261 /// existe — la app sigue, sólo sin entradas.
262 pub fn discover() -> Self {
263 apps_dir().map(Self::from_dir).unwrap_or_default()
264 }
265
266 /// **Open-with universal**: elige el primer handler de `mime` (orden de
267 /// label) y le abre `target` out-of-process vía [`AppEntry::open`].
268 /// Devuelve el `AppEntry` elegido y su `Child` (o `None` en el child si
269 /// la app es `Action`/`Wasm`, que despacha el host). `Ok(None)` si ninguna
270 /// app registrada declara abrir ese mime — el caller cae a su visor
271 /// in-process por defecto (p.ej. el `viewer_registry` de nahual-shell).
272 pub fn open_with(
273 &self,
274 mime: &str,
275 target: &str,
276 ) -> std::io::Result<Option<(&AppEntry, Option<std::process::Child>)>> {
277 match self.handlers_for(mime).into_iter().next() {
278 Some(entry) => Ok(Some((entry, entry.open(target)?))),
279 None => Ok(None),
280 }
281 }
282
283 /// Escanea `<dir>/*.toml`. Ignora en silencio los que no parsean
284 /// (con una nota a stderr), igual que el resto de los loaders del repo.
285 pub fn from_dir(dir: impl AsRef<std::path::Path>) -> Self {
286 let dir = dir.as_ref();
287 let mut entries = Vec::new();
288 if let Ok(rd) = std::fs::read_dir(dir) {
289 for e in rd.flatten() {
290 let p = e.path();
291 if p.extension().and_then(|s| s.to_str()) != Some("toml") {
292 continue;
293 }
294 match std::fs::read_to_string(&p) {
295 Ok(src) => match parse_entry(&src) {
296 Some(entry) => entries.push(entry),
297 None => eprintln!("app-bus: {p:?} no declara una app válida"),
298 },
299 Err(err) => eprintln!("app-bus: no se pudo leer {p:?}: {err}"),
300 }
301 }
302 }
303 Self::new(entries)
304 }
305}
306
307/// Siembra manifests por defecto en [`apps_dir`] si todavía no hay
308/// ninguno, para que [`AppRegistry::discover`] devuelva las apps del repo
309/// en una máquina recién instalada. No pisa nada si ya existe algún
310/// `*.toml`. Devuelve cuántos manifests escribió.
311#[cfg(feature = "std")]
312pub fn seed_default_apps() -> std::io::Result<usize> {
313 let Some(dir) = apps_dir() else {
314 return Ok(0);
315 };
316 // Si ya hay manifests, respetar la config del usuario y no tocar nada.
317 if let Ok(rd) = std::fs::read_dir(&dir) {
318 let ya_hay = rd.flatten().any(|e| {
319 e.path().extension().and_then(|s| s.to_str()) == Some("toml")
320 });
321 if ya_hay {
322 return Ok(0);
323 }
324 }
325 std::fs::create_dir_all(&dir)?;
326
327 // (id, label, icono, binario, cuadrante). Los binarios son los nombres
328 // de crate ejecutables del workspace; el cuadrante alimenta la grilla.
329 const DEFAULTS: &[(&str, &str, &str, &str, &str)] = &[
330 ("cosmos", "Cosmos", "✶", "cosmos-app-llimphi", "yachay"),
331 ("nada", "Nada", "✎", "nada", "ruway"),
332 ("pluma", "Pluma", "✒", "pluma-editor-llimphi", "unanchay"),
333 ("nahual", "Nahual", "❖", "nahual-shell-llimphi", "ruway"),
334 ("dominium", "Dominium", "◉", "dominium-app-llimphi", "yachay"),
335 ("tinkuy", "Tinkuy", "⚛", "tinkuy-llimphi", "yachay"),
336 ("takiy", "Takiy", "♪", "takiy-app-llimphi", "ruway"),
337 ("media", "Media", "▶", "media-app", "ruway"),
338 ("tullpu", "Tullpu", "✦", "tullpu-app-llimphi", "ruway"),
339 ("supay", "Supay", "✷", "supay-app-llimphi", "ruway"),
340 ("sandokan-monitor", "Monitor", "❤", "sandokan-monitor", "ukupacha"),
341 ];
342
343 let mut escritos = 0;
344 for (id, label, icon, exec, cat) in DEFAULTS {
345 let toml = alloc::format!(
346 "id = \"{id}\"\nlabel = \"{label}\"\nicon = \"{icon}\"\ncategory = \"{cat}\"\n\n[launch]\nexec = \"{exec}\"\n"
347 );
348 std::fs::write(dir.join(alloc::format!("{id}.toml")), toml)?;
349 escritos += 1;
350 }
351 Ok(escritos)
352}
353
354// =====================================================================
355// Menú global (Archivo / Editar / Ayuda …)
356// =====================================================================
357
358/// Un ítem de menú. `command` es el id que la app entiende: la barra lo
359/// re-emite por el [`Bus`] como [`BusEvent::Command`] y la app focuseada
360/// lo ejecuta. `shortcut` es sólo para pintar (la app sigue dueña del
361/// binding real).
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363pub struct MenuItem {
364 pub label: String,
365 pub command: String,
366 #[serde(default)]
367 pub shortcut: Option<String>,
368 /// Glifo (unicode) opcional para el gutter de íconos del dropdown.
369 #[serde(default)]
370 pub icon: Option<String>,
371 #[serde(default = "yes")]
372 pub enabled: bool,
373 /// Dibujar un separador *antes* de este ítem.
374 #[serde(default)]
375 pub separator_before: bool,
376}
377
378fn yes() -> bool {
379 true
380}
381
382impl MenuItem {
383 pub fn new(label: impl Into<String>, command: impl Into<String>) -> Self {
384 Self {
385 label: label.into(),
386 command: command.into(),
387 shortcut: None,
388 icon: None,
389 enabled: true,
390 separator_before: false,
391 }
392 }
393
394 pub fn shortcut(mut self, s: impl Into<String>) -> Self {
395 self.shortcut = Some(s.into());
396 self
397 }
398
399 /// Glifo del gutter izquierdo (unicode).
400 pub fn icon(mut self, glyph: impl Into<String>) -> Self {
401 self.icon = Some(glyph.into());
402 self
403 }
404
405 pub fn disabled(mut self) -> Self {
406 self.enabled = false;
407 self
408 }
409
410 pub fn separated(mut self) -> Self {
411 self.separator_before = true;
412 self
413 }
414}
415
416/// Un menú raíz (Archivo, Editar, Ayuda…) con sus ítems.
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
418pub struct Menu {
419 pub label: String,
420 pub items: Vec<MenuItem>,
421}
422
423impl Menu {
424 pub fn new(label: impl Into<String>) -> Self {
425 Self {
426 label: label.into(),
427 items: Vec::new(),
428 }
429 }
430
431 pub fn item(mut self, it: MenuItem) -> Self {
432 self.items.push(it);
433 self
434 }
435}
436
437/// El menú global completo de una app. La app lo declara; la barra de
438/// launcher lo adopta (y entonces la app no lo pinta en su ventana).
439#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
440pub struct AppMenu {
441 pub menus: Vec<Menu>,
442}
443
444impl AppMenu {
445 pub fn new() -> Self {
446 Self::default()
447 }
448
449 pub fn menu(mut self, m: Menu) -> Self {
450 self.menus.push(m);
451 self
452 }
453
454 pub fn is_empty(&self) -> bool {
455 self.menus.is_empty()
456 }
457
458 /// Esqueleto estándar Archivo/Editar/Ayuda — punto de partida para que
459 /// toda app tenga un menú base coherente sin reinventarlo. Los
460 /// `command` siguen la convención `menu.<verbo>`; la app mapea los que
461 /// le sirven y deja `disabled` los que no.
462 pub fn standard() -> Self {
463 Self::new()
464 .menu(
465 Menu::new("Archivo")
466 .item(MenuItem::new("Nuevo", "file.new").shortcut("Ctrl+N"))
467 .item(MenuItem::new("Abrir…", "file.open").shortcut("Ctrl+O"))
468 .item(MenuItem::new("Guardar", "file.save").shortcut("Ctrl+S"))
469 .item(MenuItem::new("Cerrar", "file.close").shortcut("Ctrl+W").separated()),
470 )
471 .menu(
472 Menu::new("Editar")
473 .item(MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z"))
474 .item(MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y"))
475 .item(MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated())
476 .item(MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C"))
477 .item(MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V")),
478 )
479 .menu(
480 Menu::new("Ayuda")
481 .item(MenuItem::new("Atajos", "help.shortcuts").shortcut("F1"))
482 .item(MenuItem::new("Acerca de", "help.about")),
483 )
484 }
485}
486
487// =====================================================================
488// Launcher — la instrucción de ejecución abstracta
489// =====================================================================
490
491/// Por qué no se pudo lanzar una app.
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub enum LaunchError {
494 /// Este `Launcher` no maneja el modo de la app (p.ej. un host que no
495 /// instancia WASM, o wawa que no spawnea procesos del host).
496 Unsupported,
497 /// El lanzamiento falló; mensaje libre.
498 Failed(String),
499}
500
501/// La *instrucción de ejecución* abstracta. El motor de launcher
502/// (`launcher-core`/`launcher-llimphi`) llama a `launch` y no sabe en qué
503/// entorno corre — host, shuma o wawa cada uno trae su impl.
504pub trait Launcher {
505 fn launch(&self, app: &AppEntry) -> Result<(), LaunchError>;
506}
507
508/// Launcher del host: spawnea binarios vía `std::process`. No maneja
509/// `Action`/`Wasm` (devuelve `Unsupported` — esos los resuelve el chasis).
510#[cfg(feature = "std")]
511#[derive(Debug, Clone, Copy, Default)]
512pub struct ProcessLauncher;
513
514#[cfg(feature = "std")]
515impl Launcher for ProcessLauncher {
516 fn launch(&self, app: &AppEntry) -> Result<(), LaunchError> {
517 match app.spawn() {
518 Ok(Some(_child)) => Ok(()),
519 Ok(None) => Err(LaunchError::Unsupported),
520 Err(e) => Err(LaunchError::Failed(alloc::string::ToString::to_string(&e))),
521 }
522 }
523}
524
525// =====================================================================
526// Bus de eventos (pub/sub in-process) — sólo `std`
527// =====================================================================
528
529/// Lo que viaja por el bus. El flujo del menú global: una app toma foco
530/// → `AppFocused` + `MenuChanged` → la barra adopta el menú → el usuario
531/// clickea un ítem → la barra emite `Command` → la app focuseada lo
532/// ejecuta. El dock/spotlight emiten `LaunchRequested` y el shell lo
533/// resuelve contra el [`AppRegistry`].
534#[derive(Debug, Clone, PartialEq, Eq)]
535pub enum BusEvent {
536 /// Una app tomó foco — la barra debería adoptar su menú.
537 AppFocused { app_id: String },
538 /// El menú de una app cambió (ítems habilitados/labels dinámicos).
539 MenuChanged { app_id: String, menu: AppMenu },
540 /// Dock/spotlight pidieron lanzar una app por id.
541 LaunchRequested { app_id: String },
542 /// La barra global disparó un comando del menú hacia la app focuseada.
543 Command { app_id: String, command: String },
544}
545
546/// Bus pub/sub mínimo y `Send + Sync`: fan-out a todos los suscriptores.
547/// Un suscriptor caído (receiver dropeado) se poda en el próximo publish.
548/// Clonar el `Bus` comparte el mismo conjunto de suscriptores.
549#[cfg(feature = "std")]
550#[derive(Clone, Default)]
551pub struct Bus {
552 subs: std::sync::Arc<std::sync::Mutex<Vec<std::sync::mpsc::Sender<BusEvent>>>>,
553}
554
555#[cfg(feature = "std")]
556impl Bus {
557 pub fn new() -> Self {
558 Self::default()
559 }
560
561 /// Crea un canal y devuelve su extremo de recepción. El emisor queda
562 /// registrado para recibir cada `publish` futuro.
563 pub fn subscribe(&self) -> std::sync::mpsc::Receiver<BusEvent> {
564 let (tx, rx) = std::sync::mpsc::channel();
565 self.subs.lock().unwrap().push(tx);
566 rx
567 }
568
569 /// Emite a todos los suscriptores vivos. Devuelve cuántos lo recibieron.
570 pub fn publish(&self, ev: BusEvent) -> usize {
571 let mut subs = self.subs.lock().unwrap();
572 subs.retain(|tx| tx.send(ev.clone()).is_ok());
573 subs.len()
574 }
575}
576
577// =====================================================================
578// Tests (corren con default features = std)
579// =====================================================================
580
581#[cfg(all(test, feature = "std"))]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn parse_exec_entry() {
587 let src = r#"
588 id = "cosmos"
589 label = "Cosmos"
590 icon = "✶"
591 category = "yachay"
592 handles = ["application/x-cosmos-chart"]
593 [launch]
594 exec = "cosmos-app-llimphi"
595 args = ["--release"]
596 "#;
597 let e = parse_entry(src).expect("parsea");
598 assert_eq!(e.id, "cosmos");
599 assert_eq!(e.icon.as_deref(), Some("✶"));
600 assert!(e.handles_mime("application/x-cosmos-chart"));
601 assert_eq!(
602 e.launch,
603 Launch::Exec {
604 program: "cosmos-app-llimphi".into(),
605 args: vec!["--release".into()],
606 }
607 );
608 }
609
610 #[test]
611 fn parse_action_and_wasm() {
612 let a = parse_entry("id='s'\nlabel='Shell'\n[launch]\naction='focus:shell'").unwrap();
613 assert_eq!(a.launch, Launch::Action("focus:shell".into()));
614 let w = parse_entry("id='h'\nlabel='Hola'\n[launch]\nwasm='deadbeef'").unwrap();
615 assert_eq!(w.launch, Launch::Wasm { bytecode_hex: "deadbeef".into() });
616 }
617
618 #[test]
619 fn launch_sin_modo_es_none() {
620 assert!(parse_entry("id='x'\nlabel='X'\n[launch]").is_none());
621 }
622
623 #[test]
624 fn registry_from_dir_y_consultas() {
625 let dir =
626 std::env::temp_dir().join(format!("app-bus-test-{}-{}", std::process::id(), "reg"));
627 let _ = std::fs::remove_dir_all(&dir);
628 std::fs::create_dir_all(&dir).unwrap();
629 std::fs::write(
630 dir.join("cosmos.toml"),
631 "id='cosmos'\nlabel='Cosmos'\ncategory='yachay'\nhandles=['x/chart']\n[launch]\nexec='cosmos-app-llimphi'",
632 )
633 .unwrap();
634 std::fs::write(
635 dir.join("nada.toml"),
636 "id='nada'\nlabel='Nada'\ncategory='ruway'\n[launch]\nexec='nada'",
637 )
638 .unwrap();
639 std::fs::write(dir.join("roto.toml"), "no es toml válido = =").unwrap();
640
641 let reg = AppRegistry::from_dir(&dir);
642 assert_eq!(reg.len(), 2);
643 assert_eq!(reg.all()[0].id, "cosmos");
644 assert_eq!(reg.get("nada").unwrap().label, "Nada");
645 assert_eq!(reg.handlers_for("x/chart").len(), 1);
646 assert_eq!(reg.in_category("yachay").len(), 1);
647
648 let _ = std::fs::remove_dir_all(&dir);
649 }
650
651 #[test]
652 fn manifiestos_de_ejemplo_parsean_y_resuelven_handlers() {
653 // Los manifiestos de `assets/apps/` (las apps reales de la suite que se
654 // copian a ~/.config/gioser/apps/) deben parsear y declarar sus mimes.
655 // Canario del formato: si cambia el esquema, esto avisa.
656 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/apps");
657 let reg = AppRegistry::from_dir(&dir);
658 assert_eq!(reg.len(), 2, "media + nada");
659 // media abre video/audio; nada, texto/código.
660 assert_eq!(reg.handlers_for("video/mp4")[0].id, "media");
661 assert_eq!(reg.handlers_for("text/x-rust")[0].id, "nada");
662 // El exec lleva el placeholder freedesktop.
663 let media = reg.get("media").unwrap();
664 assert_eq!(
665 media.launch,
666 Launch::Exec {
667 program: "media-app".into(),
668 args: vec!["%f".into()],
669 }
670 );
671 }
672
673 #[test]
674 fn menu_estandar_y_builder() {
675 let m = AppMenu::standard();
676 assert_eq!(m.menus.len(), 3);
677 assert_eq!(m.menus[0].label, "Archivo");
678 let custom = AppMenu::new().menu(
679 Menu::new("Carta").item(MenuItem::new("Duplicar", "carta.dup").shortcut("Ctrl+D")),
680 );
681 assert_eq!(custom.menus[0].items[0].command, "carta.dup");
682 }
683
684 #[test]
685 fn menu_roundtrip_serde() {
686 let m = AppMenu::standard();
687 let json = serde_json::to_string(&m).unwrap();
688 let back: AppMenu = serde_json::from_str(&json).unwrap();
689 assert_eq!(m, back);
690 }
691
692 #[test]
693 fn process_launcher_unsupported_para_action() {
694 // Action no es del host → Unsupported (no intenta spawnear).
695 let app = AppEntry {
696 id: "s".into(),
697 label: "Shell".into(),
698 icon: None,
699 category: None,
700 launch: Launch::Action("focus:shell".into()),
701 handles: Vec::new(),
702 };
703 assert_eq!(ProcessLauncher.launch(&app), Err(LaunchError::Unsupported));
704 }
705
706 #[test]
707 fn bus_fanout_y_poda() {
708 let bus = Bus::new();
709 let a = bus.subscribe();
710 let b = bus.subscribe();
711 let n = bus.publish(BusEvent::LaunchRequested {
712 app_id: "cosmos".into(),
713 });
714 assert_eq!(n, 2);
715 assert!(matches!(a.recv().unwrap(), BusEvent::LaunchRequested { .. }));
716 assert!(matches!(b.recv().unwrap(), BusEvent::LaunchRequested { .. }));
717 drop(a);
718 let n = bus.publish(BusEvent::AppFocused {
719 app_id: "nada".into(),
720 });
721 assert_eq!(n, 1);
722 }
723
724 // ===== open-with out-of-process =====
725
726 #[test]
727 fn expand_target_sustituye_placeholder() {
728 let args = vec!["--open".to_string(), "%f".to_string()];
729 assert_eq!(
730 expand_target(&args, "/tmp/x.png"),
731 vec!["--open".to_string(), "/tmp/x.png".to_string()]
732 );
733 // `%u` también; y substitución embebida en un arg compuesto.
734 let args = vec!["url=%u".to_string()];
735 assert_eq!(expand_target(&args, "http://a"), vec!["url=http://a".to_string()]);
736 }
737
738 #[test]
739 fn expand_target_agrega_si_no_hay_placeholder() {
740 let args = vec!["--flag".to_string()];
741 assert_eq!(
742 expand_target(&args, "/tmp/x.png"),
743 vec!["--flag".to_string(), "/tmp/x.png".to_string()]
744 );
745 }
746
747 #[test]
748 fn open_with_sin_handler_devuelve_none() {
749 let reg = AppRegistry::new(vec![]);
750 assert!(reg.open_with("image/png", "/tmp/x.png").unwrap().is_none());
751 }
752
753 #[test]
754 fn open_with_spawnea_handler_y_le_pasa_el_target() {
755 use std::io::Read;
756 // Archivo donde el "handler" escribirá el target que recibió.
757 let out =
758 std::env::temp_dir().join(format!("app-bus-openwith-{}.txt", std::process::id()));
759 let _ = std::fs::remove_file(&out);
760
761 // Handler = sh que escribe $1 (el target expandido en %f) al archivo.
762 let entry = AppEntry {
763 id: "writer".into(),
764 label: "Writer".into(),
765 icon: None,
766 category: None,
767 launch: Launch::Exec {
768 program: "sh".into(),
769 args: vec![
770 "-c".into(),
771 format!("printf '%s' \"$1\" > {}", out.display()),
772 "_".into(),
773 "%f".into(),
774 ],
775 },
776 handles: vec!["image/png".into()],
777 };
778 let reg = AppRegistry::new(vec![entry]);
779
780 let (chosen, child) = reg
781 .open_with("image/png", "TARGET-123")
782 .unwrap()
783 .expect("debe haber handler para image/png");
784 assert_eq!(chosen.id, "writer");
785 child.expect("Exec debe spawnear un Child").wait().unwrap();
786
787 let mut s = String::new();
788 std::fs::File::open(&out)
789 .unwrap()
790 .read_to_string(&mut s)
791 .unwrap();
792 assert_eq!(s, "TARGET-123", "el handler recibió el target en %f");
793 let _ = std::fs::remove_file(&out);
794 }
795}