gioser

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Sutichaykuna

⎇ b31f98883379a172efd309cd2cec98d16eb591db crates/modules/mirada/mirada-brain/src/ctl.rs Rust · 225 siq'ikuna · 7.8 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat(mirada): API de acciones — mirada-ctl + HUD interactivo b31f98883 · sergio ·

1//! `ctl` — el API de control externo del Cerebro.
2//!
3//! Mientras el keymap ([`crate::keymap`]) es la cara *configurable* de las
4//! acciones, este módulo es su cara *programable*: deja que otro proceso
5//! —un script, una taskbar, el binario `mirada-ctl`— dispare una
6//! [`DesktopAction`] o consulte el estado, sin tocar el teclado.
7//!
8//! Todo converge igualmente en `Desktop::apply`: una petición de control
9//! no es más que otro front-end del mismo embudo. El transporte es un
10//! socket Unix de petición/respuesta, con el marco `postcard` que ya usa
11//! [`mirada_protocol`]; `DesktopAction` viaja como enum serializado (no
12//! como cadena), así que el contrato es tipado de punta a punta.
13//!
14//! - El Cerebro abre un [`CtlServer`] y atiende [`CtlConn`]s en su bucle.
15//! - El cliente usa [`send_request`] — una petición, una respuesta, cierra.
16
17use std::io::{self, ErrorKind};
18use std::os::unix::net::{UnixListener, UnixStream};
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22
23use mirada_layout::WindowId;
24use mirada_protocol::{read_frame, write_frame};
25
26use crate::action::DesktopAction;
27
28/// Una orden de un cliente de control al Cerebro.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub enum CtlRequest {
31    /// Aplica una acción de escritorio — el equivalente a pulsar su atajo.
32    Do(DesktopAction),
33    /// Pide la lista de ventanas conocidas, en todos los escritorios.
34    ListWindows,
35}
36
37/// La respuesta del Cerebro a un [`CtlRequest`].
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub enum CtlReply {
40    /// La orden se aplicó.
41    Ok,
42    /// La orden no se pudo aplicar; el motivo, para mostrar al usuario.
43    Error(String),
44    /// La lista pedida con [`CtlRequest::ListWindows`].
45    Windows(Vec<WindowLine>),
46}
47
48/// Una ventana en la vista de `mirada-ctl windows`.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct WindowLine {
51    /// Id de la ventana — el que se pasa a `focus-window:N`.
52    pub id: WindowId,
53    pub app_id: String,
54    pub title: String,
55    /// Escritorio virtual donde está (1-based).
56    pub workspace: usize,
57    /// `true` si es la ventana enfocada del escritorio activo.
58    pub focused: bool,
59}
60
61/// La ruta del socket de control: `$XDG_RUNTIME_DIR/mirada-ctl.sock`, o
62/// el directorio temporal si esa variable no está.
63pub fn default_socket_path() -> PathBuf {
64    let dir = std::env::var_os("XDG_RUNTIME_DIR")
65        .map(PathBuf::from)
66        .unwrap_or_else(std::env::temp_dir);
67    dir.join("mirada-ctl.sock")
68}
69
70/// El extremo servidor del API de control — lo abre el dueño del
71/// [`Desktop`](crate::Desktop) (la app `mirada`, o `mirada-compositor`
72/// con el Cerebro embebido).
73pub struct CtlServer {
74    listener: UnixListener,
75    path: PathBuf,
76}
77
78impl CtlServer {
79    /// Abre el socket de control en `path`. Si ya hay un Cerebro vivo
80    /// escuchando ahí, falla; si encuentra un socket muerto (de un
81    /// compositor anterior), lo retira y se queda con él.
82    pub fn bind(path: &Path) -> io::Result<Self> {
83        if path.exists() {
84            if UnixStream::connect(path).is_ok() {
85                return Err(io::Error::new(
86                    ErrorKind::AddrInUse,
87                    "ya hay un Cerebro escuchando en el socket de control",
88                ));
89            }
90            let _ = std::fs::remove_file(path);
91        }
92        if let Some(dir) = path.parent() {
93            std::fs::create_dir_all(dir)?;
94        }
95        let listener = UnixListener::bind(path)?;
96        listener.set_nonblocking(true)?;
97        Ok(Self { listener, path: path.to_path_buf() })
98    }
99
100    /// Acepta una conexión pendiente sin bloquear. `None` si no hay
101    /// ninguna — pensado para llamarse cada vuelta del bucle de eventos.
102    pub fn poll(&self) -> Option<CtlConn> {
103        match self.listener.accept() {
104            Ok((stream, _)) => Some(CtlConn { stream }),
105            Err(_) => None,
106        }
107    }
108}
109
110impl Drop for CtlServer {
111    fn drop(&mut self) {
112        // Dejar el socket limpio para el próximo arranque.
113        let _ = std::fs::remove_file(&self.path);
114    }
115}
116
117/// Una conexión de control aceptada: una petición y una respuesta.
118pub struct CtlConn {
119    stream: UnixStream,
120}
121
122impl CtlConn {
123    /// Lee la petición del cliente (bloquea hasta el marco completo; es
124    /// uno solo y llega enseguida).
125    pub fn read_request(&mut self) -> io::Result<Option<CtlRequest>> {
126        self.stream.set_nonblocking(false)?;
127        read_frame(&mut self.stream)
128    }
129
130    /// Envía la respuesta. El cliente cierra al recibirla.
131    pub fn reply(&mut self, reply: &CtlReply) -> io::Result<()> {
132        write_frame(&mut self.stream, reply)
133    }
134}
135
136/// Envía una petición al Cerebro y espera su respuesta. Es el camino que
137/// usa el binario `mirada-ctl`: conecta, pregunta, cierra.
138pub fn send_request(path: &Path, request: &CtlRequest) -> io::Result<CtlReply> {
139    let mut stream = UnixStream::connect(path)?;
140    write_frame(&mut stream, request)?;
141    read_frame(&mut stream)?
142        .ok_or_else(|| io::Error::new(ErrorKind::UnexpectedEof, "el Cerebro cerró sin responder"))
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use std::thread;
149    use std::time::{SystemTime, UNIX_EPOCH};
150
151    /// Una ruta de socket única para un test (los sockets no se pueden
152    /// reabrir; cada test necesita la suya).
153    fn temp_socket(tag: &str) -> PathBuf {
154        let nanos = SystemTime::now()
155            .duration_since(UNIX_EPOCH)
156            .unwrap()
157            .as_nanos();
158        std::env::temp_dir().join(format!("mirada-ctl-test-{tag}-{nanos}.sock"))
159    }
160
161    #[test]
162    fn default_socket_path_lives_under_a_runtime_dir() {
163        let p = default_socket_path();
164        assert_eq!(p.file_name().unwrap(), "mirada-ctl.sock");
165    }
166
167    #[test]
168    fn a_request_round_trips_over_the_socket() {
169        let path = temp_socket("roundtrip");
170        let server = CtlServer::bind(&path).unwrap();
171
172        // El "Cerebro": atiende una petición y responde.
173        let srv = thread::spawn(move || loop {
174            if let Some(mut conn) = server.poll() {
175                let req = conn.read_request().unwrap().unwrap();
176                let reply = match req {
177                    CtlRequest::Do(DesktopAction::FocusNext) => CtlReply::Ok,
178                    other => CtlReply::Error(format!("inesperado: {other:?}")),
179                };
180                conn.reply(&reply).unwrap();
181                return;
182            }
183            thread::yield_now();
184        });
185
186        let reply = send_request(&path, &CtlRequest::Do(DesktopAction::FocusNext)).unwrap();
187        assert_eq!(reply, CtlReply::Ok);
188        srv.join().unwrap();
189    }
190
191    #[test]
192    fn list_windows_carries_the_window_lines() {
193        let path = temp_socket("windows");
194        let server = CtlServer::bind(&path).unwrap();
195        let lines = vec![WindowLine {
196            id: 7,
197            app_id: "org.brahman.shuma".into(),
198            title: "shell".into(),
199            workspace: 2,
200            focused: true,
201        }];
202        let expected = lines.clone();
203
204        let srv = thread::spawn(move || loop {
205            if let Some(mut conn) = server.poll() {
206                assert_eq!(conn.read_request().unwrap().unwrap(), CtlRequest::ListWindows);
207                conn.reply(&CtlReply::Windows(lines)).unwrap();
208                return;
209            }
210            thread::yield_now();
211        });
212
213        let reply = send_request(&path, &CtlRequest::ListWindows).unwrap();
214        assert_eq!(reply, CtlReply::Windows(expected));
215        srv.join().unwrap();
216    }
217
218    #[test]
219    fn binding_twice_on_a_live_socket_is_refused() {
220        let path = temp_socket("dup");
221        let _first = CtlServer::bind(&path).unwrap();
222        // El primero sigue vivo: el segundo debe rechazarse.
223        assert!(CtlServer::bind(&path).is_err());
224    }
225}