//! **El protocolo de dormir de Llimphi.** //! //! Pedido del operador (2026-10-09), textual: *«si no hay algún protocolo para //! dormir apps, creemos uno para mis apps llimphi»* — y *«que si ya con ese //! nuevo protocolo se comunique, no ejecute la genérica»*. //! //! **Por qué hace falta.** El dormido genérico de mirada //! (`mirada-compositor/src/operaciones/dormir.rs`) le pide el cierre a la //! ventana como el botón ✕ y la relanza con la línea de `/proc`. Eso pierde //! todo lo que la app abrió después de arrancar (el proyecto de Audacity), le //! salta el «¿guardar?» a la cara al operador, y no sabe cuántas ventanas va a //! abrir la app al volver. Desde afuera no tiene arreglo: una app ajena sólo //! entiende «cerrate». //! //! **El contrato, entero:** //! //! 1. Una app que sabe dormir escucha en [`ruta_socket`]`(su pid)`. Que el //! socket exista y conteste **es** el anuncio: mirada no tiene otra forma de //! preguntarlo, ni la necesita. //! 2. Mirada se conecta y escribe `sleep\n`. //! 3. La app guarda su estado en [`dir_estados`] (nombrado por su BLAKE3) y //! contesta `ok \n` — y **se va sola**, sin preguntar nada: lo que //! había ya está guardado. O contesta `no\n` («ahora no»: una operación a //! medias) y sigue como estaba. //! 4. Para despertarla, mirada la relanza con [`ENV_ESTADO`]`=` en el //! ambiente ([`comando_con_estado`]). La app lo lee al arrancar //! ([`tomar_estado`]) y se reconstruye desde ahí en vez de desde cero. //! //! Sin dependencias del lado de mirada ni del lado de la app más allá de esto: //! el compositor no conoce `llimphi-ui` y `llimphi-ui` no conoce al compositor. use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::time::Duration; /// La variable de ambiente con la que una app despertada encuentra su estado. /// En inglés porque se tipea (Regla 7). pub const ENV_ESTADO: &str = "LLIMPHI_WAKE_STATE"; /// Lo que contesta una app al pedido de dormir. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Respuesta { /// No hay nadie escuchando: la app no habla el protocolo (o ya murió). /// El que pregunta cae al camino genérico. NoHabla, /// La app habla el protocolo y dijo «ahora no». Se la deja como estaba. No, /// La app guardó su estado acá y se está yendo. Dormida(PathBuf), } /// El directorio de los sockets: `$XDG_RUNTIME_DIR/llimphi-sleep`. `None` sin /// `XDG_RUNTIME_DIR`, que es una sesión sin escritorio: ahí no hay quién duerma. pub fn dir_sockets() -> Option { let base = std::env::var_os("XDG_RUNTIME_DIR")?; Some(PathBuf::from(base).join("llimphi-sleep")) } /// El socket por el que escucha la app de pid `pid`. pub fn ruta_socket(pid: u32) -> Option { Some(dir_sockets()?.join(format!("{pid}.sock"))) } /// Dónde se guardan los estados: `$XDG_STATE_HOME/llimphi/dormidas` (o /// `~/.local/state/…`). En disco y no en el runtime dir porque un estado /// guardado tiene que sobrevivir a un cierre de sesión: es trabajo. pub fn dir_estados() -> PathBuf { let base = std::env::var_os("XDG_STATE_HOME") .map(PathBuf::from) .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state"))) .unwrap_or_else(std::env::temp_dir); base.join("llimphi").join("dormidas") } /// Guarda `estado` en `dir`, con el nombre de su BLAKE3 (`-.bin`). /// Escribe a un temporal y renombra: un estado a medias no puede quedar con el /// nombre de uno entero. pub fn guardar_estado_en(dir: &Path, app: &str, estado: &[u8]) -> std::io::Result { std::fs::create_dir_all(dir)?; let hash = blake3::hash(estado).to_hex(); let nombre: String = app .chars() .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '.' { c } else { '_' }) .collect(); let ruta = dir.join(format!("{nombre}-{}.bin", &hash[..16])); let tmp = ruta.with_extension("tmp"); std::fs::write(&tmp, estado)?; std::fs::rename(&tmp, &ruta)?; Ok(ruta) } /// [`guardar_estado_en`] sobre [`dir_estados`]. pub fn guardar_estado(app: &str, estado: &[u8]) -> std::io::Result { guardar_estado_en(&dir_estados(), app, estado) } /// **El estado con el que esta app fue despertada**, si lo fue. Lee /// [`ENV_ESTADO`], devuelve el contenido y **borra el archivo**: un estado se /// despierta una vez. Si el archivo no está (lo borró alguien, la ruta es /// vieja) devuelve `None` y la app arranca como siempre. pub fn tomar_estado() -> Option> { let ruta = PathBuf::from(std::env::var_os(ENV_ESTADO)?); tomar_estado_de(&ruta) } /// [`tomar_estado`] sobre una ruta explícita. pub fn tomar_estado_de(ruta: &Path) -> Option> { let bytes = std::fs::read(ruta).ok()?; let _ = std::fs::remove_file(ruta); Some(bytes) } /// La línea que relanza `cmd` (ya citada para `sh -c`) con su estado en el /// ambiente. Se antepone un `export` y no un `VAR=… cmd`, porque la línea de /// mirada puede empezar por un `cd … &&`. pub fn comando_con_estado(cmd: &str, estado: &Path) -> String { let ruta = estado.to_string_lossy().replace('\'', r"'\''"); format!("export {ENV_ESTADO}='{ruta}'; {cmd}") } /// **El lado de la app.** Escucha en `ruta` y, por cada `sleep`, llama a /// `dormir`, que corre la app y devuelve la ruta del estado guardado (o `None` /// para «ahora no»). Corre en un hilo propio; `dormir` es quien tiene que /// llevar el pedido al hilo del modelo y esperar la respuesta. /// /// Devuelve un guardián que borra el socket al soltarse: un socket que queda /// de una app muerta haría que mirada crea que la app habla el protocolo — no /// rompe nada (el `connect` falla y cae al genérico), pero miente. pub fn escuchar_en(ruta: PathBuf, dormir: F) -> std::io::Result where F: Fn() -> Option + Send + 'static, { if let Some(dir) = ruta.parent() { std::fs::create_dir_all(dir)?; } let _ = std::fs::remove_file(&ruta); let listener = UnixListener::bind(&ruta)?; std::thread::Builder::new().name("llimphi-dormir".into()).spawn(move || { for conn in listener.incoming() { let Ok(mut conn) = conn else { continue }; let _ = conn.set_read_timeout(Some(Duration::from_secs(5))); let mut linea = String::new(); if BufReader::new(&conn).read_line(&mut linea).is_err() { continue; } if linea.trim() != "sleep" { let _ = conn.write_all(b"no\n"); continue; } let respuesta = match dormir() { Some(ruta) => format!("ok {}\n", ruta.display()), None => "no\n".to_string(), }; let _ = conn.write_all(respuesta.as_bytes()); } })?; Ok(Escucha { ruta }) } /// [`escuchar_en`] sobre [`ruta_socket`] del proceso actual. pub fn escuchar(dormir: F) -> std::io::Result where F: Fn() -> Option + Send + 'static, { let ruta = ruta_socket(std::process::id()).ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::NotFound, "sin XDG_RUNTIME_DIR") })?; escuchar_en(ruta, dormir) } /// Mientras viva, la app escucha. Al soltarse borra el socket. pub struct Escucha { ruta: PathBuf, } impl Drop for Escucha { fn drop(&mut self) { let _ = std::fs::remove_file(&self.ruta); } } /// **El lado de mirada.** Le pide a la app de `ruta` que se duerma y espera su /// respuesta hasta `plazo`. Bloquea: el compositor lo llama desde un hilo. pub fn pedir_dormir_en(ruta: &Path, plazo: Duration) -> Respuesta { let Ok(mut conn) = UnixStream::connect(ruta) else { return Respuesta::NoHabla; }; let _ = conn.set_read_timeout(Some(plazo)); let _ = conn.set_write_timeout(Some(plazo)); if conn.write_all(b"sleep\n").is_err() { return Respuesta::NoHabla; } let mut linea = String::new(); // Un plazo vencido o una conexión cortada sin respuesta es «ahora no»: la // app habla el protocolo (aceptó la conexión), y lo prudente es dejarla. if BufReader::new(&conn).read_line(&mut linea).is_err() { return Respuesta::No; } match linea.trim().strip_prefix("ok ") { Some(r) if !r.is_empty() => Respuesta::Dormida(PathBuf::from(r)), _ => Respuesta::No, } } /// `true` si la app de pid `pid` tiene el socket puesto. Barato (un `stat`): /// sirve para decidir el camino antes de pedir nada. pub fn habla_protocolo(pid: u32) -> bool { ruta_socket(pid).is_some_and(|r| r.exists()) } #[cfg(test)] mod tests { use super::*; fn dir_de_prueba(nombre: &str) -> PathBuf { let d = std::env::temp_dir() .join(format!("llimphi-dormir-{}-{nombre}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } /// El viaje entero por un socket de verdad: la app guarda, contesta con la /// ruta, y lo que se despierta es byte a byte lo que se guardó — una vez. #[test] fn dormir_y_despertar_devuelven_el_mismo_estado() { let d = dir_de_prueba("viaje"); let sock = d.join("1.sock"); let estados = d.join("estados"); let e2 = estados.clone(); let _escucha = escuchar_en(sock.clone(), move || { guardar_estado_en(&e2, "nada", b"pestanas: a.rs b.rs").ok() }) .unwrap(); let Respuesta::Dormida(ruta) = pedir_dormir_en(&sock, Duration::from_secs(5)) else { panic!("la app tenía que dormirse"); }; assert!(ruta.starts_with(&estados)); assert_eq!(tomar_estado_de(&ruta).as_deref(), Some(&b"pestanas: a.rs b.rs"[..])); assert_eq!(tomar_estado_de(&ruta), None, "un estado se despierta una vez"); } /// «Ahora no» se respeta, y sin socket la respuesta es que no habla — que es /// lo que manda al camino genérico. Brazo de control del test de arriba. #[test] fn ahora_no_y_sin_socket_son_cosas_distintas() { let d = dir_de_prueba("no"); let sock = d.join("2.sock"); let _escucha = escuchar_en(sock.clone(), || None).unwrap(); assert_eq!(pedir_dormir_en(&sock, Duration::from_secs(5)), Respuesta::No); assert_eq!( pedir_dormir_en(&d.join("nadie.sock"), Duration::from_secs(1)), Respuesta::NoHabla ); } /// Soltar la escucha borra el socket: uno colgado anunciaría una app que ya /// no está. #[test] fn soltar_la_escucha_borra_el_socket() { let d = dir_de_prueba("drop"); let sock = d.join("3.sock"); let escucha = escuchar_en(sock.clone(), || None).unwrap(); assert!(sock.exists()); drop(escucha); assert!(!sock.exists()); } /// La línea de despertar arranca por el `export` aunque el comando empiece /// con un `cd`, y una comilla en la ruta no rompe la cita. #[test] fn la_linea_de_despertar_lleva_el_estado_en_el_ambiente() { let l = comando_con_estado("cd '/x' && exec 'nada'", Path::new("/s/it's.bin")); assert_eq!(l, r"export LLIMPHI_WAKE_STATE='/s/it'\''s.bin'; cd '/x' && exec 'nada'"); } }