diff --git a/Cargo.lock b/Cargo.lock index eb14388..8b12689 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7776,6 +7776,16 @@ dependencies = [ "mirada-brain", ] +[[package]] +name = "mirada-greeter" +version = "0.1.0" +dependencies = [ + "brahman-auth", + "gpui", + "nahual-theme", + "nahual-widget-text-input", +] + [[package]] name = "mirada-launcher" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 5ec9165..b0be68d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -291,6 +291,7 @@ members = [ "crates/apps/mirada-ctl", "crates/apps/mirada-launcher", "crates/apps/mirada-portal", + "crates/apps/mirada-greeter", ] [workspace.package] diff --git a/crates/apps/mirada-greeter/Cargo.toml b/crates/apps/mirada-greeter/Cargo.toml new file mode 100644 index 0000000..ef08741 --- /dev/null +++ b/crates/apps/mirada-greeter/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "mirada-greeter" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +publish.workspace = true +description = "mirada-greeter — el greeter de carmen: ventana GPUI de login que autentica con brahman-auth y emite un SessionTicket al compositor por stdout." + +[[bin]] +name = "mirada-greeter" +path = "src/main.rs" + +[dependencies] +gpui = { workspace = true } +nahual-theme = { workspace = true } +nahual-widget-text-input = { workspace = true } +brahman-auth = { path = "../../protocol/brahman-auth" } diff --git a/crates/apps/mirada-greeter/README.md b/crates/apps/mirada-greeter/README.md new file mode 100644 index 0000000..fd1330b --- /dev/null +++ b/crates/apps/mirada-greeter/README.md @@ -0,0 +1,42 @@ +# mirada-greeter + +El greeter (pantalla de login) del escritorio carmen. + +Una ventana GPUI: el compositor `mirada-compositor`, cuando bootea en +modo greeter, la arranca como proceso hijo, la compone a pantalla +completa (la reconoce por `app_id = "carmen.greeter"`) y le lee el +stdout. + +## Flujo + +1. El usuario teclea usuario + contraseña. `Enter` en «usuario» pasa el + foco a «contraseña»; `Enter` en «contraseña» autentica. +2. La autenticación corre con [`brahman-auth`] en un hilo de fondo (PAM + puede demorar ~2 s ante un fallo, no se congela la UI). +3. En éxito, el greeter **imprime un `SessionTicket` a stdout** y + termina. El compositor parsea esa línea y hace el traspaso a modo + sesión sin reiniciar el servidor gráfico. + +La línea de tiquet lleva el prefijo `MIRADA-SESSION-TICKET-v1`; el resto +del stdout (logs) se ignora. + +## Backend de autenticación + +| Entorno | Backend | +|---|---| +| (por defecto) | PAM, servicio `carmen` (`/etc/pam.d/carmen`) | +| `MIRADA_GREETER_PAM=` | PAM con otro servicio | +| `MIRADA_GREETER_MOCK=usuario:secreto` | Mock — credenciales fijas | + +El modo mock sirve para iterar la UI en cajas sin PAM o con el greeter +anidado dentro de otro escritorio: + +```sh +MIRADA_GREETER_MOCK=demo:demo cargo run -p mirada-greeter +``` + +## Pendiente + +El consumo del tiquet en `mirada-compositor` (modo greeter + +`BodyMode::Session` + arranque de la sesión con setuid) — siguiente +slice del DM. diff --git a/crates/apps/mirada-greeter/src/main.rs b/crates/apps/mirada-greeter/src/main.rs new file mode 100644 index 0000000..cafec1d --- /dev/null +++ b/crates/apps/mirada-greeter/src/main.rs @@ -0,0 +1,242 @@ +//! `mirada-greeter` — el greeter del escritorio carmen. +//! +//! Una ventana GPUI de login. El compositor (`mirada-compositor`) la +//! arranca como proceso hijo cuando bootea en modo greeter, la compone a +//! pantalla completa (la reconoce por `app_id = "carmen.greeter"`) y le +//! lee el stdout. +//! +//! Flujo: el usuario teclea usuario + contraseña, el greeter autentica +//! con [`brahman_auth`], y en éxito **imprime un [`SessionTicket`] a +//! stdout** y termina. El compositor parsea esa línea, hace el traspaso +//! a modo sesión (setuid al usuario + arranque) sin reiniciar el +//! servidor gráfico — la «mutación atómica» del DM. +//! +//! Backend de autenticación (ver [`pick_authenticator`]): +//! - por defecto, PAM contra el servicio `carmen`; +//! - `MIRADA_GREETER_MOCK="usuario:secreto"` usa el mock, para iterar la +//! UI en cajas sin PAM o con el greeter anidado en otro escritorio. + +use std::io::Write; +use std::sync::Arc; + +use brahman_auth::{ + AuthError, Authenticator, MockAuthenticator, PamAuthenticator, SessionTicket, UserInfo, + DEFAULT_SERVICE, +}; +use gpui::{ + div, prelude::*, px, App, Application, Bounds, Context, Entity, IntoElement, Render, + SharedString, Window, WindowBounds, WindowOptions, +}; +use nahual_theme::Theme; +use nahual_widget_text_input::{TextInput, TextInputEvent}; + +/// `app_id` con el que el compositor reconoce y compone el greeter. +const GREETER_APP_ID: &str = "carmen.greeter"; + +/// Autenticador compartible entre el hilo de UI y el de fondo. +type DynAuth = Arc; + +fn main() { + Application::new().run(|cx: &mut App| { + Theme::install_default(cx); + let auth = pick_authenticator(); + let bounds = Bounds::centered(None, gpui::size(px(960.0), px(600.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: None, + app_id: Some(GREETER_APP_ID.into()), + ..Default::default() + }, + |window, cx| cx.new(|cx| Greeter::new(auth, window, cx)), + ) + .expect("abrir la ventana del greeter"); + cx.activate(true); + }); +} + +/// Elige el backend de autenticación según el entorno. +fn pick_authenticator() -> DynAuth { + // Modo dev: credenciales fijas, sin tocar PAM. + if let Ok(spec) = std::env::var("MIRADA_GREETER_MOCK") { + if let Some((user, secret)) = spec.split_once(':') { + eprintln!("mirada-greeter · backend mock (usuario «{user}»)"); + return Arc::new(MockAuthenticator::new().with_user(user, secret)); + } + eprintln!("mirada-greeter · MIRADA_GREETER_MOCK mal formado (falta «:»), ignorado"); + } + // Camino real: PAM. Servicio sobreescribible con `MIRADA_GREETER_PAM`. + let service = + std::env::var("MIRADA_GREETER_PAM").unwrap_or_else(|_| DEFAULT_SERVICE.to_string()); + eprintln!("mirada-greeter · backend PAM (servicio «{service}»)"); + Arc::new(PamAuthenticator::new(service)) +} + +/// Estado del intento de login en curso. +enum Status { + /// Esperando que el usuario teclee. + Idle, + /// Autenticación en vuelo (en el hilo de fondo). + Authenticating, + /// Último intento falló; el mensaje es para mostrar. + Failed(String), +} + +struct Greeter { + auth: DynAuth, + username: Entity, + password: Entity, + status: Status, +} + +impl Greeter { + fn new(auth: DynAuth, window: &mut Window, cx: &mut Context) -> Self { + cx.observe_global::(|_, cx| cx.notify()).detach(); + + let username = cx.new(|cx| TextInput::new("", cx).with_placeholder("usuario")); + let password = cx.new(|cx| { + TextInput::new("", cx) + .with_placeholder("contraseña") + .with_mask() + }); + + // Enter en «usuario» pasa el foco a «contraseña». + cx.subscribe_in(&username, window, |this, _u, ev, window, cx| { + if let TextInputEvent::Confirmed(_) = ev { + this.password.read(cx).request_focus(window); + } + }) + .detach(); + + // Enter en «contraseña» dispara la autenticación. + cx.subscribe_in(&password, window, |this, _p, ev, window, cx| { + if let TextInputEvent::Confirmed(_) = ev { + this.submit(window, cx); + } + }) + .detach(); + + // Foco inicial en «usuario». + username.read(cx).request_focus(window); + + Self { + auth, + username, + password, + status: Status::Idle, + } + } + + /// Valida el formulario y lanza la autenticación en el hilo de fondo + /// (PAM puede tardar — `pam_unix` demora ~2 s ante un fallo). + fn submit(&mut self, window: &mut Window, cx: &mut Context) { + if matches!(self.status, Status::Authenticating) { + return; // intento ya en curso + } + let user = self.username.read(cx).text().trim().to_string(); + let secret = self.password.read(cx).text().to_string(); + if user.is_empty() { + self.status = Status::Failed("ingresá un usuario".into()); + self.username.read(cx).request_focus(window); + cx.notify(); + return; + } + + self.status = Status::Authenticating; + cx.notify(); + + let auth = Arc::clone(&self.auth); + cx.spawn(async move |this, cx| { + let result = cx + .background_executor() + .spawn(async move { auth.authenticate(&user, &secret) }) + .await; + let _ = this.update(cx, |me, cx| me.finish(result, cx)); + }) + .detach(); + } + + /// Procesa el resultado de la autenticación. + fn finish(&mut self, result: Result, cx: &mut Context) { + match result { + Ok(user) => { + // El compositor lee esta línea del stdout del greeter. + emit_ticket(&SessionTicket::new(user)); + cx.quit(); + } + Err(e) => { + self.status = Status::Failed(e.to_string()); + // Limpia la contraseña; el foco ya está en ese campo. + self.password.update(cx, |p, cx| p.set_text("", cx)); + cx.notify(); + } + } + } +} + +impl Render for Greeter { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + let (status_msg, status_color) = match &self.status { + Status::Idle => (SharedString::default(), theme.fg_muted), + Status::Authenticating => (SharedString::from("verificando…"), theme.fg_muted), + Status::Failed(m) => (SharedString::from(m.clone()), theme.accent_destructive()), + }; + + div() + .size_full() + .flex() + .items_center() + .justify_center() + .bg(theme.bg_app) + .child( + div() + .flex() + .flex_col() + .gap(px(12.0)) + .w(px(320.0)) + .p(px(28.0)) + .bg(theme.bg_panel) + .border_1() + .border_color(theme.border) + .rounded(px(12.0)) + .child( + div() + .text_size(px(22.0)) + .text_color(theme.fg_text) + .child("carmen"), + ) + .child( + div() + .text_size(px(12.0)) + .text_color(theme.fg_muted) + .child("iniciá tu sesión"), + ) + .child(caption(&theme, "usuario")) + .child(self.username.clone()) + .child(caption(&theme, "contraseña")) + .child(self.password.clone()) + .child( + div() + .h(px(16.0)) + .text_size(px(11.0)) + .text_color(status_color) + .child(status_msg), + ), + ) + } +} + +/// Etiqueta pequeña sobre un campo del formulario. +fn caption(theme: &Theme, text: &'static str) -> impl IntoElement { + div() + .text_size(px(10.0)) + .text_color(theme.fg_muted) + .child(text) +} + +/// Imprime el tiquet a stdout y fuerza el flush antes de terminar. +fn emit_ticket(ticket: &SessionTicket) { + println!("{}", ticket.to_line()); + let _ = std::io::stdout().flush(); +} diff --git a/crates/modules/nahual/widgets/text_input/src/lib.rs b/crates/modules/nahual/widgets/text_input/src/lib.rs index 8766487..42e9d86 100644 --- a/crates/modules/nahual/widgets/text_input/src/lib.rs +++ b/crates/modules/nahual/widgets/text_input/src/lib.rs @@ -23,8 +23,8 @@ use std::time::Duration; use gpui::{ - Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, Render, - SharedString, Task, Window, div, prelude::*, px, + div, prelude::*, px, Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, + Render, SharedString, Task, Window, }; use nahual_theme::Theme; @@ -46,6 +46,9 @@ pub struct TextInput { focus_handle: FocusHandle, /// Placeholder visible cuando `text` está vacío. placeholder: SharedString, + /// Si `true`, el contenido se dibuja como puntos (`•`) en vez del + /// texto real — para campos de contraseña. + mask: bool, /// Toggle del caret: alterna cada [`CARET_BLINK_INTERVAL`] /// entre `true` (visible) y `false` (oculto). El render lo /// considera junto con focus para decidir si dibujar `|`. @@ -89,6 +92,7 @@ impl TextInput { text: initial.into(), focus_handle: cx.focus_handle(), placeholder: SharedString::from(""), + mask: false, caret_visible: true, _blink_task: blink_task, } @@ -101,6 +105,13 @@ impl TextInput { self } + /// Dibuja el contenido como puntos (`•`) — para campos de + /// contraseña. El texto real sigue accesible vía [`Self::text`]. + pub fn with_mask(mut self) -> Self { + self.mask = true; + self + } + pub fn text(&self) -> &str { &self.text } @@ -117,12 +128,7 @@ impl TextInput { window.focus(&self.focus_handle); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _w: &mut Window, - cx: &mut Context, - ) { + fn handle_key_down(&mut self, event: &KeyDownEvent, _w: &mut Window, cx: &mut Context) { let key = event.keystroke.key.as_str(); match key { "enter" => { @@ -173,12 +179,13 @@ impl Render for TextInput { // toggle del blink loop está en `true`. El loop alterna // cada 500ms — feel familiar a los inputs del SO. let show_caret = is_focused && self.caret_visible; + let shown = display_text(&self.text, self.mask); let display: SharedString = if is_empty { self.placeholder.clone() } else if show_caret { - SharedString::from(format!("{}|", self.text)) + SharedString::from(format!("{shown}|")) } else { - SharedString::from(self.text.clone()) + SharedString::from(shown) }; let text_color = if is_empty { theme.fg_disabled @@ -194,7 +201,7 @@ impl Render for TextInput { .px(px(10.0)) .py(px(6.0)) .min_w(px(200.0)) - .bg(theme.bg_panel.clone()) + .bg(theme.bg_panel) .border_1() .border_color(border_color) .rounded(px(4.0)) @@ -203,3 +210,31 @@ impl Render for TextInput { .child(display) } } + +/// Texto a mostrar: el contenido tal cual, o un punto (`•`) por cada +/// carácter si el campo está enmascarado. +fn display_text(text: &str, mask: bool) -> String { + if mask { + "•".repeat(text.chars().count()) + } else { + text.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::display_text; + + #[test] + fn plain_text_shown_verbatim() { + assert_eq!(display_text("hola", false), "hola"); + } + + #[test] + fn masked_text_is_dots_one_per_char() { + assert_eq!(display_text("hola", true), "••••"); + // Un punto por carácter Unicode, no por byte. + assert_eq!(display_text("ñoño", true), "••••"); + assert_eq!(display_text("", true), ""); + } +} diff --git a/crates/protocol/brahman-auth/src/lib.rs b/crates/protocol/brahman-auth/src/lib.rs index 8eb0d1e..3b11c47 100644 --- a/crates/protocol/brahman-auth/src/lib.rs +++ b/crates/protocol/brahman-auth/src/lib.rs @@ -17,9 +17,11 @@ //! compositor necesita para arrancar la sesión. mod pam_backend; +mod ticket; mod user; pub use pam_backend::{PamAuthenticator, DEFAULT_SERVICE}; +pub use ticket::{SessionTicket, TICKET_TAG}; pub use user::{resolve_user, UserInfo}; use std::collections::HashMap; diff --git a/crates/protocol/brahman-auth/src/ticket.rs b/crates/protocol/brahman-auth/src/ticket.rs new file mode 100644 index 0000000..f416f00 --- /dev/null +++ b/crates/protocol/brahman-auth/src/ticket.rs @@ -0,0 +1,139 @@ +//! El tiquet de sesión: lo que el greeter le entrega al compositor tras +//! una autenticación exitosa. +//! +//! El greeter de carmen corre como proceso hijo del compositor. Cuando +//! el login tiene éxito, imprime **una línea** de tiquet a su stdout; el +//! compositor escanea las líneas del hijo buscando el prefijo +//! [`TICKET_TAG`] y, al encontrarlo, hace el traspaso a modo sesión. + +use std::path::PathBuf; + +use crate::UserInfo; + +/// Etiqueta + versión de la línea de tiquet. El compositor sólo trata +/// como tiquet las líneas que empiezan con esto — el resto del stdout +/// del greeter (logs, ruido) se ignora. +pub const TICKET_TAG: &str = "MIRADA-SESSION-TICKET-v1"; + +/// Resultado de un login: la identidad autenticada más, opcionalmente, +/// el comando de sesión elegido. El greeter lo produce; el compositor lo +/// consume para arrancar la sesión (setuid al usuario + spawn). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionTicket { + /// Identidad del usuario autenticado. + pub user: UserInfo, + /// Comando de sesión a ejecutar como el usuario. Vacío = que el + /// compositor decida (su autostart por defecto). + pub session: String, +} + +impl SessionTicket { + /// Crea un tiquet sin comando de sesión explícito. + pub fn new(user: UserInfo) -> Self { + Self { + user, + session: String::new(), + } + } + + /// Fija el comando de sesión. Encadenable. + pub fn with_session(mut self, session: impl Into) -> Self { + self.session = session.into(); + self + } + + /// Serializa el tiquet a una línea única, apta para stdout. Campos + /// separados por tabulador: ni los nombres de usuario, ni los paths, + /// ni los comandos de sesión suelen contener tabuladores. + pub fn to_line(&self) -> String { + format!( + "{TICKET_TAG}\t{}\t{}\t{}\t{}\t{}\t{}", + self.user.name, + self.user.uid, + self.user.gid, + self.user.home.display(), + self.user.shell.display(), + self.session, + ) + } + + /// Parsea una línea producida por [`to_line`]. `None` si la línea no + /// es un tiquet (otra salida del greeter) o está malformada. + pub fn from_line(line: &str) -> Option { + let mut f = line.trim_end_matches(['\r', '\n']).split('\t'); + if f.next()? != TICKET_TAG { + return None; + } + let name = f.next()?.to_string(); + let uid = f.next()?.parse().ok()?; + let gid = f.next()?.parse().ok()?; + let home = PathBuf::from(f.next()?); + let shell = PathBuf::from(f.next()?); + // El comando de sesión puede venir vacío. + let session = f.next().unwrap_or("").to_string(); + Some(SessionTicket { + user: UserInfo { + name, + uid, + gid, + home, + shell, + }, + session, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> UserInfo { + UserInfo { + name: "sergio".into(), + uid: 1000, + gid: 1000, + home: PathBuf::from("/home/sergio"), + shell: PathBuf::from("/usr/bin/bash"), + } + } + + #[test] + fn round_trip_without_session() { + let t = SessionTicket::new(sample()); + let back = SessionTicket::from_line(&t.to_line()).expect("parsea"); + assert_eq!(back, t); + assert!(back.session.is_empty()); + } + + #[test] + fn round_trip_with_session() { + let t = SessionTicket::new(sample()).with_session("shuma-shell --launcher"); + let back = SessionTicket::from_line(&t.to_line()).expect("parsea"); + assert_eq!(back, t); + assert_eq!(back.session, "shuma-shell --launcher"); + } + + #[test] + fn from_line_ignores_non_ticket() { + assert!(SessionTicket::from_line("[INFO] arrancando greeter").is_none()); + assert!(SessionTicket::from_line("").is_none()); + } + + #[test] + fn from_line_rejects_malformed() { + // Prefijo correcto pero faltan campos. + assert!(SessionTicket::from_line(&format!("{TICKET_TAG}\tsergio")).is_none()); + // uid no numérico. + assert!( + SessionTicket::from_line(&format!("{TICKET_TAG}\tsergio\tXX\t1000\t/h\t/sh\t")) + .is_none() + ); + } + + #[test] + fn tolerates_trailing_newline() { + let line = format!("{}\n", SessionTicket::new(sample()).to_line()); + assert!(SessionTicket::from_line(&line).is_some()); + } +} diff --git a/docs/changelog/mirada.md b/docs/changelog/mirada.md index b60ea44..d80ff7f 100644 --- a/docs/changelog/mirada.md +++ b/docs/changelog/mirada.md @@ -5,6 +5,25 @@ Cerebro (GPUI) ↔ Cuerpo (smithay) en dos procesos. El historial previo vive en `git log` (`feat(mirada): …`); este archivo arranca con el trabajo de escritorio (tema, DM). +### feat(mirada-greeter): greeter de carmen — login GPUI + +App nueva `crates/apps/mirada-greeter`: la pantalla de login del +escritorio. Ventana GPUI con `app_id = "carmen.greeter"` para que el +compositor la reconozca y la componga a pantalla completa. + +- Formulario usuario + contraseña (campo enmascarado). `Enter` encadena + los campos y dispara la autenticación. +- Autentica con `brahman-auth` en un hilo de fondo (PAM demora ~2 s ante + un fallo — la UI no se congela). Backend por entorno: + `MIRADA_GREETER_MOCK=usuario:secreto` (dev) o PAM (`MIRADA_GREETER_PAM`, + por defecto `carmen`). +- En éxito imprime un `SessionTicket` a stdout y termina; el compositor + lo leerá para el traspaso a modo sesión (siguiente slice). + +Para el contrato del tiquet (`brahman-auth::SessionTicket`) y el modo +enmascarado de `nahual-widget-text-input`, ver los changelogs de +`protocol/` y `nahual`. + ### feat(mirada): inyección de entorno de tema a los hijos del compositor `spawn_command` del compositor inyecta `THEME_ENV` a cada proceso hijo: diff --git a/docs/changelog/nahual.md b/docs/changelog/nahual.md index 34d81d4..26e083d 100644 --- a/docs/changelog/nahual.md +++ b/docs/changelog/nahual.md @@ -2,6 +2,13 @@ Motor GPUI: libs + widgets. Renombrado de `yahweh` el 2026-05-19. +### feat(nahual-widget-text-input): modo enmascarado para contraseñas + +`TextInput::with_mask()` dibuja el contenido como puntos (`•`, uno por +carácter Unicode) en vez del texto real; `text()` sigue devolviendo el +contenido crudo. Lo usa el campo de contraseña de `mirada-greeter`. +Lógica de enmascarado en la función pura `display_text`, con tests. + ### feat(nahual-theme): exportación del tema a GTK (módulo toolkit) Módulo nuevo `nahual-theme/src/toolkit.rs`: traduce el `Theme` activo a diff --git a/docs/changelog/protocol.md b/docs/changelog/protocol.md index aa2e945..13c1a62 100644 --- a/docs/changelog/protocol.md +++ b/docs/changelog/protocol.md @@ -2,6 +2,15 @@ Contratos canónicos + routing entre módulos. Antes: `core/brahman-*` + `shared/brahman-*`. +### feat(brahman-auth): SessionTicket — el tiquet greeter→compositor + +`brahman_auth::SessionTicket { user: UserInfo, session: String }`: lo +que el greeter le entrega al compositor tras un login exitoso. +`to_line`/`from_line` lo serializan a una **línea única** (campos por +tabulador, prefijo versionado `MIRADA-SESSION-TICKET-v1`) — el greeter +la imprime a stdout y el compositor escanea sus líneas buscando el +prefijo. 5 tests de round-trip y rechazo de líneas malformadas. + ### feat(brahman-auth): autenticación del escritorio — contrato + PAM + mock Crate nuevo `crates/protocol/brahman-auth`: la base del DM/greeter de