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 }