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

src/main.rs (31.3K)

  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 
133     font_system: FontSystem,
134     swash_cache: SwashCache,
135     auth_tx: calloop::channel::Sender<auth::AuthEvent>,
136 }
137 
138 impl AppState {
139     /// Hand the typed password to PAM on a worker thread. Blocking here would
140     /// freeze the lock screen for the length of a faillock delay.
141     fn submit(&mut self, qh: &QueueHandle<Self>) {
142         if matches!(self.phase, Phase::Checking | Phase::Unlocking) {
143             return;
144         }
145         if self.password.is_empty() {
146             self.status = Some("Enter your password".to_string());
147             self.draw_all(qh);
148             return;
149         }
150         self.phase = Phase::Checking;
151         self.status = None;
152 
153         let username = self.username.clone();
154         let password = std::mem::take(&mut self.password);
155         let ui = self.auth_tx.clone();
156         std::thread::spawn(move || {
157             let (info_tx, info_rx) = std::sync::mpsc::channel();
158             // Pump PAM's running commentary to the screen as it arrives
159             // rather than after: a faillock delay can hold `check` for
160             // seconds, and the stack explains itself during that time. The
161             // sender lives inside the transaction, so this ends on its own
162             // when `check` returns.
163             let pump_ui = ui.clone();
164             let pump = std::thread::spawn(move || {
165                 while let Ok(ev) = info_rx.recv() {
166                     let _ = pump_ui.send(ev);
167                 }
168             });
169             let verdict = auth::check(&username, &password, info_tx);
170             let _ = pump.join();
171             let _ = ui.send(if verdict.is_success() {
172                 auth::AuthEvent::Success
173             } else {
174                 auth::AuthEvent::Failure { msg: verdict.message() }
175             });
176             zero(password);
177         });
178         self.draw_all(qh);
179     }
180 
181     /// PAM accepted: release the session and go.
182     fn unlock(&mut self) {
183         // `unlock_and_destroy` before the `locked` event is a PROTOCOL ERROR,
184         // and the compositor kills the client for it — leaving the session
185         // locked with the locker gone. PAM can answer before `locked` lands
186         // (the compositor is still bringing the lock up while the user types
187         // into a surface it already configured), so this is reachable.
188         if !self.locked {
189             log::warn!("authenticated before the locked event; waiting for it");
190             self.phase = Phase::Prompt;
191             self.status = Some("Locking, one moment…".to_string());
192             self.unlock_when_locked = true;
193             return;
194         }
195         let Some(lock) = self.lock.take() else {
196             self.exit = true;
197             return;
198         };
199         self.phase = Phase::Unlocking;
200         // The ONLY call in this program that opens the session, reached only
201         // from `AuthEvent::Success`, which `auth::Verdict::is_success` is the
202         // sole producer of.
203         lock.unlock_and_destroy();
204         // Only now: the protocol says lock surfaces "should be destroyed by
205         // the client" AFTER this request, not before.
206         self.outputs.clear();
207         self.unlocked = true;
208         self.exit = true;
209     }
210 
211     fn create_lock_surface(&mut self, output: &wl_output::WlOutput, qh: &QueueHandle<Self>) {
212         let Some(lock) = self.lock.as_ref() else { return };
213         let id = output.id().protocol_id();
214         if self.outputs.contains_key(&id) {
215             return;
216         }
217         let wl_surface = self.compositor_state.create_surface(qh);
218         let lock_surface = lock.get_lock_surface(&wl_surface, output, qh, id);
219         self.outputs.insert(
220             id,
221             LockOutput {
222                 wl_surface,
223                 lock_surface,
224                 renderer: None,
225                 width: 0.0,
226                 height: 0.0,
227                 scale: 1.0,
228                 configured: false,
229             },
230         );
231     }
232 
233     fn draw_all(&mut self, _qh: &QueueHandle<Self>) {
234         let ids: Vec<u32> = self.outputs.keys().copied().collect();
235         for id in ids {
236             self.draw(id);
237         }
238     }
239 
240     /// Paint one output.
241     fn draw(&mut self, id: u32) {
242         let Some(out) = self.outputs.get(&id) else { return };
243         if !out.configured || out.width <= 0.0 || out.height <= 0.0 {
244             return;
245         }
246         let (w, h, scale) = (out.width, out.height, out.scale);
247 
248         let (dl, labels) = self.build_scene(w, h);
249         let (verts, batches, images, features) = tessellate(&dl, w, h, scale);
250 
251         let spans_src: Vec<(Buffer, &Label)> = labels
252             .iter()
253             .map(|l| (make_text_buffer(&mut self.font_system, &l.text, l.size), l))
254             .collect();
255         let spans: Vec<TextSpan> = spans_src
256             .iter()
257             .map(|(buf, l)| TextSpan {
258                 buffer: buf,
259                 left: (l.x * scale).round(),
260                 top: (l.y * scale).round(),
261                 scale,
262                 bounds: None,
263                 default_color: [l.color[0], l.color[1], l.color[2], 1.0],
264                 rotation: None,
265                 clip_circle: [0.0; 3],
266                 clip_extents: [0.0; 2],
267             })
268             .collect();
269 
270         // Split the borrow: the renderer lives in the map, the font system on
271         // self, and prepare_text needs both at once.
272         let Self { outputs, font_system, swash_cache, .. } = self;
273         let Some(out) = outputs.get_mut(&id) else { return };
274         let Some(renderer) = out.renderer.as_mut() else { return };
275         renderer.prepare_text(font_system, swash_cache, &spans);
276         renderer.draw_frame_2d(Frame2D {
277             verts: &verts,
278             batches: &batches,
279             overlay_verts: &[],
280             images: &images,
281             plate_features: &features,
282             clear_color: [0.0, 0.0, 0.0, 1.0],
283         });
284     }
285 
286     /// The lock screen itself: an opaque ground, a centred card, the password
287     /// well and its bullets, and one status line.
288     fn build_scene(&self, w: f32, h: f32) -> (cce_ui::scene::paint::DisplayList, Vec<Label>) {
289         // style-audit: opt-out the lock screen is a black surface carrying one card plate
290         let mut pc = cce_ui::scene::paint::PaintCtx::new();
291         let mut labels = Vec::new();
292 
293         // Opaque, always. A translucent lock screen would show the desktop it
294         // is hiding — the compositor already disabled the normal scene tree,
295         // but painting see-through here would still be wrong the moment
296         // anything else is composited under it.
297         //
298         // These channel values are LINEAR, not sRGB: the swapchain is an sRGB
299         // format, so the hardware encodes what the shader writes. 0.05 here
300         // is #3F3F4B on screen, not the near-black it reads as — which is how
301         // this ground first shipped a flat mid-grey. Divide by roughly ten to
302         // get the dark you meant; measure with a screenshot, never by eye
303         // over the source.
304         pc.quad(Rect { x: 0.0, y: 0.0, width: w, height: h }, [0.004, 0.004, 0.006, 1.0]);
305 
306         let card_w = 360.0f32.min(w - 40.0);
307         let card_h = 170.0f32;
308         let card = Rect {
309             x: (w - card_w) / 2.0,
310             y: (h - card_h) / 2.0,
311             width: card_w,
312             height: card_h,
313         };
314         let depth = cce_ui::color::plate_bevel_width();
315         pc.plate_spec(&cce_ui::scene::paint::PlateSpec {
316             rect: card,
317             material: cce_ui::scene::Material::opaque([0.013, 0.013, 0.017, 1.0]),
318             window_corners: (true, true, true, true),
319             depth,
320         });
321 
322         labels.push(Label {
323             text: self.username.clone(),
324             x: card.x + 24.0,
325             y: card.y + 22.0,
326             size: 15.0,
327             color: [1.0, 1.0, 1.0],
328         });
329 
330         // The password well, rim lit in the highlight the way a focused well
331         // is everywhere else in the DE.
332         let well = Rect { x: card.x + 24.0, y: card.y + 58.0, width: card_w - 48.0, height: 38.0 };
333         pc.quad(well, [0.005, 0.005, 0.007, 1.0]);
334         let well_depth = cce_ui::layout::bevel_width().min(well.height * 0.2);
335         let hc = cce_ui::color::highlight_primary_color();
336         pc.recess_tinted(well, (0.0, 0.0, 0.0, 0.0), well_depth, [hc[0], hc[1], hc[2]]);
337 
338         // One dot per character. Never the characters themselves, and never a
339         // count in the status line either — both leak the password's length to
340         // anyone watching the screen.
341         let dot_r = 3.5;
342         let dot_gap = 11.0;
343         let dots = self.password.chars().count().min(32);
344         for i in 0..dots {
345             pc.circle(
346                 well.x + 14.0 + dot_r + i as f32 * dot_gap,
347                 well.y + well.height / 2.0,
348                 dot_r,
349                 [0.80, 0.80, 0.88, 1.0],
350             );
351         }
352 
353         let (status, color) = match self.phase {
354             Phase::Checking => ("Checking…".to_string(), [0.72, 0.72, 0.80]),
355             Phase::Unlocking => ("Unlocking…".to_string(), [0.72, 0.85, 0.72]),
356             Phase::Prompt => match &self.status {
357                 Some(msg) => (msg.clone(), [0.95, 0.55, 0.55]),
358                 None if self.caps_lock => ("Caps Lock is on".to_string(), [0.95, 0.80, 0.50]),
359                 None => (String::new(), [0.55, 0.55, 0.62]),
360             },
361         };
362         if !status.is_empty() {
363             labels.push(Label {
364                 text: status,
365                 x: card.x + 24.0,
366                 y: card.y + 112.0,
367                 size: 12.0,
368                 color,
369             });
370         }
371 
372         (pc.finish(), labels)
373     }
374 }
375 
376 /// Best-effort scrub of a password buffer once it has been used.
377 ///
378 /// Honest about its limits: PAM copies the string into its own allocations and
379 /// the conversation hands libc a `strdup` of it, and neither is reachable from
380 /// here. This only clears the copy this process owns, so the window in which a
381 /// core dump could contain the password is shorter, not closed.
382 fn zero(mut s: String) {
383     unsafe {
384         for b in s.as_bytes_mut() {
385             *b = 0;
386         }
387     }
388     drop(s);
389 }
390 
391 fn make_text_buffer(font_system: &mut FontSystem, text: &str, size: f32) -> Buffer {
392     let metrics = Metrics::new(size, size * 1.4);
393     let mut buffer = Buffer::new(font_system, metrics);
394     let family = cce_ui::layout::control_label_font_parsed().0;
395     let attrs = Attrs::new().family(cce_ui::cosmic_text::Family::Name(&family));
396     buffer.set_text(font_system, text, attrs, cce_ui::cosmic_text::Shaping::Advanced);
397     buffer.shape_until_scroll(font_system, true);
398     buffer
399 }
400 
401 /// Display list → vertex buffer + renderer batches, converting the
402 /// tessellator's logical-px clips to physical. Same shape as cce-cloud's.
403 fn tessellate(
404     dl: &cce_ui::scene::paint::DisplayList,
405     sw: f32,
406     sh: f32,
407     scale: f32,
408 ) -> (Vec<Vertex>, Vec<Batch2D>, Vec<ImageQuad>, Vec<[f32; 12]>) {
409     let (verts, dl_batches, _dl_images, features) =
410         cce_ui::backend::window_runner::tessellate_display_list(dl, sw, sh, scale);
411     let batches = dl_batches
412         .iter()
413         .map(|b| Batch2D {
414             scissor: b.scissor.map(|c| {
415                 (
416                     (c.x * scale).max(0.0) as u32,
417                     (c.y * scale).max(0.0) as u32,
418                     (c.width * scale) as u32,
419                     (c.height * scale) as u32,
420                 )
421             }),
422             clip_rrect: b
423                 .clip_rrect
424                 .map(|c| [c[0] * scale, c[1] * scale, c[2] * scale, c[3] * scale, c[4] * scale]),
425             start: b.start,
426             end: b.end,
427             plate: b.plate,
428             blur_behind: b.blur_behind,
429         })
430         .collect();
431     (verts, batches, Vec::new(), features)
432 }
433 
434 // ---------------------------------------------------------------------------
435 // Protocol plumbing
436 // ---------------------------------------------------------------------------
437 
438 impl Dispatch<ExtSessionLockManagerV1, ()> for AppState {
439     fn event(
440         _state: &mut Self,
441         _proxy: &ExtSessionLockManagerV1,
442         _event: <ExtSessionLockManagerV1 as Proxy>::Event,
443         _data: &(),
444         _conn: &Connection,
445         _qh: &QueueHandle<Self>,
446     ) {
447     }
448 }
449 
450 impl Dispatch<ExtSessionLockV1, ()> for AppState {
451     fn event(
452         state: &mut Self,
453         _proxy: &ExtSessionLockV1,
454         event: <ExtSessionLockV1 as Proxy>::Event,
455         _data: &(),
456         _conn: &Connection,
457         _qh: &QueueHandle<Self>,
458     ) {
459         match event {
460             ext_session_lock_v1::Event::Locked => {
461                 log::info!("session locked");
462                 state.locked = true;
463                 if state.unlock_when_locked {
464                     state.unlock_when_locked = false;
465                     state.unlock();
466                 }
467             }
468             ext_session_lock_v1::Event::Finished => {
469                 // The compositor refused the lock or ended it. We must exit
470                 // WITHOUT calling unlock_and_destroy — that request would be
471                 // a protocol error, and pretending to unlock a session we
472                 // never locked is not ours to do.
473                 log::warn!("lock finished by the compositor; exiting without unlocking");
474                 state.finished = true;
475                 state.exit = true;
476             }
477             _ => {}
478         }
479     }
480 }
481 
482 impl Dispatch<ExtSessionLockSurfaceV1, u32> for AppState {
483     fn event(
484         state: &mut Self,
485         _proxy: &ExtSessionLockSurfaceV1,
486         event: <ExtSessionLockSurfaceV1 as Proxy>::Event,
487         id: &u32,
488         _conn: &Connection,
489         _qh: &QueueHandle<Self>,
490     ) {
491         if let ext_session_lock_surface_v1::Event::Configure { serial, width, height } = event {
492             let Some(out) = state.outputs.get_mut(id) else { return };
493             out.lock_surface.ack_configure(serial);
494             out.width = width as f32;
495             out.height = height as f32;
496             out.configured = true;
497 
498             let pw = (out.width * out.scale) as u32;
499             let ph = (out.height * out.scale) as u32;
500             match out.renderer.as_mut() {
501                 Some(r) => r.resize(pw, ph),
502                 None => {
503                     out.wl_surface.set_buffer_scale(out.scale as i32);
504                     let conn_ptr = _conn.backend().display_id().as_ptr() as *mut std::ffi::c_void;
505                     let surf_ptr = out.wl_surface.id().as_ptr() as *mut std::ffi::c_void;
506                     out.renderer =
507                         Some(unsafe { VkRenderer::new(conn_ptr, surf_ptr, pw, ph, 0.0) });
508                 }
509             }
510             state.draw(*id);
511         }
512     }
513 }
514 
515 impl CompositorHandler for AppState {
516     fn scale_factor_changed(
517         &mut self,
518         _conn: &Connection,
519         _qh: &QueueHandle<Self>,
520         surface: &wl_surface::WlSurface,
521         new_factor: i32,
522     ) {
523         let id = self
524             .outputs
525             .iter()
526             .find(|(_, o)| &o.wl_surface == surface)
527             .map(|(id, _)| *id);
528         let Some(id) = id else { return };
529         if let Some(out) = self.outputs.get_mut(&id) {
530             out.scale = new_factor as f32;
531             out.wl_surface.set_buffer_scale(new_factor);
532             if let Some(r) = out.renderer.as_mut() {
533                 r.resize((out.width * out.scale) as u32, (out.height * out.scale) as u32);
534             }
535         }
536         self.draw(id);
537     }
538 
539     fn transform_changed(
540         &mut self,
541         _: &Connection,
542         _: &QueueHandle<Self>,
543         _: &wl_surface::WlSurface,
544         _: wl_output::Transform,
545     ) {
546     }
547     fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}
548     fn surface_enter(
549         &mut self,
550         _: &Connection,
551         _: &QueueHandle<Self>,
552         _: &wl_surface::WlSurface,
553         _: &wl_output::WlOutput,
554     ) {
555     }
556     fn surface_leave(
557         &mut self,
558         _: &Connection,
559         _: &QueueHandle<Self>,
560         _: &wl_surface::WlSurface,
561         _: &wl_output::WlOutput,
562     ) {
563     }
564 }
565 
566 impl OutputHandler for AppState {
567     fn output_state(&mut self) -> &mut OutputState {
568         &mut self.output_state
569     }
570     fn new_output(&mut self, _: &Connection, qh: &QueueHandle<Self>, output: wl_output::WlOutput) {
571         // A monitor plugged in while locked still gets a prompt rather than
572         // the compositor's bare blank.
573         self.create_lock_surface(&output, qh);
574     }
575     fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
576     fn output_destroyed(
577         &mut self,
578         _: &Connection,
579         _: &QueueHandle<Self>,
580         output: wl_output::WlOutput,
581     ) {
582         self.outputs.remove(&output.id().protocol_id());
583     }
584 }
585 
586 impl SeatHandler for AppState {
587     fn seat_state(&mut self) -> &mut SeatState {
588         &mut self.seat_state
589     }
590     fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
591     fn new_capability(
592         &mut self,
593         _: &Connection,
594         qh: &QueueHandle<Self>,
595         seat: wl_seat::WlSeat,
596         capability: Capability,
597     ) {
598         if capability == Capability::Keyboard && self.keyboard.is_none() {
599             self.keyboard = self.seat_state.get_keyboard(qh, &seat, None).ok();
600         }
601     }
602     fn remove_capability(
603         &mut self,
604         _: &Connection,
605         _: &QueueHandle<Self>,
606         _: wl_seat::WlSeat,
607         capability: Capability,
608     ) {
609         if capability == Capability::Keyboard {
610             if let Some(kb) = self.keyboard.take() {
611                 kb.release();
612             }
613         }
614     }
615     fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
616 }
617 
618 impl KeyboardHandler for AppState {
619     fn enter(
620         &mut self,
621         _: &Connection,
622         _: &QueueHandle<Self>,
623         _: &wl_keyboard::WlKeyboard,
624         _: &wl_surface::WlSurface,
625         _: u32,
626         _: &[u32],
627         _: &[Keysym],
628     ) {
629     }
630     fn leave(
631         &mut self,
632         _: &Connection,
633         _: &QueueHandle<Self>,
634         _: &wl_keyboard::WlKeyboard,
635         _: &wl_surface::WlSurface,
636         _: u32,
637     ) {
638     }
639 
640     fn press_key(
641         &mut self,
642         _: &Connection,
643         qh: &QueueHandle<Self>,
644         _: &wl_keyboard::WlKeyboard,
645         _: u32,
646         event: KeyEvent,
647     ) {
648         // Everything is ignored mid-check: a held Return would otherwise
649         // queue attempts against pam_faillock and lock the account out.
650         if matches!(self.phase, Phase::Checking | Phase::Unlocking) {
651             return;
652         }
653         match event.keysym {
654             Keysym::Return | Keysym::KP_Enter => {
655                 self.submit(qh);
656                 return;
657             }
658             Keysym::BackSpace => {
659                 self.password.pop();
660                 self.status = None;
661             }
662             Keysym::Escape => {
663                 // Clears the field. It does NOT dismiss the lock — there is
664                 // no key that does.
665                 self.password.clear();
666                 self.status = None;
667             }
668             _ => {
669                 if let Some(text) = event.utf8.as_ref() {
670                     for ch in text.chars().filter(|c| !c.is_control()) {
671                         self.password.push(ch);
672                     }
673                     self.status = None;
674                 }
675             }
676         }
677         self.draw_all(qh);
678     }
679 
680     fn release_key(
681         &mut self,
682         _: &Connection,
683         _: &QueueHandle<Self>,
684         _: &wl_keyboard::WlKeyboard,
685         _: u32,
686         _: KeyEvent,
687     ) {
688     }
689 
690     fn update_modifiers(
691         &mut self,
692         _: &Connection,
693         qh: &QueueHandle<Self>,
694         _: &wl_keyboard::WlKeyboard,
695         _: u32,
696         modifiers: Modifiers,
697         _: u32,
698     ) {
699         if modifiers.caps_lock != self.caps_lock {
700             self.caps_lock = modifiers.caps_lock;
701             self.draw_all(qh);
702         }
703     }
704 }
705 
706 impl ProvidesRegistryState for AppState {
707     fn registry(&mut self) -> &mut RegistryState {
708         &mut self.registry_state
709     }
710     smithay_client_toolkit::registry_handlers![OutputState, SeatState];
711 }
712 
713 delegate_compositor!(AppState);
714 delegate_output!(AppState);
715 delegate_seat!(AppState);
716 delegate_keyboard!(AppState);
717 delegate_registry!(AppState);
718 
719 /// Usage text. Deliberately says what this binary does the moment it runs,
720 /// because the surprising thing about a locker is that there is no harmless
721 /// way to "just try it".
722 const USAGE: &str = "\
723 cce-lock — lock the current Wayland session until the user re-authenticates.
724 
725 Usage:
726   cce-lock            lock the session NOW (there is no confirmation)
727   cce-lock --help     show this and exit without locking
728   cce-lock --version  print the version and exit without locking
729 
730 Locking needs a compositor offering ext-session-lock-v1, and a PAM stack at
731 /etc/pam.d/cce-lock (installed by `ccebuild install-system`, not by the plain
732 user install). Both are checked before the screen is locked, so a missing one
733 costs nothing; that ordering is the difference between a failed lock and an
734 unlockable session.
735 ";
736 
737 /// Handle `--help` / `--version`, and refuse anything else, BEFORE main does
738 /// any of its work.
739 ///
740 /// Without this the binary ignored argv completely, so every invocation locked
741 /// the session — including `cce-lock --help`, which is the first thing anyone
742 /// types at an unfamiliar command and which took the author's live session
743 /// down on 2026-09-19. An unrecognised argument must not fall through to
744 /// locking either: a typo is a question, not a request to seize the screen.
745 fn handle_args() {
746     for arg in std::env::args().skip(1) {
747         match arg.as_str() {
748             "-h" | "--help" => {
749                 print!("{USAGE}");
750                 std::process::exit(0);
751             }
752             "-V" | "--version" => {
753                 println!("cce-lock {}", env!("CARGO_PKG_VERSION"));
754                 std::process::exit(0);
755             }
756             other => {
757                 eprintln!("cce-lock: unrecognised argument {other:?} — not locking.");
758                 eprintln!("Try `cce-lock --help`. Run with no arguments to lock.");
759                 std::process::exit(2);
760             }
761         }
762     }
763 }
764 
765 fn main() {
766     // First, before the logger and before anything touches PAM or Wayland:
767     // the only two invocations that must NOT lock the session.
768     handle_args();
769 
770     env_logger::Builder::from_default_env()
771         .filter_level(log::LevelFilter::Info)
772         .init();
773 
774     let username = users::get_current_username()
775         .map(|n| n.to_string_lossy().into_owned())
776         .unwrap_or_default();
777     if username.is_empty() {
778         eprintln!("cce-lock: cannot determine the current user; refusing to lock");
779         std::process::exit(1);
780     }
781 
782     // BEFORE locking anything. A PAM stack that will not start would reject
783     // every password with the screen already locked, and the only way out
784     // would be a TTY and a kill. Failing here costs the user nothing.
785     if let Err(e) = auth::preflight(&username) {
786         eprintln!("cce-lock: {}", e);
787         std::process::exit(1);
788     }
789 
790     let conn = match Connection::connect_to_env() {
791         Ok(c) => c,
792         Err(e) => {
793             eprintln!("cce-lock: no Wayland connection: {}", e);
794             std::process::exit(1);
795         }
796     };
797     let (globals, event_queue) = match registry_queue_init::<AppState>(&conn) {
798         Ok(v) => v,
799         Err(e) => {
800             eprintln!("cce-lock: registry init failed: {}", e);
801             std::process::exit(1);
802         }
803     };
804     let qh = event_queue.handle();
805 
806     let lock_manager: ExtSessionLockManagerV1 = match globals.bind(&qh, 1..=1, ()) {
807         Ok(m) => m,
808         Err(e) => {
809             eprintln!("cce-lock: compositor does not offer ext-session-lock-v1: {}", e);
810             std::process::exit(1);
811         }
812     };
813 
814     let mut event_loop: calloop::EventLoop<AppState> =
815         calloop::EventLoop::try_new().expect("event loop");
816     let (auth_tx, auth_rx) = calloop::channel::channel::<auth::AuthEvent>();
817 
818     cce_ui::scale::set_app_id("cce-lock".to_string());
819 
820     let mut state = AppState {
821         registry_state: RegistryState::new(&globals),
822         seat_state: SeatState::new(&globals, &qh),
823         output_state: OutputState::new(&globals, &qh),
824         compositor_state: CompositorState::bind(&globals, &qh).expect("wl_compositor"),
825         lock: None,
826         outputs: HashMap::new(),
827         keyboard: None,
828         username,
829         password: String::new(),
830         phase: Phase::Prompt,
831         status: None,
832         caps_lock: false,
833         locked: false,
834         finished: false,
835         unlock_when_locked: false,
836         unlocked: false,
837         exit: false,
838         font_system: cce_ui::create_font_system(),
839         swash_cache: SwashCache::new(),
840         auth_tx,
841     };
842 
843     state.lock = Some(lock_manager.lock(&qh, ()));
844     // Surfaces for the outputs that already exist; later ones arrive through
845     // OutputHandler::new_output.
846     let outputs: Vec<wl_output::WlOutput> = state.output_state.outputs().collect();
847     for output in &outputs {
848         state.create_lock_surface(output, &qh);
849     }
850 
851     event_loop
852         .handle()
853         .insert_source(auth_rx, |event, _, state| {
854             let calloop::channel::Event::Msg(event) = event else { return };
855             match event {
856                 auth::AuthEvent::Success => state.unlock(),
857                 auth::AuthEvent::Failure { msg } => {
858                     state.phase = Phase::Prompt;
859                     state.status = Some(msg);
860                 }
861                 auth::AuthEvent::Info { msg } => {
862                     state.status = Some(msg);
863                 }
864             }
865         })
866         .expect("auth channel");
867 
868     calloop_wayland_source::WaylandSource::new(conn.clone(), event_queue)
869         .insert(event_loop.handle())
870         .expect("wayland source");
871 
872     while !state.exit {
873         if event_loop
874             .dispatch(std::time::Duration::from_millis(50), &mut state)
875             .is_err()
876         {
877             break;
878         }
879         // Redraw outside the event handlers: an auth result arrives on the
880         // calloop channel with no qh in scope.
881         if !matches!(state.phase, Phase::Unlocking) {
882             let ids: Vec<u32> = state.outputs.keys().copied().collect();
883             for id in ids {
884                 state.draw(id);
885             }
886         }
887     }
888 
889     // A flush is NOT enough after unlock_and_destroy, and the protocol says
890     // so outright: without a sync the server may terminate this client before
891     // it processes the request, and the session would stay locked with no
892     // locker running. Round-trip, then go.
893     if state.unlocked {
894         if let Err(e) = conn.roundtrip() {
895             log::error!("roundtrip after unlock failed: {}", e);
896         }
897     }
898     let _ = conn.flush();
899     if state.finished {
900         std::process::exit(1);
901     }
902 }