⎇ b31f98883379a172efd309cd2cec98d16eb591db
crates/modules/mirada/mirada-brain/src/ctl.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat(mirada): API de acciones — mirada-ctl + HUD interactivo
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}