tawasuyu

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176777 Definitions

⎇ main crates/apps/mirada-launcher/src/main.rs Rust · 274 lines · 9.2 KB History Who touched what raw

feat(mirada): mirada-launcher — lanzador de aplicaciones b3b44e2c7 · sergio ·

1//! `mirada-launcher` — un lanzador de aplicaciones para carmen.
2//!
3//! Escanea los archivos `.desktop` del sistema (el estándar XDG), los
4//! lista en la terminal y lanza el que elijas. No tiene dependencias: la
5//! interfaz es una lista numerada que se filtra escribiendo.
6//!
7//! Pensado para correr dentro de una terminal pequeña que el compositor
8//! abre con un atajo — p. ej. atando `Super+d` a
9//! `spawn:foot -e mirada-launcher` en el keymap de mirada. Al elegir una
10//! aplicación, la lanza y termina (la terminal se cierra sola); el
11//! programa lanzado queda corriendo, reparentado a init.
12//!
13//! También sirve suelto: `mirada-launcher` en cualquier terminal.
14
15use std::collections::HashSet;
16use std::io::{self, Write};
17use std::path::PathBuf;
18
19/// Una aplicación lista para lanzar, sacada de un `.desktop`.
20struct DesktopApp {
21    /// Nombre visible (`Name=`).
22    name: String,
23    /// Comando a ejecutar, ya sin los códigos de campo (`%u`, `%F`…).
24    exec: String,
25    /// `true` si la app necesita una terminal (`Terminal=true`).
26    needs_terminal: bool,
27}
28
29fn main() {
30    let mut apps = scan_apps();
31    apps.sort_by_key(|a| a.name.to_lowercase());
32    if apps.is_empty() {
33        eprintln!("mirada-launcher · no encontré ninguna aplicación .desktop.");
34        std::process::exit(1);
35    }
36    run_ui(&apps);
37}
38
39// ---------------------------------------------------------------------
40// Escaneo de los .desktop
41// ---------------------------------------------------------------------
42
43/// Recorre los directorios XDG de aplicaciones y devuelve las que se
44/// pueden lanzar. Un `.desktop` de un directorio de mayor prioridad
45/// tapa a otro con el mismo nombre de archivo en uno de menor.
46fn scan_apps() -> Vec<DesktopApp> {
47    let mut apps = Vec::new();
48    let mut seen: HashSet<String> = HashSet::new();
49    for dir in application_dirs() {
50        collect_desktop_files(&dir, &dir, &mut seen, &mut apps);
51    }
52    apps
53}
54
55/// Los directorios `applications/` del estándar XDG, en orden de
56/// prioridad: primero el del usuario, luego los del sistema.
57fn application_dirs() -> Vec<PathBuf> {
58    let mut dirs = Vec::new();
59
60    let data_home = std::env::var_os("XDG_DATA_HOME")
61        .map(PathBuf::from)
62        .filter(|p| p.is_absolute())
63        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")));
64    if let Some(home) = data_home {
65        dirs.push(home.join("applications"));
66    }
67
68    let data_dirs = std::env::var("XDG_DATA_DIRS")
69        .ok()
70        .filter(|s| !s.is_empty())
71        .unwrap_or_else(|| "/usr/local/share:/usr/share".to_string());
72    for d in data_dirs.split(':').filter(|s| !s.is_empty()) {
73        dirs.push(PathBuf::from(d).join("applications"));
74    }
75    dirs
76}
77
78/// Recoge los `.desktop` de `dir` (y subdirectorios) sin repetir id.
79fn collect_desktop_files(
80    root: &PathBuf,
81    dir: &PathBuf,
82    seen: &mut HashSet<String>,
83    apps: &mut Vec<DesktopApp>,
84) {
85    let Ok(entries) = std::fs::read_dir(dir) else {
86        return;
87    };
88    for entry in entries.flatten() {
89        let path = entry.path();
90        if path.is_dir() {
91            collect_desktop_files(root, &path, seen, apps);
92            continue;
93        }
94        if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
95            continue;
96        }
97        // El id XDG: la ruta relativa al directorio raíz, con `/` → `-`.
98        let id = path
99            .strip_prefix(root)
100            .unwrap_or(&path)
101            .to_string_lossy()
102            .replace('/', "-");
103        if !seen.insert(id) {
104            continue; // ya lo tapó un directorio de más prioridad
105        }
106        if let Ok(text) = std::fs::read_to_string(&path) {
107            if let Some(app) = parse_desktop(&text) {
108                apps.push(app);
109            }
110        }
111    }
112}
113
114/// Extrae una [`DesktopApp`] del texto de un `.desktop`. `None` si no es
115/// una aplicación lanzable o está marcada para no mostrarse.
116fn parse_desktop(text: &str) -> Option<DesktopApp> {
117    let mut in_entry = false;
118    let (mut name, mut exec, mut kind) = (None, None, None);
119    let (mut no_display, mut hidden, mut terminal) = (false, false, false);
120
121    for line in text.lines() {
122        let line = line.trim();
123        if line.starts_with('[') {
124            // Sólo nos interesa el grupo principal; otros (acciones,
125            // etc.) se ignoran.
126            in_entry = line == "[Desktop Entry]";
127            continue;
128        }
129        if !in_entry || line.is_empty() || line.starts_with('#') {
130            continue;
131        }
132        let Some((key, value)) = line.split_once('=') else {
133            continue;
134        };
135        let value = value.trim();
136        match key.trim() {
137            "Name" => name = Some(value.to_string()),
138            "Exec" => exec = Some(value.to_string()),
139            "Type" => kind = Some(value.to_string()),
140            "NoDisplay" => no_display = value == "true",
141            "Hidden" => hidden = value == "true",
142            "Terminal" => terminal = value == "true",
143            _ => {} // Name[es], Icon, Categories…: no los usamos
144        }
145    }
146
147    if no_display || hidden {
148        return None;
149    }
150    if kind.as_deref() != Some("Application") {
151        return None;
152    }
153    let name = name?;
154    let exec = strip_field_codes(&exec?);
155    if name.is_empty() || exec.is_empty() {
156        return None;
157    }
158    Some(DesktopApp { name, exec, needs_terminal: terminal })
159}
160
161/// Quita los códigos de campo de un `Exec` de `.desktop` (`%u`, `%F`,
162/// `%i`…), que sólo tienen sentido al abrir archivos. `%%` queda en `%`.
163fn strip_field_codes(exec: &str) -> String {
164    let mut out = String::new();
165    let mut chars = exec.chars();
166    while let Some(c) = chars.next() {
167        if c == '%' {
168            // `%%` es un `%` literal; cualquier otro `%x` es un código de
169            // campo y se descarta entero.
170            if let Some('%') = chars.next() {
171                out.push('%');
172            }
173        } else {
174            out.push(c);
175        }
176    }
177    out.trim().to_string()
178}
179
180// ---------------------------------------------------------------------
181// Interfaz de terminal
182// ---------------------------------------------------------------------
183
184/// Cuántas aplicaciones se listan como mucho de una vez.
185const MAX_SHOWN: usize = 40;
186
187/// El bucle de la interfaz: muestra la lista, lee una línea y según sea
188/// un número lanza, texto filtra, o vacía sale.
189fn run_ui(apps: &[DesktopApp]) {
190    let mut filter = String::new();
191    loop {
192        let needle = filter.to_lowercase();
193        let matches: Vec<&DesktopApp> = apps
194            .iter()
195            .filter(|a| needle.is_empty() || a.name.to_lowercase().contains(&needle))
196            .collect();
197
198        // Limpia la pantalla y dibuja la lista.
199        print!("\x1b[2J\x1b[H");
200        if filter.is_empty() {
201            println!("mirada-launcher · {} aplicaciones", matches.len());
202        } else {
203            println!(
204                "mirada-launcher · {} de {} · filtro «{filter}»",
205                matches.len(),
206                apps.len()
207            );
208        }
209        println!();
210        if matches.is_empty() {
211            println!("  (sin coincidencias)");
212        }
213        for (i, a) in matches.iter().take(MAX_SHOWN).enumerate() {
214            println!("  {:>2}  {}", i + 1, a.name);
215        }
216        if matches.len() > MAX_SHOWN {
217            println!("  …  y {} más — afina el filtro", matches.len() - MAX_SHOWN);
218        }
219        println!();
220        println!("  nº = lanzar · texto = filtrar · Enter vacío = salir");
221        print!("> ");
222        io::stdout().flush().ok();
223
224        let mut line = String::new();
225        if io::stdin().read_line(&mut line).unwrap_or(0) == 0 {
226            return; // fin de entrada (Ctrl+D)
227        }
228        let line = line.trim();
229        if line.is_empty() {
230            return;
231        }
232
233        // ¿Un número? Lanza esa entrada de la lista visible.
234        if let Ok(n) = line.parse::<usize>() {
235            if (1..=matches.len().min(MAX_SHOWN)).contains(&n) {
236                launch(matches[n - 1]);
237                return;
238            }
239            continue; // número fuera de rango: vuelve a pedir
240        }
241
242        // Texto: es un filtro nuevo. Si deja una sola, lánzala directo.
243        filter = line.to_string();
244        let needle = filter.to_lowercase();
245        let now: Vec<&DesktopApp> = apps
246            .iter()
247            .filter(|a| a.name.to_lowercase().contains(&needle))
248            .collect();
249        if now.len() == 1 {
250            launch(now[0]);
251            return;
252        }
253    }
254}
255
256/// Lanza la aplicación elegida como proceso hijo y devuelve. Hereda el
257/// entorno —`WAYLAND_DISPLAY` incluido—; al terminar el lanzador, el
258/// proceso queda corriendo, reparentado a init.
259fn launch(app: &DesktopApp) {
260    let cmd = if app.needs_terminal {
261        format!("foot -e {}", app.exec)
262    } else {
263        app.exec.clone()
264    };
265    print!("\x1b[2J\x1b[H");
266    println!("mirada-launcher · lanzando «{}» …", app.name);
267    match std::process::Command::new("sh").arg("-c").arg(&cmd).spawn() {
268        Ok(_) => {}
269        Err(e) => {
270            eprintln!("mirada-launcher · no pude lanzar «{cmd}»: {e}");
271            std::process::exit(1);
272        }
273    }
274}