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