| 1 | //! X11 window management for the greeter |
| 2 | //! |
| 3 | //! Creates a fullscreen window for displaying the login interface. |
| 4 | |
| 5 | use anyhow::{Context, Result}; |
| 6 | use x11rb::connection::Connection; |
| 7 | use x11rb::protocol::xkb::{self, ConnectionExt as XkbConnectionExt}; |
| 8 | use x11rb::protocol::xproto::*; |
| 9 | use x11rb::rust_connection::RustConnection; |
| 10 | use x11rb::wrapper::ConnectionExt as _; |
| 11 | |
| 12 | /// Standard X11 cursor font glyphs |
| 13 | mod cursor_glyphs { |
| 14 | pub const XC_LEFT_PTR: u16 = 68; // Default arrow cursor |
| 15 | pub const XC_XTERM: u16 = 152; // I-beam text cursor |
| 16 | pub const XC_HAND2: u16 = 60; // Pointing hand cursor |
| 17 | } |
| 18 | |
| 19 | /// Cursor types available for the greeter |
| 20 | #[derive(Clone, Copy, PartialEq, Eq, Debug)] |
| 21 | pub enum CursorType { |
| 22 | Default, |
| 23 | Text, |
| 24 | Pointer, |
| 25 | } |
| 26 | |
| 27 | /// Greeter window wrapping X11 connection and window handle |
| 28 | pub struct GreeterWindow { |
| 29 | conn: RustConnection, |
| 30 | screen_num: usize, |
| 31 | window: Window, |
| 32 | gc: Gcontext, |
| 33 | width: u16, |
| 34 | height: u16, |
| 35 | depth: u8, |
| 36 | // Cursors |
| 37 | cursor_default: Cursor, |
| 38 | cursor_text: Cursor, |
| 39 | cursor_pointer: Cursor, |
| 40 | current_cursor: CursorType, |
| 41 | } |
| 42 | |
| 43 | impl GreeterWindow { |
| 44 | /// Create a new fullscreen greeter window |
| 45 | pub fn new() -> Result<Self> { |
| 46 | let (conn, screen_num) = x11rb::connect(None).context("Failed to connect to X server")?; |
| 47 | |
| 48 | let screen = &conn.setup().roots[screen_num]; |
| 49 | let width = screen.width_in_pixels; |
| 50 | let height = screen.height_in_pixels; |
| 51 | let root = screen.root; |
| 52 | let depth = screen.root_depth; |
| 53 | let visual = screen.root_visual; |
| 54 | |
| 55 | // Clear the root window to black to hide any leftover content from previous session |
| 56 | // garbg sets the root background using a pixmap AND stores the pixmap ID in |
| 57 | // _XROOTPMAP_ID and ESETROOT_PMAP_ID atoms. We must clear all of these. |
| 58 | |
| 59 | // First, clear the root pixmap atoms that garbg sets |
| 60 | // This prevents compositors from reading stale pixmap references |
| 61 | let xrootpmap_atom = conn |
| 62 | .intern_atom(false, b"_XROOTPMAP_ID")? |
| 63 | .reply() |
| 64 | .map(|r| r.atom) |
| 65 | .unwrap_or(x11rb::NONE); |
| 66 | let esetroot_atom = conn |
| 67 | .intern_atom(false, b"ESETROOT_PMAP_ID")? |
| 68 | .reply() |
| 69 | .map(|r| r.atom) |
| 70 | .unwrap_or(x11rb::NONE); |
| 71 | |
| 72 | // Try to free the old pixmap if it exists (to avoid memory leak) |
| 73 | if xrootpmap_atom != x11rb::NONE { |
| 74 | if let Ok(reply) = conn.get_property(false, root, xrootpmap_atom, AtomEnum::PIXMAP, 0, 1)?.reply() { |
| 75 | if reply.format == 32 && !reply.value.is_empty() { |
| 76 | let pixmap_id = u32::from_ne_bytes([ |
| 77 | reply.value[0], reply.value[1], reply.value[2], reply.value[3] |
| 78 | ]); |
| 79 | if pixmap_id != 0 { |
| 80 | let _ = conn.free_pixmap(pixmap_id); |
| 81 | } |
| 82 | } |
| 83 | } |
| 84 | // Delete the property |
| 85 | let _ = conn.delete_property(root, xrootpmap_atom); |
| 86 | } |
| 87 | if esetroot_atom != x11rb::NONE { |
| 88 | let _ = conn.delete_property(root, esetroot_atom); |
| 89 | } |
| 90 | |
| 91 | // Now clear the window's background pixmap attribute and set solid color |
| 92 | conn.change_window_attributes( |
| 93 | root, |
| 94 | &ChangeWindowAttributesAux::new() |
| 95 | .background_pixmap(x11rb::NONE) // Remove any pixmap (e.g., from garbg) |
| 96 | .background_pixel(screen.black_pixel), |
| 97 | )?; |
| 98 | conn.clear_area(false, root, 0, 0, width, height)?; |
| 99 | conn.flush()?; |
| 100 | |
| 101 | // Create window |
| 102 | let window = conn.generate_id().context("Failed to generate window ID")?; |
| 103 | conn.create_window( |
| 104 | depth, |
| 105 | window, |
| 106 | root, |
| 107 | 0, |
| 108 | 0, |
| 109 | width, |
| 110 | height, |
| 111 | 0, |
| 112 | WindowClass::INPUT_OUTPUT, |
| 113 | visual, |
| 114 | &CreateWindowAux::new() |
| 115 | .background_pixel(screen.black_pixel) |
| 116 | .event_mask( |
| 117 | EventMask::EXPOSURE |
| 118 | | EventMask::KEY_PRESS |
| 119 | | EventMask::KEY_RELEASE |
| 120 | | EventMask::BUTTON_PRESS |
| 121 | | EventMask::BUTTON_RELEASE |
| 122 | | EventMask::POINTER_MOTION |
| 123 | | EventMask::STRUCTURE_NOTIFY |
| 124 | | EventMask::FOCUS_CHANGE, |
| 125 | ), |
| 126 | ) |
| 127 | .context("Failed to create window")?; |
| 128 | |
| 129 | // Set fullscreen hint |
| 130 | let net_wm_state = conn |
| 131 | .intern_atom(false, b"_NET_WM_STATE") |
| 132 | .context("Failed to intern _NET_WM_STATE")? |
| 133 | .reply() |
| 134 | .context("Failed to get _NET_WM_STATE reply")? |
| 135 | .atom; |
| 136 | |
| 137 | let fullscreen = conn |
| 138 | .intern_atom(false, b"_NET_WM_STATE_FULLSCREEN") |
| 139 | .context("Failed to intern fullscreen atom")? |
| 140 | .reply() |
| 141 | .context("Failed to get fullscreen atom reply")? |
| 142 | .atom; |
| 143 | |
| 144 | conn.change_property32(PropMode::REPLACE, window, net_wm_state, AtomEnum::ATOM, &[ |
| 145 | fullscreen, |
| 146 | ]) |
| 147 | .context("Failed to set fullscreen property")?; |
| 148 | |
| 149 | // Override redirect - no window manager decorations |
| 150 | conn.change_window_attributes( |
| 151 | window, |
| 152 | &ChangeWindowAttributesAux::new().override_redirect(1), |
| 153 | ) |
| 154 | .context("Failed to set override redirect")?; |
| 155 | |
| 156 | // Create graphics context for image rendering |
| 157 | let gc = conn.generate_id().context("Failed to generate GC ID")?; |
| 158 | conn.create_gc(gc, window, &CreateGCAux::new()) |
| 159 | .context("Failed to create GC")?; |
| 160 | |
| 161 | // Load cursor font and create cursors |
| 162 | let cursor_font = conn.generate_id().context("Failed to generate font ID")?; |
| 163 | conn.open_font(cursor_font, b"cursor") |
| 164 | .context("Failed to open cursor font")?; |
| 165 | |
| 166 | let cursor_default = conn.generate_id().context("Failed to generate cursor ID")?; |
| 167 | conn.create_glyph_cursor( |
| 168 | cursor_default, |
| 169 | cursor_font, |
| 170 | cursor_font, |
| 171 | cursor_glyphs::XC_LEFT_PTR, |
| 172 | cursor_glyphs::XC_LEFT_PTR + 1, |
| 173 | 0xFFFF, 0xFFFF, 0xFFFF, // White foreground |
| 174 | 0, 0, 0, // Black background |
| 175 | ) |
| 176 | .context("Failed to create default cursor")?; |
| 177 | |
| 178 | let cursor_text = conn.generate_id().context("Failed to generate cursor ID")?; |
| 179 | conn.create_glyph_cursor( |
| 180 | cursor_text, |
| 181 | cursor_font, |
| 182 | cursor_font, |
| 183 | cursor_glyphs::XC_XTERM, |
| 184 | cursor_glyphs::XC_XTERM + 1, |
| 185 | 0xFFFF, 0xFFFF, 0xFFFF, |
| 186 | 0, 0, 0, |
| 187 | ) |
| 188 | .context("Failed to create text cursor")?; |
| 189 | |
| 190 | let cursor_pointer = conn.generate_id().context("Failed to generate cursor ID")?; |
| 191 | conn.create_glyph_cursor( |
| 192 | cursor_pointer, |
| 193 | cursor_font, |
| 194 | cursor_font, |
| 195 | cursor_glyphs::XC_HAND2, |
| 196 | cursor_glyphs::XC_HAND2 + 1, |
| 197 | 0xFFFF, 0xFFFF, 0xFFFF, |
| 198 | 0, 0, 0, |
| 199 | ) |
| 200 | .context("Failed to create pointer cursor")?; |
| 201 | |
| 202 | conn.close_font(cursor_font).context("Failed to close cursor font")?; |
| 203 | |
| 204 | // Set initial cursor |
| 205 | conn.change_window_attributes(window, &ChangeWindowAttributesAux::new().cursor(cursor_default)) |
| 206 | .context("Failed to set initial cursor")?; |
| 207 | |
| 208 | // Map and flush |
| 209 | conn.map_window(window).context("Failed to map window")?; |
| 210 | conn.flush().context("Failed to flush X connection")?; |
| 211 | |
| 212 | // Grab keyboard focus |
| 213 | conn.set_input_focus(InputFocus::POINTER_ROOT, window, x11rb::CURRENT_TIME) |
| 214 | .context("Failed to set input focus")?; |
| 215 | conn.flush()?; |
| 216 | |
| 217 | // Sync X11 keyboard lock state with kernel state |
| 218 | // X11 doesn't query kernel state at startup, so we must do it manually |
| 219 | sync_keyboard_locks(&conn); |
| 220 | |
| 221 | tracing::info!(width, height, "Created greeter window"); |
| 222 | |
| 223 | Ok(Self { |
| 224 | conn, |
| 225 | screen_num, |
| 226 | window, |
| 227 | gc, |
| 228 | width, |
| 229 | height, |
| 230 | depth, |
| 231 | cursor_default, |
| 232 | cursor_text, |
| 233 | cursor_pointer, |
| 234 | current_cursor: CursorType::Default, |
| 235 | }) |
| 236 | } |
| 237 | |
| 238 | /// Get window width |
| 239 | pub fn width(&self) -> u16 { |
| 240 | self.width |
| 241 | } |
| 242 | |
| 243 | /// Get window height |
| 244 | pub fn height(&self) -> u16 { |
| 245 | self.height |
| 246 | } |
| 247 | |
| 248 | /// Get the X11 connection |
| 249 | pub fn conn(&self) -> &RustConnection { |
| 250 | &self.conn |
| 251 | } |
| 252 | |
| 253 | /// Get the window ID |
| 254 | pub fn window(&self) -> Window { |
| 255 | self.window |
| 256 | } |
| 257 | |
| 258 | /// Get the root window ID |
| 259 | pub fn root(&self) -> Window { |
| 260 | self.conn.setup().roots[self.screen_num].root |
| 261 | } |
| 262 | |
| 263 | /// Get the screen number |
| 264 | pub fn screen_num(&self) -> usize { |
| 265 | self.screen_num |
| 266 | } |
| 267 | |
| 268 | /// Put an ARGB image to the window |
| 269 | /// Splits large images into chunks to avoid exceeding X11 request limits |
| 270 | pub fn put_image(&self, data: &[u8]) -> Result<()> { |
| 271 | let bytes_per_row = self.width as usize * 4; |
| 272 | let total_rows = self.height as usize; |
| 273 | |
| 274 | // X11 max request is typically 4MB, use 1MB chunks to be safe |
| 275 | const MAX_CHUNK_BYTES: usize = 1024 * 1024; |
| 276 | let rows_per_chunk = (MAX_CHUNK_BYTES / bytes_per_row).max(1); |
| 277 | |
| 278 | let mut y_offset: i16 = 0; |
| 279 | let mut remaining_rows = total_rows; |
| 280 | let mut data_offset = 0; |
| 281 | |
| 282 | while remaining_rows > 0 { |
| 283 | let chunk_rows = remaining_rows.min(rows_per_chunk); |
| 284 | let chunk_bytes = chunk_rows * bytes_per_row; |
| 285 | let chunk_data = &data[data_offset..data_offset + chunk_bytes]; |
| 286 | |
| 287 | self.conn |
| 288 | .put_image( |
| 289 | ImageFormat::Z_PIXMAP, |
| 290 | self.window, |
| 291 | self.gc, |
| 292 | self.width, |
| 293 | chunk_rows as u16, |
| 294 | 0, |
| 295 | y_offset, |
| 296 | 0, |
| 297 | self.depth, |
| 298 | chunk_data, |
| 299 | ) |
| 300 | .context("Failed to put image chunk")?; |
| 301 | |
| 302 | y_offset += chunk_rows as i16; |
| 303 | remaining_rows -= chunk_rows; |
| 304 | data_offset += chunk_bytes; |
| 305 | } |
| 306 | |
| 307 | self.conn.flush().context("Failed to flush after put_image")?; |
| 308 | Ok(()) |
| 309 | } |
| 310 | |
| 311 | /// Wait for and return the next X11 event |
| 312 | pub fn wait_for_event(&self) -> Result<x11rb::protocol::Event> { |
| 313 | self.conn |
| 314 | .wait_for_event() |
| 315 | .context("Failed to wait for X11 event") |
| 316 | } |
| 317 | |
| 318 | /// Poll for event without blocking |
| 319 | pub fn poll_for_event(&self) -> Result<Option<x11rb::protocol::Event>> { |
| 320 | self.conn |
| 321 | .poll_for_event() |
| 322 | .context("Failed to poll for X11 event") |
| 323 | } |
| 324 | |
| 325 | /// Set the cursor type (only updates if different from current) |
| 326 | pub fn set_cursor(&mut self, cursor_type: CursorType) { |
| 327 | if self.current_cursor == cursor_type { |
| 328 | return; |
| 329 | } |
| 330 | |
| 331 | let cursor = match cursor_type { |
| 332 | CursorType::Default => self.cursor_default, |
| 333 | CursorType::Text => self.cursor_text, |
| 334 | CursorType::Pointer => self.cursor_pointer, |
| 335 | }; |
| 336 | |
| 337 | let _ = self.conn.change_window_attributes( |
| 338 | self.window, |
| 339 | &ChangeWindowAttributesAux::new().cursor(cursor), |
| 340 | ); |
| 341 | let _ = self.conn.flush(); |
| 342 | self.current_cursor = cursor_type; |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | impl Drop for GreeterWindow { |
| 347 | fn drop(&mut self) { |
| 348 | let _ = self.conn.destroy_window(self.window); |
| 349 | let _ = self.conn.flush(); |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | /// Sync X11 keyboard lock state with kernel state |
| 354 | /// |
| 355 | /// X11/Xorg doesn't query the kernel's keyboard lock state at startup - it only |
| 356 | /// tracks state changes from key events. When returning from a Wayland session, |
| 357 | /// X11 starts fresh and doesn't know that caps/num lock was already active. |
| 358 | /// |
| 359 | /// This function queries the kernel's lock state via evdev and sets X11's XKB |
| 360 | /// state to match, ensuring the greeter's keyboard behavior matches the |
| 361 | /// physical keyboard state. |
| 362 | fn sync_keyboard_locks(conn: &RustConnection) { |
| 363 | |
| 364 | // Initialize XKB extension |
| 365 | if let Err(e) = conn.xkb_use_extension(1, 0) { |
| 366 | tracing::warn!("Failed to initialize XKB: {}", e); |
| 367 | return; |
| 368 | } |
| 369 | |
| 370 | // Find a keyboard device and query its lock state |
| 371 | let (caps_on, num_on) = match query_kernel_lock_state() { |
| 372 | Some(state) => state, |
| 373 | None => { |
| 374 | tracing::debug!("Could not query kernel keyboard state"); |
| 375 | return; |
| 376 | } |
| 377 | }; |
| 378 | |
| 379 | tracing::info!("Kernel keyboard state: caps_lock={}, num_lock={}", caps_on, num_on); |
| 380 | |
| 381 | // Build modifier mask to match kernel state |
| 382 | let mut mod_locks = ModMask::from(0u16); |
| 383 | if caps_on { |
| 384 | mod_locks |= ModMask::LOCK; |
| 385 | } |
| 386 | if num_on { |
| 387 | mod_locks |= ModMask::M2; |
| 388 | } |
| 389 | |
| 390 | // Set X11 XKB state to match kernel |
| 391 | let affect_locks = ModMask::LOCK | ModMask::M2; |
| 392 | match conn.xkb_latch_lock_state( |
| 393 | xkb::ID::USE_CORE_KBD.into(), |
| 394 | affect_locks, |
| 395 | mod_locks, |
| 396 | false, |
| 397 | xkb::Group::M1, |
| 398 | ModMask::from(0u16), |
| 399 | false, |
| 400 | 0u16, |
| 401 | ) { |
| 402 | Ok(_) => { |
| 403 | let _ = conn.flush(); |
| 404 | tracing::debug!("Synced X11 lock state with kernel"); |
| 405 | } |
| 406 | Err(e) => { |
| 407 | tracing::warn!("Failed to sync keyboard locks: {}", e); |
| 408 | } |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | /// Query kernel keyboard lock state via evdev EVIOCGLED ioctl |
| 413 | fn query_kernel_lock_state() -> Option<(bool, bool)> { |
| 414 | use std::fs::{self, File}; |
| 415 | use std::os::unix::io::AsRawFd; |
| 416 | |
| 417 | // evdev LED indices |
| 418 | const LED_NUML: u8 = 0; |
| 419 | const LED_CAPSL: u8 = 1; |
| 420 | |
| 421 | // EVIOCGLED ioctl - read LED state bitmap |
| 422 | // _IOR('E', 0x19, len) where len is LED_MAX/8+1 bytes |
| 423 | // For typical keyboards, we just need 1 byte |
| 424 | const EVIOCGLED_1: libc::c_ulong = 0x80014519; |
| 425 | |
| 426 | // Find keyboard devices |
| 427 | let input_dir = fs::read_dir("/dev/input").ok()?; |
| 428 | |
| 429 | for entry in input_dir.flatten() { |
| 430 | let path = entry.path(); |
| 431 | let name = path.file_name()?.to_str()?; |
| 432 | |
| 433 | // Only check event devices |
| 434 | if !name.starts_with("event") { |
| 435 | continue; |
| 436 | } |
| 437 | |
| 438 | // Try to open and query |
| 439 | if let Ok(file) = File::open(&path) { |
| 440 | let mut leds: u8 = 0; |
| 441 | let fd = file.as_raw_fd(); |
| 442 | |
| 443 | // Query LED state |
| 444 | let ret = unsafe { |
| 445 | libc::ioctl(fd, EVIOCGLED_1, &mut leds as *mut u8) |
| 446 | }; |
| 447 | |
| 448 | if ret >= 0 { |
| 449 | let caps_on = (leds & (1 << LED_CAPSL)) != 0; |
| 450 | let num_on = (leds & (1 << LED_NUML)) != 0; |
| 451 | return Some((caps_on, num_on)); |
| 452 | } |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | None |
| 457 | } |
| 458 |