Rust · 23708 bytes Raw Blame History
1 //! gardm-greeter - gar display manager greeter
2 //!
3 //! Graphical login UI that communicates with gardmd.
4
5 mod avatar;
6 mod background;
7 mod config;
8 mod garbg;
9 mod icons;
10 mod keyboard;
11 mod monitors;
12 mod render;
13 mod theme;
14 mod transition;
15 mod widgets;
16 mod window;
17
18 use anyhow::{Context, Result};
19 use gardm_ipc::{Client, Request, Response};
20 use std::time::{Duration, Instant};
21 use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
22 use x11rb::connection::Connection;
23 use x11rb::protocol::xproto::ConnectionExt;
24 use x11rb::protocol::Event;
25
26 use background::{load_blurred_background, render_to_cairo, solid_background};
27 use config::GreeterConfig;
28 use garbg::WallpaperResolver;
29 use keyboard::{keycode_to_char, keycodes};
30 use monitors::{fallback_config, MonitorConfig};
31 use render::Renderer;
32 use transition::{render_with_fade, FadeOutTransition};
33 use widgets::{FocusedField, LoginForm, PowerAction, PowerButtons, SessionSelector, UserList};
34 use window::{CursorType, GreeterWindow};
35
36 /// Cursor blink interval
37 const CURSOR_BLINK_MS: u64 = 500;
38
39 #[tokio::main]
40 async fn main() -> Result<()> {
41 // Initialize logging
42 tracing_subscriber::registry()
43 .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
44 .with(tracing_subscriber::fmt::layer())
45 .init();
46
47 tracing::info!("gardm-greeter starting");
48
49 // Kill any running compositor that might be left over from a previous session
50 // These would render on top of the greeter and block visibility
51 kill_leftover_compositors();
52
53 // Load configuration
54 let config = GreeterConfig::load().unwrap_or_default();
55 tracing::debug!(?config, "Greeter configuration");
56
57 // Build theme from config with accessibility options
58 let theme = config.build_theme();
59 tracing::debug!(
60 high_contrast = config.accessibility.high_contrast,
61 large_text = config.accessibility.large_text,
62 reduce_motion = config.accessibility.reduce_motion,
63 "Theme built"
64 );
65
66 // Create X11 window
67 let mut window = GreeterWindow::new().context("Failed to create window")?;
68 let width = window.width();
69 let height = window.height();
70 tracing::info!(width, height, "Window created");
71
72 // Detect monitors using RandR
73 let monitor_config = MonitorConfig::detect(window.conn(), window.root())
74 .unwrap_or_else(|e| {
75 tracing::warn!("Failed to detect monitors: {}, using fallback", e);
76 fallback_config(width, height)
77 });
78
79 // Get the primary monitor for UI positioning
80 let primary = monitor_config.primary_or_first().cloned().unwrap_or_else(|| {
81 monitors::Monitor {
82 x: 0,
83 y: 0,
84 width,
85 height,
86 primary: true,
87 name: "fallback".to_string(),
88 }
89 });
90
91 let center_x = primary.center_x();
92 let center_y = primary.center_y();
93 tracing::info!(
94 monitor = %primary.name,
95 center_x,
96 center_y,
97 "UI centered on primary monitor"
98 );
99
100 // Create renderer for the full virtual screen
101 let mut renderer = Renderer::new(width, height).context("Failed to create renderer")?;
102
103 // Resolve wallpaper using garbg integration
104 let wallpaper_path = if config.garbg.enabled {
105 let resolver = WallpaperResolver::new(&config.garbg.fallback);
106 resolver.resolve(None)
107 } else {
108 config.garbg.fallback.clone()
109 };
110
111 // Load background image (with fallback to solid color)
112 let background = match load_blurred_background(
113 &wallpaper_path,
114 width as u32,
115 height as u32,
116 config.visual.blur_radius,
117 config.visual.brightness,
118 ) {
119 Ok(bg) => {
120 tracing::info!(path = %wallpaper_path, "Loaded background image");
121 bg
122 }
123 Err(e) => {
124 tracing::warn!("Failed to load background: {}, using solid color", e);
125 solid_background(width as u32, height as u32, 30, 30, 40)
126 }
127 };
128
129 // Create login form centered on primary monitor
130 let mut form = LoginForm::new(center_x, center_y);
131
132 // Connect to daemon
133 let mut client = Client::connect().await.context("Failed to connect to gardmd")?;
134 tracing::info!("Connected to gardmd");
135
136 // Fetch available sessions
137 let (sessions, default_session) = match client.request(&Request::ListSessions).await? {
138 Response::Sessions { sessions, default_session } => (sessions, default_session),
139 _ => (Vec::new(), None),
140 };
141 tracing::debug!(?sessions, ?default_session, "Available sessions");
142
143 // Fetch available users
144 let users = match client.request(&Request::ListUsers).await? {
145 Response::Users { users } => users,
146 _ => Vec::new(),
147 };
148 tracing::debug!(count = users.len(), "Available users");
149
150 // Create user list centered on primary monitor (above login form)
151 let mut user_list = UserList::new(users, center_x, center_y);
152
153 // Auto-select the first user and fill the form
154 if let Some(username) = user_list.select_first() {
155 tracing::info!(username, "Auto-selected first user");
156 form.set_username(username);
157 form.focused_field = FocusedField::Password; // Skip to password field
158 }
159
160 // Create session selector (positioned below login form on primary monitor)
161 let selector_width = 200.0;
162 let selector_x = center_x - selector_width / 2.0;
163 let selector_y = center_y + 180.0; // Below the login form
164 let mut session_selector = SessionSelector::new(sessions, selector_x, selector_y, selector_width, default_session.as_deref());
165
166 // Create power buttons (bottom-right corner of primary monitor)
167 let mut power_buttons = PowerButtons::new(
168 primary.x as f64,
169 primary.y as f64,
170 primary.width as f64,
171 primary.height as f64,
172 );
173
174 // Create Pango context for text rendering
175 let pango_ctx = pangocairo::functions::create_context(&renderer.context()?);
176
177 // Timing for cursor blink
178 let mut last_cursor_toggle = Instant::now();
179
180 // Fade transition (None until login succeeds)
181 let mut fade_transition: Option<FadeOutTransition> = None;
182
183 // Mouse position tracking
184 let mut mouse_x: f64 = 0.0;
185 let mut mouse_y: f64 = 0.0;
186
187 // Main event loop
188 tracing::info!("Entering main loop");
189 loop {
190 // Check if fade transition is complete
191 if let Some(ref fade) = fade_transition {
192 if fade.is_complete() {
193 tracing::info!("Fade complete, exiting greeter");
194 std::process::exit(0);
195 }
196 }
197
198 // Toggle cursor blink (only when not fading)
199 if fade_transition.is_none()
200 && last_cursor_toggle.elapsed() >= Duration::from_millis(CURSOR_BLINK_MS)
201 {
202 form.toggle_cursor();
203 last_cursor_toggle = Instant::now();
204 }
205
206 // Render frame
207 {
208 let ctx = renderer.context()?;
209
210 // Draw background (always full opacity)
211 render_to_cairo(&ctx, &background)?;
212
213 // Draw UI elements (with fade if transitioning)
214 let opacity = fade_transition
215 .as_ref()
216 .map(|f| f.opacity())
217 .unwrap_or(1.0);
218
219 render_with_fade(&ctx, opacity, |ctx| {
220 // User list (above login form)
221 user_list.render(ctx, &pango_ctx, &theme)?;
222
223 // Login form
224 form.render(ctx, &pango_ctx, &theme)?;
225
226 // Session selector
227 session_selector.render(ctx, &pango_ctx, &theme)?;
228
229 // Power buttons
230 power_buttons.render(ctx)?;
231
232 // Power button tooltip
233 if let Some((action, center_x, top_y)) = power_buttons.hovered_tooltip_info() {
234 render_tooltip(ctx, &pango_ctx, &theme, action, center_x, top_y)?;
235 }
236
237 Ok(())
238 })?;
239 }
240
241 // Copy rendered frame to X11 window
242 let data = renderer.data()?;
243 window.put_image(&data)?;
244
245 // Skip event handling during fade
246 if fade_transition.is_some() {
247 std::thread::sleep(Duration::from_millis(16));
248 continue;
249 }
250
251 // Poll for X11 events (non-blocking)
252 while let Some(event) = window.poll_for_event()? {
253 match event {
254 Event::Expose(_) => {
255 // Already rendering every frame
256 }
257
258 Event::MotionNotify(e) => {
259 mouse_x = e.event_x as f64;
260 mouse_y = e.event_y as f64;
261
262 // Update hover states
263 power_buttons.update_hover(mouse_x, mouse_y);
264 session_selector.update_hover(mouse_x, mouse_y);
265 user_list.update_hover(mouse_x, mouse_y);
266
267 // Update cursor based on what's being hovered
268 let cursor = if form.is_over_input(mouse_x, mouse_y) {
269 CursorType::Text
270 } else if power_buttons.hovered_action().is_some()
271 || form.button_contains(mouse_x, mouse_y)
272 || session_selector.button_contains(mouse_x, mouse_y)
273 || user_list.contains(mouse_x, mouse_y)
274 {
275 CursorType::Pointer
276 } else {
277 CursorType::Default
278 };
279 window.set_cursor(cursor);
280 }
281
282 Event::ButtonPress(e) => {
283 let click_x = e.event_x as f64;
284 let click_y = e.event_y as f64;
285
286 // Check user list clicks first
287 if let Some(username) = user_list.handle_click(click_x, click_y) {
288 tracing::info!(username, "User selected from list");
289 form.set_username(username);
290 form.clear_password();
291 form.focused_field = FocusedField::Password;
292 form.clear_messages();
293 }
294 // Check input field clicks (for cursor placement)
295 else if form.handle_input_click(
296 click_x,
297 click_y,
298 &pango_ctx,
299 &theme.font_family,
300 theme.font_size_normal,
301 ) {
302 // Click was handled by input field
303 }
304 // Check login button click
305 else if form.button_contains(click_x, click_y) && form.can_submit() {
306 // Get selected session exec command and type
307 let session_exec = session_selector
308 .selected_exec()
309 .unwrap_or("gar-session.sh")
310 .to_string();
311 let session_type = session_selector
312 .selected_type()
313 .unwrap_or("x11")
314 .to_string();
315
316 // Attempt login
317 if let Some(fade) = handle_login(
318 &mut client,
319 &mut form,
320 &session_exec,
321 &session_type,
322 config.effective_fade_duration(),
323 )
324 .await?
325 {
326 fade_transition = Some(fade);
327 }
328 }
329 // Check power buttons
330 else if let Some(action) = power_buttons.handle_click(click_x, click_y) {
331 handle_power_action(&mut client, action).await?;
332 }
333 // Check session selector
334 else if session_selector.button_contains(click_x, click_y) {
335 session_selector.toggle();
336 } else if session_selector.is_expanded() {
337 if session_selector.handle_dropdown_click(click_x, click_y) {
338 // Selection made
339 tracing::info!(
340 session = ?session_selector.selected(),
341 "Session selected"
342 );
343 } else if !session_selector.contains(click_x, click_y) {
344 // Click outside dropdown - close it
345 session_selector.close();
346 }
347 }
348 }
349
350 Event::KeyPress(e) => {
351 // Close dropdown on any key press
352 if session_selector.is_expanded() {
353 session_selector.close();
354 }
355
356 match e.detail {
357 keycodes::RETURN => {
358 if form.focused_field == FocusedField::Password && form.can_submit() {
359 // Get selected session exec command and type
360 let session_exec = session_selector
361 .selected_exec()
362 .unwrap_or("gar-session.sh")
363 .to_string();
364 let session_type = session_selector
365 .selected_type()
366 .unwrap_or("x11")
367 .to_string();
368
369 // Attempt login
370 if let Some(fade) = handle_login(
371 &mut client,
372 &mut form,
373 &session_exec,
374 &session_type,
375 config.effective_fade_duration(),
376 )
377 .await?
378 {
379 fade_transition = Some(fade);
380 }
381 } else if form.focused_field == FocusedField::Username {
382 form.handle_tab();
383 }
384 }
385
386 keycodes::TAB => {
387 form.handle_tab();
388 }
389
390 keycodes::BACKSPACE => {
391 form.handle_backspace();
392 }
393
394 keycodes::DELETE => {
395 form.handle_delete();
396 }
397
398 keycodes::LEFT => {
399 let shift = e.state.contains(x11rb::protocol::xproto::KeyButMask::SHIFT);
400 form.handle_left(shift);
401 }
402
403 keycodes::RIGHT => {
404 let shift = e.state.contains(x11rb::protocol::xproto::KeyButMask::SHIFT);
405 form.handle_right(shift);
406 }
407
408 keycodes::HOME => {
409 let shift = e.state.contains(x11rb::protocol::xproto::KeyButMask::SHIFT);
410 form.handle_home(shift);
411 }
412
413 keycodes::END => {
414 let shift = e.state.contains(x11rb::protocol::xproto::KeyButMask::SHIFT);
415 form.handle_end(shift);
416 }
417
418 keycodes::UP | keycodes::DOWN => {
419 // Up/down switch between fields
420 form.handle_tab();
421 }
422
423 _ => {
424 // Regular character key
425 if let Some(c) = keycode_to_char(e.detail, e.state) {
426 form.handle_key(c);
427 }
428 }
429 }
430 }
431
432 Event::FocusOut(_) => {
433 // Regrab focus if we lose it
434 let _ = window.conn().set_input_focus(
435 x11rb::protocol::xproto::InputFocus::POINTER_ROOT,
436 window.window(),
437 x11rb::CURRENT_TIME,
438 );
439 let _ = window.conn().flush();
440 }
441
442 _ => {}
443 }
444 }
445
446 // Small sleep to avoid busy-spinning
447 std::thread::sleep(Duration::from_millis(16)); // ~60fps
448 }
449 }
450
451 /// Handle power button action
452 async fn handle_power_action(client: &mut Client, action: PowerAction) -> Result<()> {
453 let request = match action {
454 PowerAction::Shutdown => {
455 tracing::info!("Shutdown requested");
456 Request::Shutdown
457 }
458 PowerAction::Reboot => {
459 tracing::info!("Reboot requested");
460 Request::Reboot
461 }
462 PowerAction::Suspend => {
463 tracing::info!("Suspend requested");
464 Request::Suspend
465 }
466 };
467
468 match client.request(&request).await? {
469 Response::Success => {
470 tracing::info!("Power action succeeded");
471 }
472 Response::Error { message } => {
473 tracing::warn!(message, "Power action failed");
474 // TODO: Show error in UI
475 }
476 _ => {}
477 }
478
479 Ok(())
480 }
481
482 /// Handle login attempt, returns fade transition if successful
483 async fn handle_login(
484 client: &mut Client,
485 form: &mut LoginForm,
486 session_exec: &str,
487 session_type: &str,
488 fade_duration_ms: u64,
489 ) -> Result<Option<FadeOutTransition>> {
490 form.is_loading = true;
491 form.clear_messages();
492
493 // Create session for user
494 tracing::info!(username = %form.username, "Creating auth session");
495 let response = client
496 .request(&Request::CreateSession {
497 username: form.username.clone(),
498 })
499 .await?;
500
501 match response {
502 Response::AuthPrompt { prompt, .. } => {
503 tracing::debug!(prompt, "Got auth prompt");
504 }
505 Response::Error { message } => {
506 tracing::warn!(message, "Session creation failed");
507 form.set_error(message);
508 form.is_loading = false;
509 return Ok(None);
510 }
511 _ => {}
512 }
513
514 // Send password
515 tracing::debug!("Sending password");
516 let response = client
517 .request(&Request::Authenticate {
518 response: form.password.clone(),
519 })
520 .await?;
521
522 match response {
523 Response::Success => {
524 tracing::info!("Authentication successful");
525 form.set_info("Starting session...".to_string());
526
527 // Start session with selected session command
528 // Split exec on whitespace to separate command from arguments
529 let session_cmd: Vec<String> = session_exec
530 .split_whitespace()
531 .map(String::from)
532 .collect();
533 tracing::info!(?session_cmd, %session_type, "Starting session");
534
535 let response = client
536 .request(&Request::StartSession {
537 cmd: session_cmd,
538 session_type: session_type.to_string(),
539 env: vec![],
540 })
541 .await?;
542
543 match response {
544 Response::Success => {
545 tracing::info!("Session started, beginning fade transition");
546 form.is_loading = false;
547 return Ok(Some(FadeOutTransition::new(fade_duration_ms)));
548 }
549 Response::Error { message } => {
550 tracing::error!(message, "Failed to start session");
551 form.set_error(message);
552 }
553 _ => {
554 form.set_error("Unexpected response".to_string());
555 }
556 }
557 }
558 Response::AuthError { message } => {
559 tracing::warn!(message, "Authentication failed");
560 form.set_error(message);
561 form.clear_password();
562 }
563 Response::AuthPrompt { prompt, .. } => {
564 form.set_info(prompt);
565 }
566 Response::AuthInfo { message } => {
567 form.set_info(message);
568 }
569 Response::Error { message } => {
570 form.set_error(message);
571 }
572 _ => {
573 form.set_error("Unexpected response".to_string());
574 }
575 }
576
577 form.is_loading = false;
578 Ok(None)
579 }
580
581 /// Render a tooltip above a power button
582 fn render_tooltip(
583 ctx: &cairo::Context,
584 pango_ctx: &pango::Context,
585 theme: &theme::Theme,
586 action: PowerAction,
587 center_x: f64,
588 top_y: f64,
589 ) -> Result<()> {
590 use crate::render::rounded_rectangle;
591 use pango::{FontDescription, Layout};
592
593 let text = match action {
594 PowerAction::Shutdown => "Shut Down",
595 PowerAction::Reboot => "Restart",
596 PowerAction::Suspend => "Sleep",
597 };
598
599 // Create text layout to measure
600 let layout = Layout::new(pango_ctx);
601 let mut font = FontDescription::new();
602 font.set_family(&theme.font_family);
603 font.set_size(11 * pango::SCALE);
604 layout.set_font_description(Some(&font));
605 layout.set_text(text);
606
607 let (text_w, text_h) = layout.pixel_size();
608 let padding_x = 10.0;
609 let padding_y = 6.0;
610 let tooltip_width = text_w as f64 + padding_x * 2.0;
611 let tooltip_height = text_h as f64 + padding_y * 2.0;
612 let tooltip_x = center_x - tooltip_width / 2.0;
613 let tooltip_y = top_y - tooltip_height - 8.0; // 8px gap above button
614
615 // Background
616 ctx.set_source_rgba(0.1, 0.1, 0.1, 0.9);
617 rounded_rectangle(ctx, tooltip_x, tooltip_y, tooltip_width, tooltip_height, 6.0);
618 ctx.fill()?;
619
620 // Border
621 ctx.set_source_rgba(0.3, 0.3, 0.3, 0.8);
622 rounded_rectangle(ctx, tooltip_x, tooltip_y, tooltip_width, tooltip_height, 6.0);
623 ctx.set_line_width(1.0);
624 ctx.stroke()?;
625
626 // Text
627 ctx.set_source_rgb(0.95, 0.95, 0.95);
628 ctx.move_to(tooltip_x + padding_x, tooltip_y + padding_y);
629 pangocairo::functions::show_layout(ctx, &layout);
630
631 Ok(())
632 }
633
634 /// Kill any compositors that might be left over from a previous session
635 ///
636 /// When a user logs out, their compositor (garchomp, picom) may not exit cleanly
637 /// and will continue rendering on top of the greeter. We must kill these before
638 /// the greeter can be visible.
639 fn kill_leftover_compositors() {
640 use std::process::Command;
641
642 let compositors = ["garchomp", "picom", "compton", "xcompmgr"];
643
644 for name in compositors {
645 // Use -f to match full command line (needed for NixOS wrappers
646 // where process names are like .garchomp-wrapped)
647 match Command::new("pkill").args(["-f", name]).status() {
648 Ok(status) if status.success() => {
649 tracing::info!("Killed leftover compositor: {}", name);
650 // Give it a moment to exit and release X resources
651 std::thread::sleep(std::time::Duration::from_millis(100));
652 }
653 Ok(_) => {
654 // Process not found, which is fine
655 }
656 Err(e) => {
657 tracing::warn!("Failed to kill {}: {}", name, e);
658 }
659 }
660 }
661 }
662