tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! **El protocolo de dormir de Llimphi.**
2//!
3//! Pedido del operador (2026-10-09), textual: *«si no hay algún protocolo para
4//! dormir apps, creemos uno para mis apps llimphi»* — y *«que si ya con ese
5//! nuevo protocolo se comunique, no ejecute la genérica»*.
6//!
7//! **Por qué hace falta.** El dormido genérico de mirada
8//! (`mirada-compositor/src/operaciones/dormir.rs`) le pide el cierre a la
9//! ventana como el botón ✕ y la relanza con la línea de `/proc`. Eso pierde
10//! todo lo que la app abrió después de arrancar (el proyecto de Audacity), le
11//! salta el «¿guardar?» a la cara al operador, y no sabe cuántas ventanas va a
12//! abrir la app al volver. Desde afuera no tiene arreglo: una app ajena sólo
13//! entiende «cerrate».
14//!
15//! **El contrato, entero:**
16//!
17//! 1. Una app que sabe dormir escucha en [`ruta_socket`]`(su pid)`. Que el
18//! socket exista y conteste **es** el anuncio: mirada no tiene otra forma de
19//! preguntarlo, ni la necesita.
20//! 2. Mirada se conecta y escribe `sleep\n`.
21//! 3. La app guarda su estado en [`dir_estados`] (nombrado por su BLAKE3) y
22//! contesta `ok <ruta>\n` — y **se va sola**, sin preguntar nada: lo que
23//! había ya está guardado. O contesta `no\n` («ahora no»: una operación a
24//! medias) y sigue como estaba.
25//! 4. Para despertarla, mirada la relanza con [`ENV_ESTADO`]`=<ruta>` en el
26//! ambiente ([`comando_con_estado`]). La app lo lee al arrancar
27//! ([`tomar_estado`]) y se reconstruye desde ahí en vez de desde cero.
28//!
29//! Sin dependencias del lado de mirada ni del lado de la app más allá de esto:
30//! el compositor no conoce `llimphi-ui` y `llimphi-ui` no conoce al compositor.
31
32use std::io::{BufRead, BufReader, Write};
33use std::os::unix::net::{UnixListener, UnixStream};
34use std::path::{Path, PathBuf};
35use std::time::Duration;
36
37/// La variable de ambiente con la que una app despertada encuentra su estado.
38/// En inglés porque se tipea (Regla 7).
39pub const ENV_ESTADO: &str = "LLIMPHI_WAKE_STATE";
40
41/// Lo que contesta una app al pedido de dormir.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Respuesta {
44 /// No hay nadie escuchando: la app no habla el protocolo (o ya murió).
45 /// El que pregunta cae al camino genérico.
46 NoHabla,
47 /// La app habla el protocolo y dijo «ahora no». Se la deja como estaba.
48 No,
49 /// La app guardó su estado acá y se está yendo.
50 Dormida(PathBuf),
51}
52
53/// El directorio de los sockets: `$XDG_RUNTIME_DIR/llimphi-sleep`. `None` sin
54/// `XDG_RUNTIME_DIR`, que es una sesión sin escritorio: ahí no hay quién duerma.
55pub fn dir_sockets() -> Option<PathBuf> {
56 let base = std::env::var_os("XDG_RUNTIME_DIR")?;
57 Some(PathBuf::from(base).join("llimphi-sleep"))
58}
59
60/// El socket por el que escucha la app de pid `pid`.
61pub fn ruta_socket(pid: u32) -> Option<PathBuf> {
62 Some(dir_sockets()?.join(format!("{pid}.sock")))
63}
64
65/// Dónde se guardan los estados: `$XDG_STATE_HOME/llimphi/dormidas` (o
66/// `~/.local/state/…`). En disco y no en el runtime dir porque un estado
67/// guardado tiene que sobrevivir a un cierre de sesión: es trabajo.
68pub fn dir_estados() -> PathBuf {
69 let base = std::env::var_os("XDG_STATE_HOME")
70 .map(PathBuf::from)
71 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
72 .unwrap_or_else(std::env::temp_dir);
73 base.join("llimphi").join("dormidas")
74}
75
76/// Guarda `estado` en `dir`, con el nombre de su BLAKE3 (`<app>-<hash>.bin`).
77/// Escribe a un temporal y renombra: un estado a medias no puede quedar con el
78/// nombre de uno entero.
79pub fn guardar_estado_en(dir: &Path, app: &str, estado: &[u8]) -> std::io::Result<PathBuf> {
80 std::fs::create_dir_all(dir)?;
81 let hash = blake3::hash(estado).to_hex();
82 let nombre: String = app
83 .chars()
84 .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '.' { c } else { '_' })
85 .collect();
86 let ruta = dir.join(format!("{nombre}-{}.bin", &hash[..16]));
87 let tmp = ruta.with_extension("tmp");
88 std::fs::write(&tmp, estado)?;
89 std::fs::rename(&tmp, &ruta)?;
90 Ok(ruta)
91}
92
93/// [`guardar_estado_en`] sobre [`dir_estados`].
94pub fn guardar_estado(app: &str, estado: &[u8]) -> std::io::Result<PathBuf> {
95 guardar_estado_en(&dir_estados(), app, estado)
96}
97
98/// **El estado con el que esta app fue despertada**, si lo fue. Lee
99/// [`ENV_ESTADO`], devuelve el contenido y **borra el archivo**: un estado se
100/// despierta una vez. Si el archivo no está (lo borró alguien, la ruta es
101/// vieja) devuelve `None` y la app arranca como siempre.
102pub fn tomar_estado() -> Option<Vec<u8>> {
103 let ruta = PathBuf::from(std::env::var_os(ENV_ESTADO)?);
104 tomar_estado_de(&ruta)
105}
106
107/// [`tomar_estado`] sobre una ruta explícita.
108pub fn tomar_estado_de(ruta: &Path) -> Option<Vec<u8>> {
109 let bytes = std::fs::read(ruta).ok()?;
110 let _ = std::fs::remove_file(ruta);
111 Some(bytes)
112}
113
114/// La línea que relanza `cmd` (ya citada para `sh -c`) con su estado en el
115/// ambiente. Se antepone un `export` y no un `VAR=… cmd`, porque la línea de
116/// mirada puede empezar por un `cd … &&`.
117pub fn comando_con_estado(cmd: &str, estado: &Path) -> String {
118 let ruta = estado.to_string_lossy().replace('\'', r"'\''");
119 format!("export {ENV_ESTADO}='{ruta}'; {cmd}")
120}
121
122/// **El lado de la app.** Escucha en `ruta` y, por cada `sleep`, llama a
123/// `dormir`, que corre la app y devuelve la ruta del estado guardado (o `None`
124/// para «ahora no»). Corre en un hilo propio; `dormir` es quien tiene que
125/// llevar el pedido al hilo del modelo y esperar la respuesta.
126///
127/// Devuelve un guardián que borra el socket al soltarse: un socket que queda
128/// de una app muerta haría que mirada crea que la app habla el protocolo — no
129/// rompe nada (el `connect` falla y cae al genérico), pero miente.
130pub fn escuchar_en<F>(ruta: PathBuf, dormir: F) -> std::io::Result<Escucha>
131where
132 F: Fn() -> Option<PathBuf> + Send + 'static,
133{
134 if let Some(dir) = ruta.parent() {
135 std::fs::create_dir_all(dir)?;
136 }
137 let _ = std::fs::remove_file(&ruta);
138 let listener = UnixListener::bind(&ruta)?;
139 std::thread::Builder::new().name("llimphi-dormir".into()).spawn(move || {
140 for conn in listener.incoming() {
141 let Ok(mut conn) = conn else { continue };
142 let _ = conn.set_read_timeout(Some(Duration::from_secs(5)));
143 let mut linea = String::new();
144 if BufReader::new(&conn).read_line(&mut linea).is_err() {
145 continue;
146 }
147 if linea.trim() != "sleep" {
148 let _ = conn.write_all(b"no\n");
149 continue;
150 }
151 let respuesta = match dormir() {
152 Some(ruta) => format!("ok {}\n", ruta.display()),
153 None => "no\n".to_string(),
154 };
155 let _ = conn.write_all(respuesta.as_bytes());
156 }
157 })?;
158 Ok(Escucha { ruta })
159}
160
161/// [`escuchar_en`] sobre [`ruta_socket`] del proceso actual.
162pub fn escuchar<F>(dormir: F) -> std::io::Result<Escucha>
163where
164 F: Fn() -> Option<PathBuf> + Send + 'static,
165{
166 let ruta = ruta_socket(std::process::id()).ok_or_else(|| {
167 std::io::Error::new(std::io::ErrorKind::NotFound, "sin XDG_RUNTIME_DIR")
168 })?;
169 escuchar_en(ruta, dormir)
170}
171
172/// Mientras viva, la app escucha. Al soltarse borra el socket.
173pub struct Escucha {
174 ruta: PathBuf,
175}
176
177impl Drop for Escucha {
178 fn drop(&mut self) {
179 let _ = std::fs::remove_file(&self.ruta);
180 }
181}
182
183/// **El lado de mirada.** Le pide a la app de `ruta` que se duerma y espera su
184/// respuesta hasta `plazo`. Bloquea: el compositor lo llama desde un hilo.
185pub fn pedir_dormir_en(ruta: &Path, plazo: Duration) -> Respuesta {
186 let Ok(mut conn) = UnixStream::connect(ruta) else {
187 return Respuesta::NoHabla;
188 };
189 let _ = conn.set_read_timeout(Some(plazo));
190 let _ = conn.set_write_timeout(Some(plazo));
191 if conn.write_all(b"sleep\n").is_err() {
192 return Respuesta::NoHabla;
193 }
194 let mut linea = String::new();
195 // Un plazo vencido o una conexión cortada sin respuesta es «ahora no»: la
196 // app habla el protocolo (aceptó la conexión), y lo prudente es dejarla.
197 if BufReader::new(&conn).read_line(&mut linea).is_err() {
198 return Respuesta::No;
199 }
200 match linea.trim().strip_prefix("ok ") {
201 Some(r) if !r.is_empty() => Respuesta::Dormida(PathBuf::from(r)),
202 _ => Respuesta::No,
203 }
204}
205
206/// `true` si la app de pid `pid` tiene el socket puesto. Barato (un `stat`):
207/// sirve para decidir el camino antes de pedir nada.
208pub fn habla_protocolo(pid: u32) -> bool {
209 ruta_socket(pid).is_some_and(|r| r.exists())
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 fn dir_de_prueba(nombre: &str) -> PathBuf {
217 let d = std::env::temp_dir()
218 .join(format!("llimphi-dormir-{}-{nombre}", std::process::id()));
219 let _ = std::fs::remove_dir_all(&d);
220 std::fs::create_dir_all(&d).unwrap();
221 d
222 }
223
224 /// El viaje entero por un socket de verdad: la app guarda, contesta con la
225 /// ruta, y lo que se despierta es byte a byte lo que se guardó — una vez.
226 #[test]
227 fn dormir_y_despertar_devuelven_el_mismo_estado() {
228 let d = dir_de_prueba("viaje");
229 let sock = d.join("1.sock");
230 let estados = d.join("estados");
231 let e2 = estados.clone();
232 let _escucha = escuchar_en(sock.clone(), move || {
233 guardar_estado_en(&e2, "nada", b"pestanas: a.rs b.rs").ok()
234 })
235 .unwrap();
236 let Respuesta::Dormida(ruta) = pedir_dormir_en(&sock, Duration::from_secs(5)) else {
237 panic!("la app tenía que dormirse");
238 };
239 assert!(ruta.starts_with(&estados));
240 assert_eq!(tomar_estado_de(&ruta).as_deref(), Some(&b"pestanas: a.rs b.rs"[..]));
241 assert_eq!(tomar_estado_de(&ruta), None, "un estado se despierta una vez");
242 }
243
244 /// «Ahora no» se respeta, y sin socket la respuesta es que no habla — que es
245 /// lo que manda al camino genérico. Brazo de control del test de arriba.
246 #[test]
247 fn ahora_no_y_sin_socket_son_cosas_distintas() {
248 let d = dir_de_prueba("no");
249 let sock = d.join("2.sock");
250 let _escucha = escuchar_en(sock.clone(), || None).unwrap();
251 assert_eq!(pedir_dormir_en(&sock, Duration::from_secs(5)), Respuesta::No);
252 assert_eq!(
253 pedir_dormir_en(&d.join("nadie.sock"), Duration::from_secs(1)),
254 Respuesta::NoHabla
255 );
256 }
257
258 /// Soltar la escucha borra el socket: uno colgado anunciaría una app que ya
259 /// no está.
260 #[test]
261 fn soltar_la_escucha_borra_el_socket() {
262 let d = dir_de_prueba("drop");
263 let sock = d.join("3.sock");
264 let escucha = escuchar_en(sock.clone(), || None).unwrap();
265 assert!(sock.exists());
266 drop(escucha);
267 assert!(!sock.exists());
268 }
269
270 /// La línea de despertar arranca por el `export` aunque el comando empiece
271 /// con un `cd`, y una comilla en la ruta no rompe la cita.
272 #[test]
273 fn la_linea_de_despertar_lleva_el_estado_en_el_ambiente() {
274 let l = comando_con_estado("cd '/x' && exec 'nada'", Path::new("/s/it's.bin"));
275 assert_eq!(l, r"export LLIMPHI_WAKE_STATE='/s/it'\''s.bin'; cd '/x' && exec 'nada'");
276 }
277}