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 }