tawasuyu

tawasuyu/brahman

Rust 332TOML 127JSON 17Markdown 154710 Definiciones

⎇ main crates/apps/mirada-greeter/src/main.rs Rust · 242 líneas · 8.6 KB Historia Quién tocó qué crudo

feat(mirada): mirada-greeter — greeter de login del escritorio carmen bb21c28eb · sergio ·

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}