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 }