git.lucas.co / cce-display-manager
login greeter
git clone https://git.lucas.co/cce-display-manager.git

src/main.rs (106.3K)

   1 use cce_ui::widget::Handle;
   2 use cce_ui::widget::{Button, ContentBg, WidgetHost, ElementState, MouseButton, Key, NamedKey, KeyEvent, TextBox, MouseScrollDelta, WidgetHostExt};
   3 use cce_ui::engine::{EngineState, LogicalPosition, LogicalSize, WindowSettings, Vertex, quad_vertices};
   4 use calloop::channel;
   5 
   6 
   7 
   8 
   9 
  10 fn widget_vertices(w: &dyn WidgetHost, sw: f32, sh: f32) -> Vec<Vertex> {
  11     let (x, y, ww, h) = w.rect();
  12     quad_vertices(x, y, ww, h, sw, sh, w.color()).to_vec()
  13 }
  14 
  15 
  16 
  17 
  18 #[derive(Debug, Clone)]
  19 struct Session {
  20     name: String,
  21     exec: String,
  22     is_wayland: bool,
  23 }
  24 
  25 /// Parse a greeter `AUTH_SUCCESS|user|exec|is_wayland|password` line.
  26 ///
  27 /// The password is the LAST field and is taken verbatim to the end of the
  28 /// line (`splitn`), because it may itself contain `|` — a plain `split`
  29 /// silently produced six fields and dropped the login (the greeter had
  30 /// already authenticated, so the user just hung at a dead greeter).
  31 fn parse_auth_success(line: &str) -> Option<(String, String, bool, String)> {
  32     let mut parts = line.splitn(5, '|');
  33     if parts.next() != Some("AUTH_SUCCESS") {
  34         return None;
  35     }
  36     let username = parts.next()?.to_string();
  37     let exec = parts.next()?.to_string();
  38     let is_wayland = parts.next()?.parse::<bool>().unwrap_or(true);
  39     let password = parts.next()?.to_string();
  40     Some((username, exec, is_wayland, password))
  41 }
  42 
  43 /// The greeter's half of [`parse_auth_success`]: the line it prints on stdout
  44 /// for the daemon. The password goes VERBATIM — no trimming: a password with a
  45 /// leading or trailing space is a password, and trimming it made that account
  46 /// unable to log in here at all.
  47 fn auth_success_line(username: &str, exec: &str, is_wayland: bool, password: &str) -> String {
  48     format!("AUTH_SUCCESS|{}|{}|{}|{}", username, exec, is_wayland, password)
  49 }
  50 
  51 /// The PAM service the session worker opens the session on. An empty password
  52 /// is the fingerprint path (or a compositor-restart relaunch): the greeter
  53 /// already verified the user, so the session opens on the autologin stack.
  54 fn session_pam_service(password: &str) -> &'static str {
  55     if password.is_empty() {
  56         "cce-display-manager-autologin"
  57     } else {
  58         "cce-display-manager-password"
  59     }
  60 }
  61 
  62 /// A logind session id as `XDG_SESSION_ID` carries it — only then is it passed
  63 /// to `loginctl`. Ids are short alphanumeric strings ("15", "c3").
  64 fn valid_session_id(id: &str) -> bool {
  65     !id.is_empty() && id.len() <= 32 && id.chars().all(|c| c.is_ascii_alphanumeric())
  66 }
  67 
  68 fn sanitize_exec(exec: &str) -> (String, Vec<String>) {
  69     let mut parts = Vec::new();
  70     for part in exec.split_whitespace() {
  71         if part.starts_with('%') {
  72             continue; // ignore desktop entry field codes
  73         }
  74         parts.push(part.to_string());
  75     }
  76     if parts.is_empty() {
  77         return (String::new(), Vec::new());
  78     }
  79     let cmd = parts.remove(0);
  80     (cmd, parts)
  81 }
  82 
  83 fn parse_desktop_file(path: &std::path::Path, is_wayland: bool) -> Result<Session, std::io::Error> {
  84     let content = std::fs::read_to_string(path)?;
  85     let mut name = None;
  86     let mut exec = None;
  87     for line in content.lines() {
  88         let line = line.trim();
  89         if line.starts_with("Name=") {
  90             name = Some(line["Name=".len()..].to_string());
  91         } else if line.starts_with("Exec=") {
  92             exec = Some(line["Exec=".len()..].to_string());
  93         }
  94     }
  95     if let (Some(n), Some(e)) = (name, exec) {
  96         Ok(Session { name: n, exec: e, is_wayland })
  97     } else {
  98         Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid desktop file"))
  99     }
 100 }
 101 
 102 fn discover_sessions() -> Vec<Session> {
 103     let mut sessions = Vec::new();
 104     if let Ok(entries) = std::fs::read_dir("/usr/share/wayland-sessions") {
 105         for entry in entries.flatten() {
 106             if entry.path().extension().map_or(false, |ext| ext == "desktop") {
 107                 if let Ok(s) = parse_desktop_file(&entry.path(), true) {
 108                     sessions.push(s);
 109                 }
 110             }
 111         }
 112     }
 113     if let Ok(entries) = std::fs::read_dir("/usr/share/xsessions") {
 114         for entry in entries.flatten() {
 115             if entry.path().extension().map_or(false, |ext| ext == "desktop") {
 116                 if let Ok(s) = parse_desktop_file(&entry.path(), false) {
 117                     sessions.push(s);
 118                 }
 119             }
 120         }
 121     }
 122     sessions.push(Session {
 123         name: "Bash Shell".to_string(),
 124         exec: CONSOLE_SESSION_EXEC.to_string(),
 125         is_wayland: true,
 126     });
 127     sessions
 128 }
 129 
 130 // ── Custom LoginCard Container WidgetHost (narrow traits, wrapped in Adapted) ──
 131 #[derive(Debug, Clone)]
 132 struct LoginCard;
 133 
 134 impl LoginCard {
 135     fn new() -> cce_ui::widget::Adapted<LoginCard> {
 136         cce_ui::widget::Adapted::new(LoginCard)
 137     }
 138 }
 139 
 140 impl cce_ui::widget::Layout for LoginCard {}
 141 
 142 impl cce_ui::widget::Paint for LoginCard {
 143     fn color(&self) -> [f32; 4] { [0.25, 0.25, 0.28, 0.75] } // Premium gray card background with transparency
 144 
 145     fn paint(&self, rect: cce_ui::scene::layout::Rect, pc: &mut cce_ui::scene::paint::PaintCtx) {
 146         // Only the card's header labels: the card plate (soft radial-glow blob) is drawn
 147         // via custom_vertices, not the display list. The card is laid out full-screen; the
 148         // header centers off it.
 149         let card_x = (rect.width - 360.0) / 2.0;
 150         let card_y = (rect.height - 300.0) / 2.0;
 151         pc.text("CCE DISPLAY MANAGER".to_string(), card_x + 30.0, card_y + 30.0, 15.0, [0xee, 0xee, 0xf5]);
 152         pc.text("Authenticate to begin your session".to_string(), card_x + 30.0, card_y + 50.0, 11.0, [0x83, 0x83, 0x8a]);
 153     }
 154 }
 155 
 156 impl cce_ui::widget::Input for LoginCard {}
 157 
 158 #[derive(Debug, Clone)]
 159 struct StatusLabel {
 160     pub text: String,
 161     pub is_error: bool,
 162 }
 163 
 164 impl StatusLabel {
 165     fn new(text: String) -> cce_ui::widget::Adapted<StatusLabel> {
 166         cce_ui::widget::Adapted::new(Self { text, is_error: false })
 167     }
 168 }
 169 
 170 impl cce_ui::widget::Layout for StatusLabel {}
 171 
 172 impl cce_ui::widget::Paint for StatusLabel {
 173     fn color(&self) -> [f32; 4] { [0.0, 0.0, 0.0, 0.0] } // Transparent background
 174 
 175     fn paint(&self, rect: cce_ui::scene::layout::Rect, pc: &mut cce_ui::scene::paint::PaintCtx) {
 176         let col = if self.is_error {
 177             [0xee, 0x5c, 0x5c] // Soft red
 178         } else {
 179             [0x83, 0x83, 0x8a] // Dim text
 180         };
 181         pc.text(self.text.clone(), rect.x, rect.y, 11.0, col);
 182     }
 183 }
 184 
 185 impl cce_ui::widget::Input for StatusLabel {}
 186 
 187 #[derive(Debug, Clone)]
 188 struct SessionList {
 189     sessions: Vec<Session>,
 190     selected_idx: usize,
 191     hovered_idx: Option<usize>,
 192 }
 193 
 194 impl SessionList {
 195     fn new(sessions: Vec<Session>) -> cce_ui::widget::Adapted<SessionList> {
 196         cce_ui::widget::Adapted::new(Self {
 197             sessions,
 198             selected_idx: 0,
 199             hovered_idx: None,
 200         })
 201     }
 202 
 203     fn selected_session(&self) -> Option<&Session> {
 204         self.sessions.get(self.selected_idx)
 205     }
 206 
 207     /// Row rect of item `i` within the laid-out panel rect (header is 40px tall).
 208     fn item_rect(&self, rect: cce_ui::scene::layout::Rect, i: usize) -> (f32, f32, f32, f32) {
 209         (rect.x + 10.0, rect.y + 40.0 + i as f32 * 36.0, rect.width - 20.0, 32.0)
 210     }
 211 }
 212 
 213 impl cce_ui::widget::Layout for SessionList {}
 214 
 215 impl cce_ui::widget::Paint for SessionList {
 216     fn color(&self) -> [f32; 4] { [0.07, 0.07, 0.10, 0.70] } // Semi-transparent sleek dark card background
 217 
 218     fn paint(&self, rect: cce_ui::scene::layout::Rect, pc: &mut cce_ui::scene::paint::PaintCtx) {
 219         use cce_ui::scene::layout::Rect;
 220         // The legacy panel never drew its base color through the display getters (no
 221         // rounded corners, extra_quads only) — same here: borders, selection, hover.
 222         let border_color = [0.20, 0.40, 0.65, 0.5];
 223         pc.quad(Rect { x: rect.x, y: rect.y, width: rect.width, height: 1.5 }, border_color); // top
 224         pc.quad(Rect { x: rect.x, y: rect.y + rect.height - 1.5, width: rect.width, height: 1.5 }, border_color); // bottom
 225         pc.quad(Rect { x: rect.x, y: rect.y, width: 1.5, height: rect.height }, border_color); // left
 226         pc.quad(Rect { x: rect.x + rect.width - 1.5, y: rect.y, width: 1.5, height: rect.height }, border_color); // right
 227 
 228         let item_w = rect.width - 20.0;
 229 
 230         // Selected item background
 231         let selected_color = [0.20, 0.40, 0.65, 0.8]; // Solid blue highlight
 232         let sel_y = rect.y + 40.0 + self.selected_idx as f32 * 36.0;
 233         pc.quad(Rect { x: rect.x + 10.0, y: sel_y, width: item_w, height: 32.0 }, selected_color);
 234 
 235         // Hovered item background
 236         if let Some(h_idx) = self.hovered_idx {
 237             if h_idx != self.selected_idx && h_idx < self.sessions.len() {
 238                 let hover_color = [1.0, 1.0, 1.0, 0.06]; // Subtle white overlay
 239                 let h_y = rect.y + 40.0 + h_idx as f32 * 36.0;
 240                 pc.quad(Rect { x: rect.x + 10.0, y: h_y, width: item_w, height: 32.0 }, hover_color);
 241             }
 242         }
 243 
 244         // Header title + session rows
 245         pc.text("SESSION MANAGER".to_string(), rect.x + 15.0, rect.y + 18.0, 11.0, [0x83, 0x83, 0x8a]);
 246         for (i, session) in self.sessions.iter().enumerate() {
 247             let item_y = rect.y + 40.0 + i as f32 * 36.0;
 248             let display_name = if session.name == "Bash Shell" {
 249                 "Bash Shell".to_string()
 250             } else {
 251                 format!("{} ({})", session.name, if session.is_wayland { "Wayland" } else { "X11" })
 252             };
 253             let color = if i == self.selected_idx {
 254                 [0xff, 0xff, 0xff]
 255             } else {
 256                 [0xee, 0xee, 0xf5]
 257             };
 258             pc.text(display_name, rect.x + 20.0, item_y + 10.0, 12.0, color);
 259         }
 260     }
 261 }
 262 
 263 impl cce_ui::widget::Input for SessionList {
 264     fn on_event(&mut self, event: &cce_ui::widget::Event, ectx: &mut cce_ui::widget::EventCtx) -> bool {
 265         match event {
 266             // Hover row tracking — the legacy on_cursor_moved override, against the
 267             // routed rect (a move outside the panel clears the hover, as before).
 268             cce_ui::widget::Event::PointerMove { x, y, .. } => {
 269                 let old_hovered = self.hovered_idx;
 270                 self.hovered_idx = None;
 271                 let r = ectx.rect;
 272                 if *x >= r.x && *x <= r.x + r.width && *y >= r.y && *y <= r.y + r.height {
 273                     for i in 0..self.sessions.len() {
 274                         let (ix, iy, iw, ih) = self.item_rect(r, i);
 275                         if *x >= ix && *x <= ix + iw && *y >= iy && *y <= iy + ih {
 276                             self.hovered_idx = Some(i);
 277                             break;
 278                         }
 279                     }
 280                 }
 281                 self.hovered_idx != old_hovered
 282             }
 283             // Presses arrive hit-gated to the panel rect; select the clicked row.
 284             cce_ui::widget::Event::MouseButton {
 285                 button: MouseButton::Left,
 286                 state: ElementState::Pressed,
 287                 x,
 288                 y,
 289                 ..
 290             } => {
 291                 for i in 0..self.sessions.len() {
 292                     let (ix, iy, iw, ih) = self.item_rect(ectx.rect, i);
 293                     if *x >= ix && *x <= ix + iw && *y >= iy && *y <= iy + ih {
 294                         if self.selected_idx != i {
 295                             self.selected_idx = i;
 296                             return true;
 297                         }
 298                     }
 299                 }
 300                 false
 301             }
 302             _ => false,
 303         }
 304     }
 305 }
 306 
 307 // ── App State and Renderer ──
 308 struct State {
 309     bg: Handle<cce_ui::widget::Adapted<ContentBg>>,
 310     card: Handle<cce_ui::widget::Adapted<LoginCard>>,
 311     username_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 312     password_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 313     login_btn: Handle<cce_ui::widget::Adapted<cce_ui::widget::Button>>,
 314     status_lbl: Handle<cce_ui::widget::Adapted<StatusLabel>>,
 315     session_list: Handle<cce_ui::widget::Adapted<SessionList>>,
 316     ui_context: cce_ui::context::UiContext,
 317 
 318 
 319     cursor_x: f32,
 320     cursor_y: f32,
 321 
 322     width: f32,
 323     height: f32,
 324     physical_width: u32,
 325     physical_height: u32,
 326     scale: f64,
 327 
 328     // State tracking
 329     login_success: bool,
 330     is_authenticating: bool,
 331     auth_request_id: u64,
 332     auth_sender: channel::Sender<AuthEvent>,
 333     auth_receiver: Option<channel::Channel<AuthEvent>>,
 334     // The fingerprint attempt runs in a helper *process* (`--fprint-auth`),
 335     // not a thread: pam_authenticate blocks inside pam_fprintd and cannot be
 336     // interrupted, but a process can be killed — and killing it drops its
 337     // D-Bus connection, which is what makes fprintd release the sensor claim.
 338     fprint_child: Option<std::process::Child>,
 339     /// The password the in-flight authentication is checking: what the
 340     /// daemon's session worker must be handed on success. Empty for a
 341     /// fingerprint attempt. NOT the password box's text at success time —
 342     /// a fingerprint can succeed while a password is half-typed, and handing
 343     /// the worker that partial password sent it down the password PAM stack
 344     /// to fail the login the greeter had just accepted.
 345     auth_password: String,
 346     /// The field to focus on the first frame — see `relink_tree`.
 347     initial_focus: Option<Field>,
 348 }
 349 
 350 /// The greeter's two text fields.
 351 #[derive(Debug, Clone, Copy, PartialEq)]
 352 enum Field {
 353     Username,
 354     Password,
 355 }
 356 
 357 impl State {
 358     /// Which field has the keyboard, asked of the CONTEXT by id — the one
 359     /// record `set_focused` writes. NOT `Adapted::focused(ctx)`, which ignores
 360     /// the context and asks the wrapped widget's own flag: the greeter's Tab
 361     /// and Enter asked that until 2026-09-25, it never matched, and so Tab
 362     /// went one way only and Enter in either field did nothing.
 363     fn focused_field(&self) -> Option<Field> {
 364         let id = self.ui_context.focused_widget?;
 365         if id == self.username_box.id() {
 366             Some(Field::Username)
 367         } else if id == self.password_box.id() {
 368             Some(Field::Password)
 369         } else {
 370             None
 371         }
 372     }
 373 
 374     /// Give `field` the keyboard: the context's focus and both boxes' flags.
 375     fn focus_field(&mut self, field: Field) {
 376         match field {
 377             Field::Username => {
 378                 self.ui_context.set_focused_id(self.username_box.id());
 379                 self.ui_context.unfocus_id(self.password_box.id());
 380                 self.ui_context.focus_id(self.username_box.id());
 381             }
 382             Field::Password => {
 383                 self.ui_context.set_focused_id(self.password_box.id());
 384                 self.ui_context.unfocus_id(self.username_box.id());
 385                 self.ui_context.focus_id(self.password_box.id());
 386             }
 387         }
 388     }
 389 
 390 
 391     /// (Re-)register the widget tree at the widgets' CURRENT addresses. `new()` cannot do
 392     /// this — it would capture pointers into its own stack frame that dangle once the State
 393     /// moves — so this runs at the top of every frame. register/link are id-keyed and
 394     /// idempotent, and everything that resolves id→ptr afterwards (the paint walk's descent,
 395     /// propagate_event, the all_* child aggregation) then reads live widgets.
 396     fn relink_tree(&mut self) {
 397         let ctx = &mut self.ui_context;
 398         // Root Container DISSOLVED (Phase 6ax): the card and the session list are the two
 399         // dispatch/walk roots; register them directly (link_parent_child used to do it as a
 400         // side effect of the root links).
 401         ctx.link_ids(self.card.id(), self.username_box.id());
 402         ctx.link_ids(self.card.id(), self.password_box.id());
 403         ctx.link_ids(self.card.id(), self.login_btn.id());
 404         ctx.link_ids(self.card.id(), self.status_lbl.id());
 405         // Initial focus, on the first frame: new() cannot register it (its widgets are
 406         // about to move). It used to be read back off the boxes' own `base().focused`
 407         // flags, which a TextBox does not keep, so the context started with NO focus —
 408         // the caret showed in the password box but the context held nothing.
 409         if let Some(field) = self.initial_focus.take() {
 410             self.focus_field(field);
 411         }
 412     }
 413 
 414     fn apply_layout(&mut self) {
 415         let sw = self.width;
 416         let sh = self.height;
 417 
 418         // Background spans the whole screen
 419         self.ui_context[self.bg].set_rect(0.0, 0.0, sw, sh);
 420 
 421         // Center card configuration
 422         let card_w = 360.0;
 423         let card_h = 280.0;
 424         let card_x = (sw - card_w) / 2.0;
 425         let card_y = (sh - card_h) / 2.0;
 426         
 427         // Card is full screen to render aspect-ratio centered oval custom graphic
 428         self.ui_context[self.card].set_rect(0.0, 0.0, sw, sh);
 429 
 430         // Child components inside login card
 431         let content_x = card_x + 30.0;
 432         
 433         // The boxes' blocks are their detached label strip plus the toolkit's
 434         // textbox height.
 435         let tb_h = cce_ui::layout::textbox_height();
 436         let btn_h = cce_ui::layout::button_height();
 437 
 438         // Username text box
 439         let strip = self.ui_context[self.username_box].label_strip();
 440         self.ui_context[self.username_box].set_rect(content_x, card_y + 80.0, 300.0, tb_h + strip);
 441         
 442         // Password password box
 443         let strip = self.ui_context[self.password_box].label_strip();
 444         self.ui_context[self.password_box].set_rect(content_x, card_y + 145.0, 300.0, tb_h + strip);
 445 
 446         // Login button (full-width of the contents)
 447         let login_y = card_y + 205.0;
 448         self.ui_context[self.login_btn].set_rect(content_x, login_y, 300.0, btn_h);
 449 
 450         // Status message
 451         self.ui_context[self.status_lbl].set_rect(content_x, login_y + btn_h + 11.0, 300.0, 20.0);
 452 
 453         // Session list on top left
 454         let list_w = 260.0;
 455         let list_h = 40.0 + self.ui_context[self.session_list].sessions.len() as f32 * 36.0;
 456         self.ui_context[self.session_list].set_rect(30.0, 30.0, list_w, list_h);
 457     }
 458 
 459     pub fn widgets_cursor_moved(&mut self, cx: f32, cy: f32) -> bool {
 460         let mut changed = false;
 461         let event = cce_ui::widget::Event::PointerMove {
 462             x: cx,
 463             y: cy,
 464             local_x: cx,
 465             local_y: cy,
 466         };
 467         // Routed (6bd shrink): the background rides the same router as the other roots.
 468         let bg_root = self.bg.id();
 469         if self.ui_context.propagate_event(&event, bg_root) {
 470             changed = true;
 471         }
 472         let sl_root = self.session_list.id();
 473         let card_root = self.card.id();
 474         if self.ui_context.propagate_event(&event, sl_root) {
 475             changed = true;
 476         }
 477         if self.ui_context.propagate_event(&event, card_root) {
 478             changed = true;
 479         }
 480         changed
 481     }
 482 
 483     pub fn widgets_mouse_input(&mut self, button: MouseButton, state: ElementState, cx: f32, cy: f32) -> bool {
 484         let mut changed = false;
 485         let event = cce_ui::widget::Event::MouseButton {
 486             button,
 487             state,
 488             x: cx,
 489             y: cy,
 490             local_x: cx,
 491             local_y: cy,
 492         };
 493         // Routed (6bd shrink); the bg result stays outside `handled` so the
 494         // unfocus-on-missed-press rule below keys on the session list + card only.
 495         let bg_root = self.bg.id();
 496         if self.ui_context.propagate_event(&event, bg_root) {
 497             changed = true;
 498         }
 499         let sl_root = self.session_list.id();
 500         let card_root = self.card.id();
 501         let handled = self.ui_context.propagate_event(&event, sl_root)
 502             || self.ui_context.propagate_event(&event, card_root);
 503         if button == MouseButton::Left && state == ElementState::Pressed {
 504             if !handled {
 505                 self.ui_context.clear_focus();
 506                 self.ui_context.unfocus_id(self.username_box.id());
 507                 self.ui_context.unfocus_id(self.password_box.id());
 508                 changed = true;
 509             }
 510         }
 511         if handled {
 512             changed = true;
 513         }
 514         changed
 515     }
 516 
 517     pub fn widgets_keyboard_input(&mut self, event: &KeyEvent) -> bool {
 518         let mut changed = false;
 519         let ui_event = cce_ui::widget::Event::KeyInput(event.clone());
 520         // Fully short-circuited (the 6ac rule): every propagate call delivers KeyInput
 521         // to the ctx-focused widget first, so a non-short-circuited chain would insert
 522         // a typed key once per root.
 523         let bg_root = self.bg.id();
 524         let sl_root = self.session_list.id();
 525         let card_root = self.card.id();
 526         if self.ui_context.propagate_event(&ui_event, bg_root) {
 527             changed = true;
 528         } else if self.ui_context.propagate_event(&ui_event, sl_root) {
 529             changed = true;
 530         } else if self.ui_context.propagate_event(&ui_event, card_root) {
 531             changed = true;
 532         }
 533         changed
 534     }
 535     fn trigger_auth(&mut self) {
 536         let username = self.ui_context[self.username_box].text.trim().to_string();
 537         let password = self.ui_context[self.password_box].text.clone();
 538 
 539         if username.is_empty() {
 540             self.ui_context[self.status_lbl].text = "Username cannot be empty".to_string();
 541             self.ui_context[self.status_lbl].is_error = true;
 542             self.ui_context.set_focused_id(self.username_box.id());
 543             self.ui_context.focus_id(self.username_box.id());
 544         } else if password.is_empty() {
 545             if is_fprint_enabled() {
 546                 self.start_fprint_auth();
 547             } else {
 548                 self.ui_context[self.status_lbl].text = "Password cannot be empty".to_string();
 549                 self.ui_context[self.status_lbl].is_error = true;
 550                 self.ui_context.set_focused_id(self.password_box.id());
 551                 self.ui_context.focus_id(self.password_box.id());
 552             }
 553         } else {
 554             // A typed password supersedes any fingerprint attempt still
 555             // running; release the sensor so it is not left claimed.
 556             self.cancel_fprint_auth();
 557             self.auth_request_id += 1;
 558             self.auth_password = password.clone();
 559             self.ui_context[self.status_lbl].text = "Authenticating...".to_string();
 560             self.ui_context[self.status_lbl].is_error = false;
 561             self.is_authenticating = true;
 562             self.ui_context[self.login_btn].base_mut().label = Some("Authenticating...".to_string());
 563             authenticate_user(self.auth_request_id, username, password, self.auth_sender.clone());
 564         }
 565     }
 566 
 567     /// Start (or restart) the fingerprint attempt for the username in the box.
 568     fn start_fprint_auth(&mut self) {
 569         let username = self.ui_context[self.username_box].text.trim().to_string();
 570         self.cancel_fprint_auth();
 571         self.auth_request_id += 1;
 572         self.auth_password.clear();
 573         self.ui_context[self.status_lbl].text = "Scan finger to login or type password".to_string();
 574         self.ui_context[self.status_lbl].is_error = false;
 575         self.is_authenticating = true;
 576         self.ui_context[self.login_btn].base_mut().label = Some("Authenticating...".to_string());
 577         match spawn_fprint_helper(self.auth_request_id, &username, self.auth_sender.clone()) {
 578             Ok(child) => self.fprint_child = Some(child),
 579             Err(e) => {
 580                 log::error!("Failed to spawn fingerprint helper: {}", e);
 581                 self.is_authenticating = false;
 582                 self.ui_context[self.login_btn].base_mut().label = Some("Log In".to_string());
 583                 self.ui_context[self.status_lbl].text = "Fingerprint unavailable — type password".to_string();
 584                 self.ui_context[self.status_lbl].is_error = true;
 585             }
 586         }
 587     }
 588 
 589     /// Kill a running fingerprint helper, if any. Its exit drops the D-Bus
 590     /// connection pam_fprintd used to claim the sensor, so fprintd releases the
 591     /// device for the next attempt. Any late events it already queued are
 592     /// dropped by the request-id check in the auth event handler.
 593     fn cancel_fprint_auth(&mut self) {
 594         if let Some(mut child) = self.fprint_child.take() {
 595             let _ = child.kill();
 596             let _ = child.wait();
 597         }
 598     }
 599 }
 600 
 601 impl cce_ui::engine::Application for State {
 602     type Message = String;
 603 
 604     /// The runner reaches the widget tree through this. Without it (until
 605     /// 2026-09-25) the runner never shaped the text boxes before a frame —
 606     /// its step 0 walks `ui_context().tree` — so they recorded no glyph
 607     /// positions, and the caret fell back to a per-column grid from an
 608     /// inked-width estimate that drifted off the typed text, a little more
 609     /// with every character.
 610     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
 611         Some(&self.ui_context)
 612     }
 613 
 614     /// Tab is the login screen's own field order (username, password) and also cycles the
 615     /// session list while a field is not editing. The toolkit's Tab walk (on by default since
 616     /// 2026-10-08) would take it first.
 617     fn plate_navigation(&self) -> bool {
 618         false
 619     }
 620 
 621     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
 622         Some(&mut self.ui_context)
 623     }
 624 
 625     fn create(_sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
 626         let (auth_sender, auth_receiver) = channel::channel::<AuthEvent>();
 627 
 628         // Prepopulate username from last_user file if it exists
 629         let last_user_path = "/var/lib/cce-display-manager/last_user";
 630         let current_user = if std::path::Path::new(last_user_path).exists() {
 631             std::fs::read_to_string(last_user_path)
 632                 .map(|s| s.trim().to_string())
 633                 .unwrap_or_else(|_| String::new())
 634         } else {
 635             let env_user = std::env::var("USER").unwrap_or_else(|_| String::new());
 636             if env_user == "root" || env_user == "cce-display-manager" {
 637                 String::new()
 638             } else {
 639                 env_user
 640             }
 641         };
 642 
 643         let sessions = discover_sessions();
 644         let last_session_path = "/var/lib/cce-display-manager/last_session";
 645         let last_session_exec = if std::path::Path::new(last_session_path).exists() {
 646             std::fs::read_to_string(last_session_path)
 647                 .map(|s| s.trim().to_string())
 648                 .unwrap_or_else(|_| String::new())
 649         } else {
 650             String::new()
 651         };
 652 
 653         let mut selected_idx = 0;
 654         if !last_session_exec.is_empty() {
 655             if let Some(pos) = sessions.iter().position(|s| s.exec == last_session_exec) {
 656                 selected_idx = pos;
 657             }
 658         }
 659 
 660         let bg = ContentBg::new();
 661         let card = LoginCard::new();
 662         let username_box = TextBox::new(current_user).with_label("USERNAME");
 663         let password_box = TextBox::new(String::new()).with_password(true).with_label("PASSWORD");
 664         let login_btn = Button::new(0.0, 0.0, 300.0, cce_ui::layout::button_height()).with_label("Log In");
 665         let status_lbl = StatusLabel::new("Enter password to start".to_string());
 666         let mut session_list = SessionList::new(sessions);
 667         session_list.selected_idx = selected_idx;
 668 
 669         // The context owns the widgets; the app keeps their handles.
 670         let mut ui_context = cce_ui::context::UiContext::new();
 671         let mut app = Self {
 672             bg: ui_context.insert(bg),
 673             card: ui_context.insert(card),
 674             username_box: ui_context.insert(username_box),
 675             password_box: ui_context.insert(password_box),
 676             login_btn: ui_context.insert(login_btn),
 677             status_lbl: ui_context.insert(status_lbl),
 678             session_list: ui_context.insert(session_list),
 679             ui_context,
 680             cursor_x: 0.0,
 681             cursor_y: 0.0,
 682             width: 1024.0,
 683             height: 768.0,
 684             physical_width: 1024,
 685             physical_height: 768,
 686             scale: 1.0,
 687             login_success: false,
 688             is_authenticating: false,
 689             auth_request_id: 0,
 690             auth_sender,
 691             auth_receiver: Some(auth_receiver),
 692             fprint_child: None,
 693             auth_password: String::new(),
 694             initial_focus: None,
 695         };
 696 
 697         // The widget tree is NOT linked here: `app` is a stack local inside new(), so any
 698         // pointer registered now (tree registry, ui_context.focused_widget) dangles the
 699         // moment the State moves to its final address. relink_tree() registers the live
 700         // addresses at the top of every frame instead. Only the widgets' own focus FLAGS
 701         // (which move with the struct) are set here; relink_tree points focused_widget at
 702         // the flagged box.
 703         let has_username = !app.ui_context[app.username_box].text.trim().to_string().is_empty();
 704         let first = if has_username { Field::Password } else { Field::Username };
 705         match first {
 706             Field::Password => app.ui_context.focus_id(app.password_box.id()),
 707             Field::Username => app.ui_context.focus_id(app.username_box.id()),
 708         }
 709         app.initial_focus = Some(first);
 710 
 711         // Start the background fingerprint attempt if the username is
 712         // prepopulated and fprintd is enabled — unless this greeter is a rapid
 713         // respawn of one that just did the same. The daemon relaunches the
 714         // greeter whenever it exits without AUTH_SUCCESS (crash, F5, Ctrl+C),
 715         // and every relaunch used to fire a fresh fingerprint attempt on its
 716         // own: three respawns in 90s were three attempts nobody asked for.
 717         // After a respawn the user starts it explicitly (Enter on an empty
 718         // password box).
 719         let username = app.ui_context[app.username_box].text.trim().to_string();
 720         if !username.is_empty() && is_fprint_enabled() {
 721             if fprint_autostart_recently() {
 722                 log::info!("Greeter respawned within {}s of the last fingerprint auto-start; not auto-starting", FPRINT_AUTOSTART_COOLDOWN.as_secs());
 723                 app.ui_context[app.status_lbl].text = "Press Enter to scan finger, or type password".to_string();
 724             } else {
 725                 mark_fprint_autostart();
 726                 app.start_fprint_auth();
 727             }
 728         }
 729 
 730         app
 731     }
 732 
 733     fn settings(&self) -> WindowSettings {
 734         WindowSettings {
 735             title: "CCE Display Manager".to_string(),
 736             app_id: "cce-display-manager".to_string(),
 737             width: 1024,
 738             height: 768,
 739             fullscreen: false,
 740             min_size: Some((1024, 768)),
 741         }
 742     }
 743 
 744     fn update(&mut self, _msg: Self::Message, _needs_rebuild: &mut bool, _exit: &mut bool) {}
 745 
 746     fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
 747 
 748     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
 749         // Phase 6ah single paint path: the widget geometry (the legacy view_rounded_quads
 750         // then view() bodies, in the wrapper's order) and all text are this one list. The
 751         // card — the soft radial-glow blob with the circular clip disabled — stays in
 752         // custom_vertices, appended on top exactly as before (it is the escape-hatch layer,
 753         // not part of the display-list geometry).
 754         self.relink_tree();
 755         if (self.width - size.width as f32).abs() > 0.001 || (self.height - size.height as f32).abs() > 0.001 || (self.scale - scale).abs() > 0.001 {
 756             self.width = size.width as f32;
 757             self.height = size.height as f32;
 758             self.physical_width = (size.width * scale as f32) as u32;
 759             self.physical_height = (size.height * scale as f32) as u32;
 760             self.scale = scale;
 761             self.apply_layout();
 762         }
 763 
 764         let mut pc = cce_ui::scene::paint::PaintCtx::new();
 765 
 766         // Each root as it paints itself, through the toolkit's walk: the background, the
 767         // card — and, linked under it, the username and password fields, the Log In button
 768         // and the status line — and the session list. The card's own plate (the soft
 769         // glow) stays in custom_vertices. (Until 2026-10-08 the widgets were drawn through
 770         // the legacy tuple views — every rounded quad, then every plain quad, then the
 771         // text — so the fields and the button had none of the relief every other app's
 772         // controls have.)
 773         for root in [&self.ui_context[self.bg] as &dyn WidgetHost, &self.ui_context[self.card], &self.ui_context[self.session_list]] {
 774             cce_ui::scene::painter::paint_root_into(&self.ui_context, root, &mut pc);
 775         }
 776 
 777         pc.text_with(
 778             KEY_LEGEND.to_string(),
 779             20.0,
 780             self.height - 24.0,
 781             11.0,
 782             [0x60, 0x60, 0x6e],
 783             None,
 784             None,
 785         );
 786         pc.text_with(
 787             concat!("Built ", env!("CCE_BUILD_DATE")).to_string(),
 788             self.width - 130.0,
 789             self.height - 24.0,
 790             11.0,
 791             [0x60, 0x60, 0x6e],
 792             None,
 793             None,
 794         );
 795 
 796         Some(pc.finish())
 797     }
 798 
 799     fn display_list_text(&self) -> bool {
 800         true
 801     }
 802 
 803     fn custom_vertices(&mut self, verts: &mut Vec<Vertex>, _size: LogicalSize, _scale: f64) {
 804         let sw = self.width;
 805         let sh = self.height;
 806 
 807         let mut card_verts = widget_vertices(&self.ui_context[self.card], sw, sh);
 808         for v in &mut card_verts {
 809             v.clip_circle = [-999.0, 0.0, 0.0];
 810         }
 811         verts.extend(card_verts);
 812     }
 813 
 814     fn register_sources(&mut self, handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
 815         if let Some(auth_receiver) = self.auth_receiver.take() {
 816             handle.insert_source(auth_receiver, |event, _metadata, engine_state| {
 817                 let app = engine_state.inner.as_mut().unwrap();
 818                 let mut redraw = false;
 819                 match event {
 820                     channel::Event::Msg(msg) => {
 821                         let ev_request_id = match &msg {
 822                             AuthEvent::Success { request_id, .. } => *request_id,
 823                             AuthEvent::Failure { request_id, .. } => *request_id,
 824                             AuthEvent::Info { request_id, .. } => *request_id,
 825                         };
 826 
 827                         if ev_request_id != app.auth_request_id {
 828                             return;
 829                         }
 830 
 831                         match msg {
 832                             AuthEvent::Success { username, .. } => {
 833                                 app.fprint_child = None;
 834                                 app.is_authenticating = false;
 835                                 app.ui_context[app.login_btn].base_mut().label = Some("Log In".to_string());
 836                                 app.ui_context[app.status_lbl].text = format!("Welcome, {}!", username);
 837                                 app.ui_context[app.status_lbl].is_error = false;
 838                                 app.login_success = true;
 839                                 if let Some(session) = app.ui_context[app.session_list].selected_session() {
 840                                     println!("{}", auth_success_line(app.ui_context[app.username_box].text.trim(), &session.exec, session.is_wayland, &app.auth_password));
 841                                     std::process::exit(0);
 842                                 }
 843                             }
 844                             AuthEvent::Failure { err_msg, .. } => {
 845                                 let was_fprint = app.fprint_child.is_some();
 846                                 if let Some(mut child) = app.fprint_child.take() {
 847                                     let _ = child.wait();
 848                                 }
 849                                 app.is_authenticating = false;
 850                                 app.ui_context[app.login_btn].base_mut().label = Some("Log In".to_string());
 851                                 app.ui_context[app.status_lbl].text = if was_fprint {
 852                                     // Raw PAM codes ("AUTHINFO_UNAVAIL") told the
 853                                     // user nothing, least of all how to retry.
 854                                     format!("{} — press Enter to scan again, or type password", fprint_failure_text(&err_msg))
 855                                 } else {
 856                                     password_failure_text(&err_msg)
 857                                 };
 858                                 app.ui_context[app.status_lbl].is_error = true;
 859                                 app.ui_context[app.password_box].text.clear();
 860                                 app.ui_context[app.password_box].edit_buffer.clear();
 861                                 app.ui_context.set_focused_id(app.password_box.id());
 862                                 app.ui_context.focus_id(app.password_box.id());
 863                             }
 864                             AuthEvent::Info { msg, .. } => {
 865                                 app.ui_context[app.status_lbl].text = msg;
 866                                 app.ui_context[app.status_lbl].is_error = false;
 867                             }
 868                         }
 869                         redraw = true;
 870                     }
 871                     channel::Event::Closed => {}
 872                 }
 873                 if redraw {
 874                     engine_state.redraw = true;
 875                 }
 876             }).unwrap();
 877         }
 878     }
 879 
 880     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
 881         let lx = pos.x as f32;
 882         let ly = pos.y as f32;
 883         self.cursor_x = lx;
 884         self.cursor_y = ly;
 885         // The shared context menu (the username / password box's) gets the
 886         // pointer to itself while open: its row highlight.
 887         if cce_ui::widget::context_menu::is_visible() {
 888             if cce_ui::widget::context_menu::cursor_moved(lx, ly) {
 889                 *needs_rebuild = true;
 890             }
 891             return;
 892         }
 893         if self.widgets_cursor_moved(lx, ly) {
 894             *needs_rebuild = true;
 895         }
 896     }
 897 
 898     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
 899         let lx = pos.x as f32;
 900         let ly = pos.y as f32;
 901         // The shared context menu a right-click on the username or password box
 902         // opens takes every click while open: a row runs, a press anywhere else
 903         // dismisses it. Ahead of the authenticating gate, so a menu still open
 904         // when a login starts can be dismissed. The toolkit leaves this routing to
 905         // the app; without it the menu could not be closed by clicking outside it,
 906         // and its rows did nothing.
 907         if cce_ui::widget::context_menu::is_visible() {
 908             if cce_ui::widget::context_menu::mouse_input(button, state, lx, ly, Some(&mut self.ui_context)) {
 909                 *needs_rebuild = true;
 910             }
 911             return None;
 912         }
 913         if self.is_authenticating {
 914             return None;
 915         }
 916         let mut changed = false;
 917         if self.widgets_mouse_input(button, state, lx, ly) {
 918             changed = true;
 919         }
 920         if button == MouseButton::Left && state == ElementState::Pressed {
 921             if self.ui_context[self.login_btn].take_click() {
 922                 self.trigger_auth();
 923                 changed = true;
 924             }
 925         }
 926         if changed {
 927             *needs_rebuild = true;
 928         }
 929         None
 930     }
 931 
 932     fn handle_mouse_wheel(&mut self, _delta: &MouseScrollDelta, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
 933 
 934     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
 935         let logical_key = &event.logical_key;
 936         let ctrl_pressed = event.ctrl;
 937 
 938         // Check for Ctrl+C to abort/exit back to TTY
 939         if ctrl_pressed && (logical_key == &Key::Character("c".to_string()) || logical_key == &Key::Character("C".to_string())) {
 940             log::error!("Ctrl+C pressed. Aborting greeter.");
 941             std::process::exit(130);
 942         }
 943 
 944         // F1 / F2: power off / reboot — ly's keys, which this machine's
 945         // login screen used before this one. Immediate, as there: nobody is
 946         // logged in at the greeter, so there is nothing to lose.
 947         if event.state == ElementState::Pressed {
 948             let power = match logical_key {
 949                 Key::Named(NamedKey::F1) => Some(PowerAction::PowerOff),
 950                 Key::Named(NamedKey::F2) => Some(PowerAction::Reboot),
 951                 _ => None,
 952             };
 953             if let Some(action) = power {
 954                 // A new attempt number, as a typed password takes: the scan
 955                 // being cancelled reports "helper exited" as it dies, and
 956                 // without the bump that late failure overwrote this status.
 957                 self.cancel_fprint_auth();
 958                 self.auth_request_id += 1;
 959                 self.is_authenticating = false;
 960                 let (text, is_error) = match run_power_action(action) {
 961                     Ok(()) => (action.progress().to_string(), false),
 962                     Err(e) => (format!("Could not {}: {}", action.verb(), e), true),
 963                 };
 964                 self.ui_context[self.status_lbl].text = text;
 965                 self.ui_context[self.status_lbl].is_error = is_error;
 966                 *needs_rebuild = true;
 967                 return None;
 968             }
 969         }
 970 
 971         // Check for F5 to request daemon restart
 972         if logical_key == &Key::Named(NamedKey::F5) {
 973             log::info!("F5 pressed. Requesting daemon restart.");
 974             std::process::exit(135);
 975         }
 976 
 977         let is_ctrl_p = ctrl_pressed && (logical_key == &Key::Character("p".to_string()) || logical_key == &Key::Character("P".to_string()));
 978         let is_ctrl_n = ctrl_pressed && (logical_key == &Key::Character("n".to_string()) || logical_key == &Key::Character("N".to_string()));
 979 
 980         if event.state == ElementState::Pressed {
 981             // If we are currently in fingerprint authentication and the user starts typing a password,
 982             // cancel the fingerprint auth and let them type.
 983             if self.is_authenticating {
 984                 if self.ui_context[self.password_box].text.is_empty() {
 985                     let is_typing = !ctrl_pressed && match logical_key {
 986                         Key::Character(_) | Key::Named(NamedKey::Backspace) | Key::Named(NamedKey::Delete) | Key::Named(NamedKey::Space) => true,
 987                         _ => false,
 988                     };
 989                     if is_typing {
 990                         self.cancel_fprint_auth();
 991                         self.auth_request_id += 1;
 992                         self.is_authenticating = false;
 993                         self.ui_context[self.login_btn].base_mut().label = Some("Log In".to_string());
 994                         self.ui_context[self.status_lbl].text = "Enter password to start".to_string();
 995                         self.ui_context[self.status_lbl].is_error = false;
 996                     } else {
 997                         let is_nav = match logical_key {
 998                             Key::Named(NamedKey::ArrowUp) | Key::Named(NamedKey::ArrowDown) | Key::Named(NamedKey::Tab) => true,
 999                             _ => is_ctrl_p || is_ctrl_n,
1000                         };
1001                         if !is_nav {
1002                             return None;
1003                         }
1004                     }
1005                 } else {
1006                     return None;
1007                 }
1008             }
1009 
1010             let mut changed = false;
1011 
1012             // Handle Up/Down or Ctrl+P/N navigation to cycle sessions
1013             let cycle_up = (logical_key == &Key::Named(NamedKey::ArrowUp) || is_ctrl_p) && !self.ui_context[self.session_list].sessions.is_empty();
1014             let cycle_down = (logical_key == &Key::Named(NamedKey::ArrowDown) || is_ctrl_n) && !self.ui_context[self.session_list].sessions.is_empty();
1015 
1016             if cycle_up {
1017                 let len = self.ui_context[self.session_list].sessions.len();
1018                 self.ui_context[self.session_list].selected_idx = (self.ui_context[self.session_list].selected_idx + len - 1) % len;
1019                 self.ui_context[self.session_list].hovered_idx = None;
1020                 changed = true;
1021             } else if cycle_down {
1022                 let len = self.ui_context[self.session_list].sessions.len();
1023                 self.ui_context[self.session_list].selected_idx = (self.ui_context[self.session_list].selected_idx + 1) % len;
1024                 self.ui_context[self.session_list].hovered_idx = None;
1025                 changed = true;
1026             } else if logical_key == &Key::Named(NamedKey::Tab) {
1027                 let next = match self.focused_field() {
1028                     Some(Field::Username) => Field::Password,
1029                     _ => Field::Username,
1030                 };
1031                 self.focus_field(next);
1032                 changed = true;
1033             } else if logical_key == &Key::Named(NamedKey::Enter) {
1034                 // Enter logs in from anywhere — except from the username with no
1035                 // password yet, where it moves on to the password (trigger_auth
1036                 // would start a fingerprint scan or say the password is empty).
1037                 // The key goes to the focused box first so it commits its edit.
1038                 let field = self.focused_field();
1039                 let root = match field {
1040                     Some(Field::Username) => Some(self.username_box.id()),
1041                     Some(Field::Password) => Some(self.password_box.id()),
1042                     None => None,
1043                 };
1044                 if let Some(root) = root {
1045                     let kev = cce_ui::widget::Event::KeyInput(event.clone());
1046                     let _ = self.ui_context.propagate_event(&kev, root);
1047                 }
1048                 if field == Some(Field::Username) && self.ui_context[self.password_box].text.is_empty() {
1049                     self.focus_field(Field::Password);
1050                 } else {
1051                     self.trigger_auth();
1052                 }
1053                 changed = true;
1054             } else {
1055                 if self.widgets_keyboard_input(event) {
1056                     changed = true;
1057                 }
1058             }
1059 
1060             if changed {
1061                 *needs_rebuild = true;
1062             }
1063         }
1064 
1065         None
1066     }
1067 
1068     fn clear_color(&self) -> [f32; 4] {
1069         [0.03, 0.03, 0.05, 1.0]
1070     }
1071 }
1072 
1073 /// What the footer says the keys do — every key the greeter answers to that
1074 /// is not obvious from the fields themselves.
1075 const KEY_LEGEND: &str = "F1 Power off  ·  F2 Reboot  ·  F5 Restart login screen  ·  Tab Switch field  ·  Up/Down Session";
1076 
1077 #[derive(Debug, Clone, Copy, PartialEq)]
1078 enum PowerAction {
1079     PowerOff,
1080     Reboot,
1081 }
1082 
1083 impl PowerAction {
1084     /// The `systemctl` verb.
1085     fn command(self) -> &'static str {
1086         match self {
1087             PowerAction::PowerOff => "poweroff",
1088             PowerAction::Reboot => "reboot",
1089         }
1090     }
1091     fn verb(self) -> &'static str {
1092         match self {
1093             PowerAction::PowerOff => "power off",
1094             PowerAction::Reboot => "reboot",
1095         }
1096     }
1097     fn progress(self) -> &'static str {
1098         match self {
1099             PowerAction::PowerOff => "Powering off…",
1100             PowerAction::Reboot => "Rebooting…",
1101         }
1102     }
1103 }
1104 
1105 /// `systemctl poweroff` / `reboot`. The greeter runs as root, so no polkit
1106 /// agent is needed; systemctl returns once the job is queued. `Err` carries
1107 /// what to show on the status line.
1108 fn run_power_action(action: PowerAction) -> Result<(), String> {
1109     log::info!("{} requested at the greeter", action.command());
1110     match std::process::Command::new("systemctl").arg(action.command()).status() {
1111         Ok(s) if s.success() => Ok(()),
1112         Ok(s) => Err(format!("systemctl {} exited {}", action.command(), s)),
1113         Err(e) => Err(e.to_string()),
1114     }
1115 }
1116 
1117 #[derive(Debug, Clone)]
1118 enum AuthEvent {
1119     Success { request_id: u64, username: String },
1120     Failure { request_id: u64, err_msg: String },
1121     Info { request_id: u64, msg: String },
1122 }
1123 
1124 /// PAM service for the fingerprint attempt. It must be fingerprint-ONLY
1125 /// (`auth requisite pam_fprintd.so`, no system-local-login include in the auth
1126 /// stack): the old layout had pam_fprintd `sufficient` above the include, so a
1127 /// miss fell through into pam_unix with an empty password — one pam_faillock
1128 /// strike per miss, and after three the correct password was rejected too.
1129 const FPRINT_PAM_SERVICE: &str = "cce-display-manager-fprint";
1130 const PASSWORD_PAM_SERVICE: &str = "cce-display-manager-password";
1131 
1132 /// A greeter that starts within this window of the previous auto-start is a
1133 /// respawn; it does not auto-start the fingerprint attempt again.
1134 const FPRINT_AUTOSTART_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(20);
1135 const FPRINT_AUTOSTART_STAMP: &str = "/run/cce-display-manager/fprint-autostart";
1136 
1137 /// Human text for the verdict the fingerprint helper reports (a PamReturnCode
1138 /// Debug name, or a helper-level message).
1139 /// A failed password check in words. The status line used to show PAM's
1140 /// code verbatim — `AUTH_ERR` for a wrong password.
1141 fn password_failure_text(code: &str) -> String {
1142     match code {
1143         "AUTH_ERR" | "USER_UNKNOWN" => "Incorrect username or password".to_string(),
1144         "MAXTRIES" | "PERM_DENIED" => "Too many failed attempts — wait and try again".to_string(),
1145         "ACCT_EXPIRED" | "NEW_AUTHTOK_REQD" => "This account's password has expired".to_string(),
1146         "AUTHINFO_UNAVAIL" | "SERVICE_ERR" | "SYSTEM_ERR" => "Could not check the password (system error)".to_string(),
1147         other => format!("Login failed ({})", other),
1148     }
1149 }
1150 
1151 fn fprint_failure_text(code: &str) -> String {
1152     match code {
1153         // pam_fprintd: verify timed out, or the user has no enrolled prints.
1154         "AUTHINFO_UNAVAIL" => "No fingerprint read (timed out or none enrolled)".to_string(),
1155         "MAXTRIES" | "AUTH_ERR" => "Fingerprint not recognized".to_string(),
1156         "SERVICE_ERR" | "SYSTEM_ERR" => "Fingerprint reader unavailable".to_string(),
1157         other => format!("Fingerprint failed ({})", other),
1158     }
1159 }
1160 
1161 fn fprint_autostart_recently() -> bool {
1162     std::fs::metadata(FPRINT_AUTOSTART_STAMP)
1163         .and_then(|m| m.modified())
1164         .ok()
1165         .and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
1166         .map(|age| age < FPRINT_AUTOSTART_COOLDOWN)
1167         .unwrap_or(false)
1168 }
1169 
1170 fn mark_fprint_autostart() {
1171     if let Some(dir) = std::path::Path::new(FPRINT_AUTOSTART_STAMP).parent() {
1172         let _ = std::fs::create_dir_all(dir);
1173     }
1174     if let Err(e) = std::fs::write(FPRINT_AUTOSTART_STAMP, b"") {
1175         log::warn!("Could not write {}: {}", FPRINT_AUTOSTART_STAMP, e);
1176     }
1177 }
1178 
1179 fn is_fprint_enabled() -> bool {
1180     std::fs::read_to_string(format!("/etc/pam.d/{}", FPRINT_PAM_SERVICE))
1181         .map(|content| {
1182             content.lines().any(|line| {
1183                 let trimmed = line.trim();
1184                 trimmed.contains("pam_fprintd.so") && !trimmed.starts_with('#')
1185             })
1186         })
1187         .unwrap_or(false)
1188 }
1189 
1190 /// Password authentication, in a thread. Fingerprint goes through
1191 /// `spawn_fprint_helper` instead — never call this with an empty password.
1192 fn authenticate_user(request_id: u64, username: String, password: String, sender: channel::Sender<AuthEvent>) {
1193     debug_assert!(!password.is_empty(), "empty password must go through the fingerprint helper");
1194     std::thread::spawn(move || {
1195         let service = PASSWORD_PAM_SERVICE;
1196 
1197         let mut auth = match PamSession::new(service, &username, &password, request_id, Some(sender.clone())) {
1198             Ok(a) => a,
1199             Err(e) => {
1200                 let _ = sender.send(AuthEvent::Failure { request_id, err_msg: format!("{:?}", e) });
1201                 return;
1202             }
1203         };
1204 
1205         // authenticate() + acct_mgmt() is the whole credential check. Do NOT
1206         // open a PAM session here: the daemon's session worker
1207         // (launch_session) opens the real one. The greeter used to call
1208         // open_session() too, which registered a throwaway logind session
1209         // with this process as leader and ran pam_gnome_keyring's
1210         // auto_start — a fork() out of this multi-threaded Vulkan process
1211         // that then setuid()s and exec()s gnome-keyring-daemon. That child
1212         // could wedge before exec (seen 2026-09-18), and gkr-pam waits on
1213         // its pipes with no timeout, so the login froze on
1214         // "Authenticating..." after the password had been accepted.
1215         if let Err(e) = auth.authenticate() {
1216             let _ = sender.send(AuthEvent::Failure { request_id, err_msg: format!("{:?}", e) });
1217             return;
1218         }
1219 
1220         let _ = sender.send(AuthEvent::Success { request_id, username });
1221     });
1222 }
1223 
1224 /// Line protocol between the greeter and its `--fprint-auth` helper (on the
1225 /// helper's stdout — which is a pipe to the greeter, NOT the greeter's own
1226 /// stdout, which carries AUTH_SUCCESS to the daemon).
1227 const FPRINT_LINE_INFO: &str = "INFO|";
1228 const FPRINT_LINE_OK: &str = "OK";
1229 const FPRINT_LINE_FAIL: &str = "FAIL|";
1230 
1231 /// Spawn `<self> --fprint-auth <user>` and forward its result lines as
1232 /// AuthEvents tagged with `request_id`. The helper is bound to the greeter with
1233 /// PR_SET_PDEATHSIG so a crashed or respawned greeter cannot leave it running
1234 /// with the sensor claimed (that "Device was already claimed" state made every
1235 /// later attempt fail instantly).
1236 fn spawn_fprint_helper(request_id: u64, username: &str, sender: channel::Sender<AuthEvent>) -> std::io::Result<std::process::Child> {
1237     use std::os::unix::process::CommandExt;
1238     let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/cce-display-manager"));
1239     let mut cmd = std::process::Command::new(exe);
1240     cmd.arg("--fprint-auth")
1241         .arg(username)
1242         .stdin(std::process::Stdio::null())
1243         .stdout(std::process::Stdio::piped())
1244         .stderr(std::process::Stdio::inherit());
1245     unsafe {
1246         cmd.pre_exec(|| {
1247             // Runs in the child between fork and exec; PDEATHSIG survives exec.
1248             if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) != 0 {
1249                 return Err(std::io::Error::last_os_error());
1250             }
1251             // Parent already gone (raced between fork and prctl)? Then die now.
1252             if libc::getppid() == 1 {
1253                 libc::_exit(1);
1254             }
1255             Ok(())
1256         });
1257     }
1258     let mut child = cmd.spawn()?;
1259     let stdout = child.stdout.take().expect("piped stdout");
1260     let username = username.to_string();
1261     std::thread::spawn(move || {
1262         use std::io::BufRead;
1263         let mut concluded = false;
1264         for line in std::io::BufReader::new(stdout).lines() {
1265             let line = match line { Ok(l) => l, Err(_) => break };
1266             if let Some(msg) = line.strip_prefix(FPRINT_LINE_INFO) {
1267                 let _ = sender.send(AuthEvent::Info { request_id, msg: msg.to_string() });
1268             } else if line == FPRINT_LINE_OK {
1269                 concluded = true;
1270                 let _ = sender.send(AuthEvent::Success { request_id, username: username.clone() });
1271             } else if let Some(msg) = line.strip_prefix(FPRINT_LINE_FAIL) {
1272                 concluded = true;
1273                 let _ = sender.send(AuthEvent::Failure { request_id, err_msg: msg.to_string() });
1274             }
1275         }
1276         if !concluded {
1277             // EOF without a verdict: killed (cancelled) or crashed. A cancel
1278             // has already bumped auth_request_id, so this is dropped there.
1279             let _ = sender.send(AuthEvent::Failure { request_id, err_msg: "Fingerprint helper exited".to_string() });
1280         }
1281     });
1282     Ok(child)
1283 }
1284 
1285 /// `--fprint-auth <user>`: run the fingerprint-only PAM service to a verdict
1286 /// and report it on stdout. Authenticate + account check only — the greeter
1287 /// prints AUTH_SUCCESS with an empty password and the daemon opens the real
1288 /// session on cce-display-manager-autologin, so opening one here would just
1289 /// register a throwaway logind session under cage.
1290 fn run_fprint_helper(username: &str) -> ! {
1291     use std::io::Write;
1292     let (sender, receiver) = channel::channel::<AuthEvent>();
1293     let user = username.to_string();
1294     let worker = std::thread::spawn(move || {
1295         let mut auth = PamSession::new(FPRINT_PAM_SERVICE, &user, "", 0, Some(sender.clone()))
1296             .map_err(|e| format!("{:?}", e))?;
1297         auth.authenticate().map_err(|e| format!("{:?}", e))
1298     });
1299     let mut out = std::io::stdout();
1300     // Forward conversation messages (e.g. "Place your finger on the sensor")
1301     // until the worker's sender is dropped, i.e. the verdict is in.
1302     while let Ok(ev) = receiver.recv() {
1303         if let AuthEvent::Info { msg, .. } = ev {
1304             let _ = writeln!(out, "{}{}", FPRINT_LINE_INFO, msg.replace('\n', " "));
1305             let _ = out.flush();
1306         }
1307     }
1308     let verdict = match worker.join() {
1309         Ok(Ok(())) => FPRINT_LINE_OK.to_string(),
1310         Ok(Err(e)) => format!("{}{}", FPRINT_LINE_FAIL, e),
1311         Err(_) => format!("{}fingerprint worker panicked", FPRINT_LINE_FAIL),
1312     };
1313     let _ = writeln!(out, "{}", verdict);
1314     let _ = out.flush();
1315     std::process::exit(if verdict == FPRINT_LINE_OK { 0 } else { 1 });
1316 }
1317 
1318 #[derive(serde::Deserialize, Debug, Default)]
1319 struct SystemConfig {
1320     scale: Option<f64>,
1321 }
1322 
1323 fn load_system_config() -> SystemConfig {
1324     let path = "/etc/cce/cce.json";
1325     if std::path::Path::new(path).exists() {
1326         if let Ok(content) = std::fs::read_to_string(path) {
1327             if let Ok(config) = serde_json::from_str(&content) {
1328                 return config;
1329             }
1330         }
1331     }
1332     SystemConfig::default()
1333 }
1334 
1335 fn run_greeter() {
1336     let sys_config = load_system_config();
1337     let layout_scale = sys_config.scale.unwrap_or(1.0);
1338     let cursor_size = (24.0 * layout_scale) as u32;
1339     std::env::set_var("XCURSOR_SIZE", cursor_size.to_string());
1340     // cage reports a scale-1 output, so on a HiDPI panel the greeter would lay
1341     // out in physical pixels (everything half-size). cce-ui's forced-scale mode
1342     // scales layout/rendering by the system scale while keeping buffer_scale 1.
1343     if layout_scale > 1.0 && std::env::var("CCE_FORCE_SCALE").is_err() {
1344         std::env::set_var("CCE_FORCE_SCALE", layout_scale.to_string());
1345     }
1346 
1347     cce_ui::engine::run::<State>();
1348 
1349     std::process::exit(1);
1350 }
1351 
1352 struct PamSessionData {
1353     username: String,
1354     password: String,
1355     request_id: u64,
1356     sender: Option<channel::Sender<AuthEvent>>,
1357 }
1358 
1359 extern "C" fn pam_conversation_fn(
1360     num_msg: libc::c_int,
1361     msg: *mut *mut pam_sys::PamMessage,
1362     out_resp: *mut *mut pam_sys::PamResponse,
1363     appdata_ptr: *mut libc::c_void,
1364 ) -> libc::c_int {
1365     let data = unsafe { &*(appdata_ptr as *const PamSessionData) };
1366     let resp_size = std::mem::size_of::<pam_sys::PamResponse>();
1367     let resp = unsafe { libc::calloc(num_msg as usize, resp_size) as *mut pam_sys::PamResponse };
1368     if resp.is_null() {
1369         return pam_sys::PamReturnCode::BUF_ERR as libc::c_int;
1370     }
1371 
1372     for i in 0..num_msg as isize {
1373         unsafe {
1374             let m = &**msg.offset(i);
1375             let r = &mut *resp.offset(i);
1376             let style = m.msg_style;
1377             // unwrap_or_default, not unwrap: an interior NUL in the typed
1378             // password would otherwise panic across this extern "C" boundary
1379             // (process abort). An empty response just fails authentication.
1380             if style == pam_sys::PamMessageStyle::PROMPT_ECHO_ON as libc::c_int {
1381                 let user_c = std::ffi::CString::new(data.username.clone()).unwrap_or_default();
1382                 r.resp = libc::strdup(user_c.as_ptr());
1383             } else if style == pam_sys::PamMessageStyle::PROMPT_ECHO_OFF as libc::c_int {
1384                 let pass_c = std::ffi::CString::new(data.password.clone()).unwrap_or_default();
1385                 r.resp = libc::strdup(pass_c.as_ptr());
1386             } else if style == pam_sys::PamMessageStyle::ERROR_MSG as libc::c_int || style == pam_sys::PamMessageStyle::TEXT_INFO as libc::c_int {
1387                 if !m.msg.is_null() {
1388                     let msg_str = std::ffi::CStr::from_ptr(m.msg).to_string_lossy().into_owned();
1389                     if let Some(ref sender) = data.sender {
1390                         let _ = sender.send(AuthEvent::Info { request_id: data.request_id, msg: msg_str });
1391                     }
1392                 }
1393             }
1394         }
1395     }
1396 
1397     unsafe { *out_resp = resp };
1398     pam_sys::PamReturnCode::SUCCESS as libc::c_int
1399 }
1400 
1401 struct PamSession {
1402     handle: *mut pam_sys::PamHandle,
1403     _data: Box<PamSessionData>,
1404     has_open_session: bool,
1405 }
1406 
1407 impl PamSession {
1408     fn new(service: &str, username: &str, password: &str, request_id: u64, sender: Option<channel::Sender<AuthEvent>>) -> Result<Self, pam_sys::PamReturnCode> {
1409         let mut handle: *mut pam_sys::PamHandle = std::ptr::null_mut();
1410         let data = Box::new(PamSessionData {
1411             username: username.to_string(),
1412             password: password.to_string(),
1413             request_id,
1414             sender,
1415         });
1416         
1417         let conv = pam_sys::PamConversation {
1418             conv: Some(pam_conversation_fn),
1419             data_ptr: &*data as *const PamSessionData as *mut libc::c_void,
1420         };
1421 
1422         let rc = pam_sys::start(service, Some(username), &conv, &mut handle);
1423         if rc != pam_sys::PamReturnCode::SUCCESS {
1424             return Err(rc);
1425         }
1426 
1427         unsafe {
1428             let pass_c = std::ffi::CString::new(password).unwrap_or_default();
1429             let _ = pam_sys::raw::pam_set_item(handle, pam_sys::PamItemType::AUTHTOK as libc::c_int, pass_c.as_ptr() as *const libc::c_void);
1430             
1431             let raw_tty = std::fs::read_link("/proc/self/fd/0")
1432                 .ok()
1433                 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1434                 .unwrap_or_else(|| "tty1".to_string());
1435             let is_real_tty = raw_tty.starts_with("tty");
1436             let tty_name = if is_real_tty { raw_tty } else { "tty1".to_string() };
1437 
1438             let tty_c = std::ffi::CString::new(tty_name).unwrap();
1439             let _ = pam_sys::raw::pam_set_item(handle, pam_sys::PamItemType::TTY as libc::c_int, tty_c.as_ptr() as *const libc::c_void);
1440         }
1441 
1442         Ok(Self { handle, _data: data, has_open_session: false })
1443     }
1444 
1445     fn putenv(&mut self, name_value: &str) -> Result<(), pam_sys::PamReturnCode> {
1446         let c_str = std::ffi::CString::new(name_value).unwrap();
1447         let rc = unsafe { pam_sys::raw::pam_putenv(self.handle, c_str.as_ptr()) };
1448         if rc == 0 {
1449             Ok(())
1450         } else {
1451             Err(unsafe { std::mem::transmute(rc as u8) })
1452         }
1453     }
1454 
1455     fn authenticate(&mut self) -> Result<(), pam_sys::PamReturnCode> {
1456         unsafe {
1457             let rc = pam_sys::authenticate(&mut *self.handle, pam_sys::PamFlag::NONE);
1458             if rc != pam_sys::PamReturnCode::SUCCESS {
1459                 return Err(rc);
1460             }
1461 
1462             let rc = pam_sys::acct_mgmt(&mut *self.handle, pam_sys::PamFlag::NONE);
1463             if rc != pam_sys::PamReturnCode::SUCCESS {
1464                 return Err(rc);
1465             }
1466         }
1467         Ok(())
1468     }
1469 
1470     fn open_session(&mut self) -> Result<(), pam_sys::PamReturnCode> {
1471         unsafe {
1472             let rc = pam_sys::setcred(&mut *self.handle, pam_sys::PamFlag::ESTABLISH_CRED);
1473             if rc != pam_sys::PamReturnCode::SUCCESS {
1474                 return Err(rc);
1475             }
1476 
1477             let rc = pam_sys::open_session(&mut *self.handle, pam_sys::PamFlag::NONE);
1478             if rc != pam_sys::PamReturnCode::SUCCESS {
1479                 return Err(rc);
1480             }
1481 
1482             // Follow openSSH and call pam_setcred before and after open_session
1483             let rc = pam_sys::setcred(&mut *self.handle, pam_sys::PamFlag::REINITIALIZE_CRED);
1484             if rc != pam_sys::PamReturnCode::SUCCESS {
1485                 return Err(rc);
1486             }
1487         }
1488         self.has_open_session = true;
1489         Ok(())
1490     }
1491 
1492     fn get_env(&mut self) -> Vec<(String, String)> {
1493         let mut vec = Vec::new();
1494         unsafe {
1495             let env_list = pam_sys::getenvlist(&mut *self.handle);
1496             if !env_list.is_null() {
1497                 let mut idx = 0;
1498                 loop {
1499                     let env_ptr = *env_list.offset(idx);
1500                     if !env_ptr.is_null() {
1501                         idx += 1;
1502                         let env_str = std::ffi::CStr::from_ptr(env_ptr).to_string_lossy();
1503                         let split: Vec<_> = env_str.splitn(2, '=').collect();
1504                         if split.len() == 2 {
1505                             vec.push((split[0].to_string(), split[1].to_string()));
1506                         }
1507                     } else {
1508                         break;
1509                     }
1510                 }
1511                 pam_sys::raw::pam_misc_drop_env(env_list as *mut *mut libc::c_char);
1512             }
1513         }
1514         vec
1515     }
1516 }
1517 
1518 impl Drop for PamSession {
1519     fn drop(&mut self) {
1520         unsafe {
1521             if self.has_open_session {
1522                 pam_sys::close_session(&mut *self.handle, pam_sys::PamFlag::NONE);
1523             }
1524             let rc = pam_sys::setcred(&mut *self.handle, pam_sys::PamFlag::DELETE_CRED);
1525             pam_sys::end(&mut *self.handle, rc);
1526         }
1527     }
1528 }
1529 
1530 /// PID of the greeter's `cage` process while a greeter is showing, else 0.
1531 /// Shared with the resume watchdog so it can force a clean greeter respawn
1532 /// after sleep without racing the daemon's blocking read of the greeter's
1533 /// stdout. Set right after the cage is spawned, cleared once it is reaped.
1534 static GREETER_CAGE_PID: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
1535 
1536 fn run_daemon() {
1537     let uid = users::get_current_uid();
1538     if uid != 0 {
1539         log::error!("Error: Daemon mode must be run as root (UID 0). Effective UID: {}", uid);
1540         log::info!("For local development/testing, run with: cargo run -- --greeter");
1541         std::process::exit(1);
1542     }
1543 
1544     let raw_tty = std::fs::read_link("/proc/self/fd/0")
1545         .ok()
1546         .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1547         .unwrap_or_else(|| "tty1".to_string());
1548     let is_real_tty = raw_tty.starts_with("tty");
1549     let tty_name = if is_real_tty { raw_tty } else { "tty1".to_string() };
1550 
1551     // Where the daemon, the greeter, cage and the session worker log. Under
1552     // the unit's StandardOutput=journal systemd has connected stdout/stderr
1553     // to the journal and says so in JOURNAL_STREAM: leave them there —
1554     // journald keeps every boot and rotates. Otherwise (an older unit, which
1555     // points them at the tty) redirect to a file. /var/log, not /tmp: only
1556     // root can create names there, so a local user cannot pre-place a file
1557     // or symlink at the predictable path for root to open and truncate.
1558     let log_path = format!("/var/log/cce-display-manager-{}.log", tty_name);
1559     let journaled = std::env::var_os("JOURNAL_STREAM").is_some();
1560     if !journaled {
1561         if let Ok(log_file) = std::fs::OpenOptions::new()
1562             .create(true)
1563             .write(true)
1564             .truncate(true)
1565             .open(&log_path)
1566         {
1567             use std::os::unix::io::AsRawFd;
1568             let fd = log_file.as_raw_fd();
1569             unsafe {
1570                 libc::dup2(fd, 1);
1571                 libc::dup2(fd, 2);
1572             }
1573         }
1574     }
1575 
1576     log::info!("Starting display manager daemon on {}...", tty_name);
1577 
1578     let runtime_dir = format!("/run/cce-display-manager-{}", tty_name);
1579     if !std::path::Path::new(&runtime_dir).exists() {
1580         std::fs::create_dir_all(&runtime_dir).expect("failed to create runtime dir");
1581         use std::os::unix::fs::PermissionsExt;
1582         std::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
1583             .expect("failed to set runtime dir permissions");
1584     }
1585     // Set when the compositor requested a restart (`ccectl restart-compositor`
1586     // wrote the flag file and exited): the next loop iteration relaunches the
1587     // same session directly — no greeter, autologin PAM service.
1588     let mut pending_relaunch: Option<(String, String, bool)> = None;
1589 
1590     // Recover the greeter across suspend/resume: resume leaves the greeter's
1591     // cage DRM-paused and it cannot reliably reacquire the seat on its own.
1592     if is_real_tty {
1593         spawn_resume_watchdog(tty_name.clone());
1594     }
1595 
1596     loop {
1597         if let Some((username, exec, is_wayland)) = pending_relaunch.take() {
1598             log::info!(
1599                 "Compositor restart requested: relaunching '{}' for {} without the greeter",
1600                 exec, username
1601             );
1602             launch_session(username, exec, is_wayland, String::new(), &tty_name, &mut pending_relaunch);
1603             continue;
1604         }
1605 
1606         if is_real_tty {
1607             // Actively claim tty1 rather than passively waiting for it: after a
1608             // resume (or any stray VT switch) tty1 may not be foreground, and a
1609             // greeter cage spawned onto an inactive VT comes up DRM-paused.
1610             log::info!("Ensuring {} is the active TTY before spawning greeter...", tty_name);
1611             ensure_vt_active(&tty_name);
1612         }
1613 
1614         log::info!("Spawning greeter session via cage...");
1615  
1616         let mut exe_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/cce-display-manager"));
1617         if !exe_path.exists() {
1618             exe_path = std::path::PathBuf::from("/usr/bin/cce-display-manager");
1619         }
1620 
1621         let mut child = std::process::Command::new("cage")
1622             .arg("-s")
1623             .arg("--")
1624             .arg(exe_path)
1625             .arg("--greeter")
1626             .env("XDG_RUNTIME_DIR", &runtime_dir)
1627             // The greeter runs as root: cce-ui's default bundled-fonts dir
1628             // ($HOME/Dropbox/Fonts) doesn't exist for root, and an empty font
1629             // db panics on the first shaped glyph. Point it at a system
1630             // location and load installed system fonts as a fallback.
1631             .env("CCE_FONTS_DIR", "/usr/share/fonts/cce")
1632             .env("CCE_LOAD_SYSTEM_FONTS", "1")
1633             .env("LIBSEAT_BACKEND", "seatd")
1634             .env("WLR_DRM_NO_MODIFIERS", "1")
1635             .env("WLR_DRM_DEVICES", "/dev/dri/card1:/dev/dri/card0")
1636             .stdout(std::process::Stdio::piped())
1637             .spawn()
1638             .expect("failed to spawn cage compositor wrapper. Is cage installed?");
1639         GREETER_CAGE_PID.store(child.id() as i32, std::sync::atomic::Ordering::SeqCst);
1640 
1641         let stdout = child.stdout.take().expect("failed to open child stdout");
1642         let reader = std::io::BufReader::new(stdout);
1643         let mut auth_success = None;
1644 
1645         use std::io::BufRead;
1646         for line in reader.lines() {
1647             if let Ok(line_str) = line {
1648                 if line_str.starts_with("AUTH_SUCCESS|") {
1649                     if let Some((username, exec, is_wayland, password)) = parse_auth_success(&line_str) {
1650                         log::info!("[greeter-stdout] AUTH_SUCCESS|{}|{}|{}", username, exec, is_wayland);
1651                         auth_success = Some((username, exec, is_wayland, password));
1652                         // Don't read to EOF: the greeter has already exited,
1653                         // but cage can linger indefinitely after its child is
1654                         // gone (observed wedged until a manual VT switch — the
1655                         // "login hangs until Ctrl+Alt+F2" failure). Stop
1656                         // reading and terminate it ourselves below.
1657                         break;
1658                     }
1659                     // Never echo the raw line: field 5 is the password.
1660                     log::warn!("[greeter-stdout] malformed AUTH_SUCCESS line (redacted); login attempt dropped");
1661                 } else {
1662                     log::info!("[greeter-stdout] {}", line_str);
1663                 }
1664             }
1665         }
1666 
1667         if auth_success.is_some() {
1668             terminate_greeter(&mut child);
1669         }
1670         let status = child.wait().expect("failed to wait on child process");
1671         GREETER_CAGE_PID.store(0, std::sync::atomic::Ordering::SeqCst);
1672         log::info!("Greeter session exited with status: {}", status);
1673 
1674         if status.code() == Some(130) {
1675             log::info!("Abort requested via Ctrl+C. Exiting display manager daemon.");
1676             std::process::exit(0);
1677         }
1678 
1679         if status.code() == Some(135) {
1680             log::info!("Restart requested via F5. Re-executing daemon...");
1681             let mut exe_path = std::path::PathBuf::from("/usr/bin/cce-display-manager");
1682             if !exe_path.exists() {
1683                 exe_path = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/cce-display-manager"));
1684             }
1685             let args: Vec<String> = std::env::args().collect();
1686             use std::os::unix::process::CommandExt;
1687             let mut cmd = std::process::Command::new(&exe_path);
1688             cmd.args(&args[1..]);
1689             let err = cmd.exec();
1690             log::error!("Failed to re-exec daemon: {:?}", err);
1691         }
1692 
1693         if auth_success.is_none() {
1694             // Sleep briefly to prevent high CPU usage if the greeter keeps crashing on startup
1695             std::thread::sleep(std::time::Duration::from_millis(1000));
1696         }
1697 
1698         if let Some((username, exec, is_wayland, password)) = auth_success {
1699             // Write last logged-in user and session to persistent files
1700             let var_lib = "/var/lib/cce-display-manager";
1701             if let Err(e) = std::fs::create_dir_all(var_lib) {
1702                 log::error!("Failed to create var lib dir: {:?}", e);
1703             } else {
1704                 if let Err(e) = std::fs::write(format!("{}/last_user", var_lib), &username) {
1705                     log::error!("Failed to write last_user file: {:?}", e);
1706                 }
1707                 if let Err(e) = std::fs::write(format!("{}/last_session", var_lib), &exec) {
1708                     log::error!("Failed to write last_session file: {:?}", e);
1709                 }
1710             }
1711 
1712             launch_session(username, exec, is_wayland, password, &tty_name, &mut pending_relaunch);
1713         }
1714     }
1715 }
1716 
1717 /// Ask the greeter's cage to exit, escalating to SIGKILL if it doesn't. Cage
1718 /// exiting cleanly releases the seat/VT via seatd (which cleans the VT up
1719 /// without switching away); a wedged cage would otherwise block the login
1720 /// handoff forever.
1721 fn terminate_greeter(child: &mut std::process::Child) {
1722     unsafe {
1723         libc::kill(child.id() as libc::pid_t, libc::SIGTERM);
1724     }
1725     for _ in 0..30 {
1726         match child.try_wait() {
1727             Ok(Some(_)) | Err(_) => return,
1728             Ok(None) => std::thread::sleep(std::time::Duration::from_millis(100)),
1729         }
1730     }
1731     log::warn!("cage did not exit within 3s of SIGTERM; killing it");
1732     let _ = child.kill();
1733 }
1734 
1735 /// Per-message state machine over `busctl monitor` text output, detecting a
1736 /// logind resume: `PrepareForSleep(false)`. Each D-Bus message opens with a
1737 /// `Type=` header line (which resets us), a signal's header also carries
1738 /// `Member=...` (we arm only for `PrepareForSleep`), and the body carries the
1739 /// `BOOLEAN` payload. `PrepareForSleep(true)` precedes suspend and `(false)`
1740 /// follows resume, so we fire only on the `false`. Fail-safe: a format we do
1741 /// not recognise simply never fires (degrading to the pre-fix behaviour, never
1742 /// a spurious teardown).
1743 struct ResumeSignalParser {
1744     armed: bool,
1745 }
1746 
1747 impl ResumeSignalParser {
1748     fn new() -> Self {
1749         Self { armed: false }
1750     }
1751 
1752     /// Feed one output line; returns true exactly when a resume message completes.
1753     fn feed(&mut self, line: &str) -> bool {
1754         if line.contains("Type=") {
1755             self.armed = false;
1756         }
1757         if line.contains("PrepareForSleep") {
1758             self.armed = true;
1759         } else if self.armed && line.contains("BOOLEAN") {
1760             let resume = line.contains("false");
1761             self.armed = false;
1762             return resume;
1763         }
1764         false
1765     }
1766 }
1767 
1768 /// Watch logind's `PrepareForSleep` signal and, on resume, recover the greeter.
1769 /// Resume-from-suspend leaves the greeter's `cage` DRM-paused ("Atomic commit
1770 /// failed: Permission denied" looping on "Disabling seat"); it cannot reliably
1771 /// reacquire the seat on its own, so on resume we force the greeter's VT active
1772 /// and tear the cage down, letting the daemon loop spawn a fresh one on an
1773 /// active VT -- the same known-good state a service restart produces. This is a
1774 /// no-op while a user session is live (`GREETER_CAGE_PID == 0`): the running
1775 /// compositor owns the seat then, and we must not fight it. Uses `busctl`
1776 /// (always present with systemd) rather than a D-Bus crate to keep this
1777 /// login-critical binary's dependency surface minimal.
1778 fn spawn_resume_watchdog(tty_name: String) {
1779     std::thread::spawn(move || loop {
1780         let spawned = std::process::Command::new("busctl")
1781             .args(["monitor", "--system", "org.freedesktop.login1"])
1782             .stdout(std::process::Stdio::piped())
1783             .stderr(std::process::Stdio::null())
1784             .spawn();
1785         let mut child = match spawned {
1786             Ok(c) => c,
1787             Err(e) => {
1788                 log::warn!("resume watchdog: could not start busctl ({}); retrying in 5s", e);
1789                 std::thread::sleep(std::time::Duration::from_secs(5));
1790                 continue;
1791             }
1792         };
1793         if let Some(stdout) = child.stdout.take() {
1794             use std::io::BufRead;
1795             let reader = std::io::BufReader::new(stdout);
1796             let mut parser = ResumeSignalParser::new();
1797             for line in reader.lines() {
1798                 let Ok(line) = line else { break };
1799                 if parser.feed(&line) {
1800                     on_resume(&tty_name);
1801                 }
1802             }
1803         }
1804         let _ = child.wait();
1805         log::warn!("resume watchdog: busctl monitor exited; restarting in 2s");
1806         std::thread::sleep(std::time::Duration::from_secs(2));
1807     });
1808 }
1809 
1810 /// Force the greeter's VT active and, if a greeter `cage` is up, tear it down so
1811 /// the daemon loop respawns a clean one. See `spawn_resume_watchdog`. No-op when
1812 /// a user session owns the seat (`GREETER_CAGE_PID == 0`).
1813 fn on_resume(tty_name: &str) {
1814     use std::sync::atomic::Ordering;
1815     let pid = GREETER_CAGE_PID.load(Ordering::SeqCst);
1816     if pid <= 0 {
1817         return;
1818     }
1819     log::info!("Resume from sleep detected while greeter is up; forcing {} active and respawning greeter", tty_name);
1820     ensure_vt_active(tty_name);
1821     // SIGTERM first; a DRM-wedged cage can ignore it, so escalate to SIGKILL.
1822     // Re-check the PID before escalating so we never signal a cage the daemon
1823     // has already reaped and replaced with a fresh one.
1824     unsafe { libc::kill(pid, libc::SIGTERM); }
1825     for _ in 0..30 {
1826         if GREETER_CAGE_PID.load(Ordering::SeqCst) != pid {
1827             return;
1828         }
1829         std::thread::sleep(std::time::Duration::from_millis(100));
1830     }
1831     if GREETER_CAGE_PID.load(Ordering::SeqCst) == pid {
1832         log::warn!("resume watchdog: greeter cage {} did not exit on SIGTERM; killing", pid);
1833         unsafe { libc::kill(pid, libc::SIGKILL); }
1834     }
1835 }
1836 
1837 /// The greeter/cage teardown (or a stray VT switch) can leave the session's VT
1838 /// inactive; a logind session on an inactive VT never activates, so the
1839 /// compositor sits DRM-paused on a black screen. Force the VT active before
1840 /// handing the seat to the user session.
1841 fn ensure_vt_active(tty_name: &str) {
1842     let Some(vt) = tty_name.strip_prefix("tty").and_then(|s| s.parse::<u32>().ok()) else {
1843         return;
1844     };
1845     for _ in 0..20 {
1846         if let Ok(active) = std::fs::read_to_string("/sys/class/tty/tty0/active") {
1847             if active.trim() == tty_name {
1848                 return;
1849             }
1850         }
1851         let _ = std::process::Command::new("chvt").arg(vt.to_string()).status();
1852         std::thread::sleep(std::time::Duration::from_millis(100));
1853     }
1854     log::warn!("could not make {} the active VT", tty_name);
1855 }
1856 
1857 /// End a finished session's logind session: stop whatever it left running.
1858 ///
1859 /// The worker closes PAM when the session's command exits, but that only marks
1860 /// the logind session `closing` — with logind's default KillUserProcesses=no,
1861 /// every process the session started and did not reap lives on in its scope.
1862 /// Every login leaked that way (by 2026-09-25, eight sessions stuck `closing`,
1863 /// held open by 16 orphaned 1Password helpers, 1.9 GB). Done from the DAEMON,
1864 /// not the worker: pam_systemd moved the worker into the session's scope.
1865 ///
1866 /// `kill-session`, NOT `terminate-session`. Once the leader has exited, logind
1867 /// has abandoned the scope, and TerminateSession on such a session does
1868 /// nothing at all — no error, no journal line, the session stays `closing`
1869 /// (measured on the eight leaked ones). Signalling the scope's processes does
1870 /// work: SIGTERM ended every orphaned helper within a second. SIGKILL follows
1871 /// for anything still there after two seconds. Escalation is polled here
1872 /// rather than timed on a thread, because the daemon forks the next session
1873 /// worker right after this returns, and a thread busy spawning `loginctl` at
1874 /// that moment is the fork-in-a-threaded-process hazard that wedged logins.
1875 ///
1876 /// Also on a compositor-restart relaunch: the new compositor starts its
1877 /// clients fresh in the new session (nothing survives into it from the old
1878 /// scope — the leaked sessions held nothing but the orphans), so the old one
1879 /// has nothing worth keeping. If clients ever reconnect ACROSS a restart, they
1880 /// will have to be carried into the new session rather than left in this one.
1881 fn terminate_session(session_id: &str) {
1882     if !valid_session_id(session_id) {
1883         if !session_id.is_empty() {
1884             log::warn!("not ending session with unexpected id {:?}", session_id);
1885         }
1886         return;
1887     }
1888     let session_exists = || {
1889         std::process::Command::new("loginctl")
1890             .args(["show-session", session_id, "--property=Id"])
1891             .stdout(std::process::Stdio::null())
1892             .stderr(std::process::Stdio::null())
1893             .status()
1894             .is_ok_and(|s| s.success())
1895     };
1896     let kill = |signal: &str| {
1897         let _ = std::process::Command::new("loginctl")
1898             .args(["kill-session", session_id, "--signal", signal])
1899             .stderr(std::process::Stdio::null())
1900             .status();
1901     };
1902     // Nothing left running: logind already dropped it — the good case.
1903     if !session_exists() {
1904         return;
1905     }
1906     log::info!("Session {} left processes behind; sending SIGTERM", session_id);
1907     kill("SIGTERM");
1908     for _ in 0..20 {
1909         std::thread::sleep(std::time::Duration::from_millis(100));
1910         if !session_exists() {
1911             log::info!("Session {} ended", session_id);
1912             return;
1913         }
1914     }
1915     log::warn!("Session {} still running 2s after SIGTERM; sending SIGKILL", session_id);
1916     kill("SIGKILL");
1917 }
1918 
1919 /// Run the session worker (PAM open_session + user-session spawn, see
1920 /// [`run_session_worker`]) and wait for it — shared by the greeter login path
1921 /// and the compositor-restart relaunch path. If the session left a restart
1922 /// flag (`ccectl restart-compositor` writes it before a clean exit), arm
1923 /// `pending_relaunch` so the daemon loop relaunches this same session
1924 /// directly, greeter skipped (empty password → the autologin PAM service).
1925 ///
1926 /// The worker is `<this binary> --session-worker`, a fresh process — NOT a
1927 /// `fork()` of the daemon, as it was until 2026-09-25. The daemon is
1928 /// multi-threaded (the resume watchdog), and a forked child inherits whatever
1929 /// locks another thread held at that instant; the worker then did PAM, env
1930 /// writes, logging and process spawns under them — the class of wedge that
1931 /// froze the greeter's login (fixed there on 2026-09-18 by the same cure).
1932 /// `Command` with no `pre_exec` does only async-signal-safe work between its
1933 /// fork and exec. The password rides the worker's STDIN, never its argv
1934 /// (world-readable in /proc); the session id comes back on its STDOUT.
1935 fn launch_session(
1936     username: String,
1937     exec: String,
1938     is_wayland: bool,
1939     password: String,
1940     tty_name: &str,
1941     pending_relaunch: &mut Option<(String, String, bool)>,
1942 ) {
1943     use std::io::{BufRead, Write};
1944     log::info!("Launching user session Exec: '{}' (Wayland: {}) for user: '{}'", exec, is_wayland, username);
1945     // A stale flag from a previous session must not trigger a phantom relaunch.
1946     let flag_path = format!("/tmp/cce-restart-requested-{}", username);
1947     let _ = std::fs::remove_file(&flag_path);
1948 
1949     ensure_vt_active(tty_name);
1950 
1951     let spawned = std::process::Command::new(daemon_exe())
1952         .args(session_worker_args(tty_name, &username, is_wayland, &exec))
1953         .stdin(std::process::Stdio::piped())
1954         .stdout(std::process::Stdio::piped())
1955         .spawn();
1956     let mut worker = match spawned {
1957         Ok(w) => w,
1958         Err(e) => {
1959             log::error!("Failed to start the session worker: {}", e);
1960             return;
1961         }
1962     };
1963     if let Some(mut stdin) = worker.stdin.take() {
1964         // Dropped at the end of this block: EOF tells the worker it has all
1965         // of it. An empty write is the autologin path.
1966         let _ = stdin.write_all(password.as_bytes());
1967     }
1968     // Read to EOF, which comes as soon as the worker has reported (it points
1969     // its stdout at /dev/null right after) or has exited — never held open by
1970     // the session, which does not inherit the pipe.
1971     let mut session_id = String::new();
1972     if let Some(stdout) = worker.stdout.take() {
1973         for line in std::io::BufReader::new(stdout).lines().map_while(Result::ok) {
1974             if let Some(id) = parse_session_id_line(&line) {
1975                 session_id = id.to_string();
1976             }
1977         }
1978     }
1979     match worker.wait() {
1980         Ok(status) => log::info!("Session worker (PID {}) exited with {}", worker.id(), status),
1981         Err(e) => log::error!("waiting on the session worker: {}", e),
1982     }
1983     terminate_session(&session_id);
1984 
1985     // Compositor-requested restart: honor the flag only when it is a
1986     // regular file owned by the session user (anyone can create names in
1987     // /tmp). symlink_metadata, not metadata: a plain stat follows
1988     // symlinks, so another user's link pointing at any file the session
1989     // user owns would pass the owner check.
1990     if let Ok(meta) = std::fs::symlink_metadata(&flag_path) {
1991         use std::os::unix::fs::MetadataExt;
1992         let owner_ok = meta.file_type().is_file()
1993             && users::get_user_by_name(&username)
1994                 .map_or(false, |u| u.uid() == meta.uid());
1995         let _ = std::fs::remove_file(&flag_path);
1996         if owner_ok {
1997             *pending_relaunch = Some((username, exec, is_wayland));
1998             return; // relaunching immediately — no VT switch back
1999         }
2000         log::warn!("Ignoring restart flag {} with wrong owner", flag_path);
2001     }
2002 
2003     if let Some(vt) = tty_name.strip_prefix("tty").and_then(|s| s.parse::<u32>().ok()) {
2004         log::info!("Switching back to VT {}...", vt);
2005         let _ = std::process::Command::new("chvt")
2006             .arg(vt.to_string())
2007             .status();
2008     }
2009 }
2010 
2011 /// The installed binary, for the processes the daemon runs as itself.
2012 fn daemon_exe() -> std::path::PathBuf {
2013     let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("/usr/bin/cce-display-manager"));
2014     if exe.exists() { exe } else { std::path::PathBuf::from("/usr/bin/cce-display-manager") }
2015 }
2016 
2017 /// The session worker's argv after the program name. `exec` goes last and
2018 /// whole — it may contain spaces. No password: see [`launch_session`].
2019 fn session_worker_args(tty_name: &str, username: &str, is_wayland: bool, exec: &str) -> Vec<String> {
2020     vec![
2021         "--session-worker".to_string(),
2022         tty_name.to_string(),
2023         username.to_string(),
2024         is_wayland.to_string(),
2025         exec.to_string(),
2026     ]
2027 }
2028 
2029 /// The other half of [`session_worker_args`]: `(tty, username, is_wayland,
2030 /// exec)` from a full argv, or `None` if it is not a session-worker argv.
2031 fn parse_session_worker_args(args: &[String]) -> Option<(String, String, bool, String)> {
2032     if args.len() != 6 || args[1] != "--session-worker" {
2033         return None;
2034     }
2035     Some((args[2].clone(), args[3].clone(), args[4] == "true", args[5].clone()))
2036 }
2037 
2038 const SESSION_ID_PREFIX: &str = "SESSION_ID ";
2039 
2040 /// The worker's report line, `SESSION_ID <id>`: the id, if this is one.
2041 fn parse_session_id_line(line: &str) -> Option<&str> {
2042     line.strip_prefix(SESSION_ID_PREFIX).map(str::trim).filter(|id| !id.is_empty())
2043 }
2044 
2045 /// The console session's Exec — a shell ON the tty, where a graphical
2046 /// session's output goes to its log instead (see [`run_session_worker`]).
2047 const CONSOLE_SESSION_EXEC: &str = "bash";
2048 
2049 /// The graphical session's stdout/stderr, and where the previous session's
2050 /// is kept: in the user's runtime dir beside `startcce`'s own logs, one
2051 /// session back — a compositor restart starts a new session, and the log of
2052 /// the one that died is the log worth reading.
2053 fn session_log_paths(uid: u32) -> (String, String) {
2054     let log = format!("/run/user/{}/cce-session.log", uid);
2055     let old = format!("{}.old", log);
2056     (log, old)
2057 }
2058 
2059 /// `--session-worker <tty> <user> <is_wayland> <exec>`, password on stdin:
2060 /// open the user's PAM session, report its logind id on stdout, run the
2061 /// session as the user, and close PAM when it exits. Run by the daemon only.
2062 fn run_session_worker(tty_name: String, username: String, is_wayland: bool, exec: String) -> ! {
2063     use std::io::{Read, Write};
2064     use users::os::unix::UserExt;
2065     if users::get_current_uid() != 0 {
2066         log::error!("--session-worker is the daemon's; it must run as root");
2067         std::process::exit(1);
2068     }
2069     let mut password = String::new();
2070     let _ = std::io::stdin().read_to_string(&mut password);
2071 
2072     // Stdin back onto the tty the daemon was given: PamSession reads the TTY
2073     // item off fd 0, and the console session is a shell on it. Only a name
2074     // like "tty1" is opened.
2075     let tty_ok = tty_name.len() > 3 && tty_name.starts_with("tty") && tty_name[3..].chars().all(|c| c.is_ascii_digit());
2076     if tty_ok {
2077         if let Ok(tty) = std::fs::OpenOptions::new().read(true).write(true).open(format!("/dev/{}", tty_name)) {
2078             use std::os::unix::io::AsRawFd;
2079             unsafe { libc::dup2(tty.as_raw_fd(), 0) };
2080         }
2081     }
2082 
2083     let user = match users::get_user_by_name(&username) {
2084         Some(u) => u,
2085         None => {
2086             log::error!("Error: User '{}' not found in system.", username);
2087             std::process::exit(1);
2088         }
2089     };
2090 
2091     let user_uid = user.uid();
2092     let user_gid = user.primary_group_id();
2093     let home_dir = user.home_dir().to_path_buf();
2094     let shell = user.shell().to_str().unwrap_or("/bin/bash").to_string();
2095 
2096     let user_runtime_dir = format!("/run/user/{}", user_uid);
2097 
2098     // The session's identity, in the worker's own environment before PAM
2099     // open_session, for the modules that read it from there.
2100     std::env::set_var("USER", &username);
2101     std::env::set_var("LOGNAME", &username);
2102     std::env::set_var("HOME", home_dir.to_str().unwrap_or(""));
2103     std::env::set_var("SHELL", &shell);
2104     std::env::set_var("XDG_RUNTIME_DIR", &user_runtime_dir);
2105 
2106     // No fallback service: pam_start does not fail for a missing
2107     // stack (PAM falls back to /etc/pam.d/other), so the old
2108     // retry on ly's `ly-autologin` / `login` could never run.
2109     let service = session_pam_service(&password);
2110     let mut auth = match PamSession::new(service, &username, &password, 0, None) {
2111         Ok(a) => a,
2112         Err(e) => {
2113             log::error!("PAM Init Error in session worker: {:?}", e);
2114             std::process::exit(1);
2115         }
2116     };
2117 
2118     let session_type_env = if is_wayland {
2119         "XDG_SESSION_TYPE=wayland"
2120     } else {
2121         "XDG_SESSION_TYPE=x11"
2122     };
2123     let _ = auth.putenv(session_type_env);
2124     let _ = auth.putenv("XDG_SESSION_CLASS=user");
2125 
2126     if let Err(e) = auth.authenticate() {
2127         log::error!("PAM Authentication failed in session worker: {:?}", e);
2128         std::process::exit(1);
2129     }
2130 
2131     if let Err(e) = auth.open_session() {
2132         log::error!("PAM Session failed in session worker: {:?}", e);
2133         std::process::exit(1);
2134     }
2135 
2136     let pam_env = auth.get_env();
2137     log::info!("PAM Environment variables: {:?}", pam_env);
2138     let session_id = pam_env.iter().find(|(k, _)| k == "XDG_SESSION_ID").map(|(_, v)| v.clone());
2139 
2140     // Report the session to the daemon, then let go of the pipe so its read
2141     // ends now rather than when the session does.
2142     if let Some(id) = &session_id {
2143         let mut out = std::io::stdout();
2144         let _ = writeln!(out, "{}{}", SESSION_ID_PREFIX, id);
2145         let _ = out.flush();
2146     }
2147     if let Ok(null) = std::fs::OpenOptions::new().write(true).open("/dev/null") {
2148         use std::os::unix::io::AsRawFd;
2149         unsafe { libc::dup2(null.as_raw_fd(), 1) };
2150     }
2151 
2152     if let Some(id) = &session_id {
2153         log::info!("Explicitly activating logind session {} via loginctl...", id);
2154         let _ = std::process::Command::new("loginctl")
2155             .arg("activate")
2156             .arg(id)
2157             .status();
2158     }
2159 
2160     let (cmd_bin, cmd_args): (String, Vec<String>) = if is_wayland {
2161         sanitize_exec(&exec)
2162     } else {
2163         let (client_bin, client_args) = sanitize_exec(&exec);
2164         let xinit_bin = "/usr/sbin/xinit".to_string();
2165         let mut args = vec![client_bin];
2166         args.extend(client_args);
2167         args.push("--".to_string());
2168         args.push("-keeptty".to_string());
2169         (xinit_bin, args)
2170     };
2171 
2172     if cmd_bin.is_empty() {
2173         log::error!("Error: Resolved execution command is empty.");
2174         std::process::exit(1);
2175     }
2176 
2177     log::info!("Spawning session: {} with args {:?} for UID={}, GID={}", cmd_bin, cmd_args, user_uid, user_gid);
2178 
2179     // The console session is a shell on the tty; anything else writes its
2180     // stdout/stderr to the user's session log, opened in pre_exec AS THE
2181     // USER (a root open in a directory the user owns could be pointed at any
2182     // file by a symlink). Until 2026-09-25 the session inherited the
2183     // daemon's stdout, which put the user's session output into a
2184     // world-readable root log that was truncated at every daemon start.
2185     let console = exec.trim() == CONSOLE_SESSION_EXEC;
2186     let (log_path, old_path) = session_log_paths(user_uid);
2187     let log_c = std::ffi::CString::new(log_path.clone()).unwrap();
2188     let old_c = std::ffi::CString::new(old_path).unwrap();
2189     let tty_out = || -> std::process::Stdio {
2190         std::fs::OpenOptions::new()
2191             .write(true)
2192             .open(format!("/dev/{}", tty_name))
2193             .map(std::process::Stdio::from)
2194             .unwrap_or_else(|_| std::process::Stdio::null())
2195     };
2196 
2197     use std::os::unix::process::CommandExt;
2198     let mut session_cmd = std::process::Command::new(&cmd_bin);
2199     session_cmd
2200         .args(&cmd_args)
2201         .envs(pam_env)
2202         .current_dir(&home_dir)
2203         .env("USER", &username)
2204         .env("LOGNAME", &username)
2205         .env("HOME", home_dir.to_str().unwrap())
2206         .env("SHELL", &shell)
2207         .env("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
2208         .env("XDG_RUNTIME_DIR", &user_runtime_dir)
2209         .env("XDG_SESSION_TYPE", if is_wayland { "wayland" } else { "x11" })
2210         .env("XDG_SESSION_CLASS", "user")
2211         .stdin(std::process::Stdio::inherit());
2212     if console {
2213         session_cmd.stdout(tty_out()).stderr(tty_out());
2214     } else {
2215         session_cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
2216         log::info!("Session output goes to {}", log_path);
2217     }
2218 
2219     // Filter out sudo env vars so they don't leak into the user session, and
2220     // the daemon's JOURNAL_STREAM, which describes the daemon's stderr, not
2221     // the session's (systemd-aware programs read it).
2222     for key in &["SUDO_USER", "SUDO_UID", "SUDO_GID", "SUDO_COMMAND", "JOURNAL_STREAM"] {
2223         session_cmd.env_remove(key);
2224     }
2225 
2226     let username_c = std::ffi::CString::new(username.clone()).unwrap();
2227     unsafe {
2228         session_cmd.pre_exec(move || {
2229             if libc::initgroups(username_c.as_ptr(), user_gid as libc::gid_t) != 0 {
2230                 return Err(std::io::Error::last_os_error());
2231             }
2232             if libc::setgid(user_gid as libc::gid_t) != 0 {
2233                 return Err(std::io::Error::last_os_error());
2234             }
2235             if libc::setuid(user_uid as libc::uid_t) != 0 {
2236                 return Err(std::io::Error::last_os_error());
2237             }
2238             if !console {
2239                 // As the user now. A log that cannot be opened (the runtime
2240                 // dir not there yet, say) leaves output at /dev/null rather
2241                 // than failing the login.
2242                 libc::rename(log_c.as_ptr(), old_c.as_ptr());
2243                 let fd = libc::open(
2244                     log_c.as_ptr(),
2245                     libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC | libc::O_NOFOLLOW | libc::O_CLOEXEC,
2246                     0o600,
2247                 );
2248                 if fd >= 0 {
2249                     libc::dup2(fd, 1);
2250                     libc::dup2(fd, 2);
2251                     libc::close(fd);
2252                 }
2253             }
2254             Ok(())
2255         });
2256     }
2257 
2258     match session_cmd.spawn() {
2259         Ok(mut child_proc) => {
2260             let _ = child_proc.wait();
2261         }
2262         Err(e) => {
2263             log::error!("Failed to launch session: {}", e);
2264         }
2265     }
2266     log::info!("User session ended.");
2267     std::mem::drop(auth);
2268     std::process::exit(0);
2269 }
2270 
2271 fn main() {
2272     if std::env::var("RUST_LOG").is_err() {
2273         std::env::set_var("RUST_LOG", "info");
2274     }
2275     env_logger::init();
2276     let args: Vec<String> = std::env::args().collect();
2277     if args.len() > 1 && args[1] == "--greeter" {
2278         run_greeter();
2279     } else if args.len() > 2 && args[1] == "--fprint-auth" {
2280         run_fprint_helper(&args[2]);
2281     } else if let Some((tty, user, is_wayland, exec)) = parse_session_worker_args(&args) {
2282         run_session_worker(tty, user, is_wayland, exec);
2283     } else if args.len() > 1 && args[1] == "--session-worker" {
2284         log::error!("--session-worker takes <tty> <user> <is_wayland> <exec>");
2285         std::process::exit(2);
2286     } else {
2287         run_daemon();
2288     }
2289 }
2290 
2291 
2292 
2293 
2294 #[cfg(test)]
2295 mod tests {
2296     use super::parse_auth_success;
2297 
2298     #[test]
2299     fn auth_success_plain() {
2300         let got = parse_auth_success("AUTH_SUCCESS|lucas|startcce|true|hunter2");
2301         assert_eq!(
2302             got,
2303             Some(("lucas".into(), "startcce".into(), true, "hunter2".into()))
2304         );
2305     }
2306 
2307     #[test]
2308     fn auth_success_password_with_pipes() {
2309         // The password is the last field and may contain the separator.
2310         let got = parse_auth_success("AUTH_SUCCESS|lucas|startcce|true|a|b|c");
2311         assert_eq!(
2312             got,
2313             Some(("lucas".into(), "startcce".into(), true, "a|b|c".into()))
2314         );
2315     }
2316 
2317     #[test]
2318     fn auth_success_empty_password_fingerprint_path() {
2319         let got = parse_auth_success("AUTH_SUCCESS|lucas|startcce|true|");
2320         assert_eq!(
2321             got,
2322             Some(("lucas".into(), "startcce".into(), true, String::new()))
2323         );
2324     }
2325 
2326     #[test]
2327     fn auth_success_malformed() {
2328         assert_eq!(parse_auth_success("AUTH_SUCCESS|lucas|startcce"), None);
2329         assert_eq!(parse_auth_success("AUTH_SUCCESS|"), None);
2330         assert_eq!(parse_auth_success("NOT_A_THING|x|y|z|w"), None);
2331     }
2332 
2333     /// The greeter's line and the daemon's parse agree, and a password is
2334     /// carried exactly — spaces at either end, and the `|` separator, intact.
2335     #[test]
2336     fn auth_success_round_trips_the_password_verbatim() {
2337         for pw in ["hunter2", " leading", "trailing ", "  both  ", "a|b", ""] {
2338             let line = super::auth_success_line("lucas", "startcce", true, pw);
2339             assert_eq!(
2340                 parse_auth_success(&line),
2341                 Some(("lucas".into(), "startcce".into(), true, pw.to_string())),
2342                 "{pw:?}"
2343             );
2344         }
2345     }
2346 
2347     #[test]
2348     fn an_empty_password_opens_on_the_autologin_stack() {
2349         assert_eq!(super::session_pam_service(""), "cce-display-manager-autologin");
2350         assert_eq!(super::session_pam_service("x"), "cce-display-manager-password");
2351         assert_eq!(super::session_pam_service(" "), "cce-display-manager-password");
2352     }
2353 
2354     #[test]
2355     fn only_plain_session_ids_reach_loginctl() {
2356         for ok in ["15", "c3", "2"] {
2357             assert!(super::valid_session_id(ok), "{ok}");
2358         }
2359         for bad in ["", "15 --all", "-h", "1;rm", "../x", &"9".repeat(40)] {
2360             assert!(!super::valid_session_id(bad), "{bad}");
2361         }
2362     }
2363 
2364     /// One keyring provider: the TPM-sealed gnome-keyring-daemon unit (see
2365     /// the README). pam_gnome_keyring in a login stack started a SECOND
2366     /// daemon at every login, which failed to unlock (the keyring's password
2367     /// is the sealed one, not the login password) and raced the unit.
2368     #[test]
2369     fn no_pam_stack_starts_a_keyring() {
2370         let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/pam");
2371         let mut seen = 0;
2372         for entry in std::fs::read_dir(dir).expect("pam/") {
2373             let path = entry.unwrap().path();
2374             let text = std::fs::read_to_string(&path).unwrap();
2375             seen += 1;
2376             for line in text.lines().filter(|l| !l.trim_start().starts_with('#')) {
2377                 assert!(
2378                     !line.contains("pam_gnome_keyring") && !line.contains("pam_kwallet"),
2379                     "{}: {line}",
2380                     path.display()
2381                 );
2382             }
2383         }
2384         assert!(seen >= 4, "the four stacks were read");
2385     }
2386 
2387     /// The daemon's argv for the worker and the worker's parse agree, an
2388     /// Exec with spaces arrives whole, and no password is in it.
2389     #[test]
2390     fn session_worker_args_round_trip() {
2391         let args = super::session_worker_args("tty1", "lucas", true, "/home/lucas/.local/bin/startcce --logging");
2392         assert!(!args.iter().any(|a| a.contains("hunter2")));
2393         let mut argv = vec!["/usr/bin/cce-display-manager".to_string()];
2394         argv.extend(args);
2395         assert_eq!(
2396             super::parse_session_worker_args(&argv),
2397             Some(("tty1".into(), "lucas".into(), true, "/home/lucas/.local/bin/startcce --logging".into()))
2398         );
2399         let argv_x11: Vec<String> = ["x", "--session-worker", "tty2", "u", "false", "startx"].iter().map(|s| s.to_string()).collect();
2400         assert_eq!(super::parse_session_worker_args(&argv_x11).map(|t| t.2), Some(false));
2401         // Anything else is not a worker argv.
2402         let short: Vec<String> = ["x", "--session-worker", "tty1"].iter().map(|s| s.to_string()).collect();
2403         assert_eq!(super::parse_session_worker_args(&short), None);
2404         let greeter: Vec<String> = ["x", "--greeter", "a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
2405         assert_eq!(super::parse_session_worker_args(&greeter), None);
2406     }
2407 
2408     #[test]
2409     fn the_worker_reports_its_session_id_on_one_line() {
2410         assert_eq!(super::parse_session_id_line("SESSION_ID 17"), Some("17"));
2411         assert_eq!(super::parse_session_id_line("SESSION_ID c3\r"), Some("c3"));
2412         assert_eq!(super::parse_session_id_line("SESSION_ID "), None);
2413         assert_eq!(super::parse_session_id_line("[INFO] something else"), None);
2414     }
2415 
2416     #[test]
2417     fn the_session_log_lives_in_the_users_runtime_dir_one_session_back() {
2418         assert_eq!(
2419             super::session_log_paths(1000),
2420             ("/run/user/1000/cce-session.log".to_string(), "/run/user/1000/cce-session.log.old".to_string())
2421         );
2422     }
2423 
2424     /// The console entry's Exec is the constant the worker keys the tty on,
2425     /// so the Bash session keeps its terminal.
2426     #[test]
2427     fn the_console_session_is_the_one_the_worker_keeps_on_the_tty() {
2428         let sessions = super::discover_sessions();
2429         let bash = sessions.iter().find(|s| s.name == "Bash Shell").expect("the console entry");
2430         assert_eq!(bash.exec, super::CONSOLE_SESSION_EXEC);
2431     }
2432 
2433     /// The footer names every key the greeter answers to beyond the fields
2434     /// themselves — the function keys above all, which nothing else reveals.
2435     #[test]
2436     fn the_key_legend_names_the_function_keys() {
2437         for key in ["F1", "F2", "F5", "Tab"] {
2438             assert!(super::KEY_LEGEND.contains(key), "{key} missing from {:?}", super::KEY_LEGEND);
2439         }
2440         assert!(super::KEY_LEGEND.contains("Power off") && super::KEY_LEGEND.contains("Reboot"));
2441     }
2442 
2443     #[test]
2444     fn power_keys_run_the_matching_systemctl_verb() {
2445         assert_eq!(super::PowerAction::PowerOff.command(), "poweroff");
2446         assert_eq!(super::PowerAction::Reboot.command(), "reboot");
2447     }
2448 
2449     /// A wrong username and a wrong password read the same — the greeter
2450     /// does not say which names exist — and no raw PAM code reaches the
2451     /// status line for the common failures.
2452     #[test]
2453     fn password_failures_read_as_words() {
2454         assert_eq!(super::password_failure_text("AUTH_ERR"), super::password_failure_text("USER_UNKNOWN"));
2455         for code in ["AUTH_ERR", "USER_UNKNOWN", "MAXTRIES", "ACCT_EXPIRED", "SYSTEM_ERR"] {
2456             assert!(!super::password_failure_text(code).contains(code), "{code}");
2457         }
2458     }
2459 
2460     #[test]
2461     fn auth_success_bad_bool_defaults_wayland() {
2462         let got = parse_auth_success("AUTH_SUCCESS|lucas|startcce|banana|pw");
2463         assert_eq!(got.map(|t| t.2), Some(true));
2464     }
2465 }
2466 
2467 
2468 #[cfg(test)]
2469 mod resume_parser_tests {
2470     use super::ResumeSignalParser;
2471 
2472     // A representative PrepareForSleep signal as `busctl monitor` prints it.
2473     fn feed_all(lines: &[&str]) -> usize {
2474         let mut p = ResumeSignalParser::new();
2475         lines.iter().filter(|l| p.feed(l)).count()
2476     }
2477 
2478     #[test]
2479     fn detects_resume_false() {
2480         let msg = [
2481             "\u{2023} Type=signal  Endian=l  Flags=1  Version=1  Cookie=42",
2482             "  Sender=:1.3  Path=/org/freedesktop/login1  Interface=org.freedesktop.login1.Manager  Member=PrepareForSleep",
2483             "  MESSAGE \"b\" {",
2484             "          BOOLEAN false;",
2485             "  };",
2486         ];
2487         assert_eq!(feed_all(&msg), 1, "resume (false) must fire once");
2488     }
2489 
2490     #[test]
2491     fn ignores_suspend_true() {
2492         let msg = [
2493             "\u{2023} Type=signal  Endian=l  Flags=1  Version=1  Cookie=41",
2494             "  Sender=:1.3  Path=/org/freedesktop/login1  Interface=org.freedesktop.login1.Manager  Member=PrepareForSleep",
2495             "  MESSAGE \"b\" {",
2496             "          BOOLEAN true;",
2497             "  };",
2498         ];
2499         assert_eq!(feed_all(&msg), 0, "suspend (true) must not fire");
2500     }
2501 
2502     #[test]
2503     fn ignores_other_signal_with_boolean() {
2504         // A different signal carrying a BOOLEAN false must not be mistaken for
2505         // a resume: the Type= header resets us and there is no PrepareForSleep.
2506         let msg = [
2507             "\u{2023} Type=signal  Endian=l  Flags=1  Version=1  Cookie=99",
2508             "  Sender=:1.3  Path=/org/freedesktop/login1  Interface=org.freedesktop.login1.Manager  Member=SessionRemoved",
2509             "  MESSAGE \"b\" {",
2510             "          BOOLEAN false;",
2511             "  };",
2512         ];
2513         assert_eq!(feed_all(&msg), 0, "unrelated signal must not fire");
2514     }
2515 
2516     #[test]
2517     fn full_cycle_fires_once_on_resume() {
2518         // Suspend then resume, back to back: exactly one fire, on resume.
2519         let mut p = ResumeSignalParser::new();
2520         let stream = [
2521             "\u{2023} Type=signal  Cookie=1",
2522             "  Interface=org.freedesktop.login1.Manager  Member=PrepareForSleep",
2523             "          BOOLEAN true;",
2524             "\u{2023} Type=signal  Cookie=2",
2525             "  Interface=org.freedesktop.login1.Manager  Member=PrepareForSleep",
2526             "          BOOLEAN false;",
2527         ];
2528         let fires: usize = stream.iter().filter(|l| p.feed(l)).count();
2529         assert_eq!(fires, 1);
2530     }
2531 }