Rust · 16294 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::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 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 // Create session selector (positioned below login form on primary monitor)
150 let selector_width = 200.0;
151 let selector_x = center_x - selector_width / 2.0;
152 let selector_y = center_y + 180.0; // Below the login form
153 let mut session_selector = SessionSelector::new(sessions, selector_x, selector_y, selector_width);
154
155 // Create power buttons (bottom-right corner of primary monitor)
156 let mut power_buttons = PowerButtons::new(
157 primary.x as f64,
158 primary.y as f64,
159 primary.width as f64,
160 primary.height as f64,
161 );
162
163 // Create Pango context for text rendering
164 let pango_ctx = pangocairo::functions::create_context(&renderer.context()?);
165
166 // Timing for cursor blink
167 let mut last_cursor_toggle = Instant::now();
168
169 // Fade transition (None until login succeeds)
170 let mut fade_transition: Option<FadeOutTransition> = None;
171
172 // Mouse position tracking
173 let mut mouse_x: f64 = 0.0;
174 let mut mouse_y: f64 = 0.0;
175
176 // Main event loop
177 tracing::info!("Entering main loop");
178 loop {
179 // Check if fade transition is complete
180 if let Some(ref fade) = fade_transition {
181 if fade.is_complete() {
182 tracing::info!("Fade complete, exiting greeter");
183 std::process::exit(0);
184 }
185 }
186
187 // Toggle cursor blink (only when not fading)
188 if fade_transition.is_none()
189 && last_cursor_toggle.elapsed() >= Duration::from_millis(CURSOR_BLINK_MS)
190 {
191 form.toggle_cursor();
192 last_cursor_toggle = Instant::now();
193 }
194
195 // Render frame
196 {
197 let ctx = renderer.context()?;
198
199 // Draw background (always full opacity)
200 render_to_cairo(&ctx, &background)?;
201
202 // Draw UI elements (with fade if transitioning)
203 let opacity = fade_transition
204 .as_ref()
205 .map(|f| f.opacity())
206 .unwrap_or(1.0);
207
208 render_with_fade(&ctx, opacity, |ctx| {
209 // User list (above login form)
210 user_list.render(ctx, &pango_ctx, &theme)?;
211
212 // Login form
213 form.render(ctx, &pango_ctx, &theme)?;
214
215 // Session selector
216 session_selector.render(ctx, &pango_ctx, &theme)?;
217
218 // Power buttons
219 power_buttons.render(ctx)?;
220
221 Ok(())
222 })?;
223 }
224
225 // Copy rendered frame to X11 window
226 let data = renderer.data()?;
227 window.put_image(&data)?;
228
229 // Skip event handling during fade
230 if fade_transition.is_some() {
231 std::thread::sleep(Duration::from_millis(16));
232 continue;
233 }
234
235 // Poll for X11 events (non-blocking)
236 while let Some(event) = window.poll_for_event()? {
237 match event {
238 Event::Expose(_) => {
239 // Already rendering every frame
240 }
241
242 Event::MotionNotify(e) => {
243 mouse_x = e.event_x as f64;
244 mouse_y = e.event_y as f64;
245
246 // Update hover states
247 power_buttons.update_hover(mouse_x, mouse_y);
248 session_selector.update_hover(mouse_x, mouse_y);
249 user_list.update_hover(mouse_x, mouse_y);
250 }
251
252 Event::ButtonPress(e) => {
253 let click_x = e.event_x as f64;
254 let click_y = e.event_y as f64;
255
256 // Check user list clicks first
257 if let Some(username) = user_list.handle_click(click_x, click_y) {
258 tracing::info!(username, "User selected from list");
259 form.username = username;
260 form.password.clear();
261 form.focused_field = FocusedField::Password;
262 form.clear_messages();
263 }
264 // Check power buttons
265 else if let Some(action) = power_buttons.handle_click(click_x, click_y) {
266 handle_power_action(&mut client, action).await?;
267 }
268 // Check session selector
269 else if session_selector.button_contains(click_x, click_y) {
270 session_selector.toggle();
271 } else if session_selector.is_expanded() {
272 if session_selector.handle_dropdown_click(click_x, click_y) {
273 // Selection made
274 tracing::info!(
275 session = ?session_selector.selected(),
276 "Session selected"
277 );
278 } else if !session_selector.contains(click_x, click_y) {
279 // Click outside dropdown - close it
280 session_selector.close();
281 }
282 }
283 }
284
285 Event::KeyPress(e) => {
286 // Close dropdown on any key press
287 if session_selector.is_expanded() {
288 session_selector.close();
289 }
290
291 match e.detail {
292 keycodes::ESCAPE => {
293 tracing::info!("Escape pressed, exiting");
294 return Ok(());
295 }
296
297 keycodes::RETURN => {
298 if form.focused_field == FocusedField::Password && form.can_submit() {
299 // Get selected session exec command
300 let session_exec = session_selector
301 .selected_exec()
302 .unwrap_or("gar-session.sh")
303 .to_string();
304
305 // Attempt login
306 if let Some(fade) = handle_login(
307 &mut client,
308 &mut form,
309 &session_exec,
310 config.effective_fade_duration(),
311 )
312 .await?
313 {
314 fade_transition = Some(fade);
315 }
316 } else if form.focused_field == FocusedField::Username {
317 form.handle_tab();
318 }
319 }
320
321 keycodes::TAB => {
322 form.handle_tab();
323 }
324
325 keycodes::BACKSPACE => {
326 form.handle_backspace();
327 }
328
329 _ => {
330 // Regular character key
331 if let Some(c) = keycode_to_char(e.detail, e.state) {
332 form.handle_key(c);
333 }
334 }
335 }
336 }
337
338 Event::FocusOut(_) => {
339 // Regrab focus if we lose it
340 let _ = window.conn().set_input_focus(
341 x11rb::protocol::xproto::InputFocus::POINTER_ROOT,
342 window.window(),
343 x11rb::CURRENT_TIME,
344 );
345 let _ = window.conn().flush();
346 }
347
348 _ => {}
349 }
350 }
351
352 // Small sleep to avoid busy-spinning
353 std::thread::sleep(Duration::from_millis(16)); // ~60fps
354 }
355 }
356
357 /// Handle power button action
358 async fn handle_power_action(client: &mut Client, action: PowerAction) -> Result<()> {
359 let request = match action {
360 PowerAction::Shutdown => {
361 tracing::info!("Shutdown requested");
362 Request::Shutdown
363 }
364 PowerAction::Reboot => {
365 tracing::info!("Reboot requested");
366 Request::Reboot
367 }
368 PowerAction::Suspend => {
369 tracing::info!("Suspend requested");
370 Request::Suspend
371 }
372 };
373
374 match client.request(&request).await? {
375 Response::Success => {
376 tracing::info!("Power action succeeded");
377 }
378 Response::Error { message } => {
379 tracing::warn!(message, "Power action failed");
380 // TODO: Show error in UI
381 }
382 _ => {}
383 }
384
385 Ok(())
386 }
387
388 /// Handle login attempt, returns fade transition if successful
389 async fn handle_login(
390 client: &mut Client,
391 form: &mut LoginForm,
392 session_exec: &str,
393 fade_duration_ms: u64,
394 ) -> Result<Option<FadeOutTransition>> {
395 form.is_loading = true;
396 form.clear_messages();
397
398 // Create session for user
399 tracing::info!(username = %form.username, "Creating auth session");
400 let response = client
401 .request(&Request::CreateSession {
402 username: form.username.clone(),
403 })
404 .await?;
405
406 match response {
407 Response::AuthPrompt { prompt, .. } => {
408 tracing::debug!(prompt, "Got auth prompt");
409 }
410 Response::Error { message } => {
411 tracing::warn!(message, "Session creation failed");
412 form.set_error(message);
413 form.is_loading = false;
414 return Ok(None);
415 }
416 _ => {}
417 }
418
419 // Send password
420 tracing::debug!("Sending password");
421 let response = client
422 .request(&Request::Authenticate {
423 response: form.password.clone(),
424 })
425 .await?;
426
427 match response {
428 Response::Success => {
429 tracing::info!("Authentication successful");
430 form.set_info("Starting session...".to_string());
431
432 // Start session with selected session command
433 let session_cmd = vec![session_exec.to_string()];
434 tracing::info!(?session_cmd, "Starting session");
435
436 let response = client
437 .request(&Request::StartSession {
438 cmd: session_cmd,
439 env: vec![],
440 })
441 .await?;
442
443 match response {
444 Response::Success => {
445 tracing::info!("Session started, beginning fade transition");
446 form.is_loading = false;
447 return Ok(Some(FadeOutTransition::new(fade_duration_ms)));
448 }
449 Response::Error { message } => {
450 tracing::error!(message, "Failed to start session");
451 form.set_error(message);
452 }
453 _ => {
454 form.set_error("Unexpected response".to_string());
455 }
456 }
457 }
458 Response::AuthError { message } => {
459 tracing::warn!(message, "Authentication failed");
460 form.set_error(message);
461 form.clear_password();
462 }
463 Response::AuthPrompt { prompt, .. } => {
464 form.set_info(prompt);
465 }
466 Response::AuthInfo { message } => {
467 form.set_info(message);
468 }
469 Response::Error { message } => {
470 form.set_error(message);
471 }
472 _ => {
473 form.set_error("Unexpected response".to_string());
474 }
475 }
476
477 form.is_loading = false;
478 Ok(None)
479 }
480