feat(mirada): mirada-greeter — greeter de login del escritorio carmen
1//! `mirada-greeter` — el greeter del escritorio carmen.
2//!
3//! Una ventana GPUI de login. El compositor (`mirada-compositor`) la
4//! arranca como proceso hijo cuando bootea en modo greeter, la compone a
5//! pantalla completa (la reconoce por `app_id = "carmen.greeter"`) y le
6//! lee el stdout.
7//!
8//! Flujo: el usuario teclea usuario + contraseña, el greeter autentica
9//! con [`brahman_auth`], y en éxito **imprime un [`SessionTicket`] a
10//! stdout** y termina. El compositor parsea esa línea, hace el traspaso
11//! a modo sesión (setuid al usuario + arranque) sin reiniciar el
12//! servidor gráfico — la «mutación atómica» del DM.
13//!
14//! Backend de autenticación (ver [`pick_authenticator`]):
15//! - por defecto, PAM contra el servicio `carmen`;
16//! - `MIRADA_GREETER_MOCK="usuario:secreto"` usa el mock, para iterar la
17//! UI en cajas sin PAM o con el greeter anidado en otro escritorio.
18
19use std::io::Write;
20use std::sync::Arc;
21
22use brahman_auth::{
23 AuthError, Authenticator, MockAuthenticator, PamAuthenticator, SessionTicket, UserInfo,
24 DEFAULT_SERVICE,
25};
26use gpui::{
27 div, prelude::*, px, App, Application, Bounds, Context, Entity, IntoElement, Render,
28 SharedString, Window, WindowBounds, WindowOptions,
29};
30use nahual_theme::Theme;
31use nahual_widget_text_input::{TextInput, TextInputEvent};
32
33/// `app_id` con el que el compositor reconoce y compone el greeter.
34const GREETER_APP_ID: &str = "carmen.greeter";
35
36/// Autenticador compartible entre el hilo de UI y el de fondo.
37type DynAuth = Arc<dyn Authenticator + Send + Sync>;
38
39fn main() {
40 Application::new().run(|cx: &mut App| {
41 Theme::install_default(cx);
42 let auth = pick_authenticator();
43 let bounds = Bounds::centered(None, gpui::size(px(960.0), px(600.0)), cx);
44 cx.open_window(
45 WindowOptions {
46 window_bounds: Some(WindowBounds::Windowed(bounds)),
47 titlebar: None,
48 app_id: Some(GREETER_APP_ID.into()),
49 ..Default::default()
50 },
51 |window, cx| cx.new(|cx| Greeter::new(auth, window, cx)),
52 )
53 .expect("abrir la ventana del greeter");
54 cx.activate(true);
55 });
56}
57
58/// Elige el backend de autenticación según el entorno.
59fn pick_authenticator() -> DynAuth {
60 // Modo dev: credenciales fijas, sin tocar PAM.
61 if let Ok(spec) = std::env::var("MIRADA_GREETER_MOCK") {
62 if let Some((user, secret)) = spec.split_once(':') {
63 eprintln!("mirada-greeter · backend mock (usuario «{user}»)");
64 return Arc::new(MockAuthenticator::new().with_user(user, secret));
65 }
66 eprintln!("mirada-greeter · MIRADA_GREETER_MOCK mal formado (falta «:»), ignorado");
67 }
68 // Camino real: PAM. Servicio sobreescribible con `MIRADA_GREETER_PAM`.
69 let service =
70 std::env::var("MIRADA_GREETER_PAM").unwrap_or_else(|_| DEFAULT_SERVICE.to_string());
71 eprintln!("mirada-greeter · backend PAM (servicio «{service}»)");
72 Arc::new(PamAuthenticator::new(service))
73}
74
75/// Estado del intento de login en curso.
76enum Status {
77 /// Esperando que el usuario teclee.
78 Idle,
79 /// Autenticación en vuelo (en el hilo de fondo).
80 Authenticating,
81 /// Último intento falló; el mensaje es para mostrar.
82 Failed(String),
83}
84
85struct Greeter {
86 auth: DynAuth,
87 username: Entity<TextInput>,
88 password: Entity<TextInput>,
89 status: Status,
90}
91
92impl Greeter {
93 fn new(auth: DynAuth, window: &mut Window, cx: &mut Context<Self>) -> Self {
94 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
95
96 let username = cx.new(|cx| TextInput::new("", cx).with_placeholder("usuario"));
97 let password = cx.new(|cx| {
98 TextInput::new("", cx)
99 .with_placeholder("contraseña")
100 .with_mask()
101 });
102
103 // Enter en «usuario» pasa el foco a «contraseña».
104 cx.subscribe_in(&username, window, |this, _u, ev, window, cx| {
105 if let TextInputEvent::Confirmed(_) = ev {
106 this.password.read(cx).request_focus(window);
107 }
108 })
109 .detach();
110
111 // Enter en «contraseña» dispara la autenticación.
112 cx.subscribe_in(&password, window, |this, _p, ev, window, cx| {
113 if let TextInputEvent::Confirmed(_) = ev {
114 this.submit(window, cx);
115 }
116 })
117 .detach();
118
119 // Foco inicial en «usuario».
120 username.read(cx).request_focus(window);
121
122 Self {
123 auth,
124 username,
125 password,
126 status: Status::Idle,
127 }
128 }
129
130 /// Valida el formulario y lanza la autenticación en el hilo de fondo
131 /// (PAM puede tardar — `pam_unix` demora ~2 s ante un fallo).
132 fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
133 if matches!(self.status, Status::Authenticating) {
134 return; // intento ya en curso
135 }
136 let user = self.username.read(cx).text().trim().to_string();
137 let secret = self.password.read(cx).text().to_string();
138 if user.is_empty() {
139 self.status = Status::Failed("ingresá un usuario".into());
140 self.username.read(cx).request_focus(window);
141 cx.notify();
142 return;
143 }
144
145 self.status = Status::Authenticating;
146 cx.notify();
147
148 let auth = Arc::clone(&self.auth);
149 cx.spawn(async move |this, cx| {
150 let result = cx
151 .background_executor()
152 .spawn(async move { auth.authenticate(&user, &secret) })
153 .await;
154 let _ = this.update(cx, |me, cx| me.finish(result, cx));
155 })
156 .detach();
157 }
158
159 /// Procesa el resultado de la autenticación.
160 fn finish(&mut self, result: Result<UserInfo, AuthError>, cx: &mut Context<Self>) {
161 match result {
162 Ok(user) => {
163 // El compositor lee esta línea del stdout del greeter.
164 emit_ticket(&SessionTicket::new(user));
165 cx.quit();
166 }
167 Err(e) => {
168 self.status = Status::Failed(e.to_string());
169 // Limpia la contraseña; el foco ya está en ese campo.
170 self.password.update(cx, |p, cx| p.set_text("", cx));
171 cx.notify();
172 }
173 }
174 }
175}
176
177impl Render for Greeter {
178 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
179 let theme = Theme::global(cx).clone();
180 let (status_msg, status_color) = match &self.status {
181 Status::Idle => (SharedString::default(), theme.fg_muted),
182 Status::Authenticating => (SharedString::from("verificando…"), theme.fg_muted),
183 Status::Failed(m) => (SharedString::from(m.clone()), theme.accent_destructive()),
184 };
185
186 div()
187 .size_full()
188 .flex()
189 .items_center()
190 .justify_center()
191 .bg(theme.bg_app)
192 .child(
193 div()
194 .flex()
195 .flex_col()
196 .gap(px(12.0))
197 .w(px(320.0))
198 .p(px(28.0))
199 .bg(theme.bg_panel)
200 .border_1()
201 .border_color(theme.border)
202 .rounded(px(12.0))
203 .child(
204 div()
205 .text_size(px(22.0))
206 .text_color(theme.fg_text)
207 .child("carmen"),
208 )
209 .child(
210 div()
211 .text_size(px(12.0))
212 .text_color(theme.fg_muted)
213 .child("iniciá tu sesión"),
214 )
215 .child(caption(&theme, "usuario"))
216 .child(self.username.clone())
217 .child(caption(&theme, "contraseña"))
218 .child(self.password.clone())
219 .child(
220 div()
221 .h(px(16.0))
222 .text_size(px(11.0))
223 .text_color(status_color)
224 .child(status_msg),
225 ),
226 )
227 }
228}
229
230/// Etiqueta pequeña sobre un campo del formulario.
231fn caption(theme: &Theme, text: &'static str) -> impl IntoElement {
232 div()
233 .text_size(px(10.0))
234 .text_color(theme.fg_muted)
235 .child(text)
236}
237
238/// Imprime el tiquet a stdout y fuerza el flush antes de terminar.
239fn emit_ticket(ticket: &SessionTicket) {
240 println!("{}", ticket.to_line());
241 let _ = std::io::stdout().flush();
242}