git.lucas.co / cce-lock
session locker (ext-session-lock + PAM)
git clone https://git.lucas.co/cce-lock.git

src/main.rs (36.8K)

   1 //! cce-lock — the cce desktop's session locker.
   2 //!
   3 //! An `ext-session-lock-v1` client: it asks the compositor to lock the
   4 //! session, paints a password prompt on every output, and calls
   5 //! `unlock_and_destroy` only when PAM has accepted the user's credentials.
   6 //!
   7 //! Two properties of the protocol are what make this safe, and both are worth
   8 //! knowing before changing anything here:
   9 //!
  10 //! - **The compositor blanks the session the moment the lock is granted**,
  11 //!   before this process has painted anything. There is no window between
  12 //!   "locked" and "prompt drawn" in which the desktop is visible.
  13 //! - **If this process dies while locked, the session STAYS locked.** cce-fx's
  14 //!   `handle_destroy` (cce-compositor/src/server/lock_manager.rs) deliberately
  15 //!   does not clear the lock state — only the `unlock` request does. So
  16 //!   crashing is a safe failure here, and `kill` is not a bypass. A later
  17 //!   locker can take over an already-locked session; the compositor hands it
  18 //!   `locked` immediately.
  19 //!
  20 //! Which means the dangerous failure is not "it crashed" but "it cannot ever
  21 //! succeed" — a PAM stack that will not start, so no password is ever
  22 //! accepted. [`auth::preflight`] is the guard: the lock is not even requested
  23 //! until PAM has proven it can start.
  24 //!
  25 //! Like cce-cloud, this drives its own event loop and renders through
  26 //! `cce_ui::vk::VkRenderer` rather than implementing cce-ui's `Application`
  27 //! trait — the engine runner creates xdg/layer surfaces, and a lock surface
  28 //! is neither.
  29 
  30 mod auth;
  31 
  32 use std::collections::HashMap;
  33 
  34 use smithay_client_toolkit::{
  35     compositor::{CompositorHandler, CompositorState},
  36     delegate_compositor, delegate_keyboard, delegate_output, delegate_registry, delegate_seat,
  37     output::{OutputHandler, OutputState},
  38     registry::{ProvidesRegistryState, RegistryState},
  39     seat::{
  40         keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers},
  41         Capability, SeatHandler, SeatState,
  42     },
  43 };
  44 use wayland_client::{
  45     globals::registry_queue_init,
  46     protocol::{wl_keyboard, wl_output, wl_seat, wl_surface},
  47     Connection, Dispatch, Proxy, QueueHandle,
  48 };
  49 use wayland_protocols::ext::session_lock::v1::client::{
  50     ext_session_lock_manager_v1::ExtSessionLockManagerV1,
  51     ext_session_lock_surface_v1::{self, ExtSessionLockSurfaceV1},
  52     ext_session_lock_v1::{self, ExtSessionLockV1},
  53 };
  54 
  55 use cce_ui::cosmic_text::{Attrs, Buffer, FontSystem, Metrics, SwashCache};
  56 use cce_ui::scene::layout::Rect;
  57 use cce_ui::vk::{Batch2D, Frame2D, ImageQuad, TextSpan, VkRenderer};
  58 use cce_ui::engine::Vertex;
  59 
  60 /// A label queued for the text pass: logical position, size, colour.
  61 struct Label {
  62     text: String,
  63     x: f32,
  64     y: f32,
  65     size: f32,
  66     color: [f32; 3],
  67 }
  68 
  69 /// One output's lock surface and everything needed to paint it.
  70 struct LockOutput {
  71     wl_surface: wl_surface::WlSurface,
  72     lock_surface: ExtSessionLockSurfaceV1,
  73     renderer: Option<VkRenderer>,
  74     /// Logical size from the last `configure`; 0 until the first one arrives.
  75     width: f32,
  76     height: f32,
  77     scale: f32,
  78     /// A buffer may not be attached before the first configure is acked.
  79     configured: bool,
  80 }
  81 
  82 impl Drop for LockOutput {
  83     fn drop(&mut self) {
  84         // Swapchain teardown must precede the wl_surface's destruction.
  85         self.renderer.take();
  86         self.lock_surface.destroy();
  87         self.wl_surface.destroy();
  88     }
  89 }
  90 
  91 /// What the UI is doing, which is also what it says on screen.
  92 enum Phase {
  93     /// Waiting for a password.
  94     Prompt,
  95     /// A worker thread is inside PAM. Input is ignored until it answers, so a
  96     /// held Return cannot queue a hundred attempts against pam_faillock.
  97     Checking,
  98     /// PAM accepted; the unlock request has gone out and we are leaving.
  99     Unlocking,
 100 }
 101 
 102 struct AppState {
 103     registry_state: RegistryState,
 104     seat_state: SeatState,
 105     output_state: OutputState,
 106     compositor_state: CompositorState,
 107 
 108     lock: Option<ExtSessionLockV1>,
 109     /// Keyed by the wl_output's id, so a surface can be found from either side.
 110     outputs: HashMap<u32, LockOutput>,
 111     keyboard: Option<wl_keyboard::WlKeyboard>,
 112 
 113     username: String,
 114     password: String,
 115     phase: Phase,
 116     status: Option<String>,
 117     caps_lock: bool,
 118     /// Set once the compositor confirms the session is locked and the previous
 119     /// contents are hidden.
 120     locked: bool,
 121     /// The compositor ended the lock without us asking (`finished`): we must
 122     /// exit WITHOUT unlocking.
 123     finished: bool,
 124     /// PAM accepted before the `locked` event arrived; unlock as soon as it
 125     /// does. Set only from [`Self::unlock`], which only `AuthEvent::Success`
 126     /// reaches.
 127     unlock_when_locked: bool,
 128     /// `unlock_and_destroy` has been sent. Main must round-trip on this
 129     /// before exiting.
 130     unlocked: bool,
 131     exit: bool,
 132     /// Something on screen changed since the last paint. The main loop paints
 133     /// only then — until 2026-10-05 it repainted every output on every pass of
 134     /// a 50 ms dispatch, ~20 full frames a second for as long as the screen
 135     /// stayed locked, with nothing on it moving.
 136     dirty: bool,
 137 
 138     font_system: FontSystem,
 139     swash_cache: SwashCache,
 140     auth_tx: calloop::channel::Sender<auth::AuthEvent>,
 141     /// For the keyboard's repeat timer, and for the repeats it delivers,
 142     /// which arrive without a queue handle of their own.
 143     loop_handle: calloop::LoopHandle<'static, AppState>,
 144     qh: QueueHandle<AppState>,
 145 }
 146 
 147 impl AppState {
 148     /// Hand the typed password to PAM on a worker thread. Blocking here would
 149     /// freeze the lock screen for the length of a faillock delay.
 150     fn submit(&mut self, qh: &QueueHandle<Self>) {
 151         if matches!(self.phase, Phase::Checking | Phase::Unlocking) {
 152             return;
 153         }
 154         if self.password.is_empty() {
 155             self.status = Some("Enter your password".to_string());
 156             self.draw_all(qh);
 157             return;
 158         }
 159         self.phase = Phase::Checking;
 160         self.status = None;
 161 
 162         let username = self.username.clone();
 163         let password = std::mem::take(&mut self.password);
 164         let ui = self.auth_tx.clone();
 165         std::thread::spawn(move || {
 166             let (info_tx, info_rx) = std::sync::mpsc::channel();
 167             // Pump PAM's running commentary to the screen as it arrives
 168             // rather than after: a faillock delay can hold `check` for
 169             // seconds, and the stack explains itself during that time. The
 170             // sender lives inside the transaction, so this ends on its own
 171             // when `check` returns.
 172             let pump_ui = ui.clone();
 173             let pump = std::thread::spawn(move || {
 174                 while let Ok(ev) = info_rx.recv() {
 175                     let _ = pump_ui.send(ev);
 176                 }
 177             });
 178             let verdict = auth::check(&username, &password, info_tx);
 179             let _ = pump.join();
 180             let _ = ui.send(if verdict.is_success() {
 181                 auth::AuthEvent::Success
 182             } else {
 183                 auth::AuthEvent::Failure { msg: verdict.message() }
 184             });
 185             zero(password);
 186         });
 187         self.draw_all(qh);
 188     }
 189 
 190     /// PAM accepted: release the session and go.
 191     fn unlock(&mut self) {
 192         // `unlock_and_destroy` before the `locked` event is a PROTOCOL ERROR,
 193         // and the compositor kills the client for it — leaving the session
 194         // locked with the locker gone. PAM can answer before `locked` lands
 195         // (the compositor is still bringing the lock up while the user types
 196         // into a surface it already configured), so this is reachable.
 197         if !self.locked {
 198             log::warn!("authenticated before the locked event; waiting for it");
 199             self.phase = Phase::Prompt;
 200             self.status = Some("Locking, one moment…".to_string());
 201             self.unlock_when_locked = true;
 202             self.dirty = true;
 203             return;
 204         }
 205         let Some(lock) = self.lock.take() else {
 206             self.exit = true;
 207             return;
 208         };
 209         self.phase = Phase::Unlocking;
 210         // The ONLY call in this program that opens the session, reached only
 211         // from `AuthEvent::Success`, which `auth::Verdict::is_success` is the
 212         // sole producer of.
 213         lock.unlock_and_destroy();
 214         // Only now: the protocol says lock surfaces "should be destroyed by
 215         // the client" AFTER this request, not before.
 216         self.outputs.clear();
 217         self.unlocked = true;
 218         self.exit = true;
 219     }
 220 
 221     fn create_lock_surface(&mut self, output: &wl_output::WlOutput, qh: &QueueHandle<Self>) {
 222         let Some(lock) = self.lock.as_ref() else { return };
 223         let id = output.id().protocol_id();
 224         if self.outputs.contains_key(&id) {
 225             return;
 226         }
 227         let wl_surface = self.compositor_state.create_surface(qh);
 228         let lock_surface = lock.get_lock_surface(&wl_surface, output, qh, id);
 229         self.outputs.insert(
 230             id,
 231             LockOutput {
 232                 wl_surface,
 233                 lock_surface,
 234                 renderer: None,
 235                 width: 0.0,
 236                 height: 0.0,
 237                 scale: 1.0,
 238                 configured: false,
 239             },
 240         );
 241     }
 242 
 243     /// Ask for a repaint of every output; the main loop does it once the
 244     /// events in hand are handled.
 245     fn draw_all(&mut self, _qh: &QueueHandle<Self>) {
 246         self.dirty = true;
 247     }
 248 
 249     /// Paint every output now. False when a frame did not present (swapchain
 250     /// out of date) and the paint must be retried.
 251     fn paint_all(&mut self) -> bool {
 252         let ids: Vec<u32> = self.outputs.keys().copied().collect();
 253         let mut ok = true;
 254         for id in ids {
 255             ok &= self.draw(id);
 256         }
 257         ok
 258     }
 259 
 260     /// Paint one output. False only when a frame was drawn and did not
 261     /// present; an output with nothing to paint on yet (no configure, no
 262     /// renderer) is not a failure — its configure asks for a paint.
 263     fn draw(&mut self, id: u32) -> bool {
 264         let Some(out) = self.outputs.get(&id) else { return true };
 265         if !out.configured || out.width <= 0.0 || out.height <= 0.0 {
 266             return true;
 267         }
 268         let (w, h, scale) = (out.width, out.height, out.scale);
 269 
 270         let (dl, labels) = self.build_scene(w, h);
 271         let (verts, batches, images, features) = tessellate(&dl, w, h, scale);
 272 
 273         let spans_src: Vec<(Buffer, &Label)> = labels
 274             .iter()
 275             .map(|l| (make_text_buffer(&mut self.font_system, &l.text, l.size), l))
 276             .collect();
 277         let spans: Vec<TextSpan> = spans_src
 278             .iter()
 279             .map(|(buf, l)| TextSpan {
 280                 buffer: buf,
 281                 left: (l.x * scale).round(),
 282                 top: (l.y * scale).round(),
 283                 scale,
 284                 bounds: None,
 285                 default_color: [l.color[0], l.color[1], l.color[2], 1.0],
 286                 rotation: None,
 287                 clip_circle: [0.0; 3],
 288                 clip_extents: [0.0; 2],
 289             })
 290             .collect();
 291 
 292         // Split the borrow: the renderer lives in the map, the font system on
 293         // self, and prepare_text needs both at once.
 294         let Self { outputs, font_system, swash_cache, .. } = self;
 295         let Some(out) = outputs.get_mut(&id) else { return true };
 296         let Some(renderer) = out.renderer.as_mut() else { return true };
 297         renderer.prepare_text(font_system, swash_cache, &spans);
 298         renderer.draw_frame_2d(Frame2D {
 299             verts: &verts,
 300             batches: &batches,
 301             overlay_verts: &[],
 302             images: &images,
 303             plate_features: &features,
 304             clear_color: [0.0, 0.0, 0.0, 1.0],
 305             // Always a full frame: the lock screen repaints whole.
 306             damage: None,
 307         })
 308     }
 309 
 310     /// The lock screen itself: an opaque ground, a centred card, the password
 311     /// well and its bullets, and one status line.
 312     fn build_scene(&self, w: f32, h: f32) -> (cce_ui::scene::paint::DisplayList, Vec<Label>) {
 313         // style-audit: opt-out the lock screen is a black surface carrying one card plate
 314         let mut pc = cce_ui::scene::paint::PaintCtx::new();
 315         let mut labels = Vec::new();
 316 
 317         // Opaque, always. A translucent lock screen would show the desktop it
 318         // is hiding — the compositor already disabled the normal scene tree,
 319         // but painting see-through here would still be wrong the moment
 320         // anything else is composited under it.
 321         //
 322         // These channel values are LINEAR, not sRGB: the swapchain is an sRGB
 323         // format, so the hardware encodes what the shader writes. 0.05 here
 324         // is #3F3F4B on screen, not the near-black it reads as — which is how
 325         // this ground first shipped a flat mid-grey. Divide by roughly ten to
 326         // get the dark you meant; measure with a screenshot, never by eye
 327         // over the source.
 328         pc.quad(Rect { x: 0.0, y: 0.0, width: w, height: h }, [0.004, 0.004, 0.006, 1.0]);
 329 
 330         let card_w = 360.0f32.min(w - 40.0);
 331         let card_h = 170.0f32;
 332         let card = Rect {
 333             x: (w - card_w) / 2.0,
 334             y: (h - card_h) / 2.0,
 335             width: card_w,
 336             height: card_h,
 337         };
 338         let depth = cce_ui::color::plate_bevel_width();
 339         pc.plate_spec(&cce_ui::scene::paint::PlateSpec {
 340             rect: card,
 341             material: cce_ui::scene::Material::opaque([0.013, 0.013, 0.017, 1.0]),
 342             window_corners: (true, true, true, true),
 343             depth,
 344         });
 345 
 346         labels.push(Label {
 347             text: self.username.clone(),
 348             x: card.x + 24.0,
 349             y: card.y + 22.0,
 350             size: 15.0,
 351             color: [1.0, 1.0, 1.0],
 352         });
 353 
 354         // The password well, rim lit in the highlight the way a focused well
 355         // is everywhere else in the DE.
 356         let well = Rect { x: card.x + 24.0, y: card.y + 58.0, width: card_w - 48.0, height: 38.0 };
 357         pc.quad(well, [0.005, 0.005, 0.007, 1.0]);
 358         let well_depth = cce_ui::layout::bevel_width().min(well.height * 0.2);
 359         let hc = cce_ui::color::highlight_primary_color();
 360         pc.recess_tinted(well, (0.0, 0.0, 0.0, 0.0), well_depth, [hc[0], hc[1], hc[2]]);
 361 
 362         // One dot per character. Never the characters themselves, and never a
 363         // count in the status line either — both leak the password's length to
 364         // anyone watching the screen.
 365         let dots = dots_shown(self.password.chars().count(), well.width);
 366         for i in 0..dots {
 367             pc.circle(
 368                 well.x + DOT_INSET + DOT_R + i as f32 * DOT_GAP,
 369                 well.y + well.height / 2.0,
 370                 DOT_R,
 371                 [0.80, 0.80, 0.88, 1.0],
 372             );
 373         }
 374 
 375         let (status, color) = match self.phase {
 376             Phase::Checking => ("Checking…".to_string(), [0.72, 0.72, 0.80]),
 377             Phase::Unlocking => ("Unlocking…".to_string(), [0.72, 0.85, 0.72]),
 378             Phase::Prompt => match &self.status {
 379                 Some(msg) => (msg.clone(), [0.95, 0.55, 0.55]),
 380                 None if self.caps_lock => ("Caps Lock is on".to_string(), [0.95, 0.80, 0.50]),
 381                 None => (String::new(), [0.55, 0.55, 0.62]),
 382             },
 383         };
 384         if !status.is_empty() {
 385             labels.push(Label {
 386                 text: status,
 387                 x: card.x + 24.0,
 388                 y: card.y + 112.0,
 389                 size: 12.0,
 390                 color,
 391             });
 392         }
 393 
 394         (pc.finish(), labels)
 395     }
 396 }
 397 
 398 /// Best-effort scrub of a password buffer once it has been used.
 399 ///
 400 /// Honest about its limits: PAM copies the string into its own allocations and
 401 /// the conversation hands libc a `strdup` of it, and neither is reachable from
 402 /// here. This only clears the copy this process owns, so the window in which a
 403 /// core dump could contain the password is shorter, not closed.
 404 fn zero(mut s: String) {
 405     unsafe {
 406         for b in s.as_bytes_mut() {
 407             *b = 0;
 408         }
 409     }
 410     drop(s);
 411 }
 412 
 413 fn make_text_buffer(font_system: &mut FontSystem, text: &str, size: f32) -> Buffer {
 414     let metrics = Metrics::new(size, size * 1.4);
 415     let mut buffer = Buffer::new(font_system, metrics);
 416     let family = cce_ui::layout::control_label_font_parsed().0;
 417     let attrs = Attrs::new().family(cce_ui::cosmic_text::Family::Name(&family));
 418     buffer.set_text(font_system, text, attrs, cce_ui::cosmic_text::Shaping::Advanced);
 419     buffer.shape_until_scroll(font_system, true);
 420     buffer
 421 }
 422 
 423 /// Display list → vertex buffer + renderer batches, converting the
 424 /// tessellator's logical-px clips to physical. Same shape as cce-cloud's.
 425 fn tessellate(
 426     dl: &cce_ui::scene::paint::DisplayList,
 427     sw: f32,
 428     sh: f32,
 429     scale: f32,
 430 ) -> (Vec<Vertex>, Vec<Batch2D>, Vec<ImageQuad>, Vec<[f32; 12]>) {
 431     let (verts, dl_batches, _dl_images, features) =
 432         cce_ui::backend::window_runner::tessellate_display_list(dl, sw, sh, scale);
 433     let batches = dl_batches
 434         .iter()
 435         .map(|b| Batch2D {
 436             scissor: b.scissor.map(|c| {
 437                 (
 438                     (c.x * scale).max(0.0) as u32,
 439                     (c.y * scale).max(0.0) as u32,
 440                     (c.width * scale) as u32,
 441                     (c.height * scale) as u32,
 442                 )
 443             }),
 444             clip_rrect: b
 445                 .clip_rrect
 446                 .map(|c| [c[0] * scale, c[1] * scale, c[2] * scale, c[3] * scale, c[4] * scale]),
 447             start: b.start,
 448             end: b.end,
 449             plate: b.plate,
 450             blur_behind: b.blur_behind,
 451         })
 452         .collect();
 453     (verts, batches, Vec::new(), features)
 454 }
 455 
 456 // ---------------------------------------------------------------------------
 457 // Protocol plumbing
 458 // ---------------------------------------------------------------------------
 459 
 460 impl Dispatch<ExtSessionLockManagerV1, ()> for AppState {
 461     fn event(
 462         _state: &mut Self,
 463         _proxy: &ExtSessionLockManagerV1,
 464         _event: <ExtSessionLockManagerV1 as Proxy>::Event,
 465         _data: &(),
 466         _conn: &Connection,
 467         _qh: &QueueHandle<Self>,
 468     ) {
 469     }
 470 }
 471 
 472 impl Dispatch<ExtSessionLockV1, ()> for AppState {
 473     fn event(
 474         state: &mut Self,
 475         _proxy: &ExtSessionLockV1,
 476         event: <ExtSessionLockV1 as Proxy>::Event,
 477         _data: &(),
 478         _conn: &Connection,
 479         _qh: &QueueHandle<Self>,
 480     ) {
 481         match event {
 482             ext_session_lock_v1::Event::Locked => {
 483                 log::info!("session locked");
 484                 state.locked = true;
 485                 if state.unlock_when_locked {
 486                     state.unlock_when_locked = false;
 487                     state.unlock();
 488                 }
 489             }
 490             ext_session_lock_v1::Event::Finished => {
 491                 // The compositor refused the lock or ended it. We must exit
 492                 // WITHOUT calling unlock_and_destroy — that request would be
 493                 // a protocol error, and pretending to unlock a session we
 494                 // never locked is not ours to do.
 495                 log::warn!("lock finished by the compositor; exiting without unlocking");
 496                 state.finished = true;
 497                 state.exit = true;
 498             }
 499             _ => {}
 500         }
 501     }
 502 }
 503 
 504 impl Dispatch<ExtSessionLockSurfaceV1, u32> for AppState {
 505     fn event(
 506         state: &mut Self,
 507         _proxy: &ExtSessionLockSurfaceV1,
 508         event: <ExtSessionLockSurfaceV1 as Proxy>::Event,
 509         id: &u32,
 510         _conn: &Connection,
 511         _qh: &QueueHandle<Self>,
 512     ) {
 513         if let ext_session_lock_surface_v1::Event::Configure { serial, width, height } = event {
 514             let Some(out) = state.outputs.get_mut(id) else { return };
 515             out.lock_surface.ack_configure(serial);
 516             out.width = width as f32;
 517             out.height = height as f32;
 518             out.configured = true;
 519 
 520             let pw = (out.width * out.scale) as u32;
 521             let ph = (out.height * out.scale) as u32;
 522             match out.renderer.as_mut() {
 523                 Some(r) => r.resize(pw, ph),
 524                 None => {
 525                     out.wl_surface.set_buffer_scale(out.scale as i32);
 526                     let conn_ptr = _conn.backend().display_id().as_ptr() as *mut std::ffi::c_void;
 527                     let surf_ptr = out.wl_surface.id().as_ptr() as *mut std::ffi::c_void;
 528                     // A lost surface means the connection is dying under us.
 529                     // Never an unlock, and not an exit either: the output just
 530                     // stays unpainted (`draw` skips it), the next configure
 531                     // tries again, and a dead connection ends the main loop's
 532                     // dispatch the way it always has — still locked.
 533                     match unsafe { VkRenderer::try_new(conn_ptr, surf_ptr, pw, ph, 0.0) } {
 534                         Ok(r) => out.renderer = Some(r),
 535                         Err(lost) => log::warn!("output {id}: {lost}; not painting it"),
 536                     }
 537                 }
 538             }
 539             state.dirty = true;
 540         }
 541     }
 542 }
 543 
 544 impl CompositorHandler for AppState {
 545     fn scale_factor_changed(
 546         &mut self,
 547         _conn: &Connection,
 548         _qh: &QueueHandle<Self>,
 549         surface: &wl_surface::WlSurface,
 550         new_factor: i32,
 551     ) {
 552         let id = self
 553             .outputs
 554             .iter()
 555             .find(|(_, o)| &o.wl_surface == surface)
 556             .map(|(id, _)| *id);
 557         let Some(id) = id else { return };
 558         if let Some(out) = self.outputs.get_mut(&id) {
 559             out.scale = new_factor as f32;
 560             out.wl_surface.set_buffer_scale(new_factor);
 561             if let Some(r) = out.renderer.as_mut() {
 562                 r.resize((out.width * out.scale) as u32, (out.height * out.scale) as u32);
 563             }
 564         }
 565         self.dirty = true;
 566     }
 567 
 568     fn transform_changed(
 569         &mut self,
 570         _: &Connection,
 571         _: &QueueHandle<Self>,
 572         _: &wl_surface::WlSurface,
 573         _: wl_output::Transform,
 574     ) {
 575     }
 576     fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}
 577     fn surface_enter(
 578         &mut self,
 579         _: &Connection,
 580         _: &QueueHandle<Self>,
 581         _: &wl_surface::WlSurface,
 582         _: &wl_output::WlOutput,
 583     ) {
 584     }
 585     fn surface_leave(
 586         &mut self,
 587         _: &Connection,
 588         _: &QueueHandle<Self>,
 589         _: &wl_surface::WlSurface,
 590         _: &wl_output::WlOutput,
 591     ) {
 592     }
 593 }
 594 
 595 impl OutputHandler for AppState {
 596     fn output_state(&mut self) -> &mut OutputState {
 597         &mut self.output_state
 598     }
 599     fn new_output(&mut self, _: &Connection, qh: &QueueHandle<Self>, output: wl_output::WlOutput) {
 600         // A monitor plugged in while locked still gets a prompt rather than
 601         // the compositor's bare blank.
 602         self.create_lock_surface(&output, qh);
 603     }
 604     fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
 605     fn output_destroyed(
 606         &mut self,
 607         _: &Connection,
 608         _: &QueueHandle<Self>,
 609         output: wl_output::WlOutput,
 610     ) {
 611         self.outputs.remove(&output.id().protocol_id());
 612     }
 613 }
 614 
 615 impl SeatHandler for AppState {
 616     fn seat_state(&mut self) -> &mut SeatState {
 617         &mut self.seat_state
 618     }
 619     fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
 620     fn new_capability(
 621         &mut self,
 622         _: &Connection,
 623         qh: &QueueHandle<Self>,
 624         seat: wl_seat::WlSeat,
 625         capability: Capability,
 626     ) {
 627         if capability == Capability::Keyboard && self.keyboard.is_none() {
 628             // With repeat, so a held Backspace clears the field the way it
 629             // does everywhere else (`repeat_key`); until 2026-09-26 it was
 630             // bound without, and deleted one character per press.
 631             self.keyboard = self
 632                 .seat_state
 633                 .get_keyboard_with_repeat(
 634                     qh,
 635                     &seat,
 636                     None,
 637                     self.loop_handle.clone(),
 638                     Box::new(|state: &mut AppState, _keyboard, event| state.repeat_key(event)),
 639                 )
 640                 .ok();
 641         }
 642     }
 643     fn remove_capability(
 644         &mut self,
 645         _: &Connection,
 646         _: &QueueHandle<Self>,
 647         _: wl_seat::WlSeat,
 648         capability: Capability,
 649     ) {
 650         if capability == Capability::Keyboard {
 651             if let Some(kb) = self.keyboard.take() {
 652                 kb.release();
 653             }
 654         }
 655     }
 656     fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
 657 }
 658 
 659 impl KeyboardHandler for AppState {
 660     fn enter(
 661         &mut self,
 662         _: &Connection,
 663         _: &QueueHandle<Self>,
 664         _: &wl_keyboard::WlKeyboard,
 665         _: &wl_surface::WlSurface,
 666         _: u32,
 667         _: &[u32],
 668         _: &[Keysym],
 669     ) {
 670     }
 671     fn leave(
 672         &mut self,
 673         _: &Connection,
 674         _: &QueueHandle<Self>,
 675         _: &wl_keyboard::WlKeyboard,
 676         _: &wl_surface::WlSurface,
 677         _: u32,
 678     ) {
 679     }
 680 
 681     fn press_key(
 682         &mut self,
 683         _: &Connection,
 684         qh: &QueueHandle<Self>,
 685         _: &wl_keyboard::WlKeyboard,
 686         _: u32,
 687         event: KeyEvent,
 688     ) {
 689         self.key_pressed(qh, event);
 690     }
 691 
 692     fn release_key(
 693         &mut self,
 694         _: &Connection,
 695         _: &QueueHandle<Self>,
 696         _: &wl_keyboard::WlKeyboard,
 697         _: u32,
 698         _: KeyEvent,
 699     ) {
 700     }
 701 
 702     fn update_modifiers(
 703         &mut self,
 704         _: &Connection,
 705         qh: &QueueHandle<Self>,
 706         _: &wl_keyboard::WlKeyboard,
 707         _: u32,
 708         modifiers: Modifiers,
 709         _: u32,
 710     ) {
 711         if modifiers.caps_lock != self.caps_lock {
 712             self.caps_lock = modifiers.caps_lock;
 713             self.draw_all(qh);
 714         }
 715     }
 716 }
 717 
 718 impl AppState {
 719     /// A held key's repeat, fed back in as another press — for the keys
 720     /// `lock_key_repeats` allows, and never Return.
 721     fn repeat_key(&mut self, event: KeyEvent) {
 722         if lock_key_repeats(event.keysym, event.utf8.as_deref()) {
 723             let qh = self.qh.clone();
 724             self.key_pressed(&qh, event);
 725         }
 726     }
 727 
 728     fn key_pressed(&mut self, qh: &QueueHandle<Self>, event: KeyEvent) {
 729         // Everything is ignored mid-check: a held Return would otherwise
 730         // queue attempts against pam_faillock and lock the account out.
 731         if matches!(self.phase, Phase::Checking | Phase::Unlocking) {
 732             return;
 733         }
 734         match event.keysym {
 735             Keysym::Return | Keysym::KP_Enter => {
 736                 self.submit(qh);
 737                 return;
 738             }
 739             Keysym::BackSpace => {
 740                 self.password.pop();
 741                 self.status = None;
 742             }
 743             Keysym::Escape => {
 744                 // Clears the field. It does NOT dismiss the lock — there is
 745                 // no key that does.
 746                 self.password.clear();
 747                 self.status = None;
 748             }
 749             _ => {
 750                 if let Some(text) = event.utf8.as_ref() {
 751                     for ch in text.chars().filter(|c| !c.is_control()) {
 752                         self.password.push(ch);
 753                     }
 754                     self.status = None;
 755                 }
 756             }
 757         }
 758         self.draw_all(qh);
 759     }
 760 }
 761 
 762 /// Password dots: radius, centre-to-centre step, and the well's inner margin.
 763 const DOT_R: f32 = 3.5;
 764 const DOT_GAP: f32 = 11.0;
 765 const DOT_INSET: f32 = 14.0;
 766 
 767 /// How many dots a `len`-character password shows in a well `well_w` wide:
 768 /// one per character, up to as many as fit inside the well with its margin
 769 /// on both sides. A full well stays full as typing goes on. The cap was a
 770 /// fixed 32 until 2026-09-26, for a well that holds 26 at its usual width,
 771 /// so a long password — easy to reach once Backspace and letters repeat —
 772 /// ran its dots out past the well's right edge.
 773 fn dots_shown(len: usize, well_w: f32) -> usize {
 774     let room = well_w - 2.0 * DOT_INSET - 2.0 * DOT_R;
 775     let fit = if room < 0.0 { 0 } else { (room / DOT_GAP).floor() as usize + 1 };
 776     len.min(fit)
 777 }
 778 
 779 /// Whether a held key repeats on the lock screen: Backspace and typed
 780 /// characters. Never Return — each repeat would be another password attempt
 781 /// against pam_faillock — and not Escape, which has nothing more to clear.
 782 fn lock_key_repeats(keysym: Keysym, utf8: Option<&str>) -> bool {
 783     match keysym {
 784         Keysym::BackSpace => true,
 785         Keysym::Return | Keysym::KP_Enter | Keysym::Escape => false,
 786         _ => utf8.is_some_and(|t| !t.is_empty() && !t.chars().any(char::is_control)),
 787     }
 788 }
 789 
 790 #[cfg(test)]
 791 mod repeat_tests {
 792     use super::{lock_key_repeats, Keysym};
 793 
 794     #[test]
 795     fn dots_stop_at_the_well_edge() {
 796         use super::{dots_shown, DOT_GAP, DOT_INSET, DOT_R};
 797         // The usual card: a 312 px well holds 26.
 798         assert_eq!(dots_shown(5, 312.0), 5);
 799         assert_eq!(dots_shown(40, 312.0), 26);
 800         // The last dot's right edge stays inside the margin.
 801         let last_right = DOT_INSET + DOT_R + 25.0 * DOT_GAP + DOT_R;
 802         assert!(last_right <= 312.0 - DOT_INSET);
 803         // A narrow card holds fewer; a well too small for one holds none.
 804         assert!(dots_shown(40, 120.0) < 26);
 805         assert_eq!(dots_shown(3, 10.0), 0);
 806     }
 807 
 808     #[test]
 809     fn only_backspace_and_text_repeat_and_never_return() {
 810         assert!(lock_key_repeats(Keysym::BackSpace, Some("\u{8}")));
 811         assert!(lock_key_repeats(Keysym::a, Some("a")));
 812         assert!(!lock_key_repeats(Keysym::Return, Some("\r")));
 813         assert!(!lock_key_repeats(Keysym::KP_Enter, Some("\r")));
 814         assert!(!lock_key_repeats(Keysym::Escape, Some("\u{1b}")));
 815         assert!(!lock_key_repeats(Keysym::Shift_L, None));
 816     }
 817 }
 818 
 819 impl ProvidesRegistryState for AppState {
 820     fn registry(&mut self) -> &mut RegistryState {
 821         &mut self.registry_state
 822     }
 823     smithay_client_toolkit::registry_handlers![OutputState, SeatState];
 824 }
 825 
 826 delegate_compositor!(AppState);
 827 delegate_output!(AppState);
 828 delegate_seat!(AppState);
 829 delegate_keyboard!(AppState);
 830 delegate_registry!(AppState);
 831 
 832 /// Usage text. Deliberately says what this binary does the moment it runs,
 833 /// because the surprising thing about a locker is that there is no harmless
 834 /// way to "just try it".
 835 const USAGE: &str = "\
 836 cce-lock — lock the current Wayland session until the user re-authenticates.
 837 
 838 Usage:
 839   cce-lock            lock the session NOW (there is no confirmation)
 840   cce-lock --help     show this and exit without locking
 841   cce-lock --version  print the version and exit without locking
 842 
 843 Locking needs a compositor offering ext-session-lock-v1, and a PAM stack at
 844 /etc/pam.d/cce-lock (installed by `ccebuild install-system`, not by the plain
 845 user install). Both are checked before the screen is locked, so a missing one
 846 costs nothing; that ordering is the difference between a failed lock and an
 847 unlockable session.
 848 ";
 849 
 850 /// Handle `--help` / `--version`, and refuse anything else, BEFORE main does
 851 /// any of its work.
 852 ///
 853 /// Without this the binary ignored argv completely, so every invocation locked
 854 /// the session — including `cce-lock --help`, which is the first thing anyone
 855 /// types at an unfamiliar command and which took the author's live session
 856 /// down on 2026-09-19. An unrecognised argument must not fall through to
 857 /// locking either: a typo is a question, not a request to seize the screen.
 858 fn handle_args() {
 859     for arg in std::env::args().skip(1) {
 860         match arg.as_str() {
 861             "-h" | "--help" => {
 862                 print!("{USAGE}");
 863                 std::process::exit(0);
 864             }
 865             "-V" | "--version" => {
 866                 println!("cce-lock {}", env!("CARGO_PKG_VERSION"));
 867                 std::process::exit(0);
 868             }
 869             other => {
 870                 eprintln!("cce-lock: unrecognised argument {other:?} — not locking.");
 871                 eprintln!("Try `cce-lock --help`. Run with no arguments to lock.");
 872                 std::process::exit(2);
 873             }
 874         }
 875     }
 876 }
 877 
 878 fn main() {
 879     // First, before the logger and before anything touches PAM or Wayland:
 880     // the only two invocations that must NOT lock the session.
 881     handle_args();
 882 
 883     env_logger::Builder::from_default_env()
 884         .filter_level(log::LevelFilter::Info)
 885         .init();
 886 
 887     let username = users::get_current_username()
 888         .map(|n| n.to_string_lossy().into_owned())
 889         .unwrap_or_default();
 890     if username.is_empty() {
 891         eprintln!("cce-lock: cannot determine the current user; refusing to lock");
 892         std::process::exit(1);
 893     }
 894 
 895     // BEFORE locking anything. A PAM stack that will not start would reject
 896     // every password with the screen already locked, and the only way out
 897     // would be a TTY and a kill. Failing here costs the user nothing.
 898     if let Err(e) = auth::preflight(&username) {
 899         eprintln!("cce-lock: {}", e);
 900         std::process::exit(1);
 901     }
 902 
 903     let conn = match Connection::connect_to_env() {
 904         Ok(c) => c,
 905         Err(e) => {
 906             eprintln!("cce-lock: no Wayland connection: {}", e);
 907             std::process::exit(1);
 908         }
 909     };
 910     let (globals, event_queue) = match registry_queue_init::<AppState>(&conn) {
 911         Ok(v) => v,
 912         Err(e) => {
 913             eprintln!("cce-lock: registry init failed: {}", e);
 914             std::process::exit(1);
 915         }
 916     };
 917     let qh = event_queue.handle();
 918 
 919     let lock_manager: ExtSessionLockManagerV1 = match globals.bind(&qh, 1..=1, ()) {
 920         Ok(m) => m,
 921         Err(e) => {
 922             eprintln!("cce-lock: compositor does not offer ext-session-lock-v1: {}", e);
 923             std::process::exit(1);
 924         }
 925     };
 926 
 927     let mut event_loop: calloop::EventLoop<AppState> =
 928         calloop::EventLoop::try_new().expect("event loop");
 929     let (auth_tx, auth_rx) = calloop::channel::channel::<auth::AuthEvent>();
 930 
 931     cce_ui::scale::set_app_id("cce-lock".to_string());
 932 
 933     let mut state = AppState {
 934         registry_state: RegistryState::new(&globals),
 935         seat_state: SeatState::new(&globals, &qh),
 936         output_state: OutputState::new(&globals, &qh),
 937         compositor_state: CompositorState::bind(&globals, &qh).expect("wl_compositor"),
 938         lock: None,
 939         outputs: HashMap::new(),
 940         keyboard: None,
 941         username,
 942         password: String::new(),
 943         phase: Phase::Prompt,
 944         status: None,
 945         caps_lock: false,
 946         locked: false,
 947         finished: false,
 948         unlock_when_locked: false,
 949         unlocked: false,
 950         exit: false,
 951         dirty: false,
 952         font_system: cce_ui::create_font_system(),
 953         swash_cache: SwashCache::new(),
 954         auth_tx,
 955         loop_handle: event_loop.handle(),
 956         qh: qh.clone(),
 957     };
 958 
 959     state.lock = Some(lock_manager.lock(&qh, ()));
 960     // Surfaces for the outputs that already exist; later ones arrive through
 961     // OutputHandler::new_output.
 962     let outputs: Vec<wl_output::WlOutput> = state.output_state.outputs().collect();
 963     for output in &outputs {
 964         state.create_lock_surface(output, &qh);
 965     }
 966 
 967     event_loop
 968         .handle()
 969         .insert_source(auth_rx, |event, _, state| {
 970             let calloop::channel::Event::Msg(event) = event else { return };
 971             match event {
 972                 auth::AuthEvent::Success => state.unlock(),
 973                 auth::AuthEvent::Failure { msg } => {
 974                     state.phase = Phase::Prompt;
 975                     state.status = Some(msg);
 976                     state.dirty = true;
 977                 }
 978                 auth::AuthEvent::Info { msg } => {
 979                     state.status = Some(msg);
 980                     state.dirty = true;
 981                 }
 982             }
 983         })
 984         .expect("auth channel");
 985 
 986     calloop_wayland_source::WaylandSource::new(conn.clone(), event_queue)
 987         .insert(event_loop.handle())
 988         .expect("wayland source");
 989 
 990     // Blocks until something happens: every wake-up is an event source on
 991     // this loop — the Wayland connection, the auth channel, the keyboard's
 992     // repeat timer. The one exception is a frame that failed to present,
 993     // which is retried on a short timeout until it does.
 994     let mut retry_paint = false;
 995     while !state.exit {
 996         let timeout = retry_paint.then(|| std::time::Duration::from_millis(50));
 997         if event_loop.dispatch(timeout, &mut state).is_err() {
 998             break;
 999         }
1000         // Paint outside the event handlers, once per batch of events: an auth
1001         // result arrives on the calloop channel with no qh in scope, and a
1002         // burst of keys is one frame, not one each.
1003         if (state.dirty || retry_paint) && !matches!(state.phase, Phase::Unlocking) {
1004             state.dirty = false;
1005             retry_paint = !state.paint_all();
1006         }
1007     }
1008 
1009     // A flush is NOT enough after unlock_and_destroy, and the protocol says
1010     // so outright: without a sync the server may terminate this client before
1011     // it processes the request, and the session would stay locked with no
1012     // locker running. Round-trip, then go.
1013     if state.unlocked {
1014         if let Err(e) = conn.roundtrip() {
1015             log::error!("roundtrip after unlock failed: {}", e);
1016         }
1017     }
1018     let _ = conn.flush();
1019     if state.finished {
1020         std::process::exit(1);
1021     }
1022 }