⎇ b3b44e2c72c1c3a5ab166bae7cdbf5175ead382e
crates/apps/mirada-launcher/src/main.rs
Historia
Quién tocó qué
crudo
feat(mirada): mirada-launcher — lanzador de aplicaciones
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}