@@ -0,0 +1,295 @@ |
| 1 | +//! Fullscreen locker window with secure input grabs |
| 2 | +//! |
| 3 | +//! Creates an override-redirect window that covers all monitors |
| 4 | +//! and grabs keyboard/pointer to prevent escape. |
| 5 | + |
| 6 | +use anyhow::{Context, Result}; |
| 7 | +use std::time::Duration; |
| 8 | +use x11rb::connection::Connection; |
| 9 | +use x11rb::protocol::xproto::*; |
| 10 | +use x11rb::rust_connection::RustConnection; |
| 11 | +use x11rb::wrapper::ConnectionExt as WrapperConnectionExt; |
| 12 | +use x11rb::CURRENT_TIME; |
| 13 | + |
| 14 | +use super::monitors::MonitorConfig; |
| 15 | + |
| 16 | +/// Fullscreen locker window |
| 17 | +pub struct LockerWindow { |
| 18 | + conn: RustConnection, |
| 19 | + screen_num: usize, |
| 20 | + window: Window, |
| 21 | + gc: Gcontext, |
| 22 | + width: u16, |
| 23 | + height: u16, |
| 24 | + depth: u8, |
| 25 | +} |
| 26 | + |
| 27 | +impl LockerWindow { |
| 28 | + /// Create a new fullscreen locker window |
| 29 | + /// |
| 30 | + /// This will: |
| 31 | + /// 1. Create an override-redirect fullscreen window |
| 32 | + /// 2. Grab the keyboard (retrying until successful) |
| 33 | + /// 3. Grab the pointer |
| 34 | + pub fn new() -> Result<Self> { |
| 35 | + let (conn, screen_num) = |
| 36 | + x11rb::connect(None).context("Failed to connect to X server")?; |
| 37 | + |
| 38 | + let screen = &conn.setup().roots[screen_num]; |
| 39 | + let width = screen.width_in_pixels; |
| 40 | + let height = screen.height_in_pixels; |
| 41 | + let root = screen.root; |
| 42 | + let depth = screen.root_depth; |
| 43 | + let visual = screen.root_visual; |
| 44 | + |
| 45 | + tracing::info!(width, height, "Connected to X server"); |
| 46 | + |
| 47 | + // Create fullscreen window |
| 48 | + let window = conn.generate_id().context("Failed to generate window ID")?; |
| 49 | + conn.create_window( |
| 50 | + depth, |
| 51 | + window, |
| 52 | + root, |
| 53 | + 0, |
| 54 | + 0, |
| 55 | + width, |
| 56 | + height, |
| 57 | + 0, // border_width |
| 58 | + WindowClass::INPUT_OUTPUT, |
| 59 | + visual, |
| 60 | + &CreateWindowAux::new() |
| 61 | + .background_pixel(screen.black_pixel) |
| 62 | + .override_redirect(1) // Bypass window manager |
| 63 | + .event_mask( |
| 64 | + EventMask::EXPOSURE |
| 65 | + | EventMask::KEY_PRESS |
| 66 | + | EventMask::KEY_RELEASE |
| 67 | + | EventMask::BUTTON_PRESS |
| 68 | + | EventMask::BUTTON_RELEASE |
| 69 | + | EventMask::POINTER_MOTION |
| 70 | + | EventMask::STRUCTURE_NOTIFY, |
| 71 | + ), |
| 72 | + ) |
| 73 | + .context("Failed to create window")?; |
| 74 | + |
| 75 | + // Set fullscreen hints (for compositors that respect them) |
| 76 | + Self::set_fullscreen_hints(&conn, window)?; |
| 77 | + |
| 78 | + // Create graphics context for rendering |
| 79 | + let gc = conn.generate_id().context("Failed to generate GC ID")?; |
| 80 | + conn.create_gc(gc, window, &CreateGCAux::new()) |
| 81 | + .context("Failed to create GC")?; |
| 82 | + |
| 83 | + // Map the window |
| 84 | + conn.map_window(window).context("Failed to map window")?; |
| 85 | + conn.flush().context("Failed to flush after map")?; |
| 86 | + |
| 87 | + // Grab keyboard - CRITICAL for security |
| 88 | + // Must retry because another application might have a grab |
| 89 | + Self::grab_keyboard(&conn, window)?; |
| 90 | + |
| 91 | + // Grab pointer - prevents clicking through to other windows |
| 92 | + Self::grab_pointer(&conn, window)?; |
| 93 | + |
| 94 | + // Raise window to top and focus |
| 95 | + conn.configure_window(window, &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE))?; |
| 96 | + conn.set_input_focus(InputFocus::POINTER_ROOT, window, CURRENT_TIME)?; |
| 97 | + conn.flush()?; |
| 98 | + |
| 99 | + tracing::info!("Locker window created and input grabbed"); |
| 100 | + |
| 101 | + Ok(Self { |
| 102 | + conn, |
| 103 | + screen_num, |
| 104 | + window, |
| 105 | + gc, |
| 106 | + width, |
| 107 | + height, |
| 108 | + depth, |
| 109 | + }) |
| 110 | + } |
| 111 | + |
| 112 | + /// Set fullscreen window hints |
| 113 | + fn set_fullscreen_hints(conn: &RustConnection, window: Window) -> Result<()> { |
| 114 | + let net_wm_state = conn |
| 115 | + .intern_atom(false, b"_NET_WM_STATE") |
| 116 | + .context("Failed to intern _NET_WM_STATE")? |
| 117 | + .reply() |
| 118 | + .context("Failed to get _NET_WM_STATE reply")? |
| 119 | + .atom; |
| 120 | + |
| 121 | + let fullscreen = conn |
| 122 | + .intern_atom(false, b"_NET_WM_STATE_FULLSCREEN") |
| 123 | + .context("Failed to intern fullscreen atom")? |
| 124 | + .reply() |
| 125 | + .context("Failed to get fullscreen atom reply")? |
| 126 | + .atom; |
| 127 | + |
| 128 | + conn.change_property32( |
| 129 | + PropMode::REPLACE, |
| 130 | + window, |
| 131 | + net_wm_state, |
| 132 | + AtomEnum::ATOM, |
| 133 | + &[fullscreen], |
| 134 | + ) |
| 135 | + .context("Failed to set fullscreen property")?; |
| 136 | + |
| 137 | + Ok(()) |
| 138 | + } |
| 139 | + |
| 140 | + /// Grab keyboard with retry logic |
| 141 | + fn grab_keyboard(conn: &RustConnection, window: Window) -> Result<()> { |
| 142 | + const MAX_RETRIES: u32 = 100; |
| 143 | + const RETRY_DELAY: Duration = Duration::from_millis(50); |
| 144 | + |
| 145 | + for attempt in 0..MAX_RETRIES { |
| 146 | + let reply = conn |
| 147 | + .grab_keyboard( |
| 148 | + true, // owner_events |
| 149 | + window, |
| 150 | + CURRENT_TIME, |
| 151 | + GrabMode::ASYNC, |
| 152 | + GrabMode::ASYNC, |
| 153 | + )? |
| 154 | + .reply() |
| 155 | + .context("Failed to get keyboard grab reply")?; |
| 156 | + |
| 157 | + match reply.status { |
| 158 | + GrabStatus::SUCCESS => { |
| 159 | + tracing::debug!(attempt, "Keyboard grab successful"); |
| 160 | + return Ok(()); |
| 161 | + } |
| 162 | + status => { |
| 163 | + tracing::trace!(?status, attempt, "Keyboard grab failed, retrying"); |
| 164 | + std::thread::sleep(RETRY_DELAY); |
| 165 | + } |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + anyhow::bail!("Failed to grab keyboard after {} retries", MAX_RETRIES) |
| 170 | + } |
| 171 | + |
| 172 | + /// Grab pointer to prevent clicking through |
| 173 | + fn grab_pointer(conn: &RustConnection, window: Window) -> Result<()> { |
| 174 | + let reply = conn |
| 175 | + .grab_pointer( |
| 176 | + true, // owner_events |
| 177 | + window, |
| 178 | + EventMask::BUTTON_PRESS | EventMask::POINTER_MOTION, |
| 179 | + GrabMode::ASYNC, |
| 180 | + GrabMode::ASYNC, |
| 181 | + window, // confine_to |
| 182 | + x11rb::NONE, // cursor (use default) |
| 183 | + CURRENT_TIME, |
| 184 | + )? |
| 185 | + .reply() |
| 186 | + .context("Failed to get pointer grab reply")?; |
| 187 | + |
| 188 | + match reply.status { |
| 189 | + GrabStatus::SUCCESS => { |
| 190 | + tracing::debug!("Pointer grab successful"); |
| 191 | + Ok(()) |
| 192 | + } |
| 193 | + status => { |
| 194 | + anyhow::bail!("Failed to grab pointer: {:?}", status) |
| 195 | + } |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + /// Get window width |
| 200 | + pub fn width(&self) -> u16 { |
| 201 | + self.width |
| 202 | + } |
| 203 | + |
| 204 | + /// Get window height |
| 205 | + pub fn height(&self) -> u16 { |
| 206 | + self.height |
| 207 | + } |
| 208 | + |
| 209 | + /// Get window depth |
| 210 | + pub fn depth(&self) -> u8 { |
| 211 | + self.depth |
| 212 | + } |
| 213 | + |
| 214 | + /// Get the X11 connection |
| 215 | + pub fn conn(&self) -> &RustConnection { |
| 216 | + &self.conn |
| 217 | + } |
| 218 | + |
| 219 | + /// Get the window ID |
| 220 | + pub fn window(&self) -> Window { |
| 221 | + self.window |
| 222 | + } |
| 223 | + |
| 224 | + /// Get the root window ID |
| 225 | + pub fn root(&self) -> Window { |
| 226 | + self.conn.setup().roots[self.screen_num].root |
| 227 | + } |
| 228 | + |
| 229 | + /// Put an ARGB image to the window |
| 230 | + /// Splits large images into chunks to avoid exceeding X11 request limits |
| 231 | + pub fn put_image(&self, data: &[u8]) -> Result<()> { |
| 232 | + let bytes_per_row = self.width as usize * 4; |
| 233 | + let total_rows = self.height as usize; |
| 234 | + |
| 235 | + // X11 max request is typically 4MB, use 1MB chunks to be safe |
| 236 | + const MAX_CHUNK_BYTES: usize = 1024 * 1024; |
| 237 | + let rows_per_chunk = (MAX_CHUNK_BYTES / bytes_per_row).max(1); |
| 238 | + |
| 239 | + let mut y_offset: i16 = 0; |
| 240 | + let mut remaining_rows = total_rows; |
| 241 | + let mut data_offset = 0; |
| 242 | + |
| 243 | + while remaining_rows > 0 { |
| 244 | + let chunk_rows = remaining_rows.min(rows_per_chunk); |
| 245 | + let chunk_bytes = chunk_rows * bytes_per_row; |
| 246 | + let chunk_data = &data[data_offset..data_offset + chunk_bytes]; |
| 247 | + |
| 248 | + self.conn |
| 249 | + .put_image( |
| 250 | + ImageFormat::Z_PIXMAP, |
| 251 | + self.window, |
| 252 | + self.gc, |
| 253 | + self.width, |
| 254 | + chunk_rows as u16, |
| 255 | + 0, |
| 256 | + y_offset, |
| 257 | + 0, |
| 258 | + self.depth, |
| 259 | + chunk_data, |
| 260 | + ) |
| 261 | + .context("Failed to put image chunk")?; |
| 262 | + |
| 263 | + y_offset += chunk_rows as i16; |
| 264 | + remaining_rows -= chunk_rows; |
| 265 | + data_offset += chunk_bytes; |
| 266 | + } |
| 267 | + |
| 268 | + self.conn.flush().context("Failed to flush after put_image")?; |
| 269 | + Ok(()) |
| 270 | + } |
| 271 | + |
| 272 | + /// Poll for event without blocking |
| 273 | + pub fn poll_for_event(&self) -> Result<Option<x11rb::protocol::Event>> { |
| 274 | + self.conn |
| 275 | + .poll_for_event() |
| 276 | + .context("Failed to poll for X11 event") |
| 277 | + } |
| 278 | + |
| 279 | + /// Get monitor configuration using RandR |
| 280 | + pub fn get_monitors(&self) -> Result<MonitorConfig> { |
| 281 | + MonitorConfig::detect(&self.conn, self.root()) |
| 282 | + } |
| 283 | +} |
| 284 | + |
| 285 | +impl Drop for LockerWindow { |
| 286 | + fn drop(&mut self) { |
| 287 | + // Release grabs |
| 288 | + let _ = self.conn.ungrab_keyboard(CURRENT_TIME); |
| 289 | + let _ = self.conn.ungrab_pointer(CURRENT_TIME); |
| 290 | + // Destroy window |
| 291 | + let _ = self.conn.destroy_window(self.window); |
| 292 | + let _ = self.conn.flush(); |
| 293 | + tracing::debug!("Locker window destroyed, grabs released"); |
| 294 | + } |
| 295 | +} |