tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-dormir/src/lib.rs Rust · 277 siq'ikuna · 11.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

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}