session locker (ext-session-lock + PAM)
git clone https://git.lucas.co/cce-lock.git
The session locker, under version control at last
cce-lock was written this evening and has been living as loose files in
the workspace root ever since — no repository, no remote, one `rm` from
gone, and the only crate here that could not be pushed with the others.
This is `git init` and the existing tree, not new work.
What it is: a client of ext-session-lock-v1 (wayland-protocols staging)
that re-verifies the user through PAM and unlocks. It authenticates and
nothing more — `pam/cce-lock` deliberately carries no `session` stack,
because a locker that called pam_open_session would run gkr-pam's
auto_start and fork out of a multi-threaded Vulkan process, which is
what froze the greeter on "Authenticating..." on 2026-09-18; and no
`password` stack, because a locker must never be a route to changing
one. That file installs to /etc/pam.d/cce-lock, which needs
`ccebuild install-system`, not the plain user install.
`/target` is ignored. No Cargo.lock is committed yet, unlike its
siblings: this crate builds as a workspace member, so its lock lives at
the unversioned root, and generating a standalone one from in here
would rewrite that shared file under the other sessions. Worth adding
deliberately, on its own, when nobody else is building.
.gitignore | 1 +
Cargo.toml | 21 ++
pam/cce-lock | 17 ++
src/auth.rs | 246 +++++++++++++++++
src/main.rs | 852 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1137 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..d360033
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "cce-lock"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { path = "../cce-ui" }
+# Raw pam_sys, not the `pam` wrapper: the locker needs a conversation function
+# that answers PROMPT_ECHO_OFF from a buffer it holds, and nothing else.
+pam-sys = "0.5.6"
+libc = "0.2"
+users = "0.8.1"
+smithay-client-toolkit = { version = "0.19.2", features = ["calloop"] }
+calloop = "0.13.0"
+calloop-wayland-source = "0.3.0"
+wayland-client = { version = "0.31", features = ["system"] }
+# ext-session-lock-v1 lives in the staging set.
+wayland-protocols = { version = "0.31", features = ["client", "staging"] }
+xkeysym = "0.2"
+log = "0.4"
+env_logger = "0.11"
diff --git a/pam/cce-lock b/pam/cce-lock
new file mode 100644
index 0000000..8a1bcf2
--- /dev/null
+++ b/pam/cce-lock
@@ -0,0 +1,17 @@
+#%PAM-1.0
+#
+# Screen locker: a CREDENTIAL CHECK and nothing else.
+#
+# No `session` stack on purpose. cce-lock never calls pam_open_session — the
+# user's session already exists; the locker only re-verifies the person in
+# front of it. The greeter learned this the hard way: opening a session there
+# ran pam_gnome_keyring's auto_start, a fork() out of a multi-threaded Vulkan
+# process, which could wedge before exec and freeze login on
+# "Authenticating..." (2026-09-18). A locker that did the same would freeze
+# with the session still locked.
+#
+# No `password` stack either: a locker must never be a route to changing a
+# password.
+
+auth include system-local-login
+account include system-local-login
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 0000000..b7599b6
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,246 @@
+//! PAM credential checking for the locker.
+//!
+//! Deliberately the smallest thing that can answer "is this the person who
+//! owns this session": `pam_authenticate` followed by `pam_acct_mgmt`, and
+//! nothing else. In particular there is **no `pam_open_session`** — the
+//! session the locker is guarding already exists, and opening a second one
+//! from here would run the session stack's side effects (pam_gnome_keyring's
+//! `auto_start` forks out of this multi-threaded Vulkan process) for no
+//! reason. The greeter hit exactly that and froze on "Authenticating..." with
+//! the password already accepted; here the equivalent freeze would leave the
+//! screen locked.
+//!
+//! The verdict is deliberately a plain `bool` chosen in ONE place
+//! ([`Verdict::is_success`]), so there is no path to an unlock that did not
+//! come from both PAM calls returning SUCCESS.
+
+use std::ffi::{CStr, CString};
+
+/// PAM service name — `/etc/pam.d/cce-lock`, shipped in this crate's `pam/`
+/// dir and installed by `ccebuild install-system`.
+///
+/// A constant, never an environment variable or argument: a locker whose PAM
+/// stack can be chosen by its caller is a locker anyone with a shell can point
+/// at a permissive service.
+pub const PAM_SERVICE: &str = "cce-lock";
+
+/// What the worker thread reports back to the UI.
+pub enum AuthEvent {
+ /// PAM accepted the credentials. The ONLY value that may unlock.
+ Success,
+ /// PAM rejected them, or errored. `msg` is for the user, not a log line.
+ Failure { msg: String },
+ /// A `TEXT_INFO` / `ERROR_MSG` from the stack while it ran — a faillock
+ /// delay notice, an expiry warning.
+ Info { msg: String },
+}
+
+/// Everything the conversation function is allowed to see.
+struct ConvData {
+ username: String,
+ password: String,
+ sender: std::sync::mpsc::Sender<AuthEvent>,
+}
+
+/// The PAM conversation: answer the password prompt from the buffer, echo the
+/// username back for an echoing prompt, and forward anything informational.
+///
+/// `extern "C"`, so it must not unwind: every fallible conversion below is
+/// total.
+extern "C" fn converse(
+ num_msg: libc::c_int,
+ msg: *mut *mut pam_sys::PamMessage,
+ out_resp: *mut *mut pam_sys::PamResponse,
+ appdata_ptr: *mut libc::c_void,
+) -> libc::c_int {
+ if appdata_ptr.is_null() || num_msg <= 0 {
+ return pam_sys::PamReturnCode::CONV_ERR as libc::c_int;
+ }
+ let data = unsafe { &*(appdata_ptr as *const ConvData) };
+
+ let resp = unsafe {
+ libc::calloc(num_msg as usize, std::mem::size_of::<pam_sys::PamResponse>())
+ as *mut pam_sys::PamResponse
+ };
+ if resp.is_null() {
+ return pam_sys::PamReturnCode::BUF_ERR as libc::c_int;
+ }
+
+ for i in 0..num_msg as isize {
+ unsafe {
+ let m = &**msg.offset(i);
+ let r = &mut *resp.offset(i);
+ let style = m.msg_style;
+ if style == pam_sys::PamMessageStyle::PROMPT_ECHO_OFF as libc::c_int {
+ // unwrap_or_default, not unwrap: an interior NUL in the typed
+ // password would otherwise panic across this extern "C"
+ // boundary, which aborts the process — and aborting the
+ // locker leaves the session locked with no way in. An empty
+ // response just fails the attempt.
+ let pass = CString::new(data.password.clone()).unwrap_or_default();
+ r.resp = libc::strdup(pass.as_ptr());
+ } else if style == pam_sys::PamMessageStyle::PROMPT_ECHO_ON as libc::c_int {
+ let user = CString::new(data.username.clone()).unwrap_or_default();
+ r.resp = libc::strdup(user.as_ptr());
+ } else if !m.msg.is_null() {
+ let text = CStr::from_ptr(m.msg).to_string_lossy().into_owned();
+ let _ = data.sender.send(AuthEvent::Info { msg: text });
+ }
+ }
+ }
+
+ unsafe { *out_resp = resp };
+ pam_sys::PamReturnCode::SUCCESS as libc::c_int
+}
+
+/// A PAM transaction, ended on drop so no handle outlives an attempt.
+struct Transaction {
+ handle: *mut pam_sys::PamHandle,
+ last: pam_sys::PamReturnCode,
+ // Held alive for as long as PAM holds the pointer into it.
+ _data: Box<ConvData>,
+}
+
+impl Transaction {
+ fn start(username: &str, password: &str, sender: std::sync::mpsc::Sender<AuthEvent>) -> Result<Self, pam_sys::PamReturnCode> {
+ let data = Box::new(ConvData {
+ username: username.to_string(),
+ password: password.to_string(),
+ sender,
+ });
+ let conv = pam_sys::PamConversation {
+ conv: Some(converse),
+ data_ptr: &*data as *const ConvData as *mut libc::c_void,
+ };
+ let mut handle: *mut pam_sys::PamHandle = std::ptr::null_mut();
+ let rc = pam_sys::start(PAM_SERVICE, Some(username), &conv, &mut handle);
+ if rc != pam_sys::PamReturnCode::SUCCESS {
+ return Err(rc);
+ }
+ Ok(Self { handle, last: pam_sys::PamReturnCode::SUCCESS, _data: data })
+ }
+}
+
+impl Drop for Transaction {
+ fn drop(&mut self) {
+ if !self.handle.is_null() {
+ unsafe { pam_sys::end(&mut *self.handle, self.last) };
+ }
+ }
+}
+
+/// The single place an unlock can be authorized.
+pub struct Verdict {
+ authenticate: pam_sys::PamReturnCode,
+ acct_mgmt: pam_sys::PamReturnCode,
+}
+
+impl Verdict {
+ /// True only when BOTH PAM calls returned SUCCESS. Every other outcome —
+ /// a rejection, an expired account, an internal PAM error, a stack that
+ /// could not be started — is a failure, because a locker that opens on
+ /// anything it does not understand is not a lock.
+ pub fn is_success(&self) -> bool {
+ self.authenticate == pam_sys::PamReturnCode::SUCCESS
+ && self.acct_mgmt == pam_sys::PamReturnCode::SUCCESS
+ }
+
+ /// What to show the user. PAM's own codes are not phrased for a lock
+ /// screen, and echoing them leaks stack detail to whoever is standing
+ /// there, so a wrong password gets one plain sentence.
+ pub fn message(&self) -> String {
+ if self.authenticate == pam_sys::PamReturnCode::AUTH_ERR {
+ "Incorrect password".to_string()
+ } else if self.authenticate != pam_sys::PamReturnCode::SUCCESS {
+ format!("Authentication failed ({:?})", self.authenticate)
+ } else {
+ format!("Account unavailable ({:?})", self.acct_mgmt)
+ }
+ }
+}
+
+/// Check `password` against `username`'s credentials. Blocks — PAM stacks
+/// sleep on failure (pam_faillock) — so callers run this on a worker thread.
+pub fn check(username: &str, password: &str, sender: std::sync::mpsc::Sender<AuthEvent>) -> Verdict {
+ let mut tx = match Transaction::start(username, password, sender) {
+ Ok(tx) => tx,
+ Err(rc) => {
+ log::error!("pam_start({}) failed: {:?}", PAM_SERVICE, rc);
+ // Not an unlock: a stack that will not start cannot vouch for
+ // anyone. `preflight` exists so this is caught before locking.
+ return Verdict { authenticate: rc, acct_mgmt: rc };
+ }
+ };
+
+ let authenticate = unsafe { pam_sys::authenticate(&mut *tx.handle, pam_sys::PamFlag::NONE) };
+ tx.last = authenticate;
+ if authenticate != pam_sys::PamReturnCode::SUCCESS {
+ return Verdict { authenticate, acct_mgmt: authenticate };
+ }
+
+ let acct_mgmt = unsafe { pam_sys::acct_mgmt(&mut *tx.handle, pam_sys::PamFlag::NONE) };
+ tx.last = acct_mgmt;
+ Verdict { authenticate, acct_mgmt }
+}
+
+/// Prove the PAM stack can be started BEFORE the session is locked.
+///
+/// This is the difference between "the lock did not engage" and "you cannot
+/// get back in". Without `/etc/pam.d/cce-lock` installed, `pam_start` fails
+/// and every attempt would be rejected — with the screen already locked and
+/// the only way out a TTY and a kill. So the locker refuses to lock at all
+/// unless this passes.
+pub fn preflight(username: &str) -> Result<(), String> {
+ // `pam_start` does NOT fail on a missing service file: libpam falls back
+ // to /etc/pam.d/other, which is pam_deny on Arch, so the start succeeds
+ // and every password is then rejected. That locked the live session out
+ // on 2026-09-19. The file itself has to be looked for.
+ let installed = ["/etc/pam.d", "/usr/lib/pam.d"]
+ .iter()
+ .any(|dir| std::path::Path::new(dir).join(PAM_SERVICE).is_file());
+ if !installed {
+ return Err(format!(
+ "PAM service file /etc/pam.d/{} is not installed — every password \
+ would be rejected by the `other` fallback. Run: ccebuild install-system",
+ PAM_SERVICE
+ ));
+ }
+
+ let (tx, _rx) = std::sync::mpsc::channel();
+ match Transaction::start(username, "", tx) {
+ Ok(_) => Ok(()),
+ Err(rc) => Err(format!(
+ "PAM service {:?} unavailable ({:?}) — is /etc/pam.d/{} installed? \
+ Run: ccebuild install-system",
+ PAM_SERVICE, rc, PAM_SERVICE
+ )),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The one gate. If this ever admits a non-SUCCESS pair, the lock opens
+ /// on a failed credential check.
+ #[test]
+ fn only_success_on_both_calls_unlocks() {
+ let ok = pam_sys::PamReturnCode::SUCCESS;
+ let bad = pam_sys::PamReturnCode::AUTH_ERR;
+ let other = pam_sys::PamReturnCode::ABORT;
+
+ assert!(Verdict { authenticate: ok, acct_mgmt: ok }.is_success());
+ assert!(!Verdict { authenticate: bad, acct_mgmt: ok }.is_success());
+ assert!(!Verdict { authenticate: ok, acct_mgmt: bad }.is_success());
+ assert!(!Verdict { authenticate: other, acct_mgmt: other }.is_success());
+ }
+
+ #[test]
+ fn a_wrong_password_says_so_without_leaking_the_stack() {
+ let v = Verdict {
+ authenticate: pam_sys::PamReturnCode::AUTH_ERR,
+ acct_mgmt: pam_sys::PamReturnCode::AUTH_ERR,
+ };
+ assert_eq!(v.message(), "Incorrect password");
+ }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..4fb09a5
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,852 @@
+//! cce-lock — the cce desktop's session locker.
+//!
+//! An `ext-session-lock-v1` client: it asks the compositor to lock the
+//! session, paints a password prompt on every output, and calls
+//! `unlock_and_destroy` only when PAM has accepted the user's credentials.
+//!
+//! Two properties of the protocol are what make this safe, and both are worth
+//! knowing before changing anything here:
+//!
+//! - **The compositor blanks the session the moment the lock is granted**,
+//! before this process has painted anything. There is no window between
+//! "locked" and "prompt drawn" in which the desktop is visible.
+//! - **If this process dies while locked, the session STAYS locked.** cce-fx's
+//! `handle_destroy` (cce-compositor/src/server/lock_manager.rs) deliberately
+//! does not clear the lock state — only the `unlock` request does. So
+//! crashing is a safe failure here, and `kill` is not a bypass. A later
+//! locker can take over an already-locked session; the compositor hands it
+//! `locked` immediately.
+//!
+//! Which means the dangerous failure is not "it crashed" but "it cannot ever
+//! succeed" — a PAM stack that will not start, so no password is ever
+//! accepted. [`auth::preflight`] is the guard: the lock is not even requested
+//! until PAM has proven it can start.
+//!
+//! Like cce-cloud, this drives its own event loop and renders through
+//! `cce_ui::vk::VkRenderer` rather than implementing cce-ui's `Application`
+//! trait — the engine runner creates xdg/layer surfaces, and a lock surface
+//! is neither.
+
+mod auth;
+
+use std::collections::HashMap;
+
+use smithay_client_toolkit::{
+ compositor::{CompositorHandler, CompositorState},
+ delegate_compositor, delegate_keyboard, delegate_output, delegate_registry, delegate_seat,
+ output::{OutputHandler, OutputState},
+ registry::{ProvidesRegistryState, RegistryState},
+ seat::{
+ keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers},
+ Capability, SeatHandler, SeatState,
+ },
+};
+use wayland_client::{
+ globals::registry_queue_init,
+ protocol::{wl_keyboard, wl_output, wl_seat, wl_surface},
+ Connection, Dispatch, Proxy, QueueHandle,
+};
+use wayland_protocols::ext::session_lock::v1::client::{
+ ext_session_lock_manager_v1::ExtSessionLockManagerV1,
+ ext_session_lock_surface_v1::{self, ExtSessionLockSurfaceV1},
+ ext_session_lock_v1::{self, ExtSessionLockV1},
+};
+
+use cce_ui::cosmic_text::{Attrs, Buffer, FontSystem, Metrics, SwashCache};
+use cce_ui::scene::layout::Rect;
+use cce_ui::vk::{Batch2D, Frame2D, ImageQuad, TextSpan, VkRenderer};
+use cce_ui::engine::Vertex;
+
+/// A label queued for the text pass: logical position, size, colour.
+struct Label {
+ text: String,
+ x: f32,
+ y: f32,
+ size: f32,
+ color: [f32; 3],
+}
+
+/// One output's lock surface and everything needed to paint it.
+struct LockOutput {
+ wl_surface: wl_surface::WlSurface,
+ lock_surface: ExtSessionLockSurfaceV1,
+ renderer: Option<VkRenderer>,
+ /// Logical size from the last `configure`; 0 until the first one arrives.
+ width: f32,
+ height: f32,
+ scale: f32,
+ /// A buffer may not be attached before the first configure is acked.
+ configured: bool,
+}
+
+impl Drop for LockOutput {
+ fn drop(&mut self) {
+ // Swapchain teardown must precede the wl_surface's destruction.
+ self.renderer.take();
+ self.lock_surface.destroy();
+ self.wl_surface.destroy();
+ }
+}
+
+/// What the UI is doing, which is also what it says on screen.
+enum Phase {
+ /// Waiting for a password.
+ Prompt,
+ /// A worker thread is inside PAM. Input is ignored until it answers, so a
+ /// held Return cannot queue a hundred attempts against pam_faillock.
+ Checking,
+ /// PAM accepted; the unlock request has gone out and we are leaving.
+ Unlocking,
+}
+
+struct AppState {
+ registry_state: RegistryState,
+ seat_state: SeatState,
+ output_state: OutputState,
+ compositor_state: CompositorState,
+
+ lock: Option<ExtSessionLockV1>,
+ /// Keyed by the wl_output's id, so a surface can be found from either side.
+ outputs: HashMap<u32, LockOutput>,
+ keyboard: Option<wl_keyboard::WlKeyboard>,
+
+ username: String,
+ password: String,
+ phase: Phase,
+ status: Option<String>,
+ caps_lock: bool,
+ /// Set once the compositor confirms the session is locked and the previous
+ /// contents are hidden.
+ locked: bool,
+ /// The compositor ended the lock without us asking (`finished`): we must
+ /// exit WITHOUT unlocking.
+ finished: bool,
+ /// PAM accepted before the `locked` event arrived; unlock as soon as it
+ /// does. Set only from [`Self::unlock`], which only `AuthEvent::Success`
+ /// reaches.
+ unlock_when_locked: bool,
+ /// `unlock_and_destroy` has been sent. Main must round-trip on this
+ /// before exiting.
+ unlocked: bool,
+ exit: bool,
+
+ font_system: FontSystem,
+ swash_cache: SwashCache,
+ auth_tx: calloop::channel::Sender<auth::AuthEvent>,
+}
+
+impl AppState {
+ /// Hand the typed password to PAM on a worker thread. Blocking here would
+ /// freeze the lock screen for the length of a faillock delay.
+ fn submit(&mut self, qh: &QueueHandle<Self>) {
+ if matches!(self.phase, Phase::Checking | Phase::Unlocking) {
+ return;
+ }
+ if self.password.is_empty() {
+ self.status = Some("Enter your password".to_string());
+ self.draw_all(qh);
+ return;
+ }
+ self.phase = Phase::Checking;
+ self.status = None;
+
+ let username = self.username.clone();
+ let password = std::mem::take(&mut self.password);
+ let ui = self.auth_tx.clone();
+ std::thread::spawn(move || {
+ let (info_tx, info_rx) = std::sync::mpsc::channel();
+ // Pump PAM's running commentary to the screen as it arrives
+ // rather than after: a faillock delay can hold `check` for
+ // seconds, and the stack explains itself during that time. The
+ // sender lives inside the transaction, so this ends on its own
+ // when `check` returns.
+ let pump_ui = ui.clone();
+ let pump = std::thread::spawn(move || {
+ while let Ok(ev) = info_rx.recv() {
+ let _ = pump_ui.send(ev);
+ }
+ });
+ let verdict = auth::check(&username, &password, info_tx);
+ let _ = pump.join();
+ let _ = ui.send(if verdict.is_success() {
+ auth::AuthEvent::Success
+ } else {
+ auth::AuthEvent::Failure { msg: verdict.message() }
+ });
+ zero(password);
+ });
+ self.draw_all(qh);
+ }
+
+ /// PAM accepted: release the session and go.
+ fn unlock(&mut self) {
+ // `unlock_and_destroy` before the `locked` event is a PROTOCOL ERROR,
+ // and the compositor kills the client for it — leaving the session
+ // locked with the locker gone. PAM can answer before `locked` lands
+ // (the compositor is still bringing the lock up while the user types
+ // into a surface it already configured), so this is reachable.
+ if !self.locked {
+ log::warn!("authenticated before the locked event; waiting for it");
+ self.phase = Phase::Prompt;
+ self.status = Some("Locking, one moment…".to_string());
+ self.unlock_when_locked = true;
+ return;
+ }
+ let Some(lock) = self.lock.take() else {
+ self.exit = true;
+ return;
+ };
+ self.phase = Phase::Unlocking;
+ // The ONLY call in this program that opens the session, reached only
+ // from `AuthEvent::Success`, which `auth::Verdict::is_success` is the
+ // sole producer of.
+ lock.unlock_and_destroy();
+ // Only now: the protocol says lock surfaces "should be destroyed by
+ // the client" AFTER this request, not before.
+ self.outputs.clear();
+ self.unlocked = true;
+ self.exit = true;
+ }
+
+ fn create_lock_surface(&mut self, output: &wl_output::WlOutput, qh: &QueueHandle<Self>) {
+ let Some(lock) = self.lock.as_ref() else { return };
+ let id = output.id().protocol_id();
+ if self.outputs.contains_key(&id) {
+ return;
+ }
+ let wl_surface = self.compositor_state.create_surface(qh);
+ let lock_surface = lock.get_lock_surface(&wl_surface, output, qh, id);
+ self.outputs.insert(
+ id,
+ LockOutput {
+ wl_surface,
+ lock_surface,
+ renderer: None,
+ width: 0.0,
+ height: 0.0,
+ scale: 1.0,
+ configured: false,
+ },
+ );
+ }
+
+ fn draw_all(&mut self, _qh: &QueueHandle<Self>) {
+ let ids: Vec<u32> = self.outputs.keys().copied().collect();
+ for id in ids {
+ self.draw(id);
+ }
+ }
+
+ /// Paint one output.
+ fn draw(&mut self, id: u32) {
+ let Some(out) = self.outputs.get(&id) else { return };
+ if !out.configured || out.width <= 0.0 || out.height <= 0.0 {
+ return;
+ }
+ let (w, h, scale) = (out.width, out.height, out.scale);
+
+ let (dl, labels) = self.build_scene(w, h);
+ let (verts, batches, images, features) = tessellate(&dl, w, h, scale);
+
+ let spans_src: Vec<(Buffer, &Label)> = labels
+ .iter()
+ .map(|l| (make_text_buffer(&mut self.font_system, &l.text, l.size), l))
+ .collect();
+ let spans: Vec<TextSpan> = spans_src
+ .iter()
+ .map(|(buf, l)| TextSpan {
+ buffer: buf,
+ left: (l.x * scale).round(),
+ top: (l.y * scale).round(),
+ scale,
+ bounds: None,
+ default_color: [l.color[0], l.color[1], l.color[2], 1.0],
+ rotation: None,
+ clip_circle: [0.0; 3],
+ clip_extents: [0.0; 2],
+ })
+ .collect();
+
+ // Split the borrow: the renderer lives in the map, the font system on
+ // self, and prepare_text needs both at once.
+ let Self { outputs, font_system, swash_cache, .. } = self;
+ let Some(out) = outputs.get_mut(&id) else { return };
+ let Some(renderer) = out.renderer.as_mut() else { return };
+ renderer.prepare_text(font_system, swash_cache, &spans);
+ renderer.draw_frame_2d(Frame2D {
+ verts: &verts,
+ batches: &batches,
+ overlay_verts: &[],
+ images: &images,
+ plate_features: &features,
+ clear_color: [0.0, 0.0, 0.0, 1.0],
+ });
+ }
+
+ /// The lock screen itself: an opaque ground, a centred card, the password
+ /// well and its bullets, and one status line.
+ fn build_scene(&self, w: f32, h: f32) -> (cce_ui::scene::paint::DisplayList, Vec<Label>) {
+ let mut pc = cce_ui::scene::paint::PaintCtx::new();
+ let mut labels = Vec::new();
+
+ // Opaque, always. A translucent lock screen would show the desktop it
+ // is hiding — the compositor already disabled the normal scene tree,
+ // but painting see-through here would still be wrong the moment
+ // anything else is composited under it.
+ //
+ // These channel values are LINEAR, not sRGB: the swapchain is an sRGB
+ // format, so the hardware encodes what the shader writes. 0.05 here
+ // is #3F3F4B on screen, not the near-black it reads as — which is how
+ // this ground first shipped a flat mid-grey. Divide by roughly ten to
+ // get the dark you meant; measure with a screenshot, never by eye
+ // over the source.
+ pc.quad(Rect { x: 0.0, y: 0.0, width: w, height: h }, [0.004, 0.004, 0.006, 1.0]);
+
+ let card_w = 360.0f32.min(w - 40.0);
+ let card_h = 170.0f32;
+ let card = Rect {
+ x: (w - card_w) / 2.0,
+ y: (h - card_h) / 2.0,
+ width: card_w,
+ height: card_h,
+ };
+ let depth = cce_ui::color::plate_bevel_width();
+ pc.plate_spec(&cce_ui::scene::paint::PlateSpec {
+ rect: card,
+ color: [0.013, 0.013, 0.017, 1.0],
+ blur: false,
+ window_corners: (true, true, true, true),
+ depth,
+ });
+
+ labels.push(Label {
+ text: self.username.clone(),
+ x: card.x + 24.0,
+ y: card.y + 22.0,
+ size: 15.0,
+ color: [1.0, 1.0, 1.0],
+ });
+
+ // The password well, rim lit in the highlight the way a focused well
+ // is everywhere else in the DE.
+ let well = Rect { x: card.x + 24.0, y: card.y + 58.0, width: card_w - 48.0, height: 38.0 };
+ pc.quad(well, [0.005, 0.005, 0.007, 1.0]);
+ let well_depth = cce_ui::layout::bevel_width().min(well.height * 0.2);
+ let hc = cce_ui::color::highlight_primary_color();
+ pc.recess_tinted(well, (0.0, 0.0, 0.0, 0.0), well_depth, [hc[0], hc[1], hc[2]]);
+
+ // One dot per character. Never the characters themselves, and never a
+ // count in the status line either — both leak the password's length to
+ // anyone watching the screen.
+ let dot_r = 3.5;
+ let dot_gap = 11.0;
+ let dots = self.password.chars().count().min(32);
+ for i in 0..dots {
+ pc.circle(
+ well.x + 14.0 + dot_r + i as f32 * dot_gap,
+ well.y + well.height / 2.0,
+ dot_r,
+ [0.80, 0.80, 0.88, 1.0],
+ );
+ }
+
+ let (status, color) = match self.phase {
+ Phase::Checking => ("Checking…".to_string(), [0.72, 0.72, 0.80]),
+ Phase::Unlocking => ("Unlocking…".to_string(), [0.72, 0.85, 0.72]),
+ Phase::Prompt => match &self.status {
+ Some(msg) => (msg.clone(), [0.95, 0.55, 0.55]),
+ None if self.caps_lock => ("Caps Lock is on".to_string(), [0.95, 0.80, 0.50]),
+ None => (String::new(), [0.55, 0.55, 0.62]),
+ },
+ };
+ if !status.is_empty() {
+ labels.push(Label {
+ text: status,
+ x: card.x + 24.0,
+ y: card.y + 112.0,
+ size: 12.0,
+ color,
+ });
+ }
+
+ (pc.finish(), labels)
+ }
+}
+
+/// Best-effort scrub of a password buffer once it has been used.
+///
+/// Honest about its limits: PAM copies the string into its own allocations and
+/// the conversation hands libc a `strdup` of it, and neither is reachable from
+/// here. This only clears the copy this process owns, so the window in which a
+/// core dump could contain the password is shorter, not closed.
+fn zero(mut s: String) {
+ unsafe {
+ for b in s.as_bytes_mut() {
+ *b = 0;
+ }
+ }
+ drop(s);
+}
+
+fn make_text_buffer(font_system: &mut FontSystem, text: &str, size: f32) -> Buffer {
+ let metrics = Metrics::new(size, size * 1.4);
+ let mut buffer = Buffer::new(font_system, metrics);
+ let family = cce_ui::layout::control_label_font_parsed().0;
+ let attrs = Attrs::new().family(cce_ui::cosmic_text::Family::Name(&family));
+ buffer.set_text(font_system, text, attrs, cce_ui::cosmic_text::Shaping::Advanced);
+ buffer.shape_until_scroll(font_system, true);
+ buffer
+}
+
+/// Display list → vertex buffer + renderer batches, converting the
+/// tessellator's logical-px clips to physical. Same shape as cce-cloud's.
+fn tessellate(
+ dl: &cce_ui::scene::paint::DisplayList,
+ sw: f32,
+ sh: f32,
+ scale: f32,
+) -> (Vec<Vertex>, Vec<Batch2D>, Vec<ImageQuad>, Vec<[f32; 12]>) {
+ let (verts, dl_batches, _dl_images, features) =
+ cce_ui::backend::window_runner::tessellate_display_list(dl, sw, sh, scale);
+ let batches = dl_batches
+ .iter()
+ .map(|b| Batch2D {
+ scissor: b.scissor.map(|c| {
+ (
+ (c.x * scale).max(0.0) as u32,
+ (c.y * scale).max(0.0) as u32,
+ (c.width * scale) as u32,
+ (c.height * scale) as u32,
+ )
+ }),
+ clip_rrect: b
+ .clip_rrect
+ .map(|c| [c[0] * scale, c[1] * scale, c[2] * scale, c[3] * scale, c[4] * scale]),
+ start: b.start,
+ end: b.end,
+ plate: b.plate,
+ blur_behind: b.blur_behind,
+ })
+ .collect();
+ (verts, batches, Vec::new(), features)
+}
+
+// ---------------------------------------------------------------------------
+// Protocol plumbing
+// ---------------------------------------------------------------------------
+
+impl Dispatch<ExtSessionLockManagerV1, ()> for AppState {
+ fn event(
+ _state: &mut Self,
+ _proxy: &ExtSessionLockManagerV1,
+ _event: <ExtSessionLockManagerV1 as Proxy>::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle<Self>,
+ ) {
+ }
+}
+
+impl Dispatch<ExtSessionLockV1, ()> for AppState {
+ fn event(
+ state: &mut Self,
+ _proxy: &ExtSessionLockV1,
+ event: <ExtSessionLockV1 as Proxy>::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle<Self>,
+ ) {
+ match event {
+ ext_session_lock_v1::Event::Locked => {
+ log::info!("session locked");
+ state.locked = true;
+ if state.unlock_when_locked {
+ state.unlock_when_locked = false;
+ state.unlock();
+ }
+ }
+ ext_session_lock_v1::Event::Finished => {
+ // The compositor refused the lock or ended it. We must exit
+ // WITHOUT calling unlock_and_destroy — that request would be
+ // a protocol error, and pretending to unlock a session we
+ // never locked is not ours to do.
+ log::warn!("lock finished by the compositor; exiting without unlocking");
+ state.finished = true;
+ state.exit = true;
+ }
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch<ExtSessionLockSurfaceV1, u32> for AppState {
+ fn event(
+ state: &mut Self,
+ _proxy: &ExtSessionLockSurfaceV1,
+ event: <ExtSessionLockSurfaceV1 as Proxy>::Event,
+ id: &u32,
+ _conn: &Connection,
+ _qh: &QueueHandle<Self>,
+ ) {
+ if let ext_session_lock_surface_v1::Event::Configure { serial, width, height } = event {
+ let Some(out) = state.outputs.get_mut(id) else { return };
+ out.lock_surface.ack_configure(serial);
+ out.width = width as f32;
+ out.height = height as f32;
+ out.configured = true;
+
+ let pw = (out.width * out.scale) as u32;
+ let ph = (out.height * out.scale) as u32;
+ match out.renderer.as_mut() {
+ Some(r) => r.resize(pw, ph),
+ None => {
+ out.wl_surface.set_buffer_scale(out.scale as i32);
+ let conn_ptr = _conn.backend().display_id().as_ptr() as *mut std::ffi::c_void;
+ let surf_ptr = out.wl_surface.id().as_ptr() as *mut std::ffi::c_void;
+ out.renderer =
+ Some(unsafe { VkRenderer::new(conn_ptr, surf_ptr, pw, ph, 0.0) });
+ }
+ }
+ state.draw(*id);
+ }
+ }
+}
+
+impl CompositorHandler for AppState {
+ fn scale_factor_changed(
+ &mut self,
+ _conn: &Connection,
+ _qh: &QueueHandle<Self>,
+ surface: &wl_surface::WlSurface,
+ new_factor: i32,
+ ) {
+ let id = self
+ .outputs
+ .iter()
+ .find(|(_, o)| &o.wl_surface == surface)
+ .map(|(id, _)| *id);
+ let Some(id) = id else { return };
+ if let Some(out) = self.outputs.get_mut(&id) {
+ out.scale = new_factor as f32;
+ out.wl_surface.set_buffer_scale(new_factor);
+ if let Some(r) = out.renderer.as_mut() {
+ r.resize((out.width * out.scale) as u32, (out.height * out.scale) as u32);
+ }
+ }
+ self.draw(id);
+ }
+
+ fn transform_changed(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_surface::WlSurface,
+ _: wl_output::Transform,
+ ) {
+ }
+ fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {}
+ fn surface_enter(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_surface::WlSurface,
+ _: &wl_output::WlOutput,
+ ) {
+ }
+ fn surface_leave(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_surface::WlSurface,
+ _: &wl_output::WlOutput,
+ ) {
+ }
+}
+
+impl OutputHandler for AppState {
+ fn output_state(&mut self) -> &mut OutputState {
+ &mut self.output_state
+ }
+ fn new_output(&mut self, _: &Connection, qh: &QueueHandle<Self>, output: wl_output::WlOutput) {
+ // A monitor plugged in while locked still gets a prompt rather than
+ // the compositor's bare blank.
+ self.create_lock_surface(&output, qh);
+ }
+ fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
+ fn output_destroyed(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ output: wl_output::WlOutput,
+ ) {
+ self.outputs.remove(&output.id().protocol_id());
+ }
+}
+
+impl SeatHandler for AppState {
+ fn seat_state(&mut self) -> &mut SeatState {
+ &mut self.seat_state
+ }
+ fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
+ fn new_capability(
+ &mut self,
+ _: &Connection,
+ qh: &QueueHandle<Self>,
+ seat: wl_seat::WlSeat,
+ capability: Capability,
+ ) {
+ if capability == Capability::Keyboard && self.keyboard.is_none() {
+ self.keyboard = self.seat_state.get_keyboard(qh, &seat, None).ok();
+ }
+ }
+ fn remove_capability(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: wl_seat::WlSeat,
+ capability: Capability,
+ ) {
+ if capability == Capability::Keyboard {
+ if let Some(kb) = self.keyboard.take() {
+ kb.release();
+ }
+ }
+ }
+ fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
+}
+
+impl KeyboardHandler for AppState {
+ fn enter(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_keyboard::WlKeyboard,
+ _: &wl_surface::WlSurface,
+ _: u32,
+ _: &[u32],
+ _: &[Keysym],
+ ) {
+ }
+ fn leave(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_keyboard::WlKeyboard,
+ _: &wl_surface::WlSurface,
+ _: u32,
+ ) {
+ }
+
+ fn press_key(
+ &mut self,
+ _: &Connection,
+ qh: &QueueHandle<Self>,
+ _: &wl_keyboard::WlKeyboard,
+ _: u32,
+ event: KeyEvent,
+ ) {
+ // Everything is ignored mid-check: a held Return would otherwise
+ // queue attempts against pam_faillock and lock the account out.
+ if matches!(self.phase, Phase::Checking | Phase::Unlocking) {
+ return;
+ }
+ match event.keysym {
+ Keysym::Return | Keysym::KP_Enter => {
+ self.submit(qh);
+ return;
+ }
+ Keysym::BackSpace => {
+ self.password.pop();
+ self.status = None;
+ }
+ Keysym::Escape => {
+ // Clears the field. It does NOT dismiss the lock — there is
+ // no key that does.
+ self.password.clear();
+ self.status = None;
+ }
+ _ => {
+ if let Some(text) = event.utf8.as_ref() {
+ for ch in text.chars().filter(|c| !c.is_control()) {
+ self.password.push(ch);
+ }
+ self.status = None;
+ }
+ }
+ }
+ self.draw_all(qh);
+ }
+
+ fn release_key(
+ &mut self,
+ _: &Connection,
+ _: &QueueHandle<Self>,
+ _: &wl_keyboard::WlKeyboard,
+ _: u32,
+ _: KeyEvent,
+ ) {
+ }
+
+ fn update_modifiers(
+ &mut self,
+ _: &Connection,
+ qh: &QueueHandle<Self>,
+ _: &wl_keyboard::WlKeyboard,
+ _: u32,
+ modifiers: Modifiers,
+ _: u32,
+ ) {
+ if modifiers.caps_lock != self.caps_lock {
+ self.caps_lock = modifiers.caps_lock;
+ self.draw_all(qh);
+ }
+ }
+}
+
+impl ProvidesRegistryState for AppState {
+ fn registry(&mut self) -> &mut RegistryState {
+ &mut self.registry_state
+ }
+ smithay_client_toolkit::registry_handlers![OutputState, SeatState];
+}
+
+delegate_compositor!(AppState);
+delegate_output!(AppState);
+delegate_seat!(AppState);
+delegate_keyboard!(AppState);
+delegate_registry!(AppState);
+
+fn main() {
+ env_logger::Builder::from_default_env()
+ .filter_level(log::LevelFilter::Info)
+ .init();
+
+ let username = users::get_current_username()
+ .map(|n| n.to_string_lossy().into_owned())
+ .unwrap_or_default();
+ if username.is_empty() {
+ eprintln!("cce-lock: cannot determine the current user; refusing to lock");
+ std::process::exit(1);
+ }
+
+ // BEFORE locking anything. A PAM stack that will not start would reject
+ // every password with the screen already locked, and the only way out
+ // would be a TTY and a kill. Failing here costs the user nothing.
+ if let Err(e) = auth::preflight(&username) {
+ eprintln!("cce-lock: {}", e);
+ std::process::exit(1);
+ }
+
+ let conn = match Connection::connect_to_env() {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("cce-lock: no Wayland connection: {}", e);
+ std::process::exit(1);
+ }
+ };
+ let (globals, event_queue) = match registry_queue_init::<AppState>(&conn) {
+ Ok(v) => v,
+ Err(e) => {
+ eprintln!("cce-lock: registry init failed: {}", e);
+ std::process::exit(1);
+ }
+ };
+ let qh = event_queue.handle();
+
+ let lock_manager: ExtSessionLockManagerV1 = match globals.bind(&qh, 1..=1, ()) {
+ Ok(m) => m,
+ Err(e) => {
+ eprintln!("cce-lock: compositor does not offer ext-session-lock-v1: {}", e);
+ std::process::exit(1);
+ }
+ };
+
+ let mut event_loop: calloop::EventLoop<AppState> =
+ calloop::EventLoop::try_new().expect("event loop");
+ let (auth_tx, auth_rx) = calloop::channel::channel::<auth::AuthEvent>();
+
+ cce_ui::scale::set_app_id("cce-lock".to_string());
+
+ let mut state = AppState {
+ registry_state: RegistryState::new(&globals),
+ seat_state: SeatState::new(&globals, &qh),
+ output_state: OutputState::new(&globals, &qh),
+ compositor_state: CompositorState::bind(&globals, &qh).expect("wl_compositor"),
+ lock: None,
+ outputs: HashMap::new(),
+ keyboard: None,
+ username,
+ password: String::new(),
+ phase: Phase::Prompt,
+ status: None,
+ caps_lock: false,
+ locked: false,
+ finished: false,
+ unlock_when_locked: false,
+ unlocked: false,
+ exit: false,
+ font_system: cce_ui::create_font_system(),
+ swash_cache: SwashCache::new(),
+ auth_tx,
+ };
+
+ state.lock = Some(lock_manager.lock(&qh, ()));
+ // Surfaces for the outputs that already exist; later ones arrive through
+ // OutputHandler::new_output.
+ let outputs: Vec<wl_output::WlOutput> = state.output_state.outputs().collect();
+ for output in &outputs {
+ state.create_lock_surface(output, &qh);
+ }
+
+ event_loop
+ .handle()
+ .insert_source(auth_rx, |event, _, state| {
+ let calloop::channel::Event::Msg(event) = event else { return };
+ match event {
+ auth::AuthEvent::Success => state.unlock(),
+ auth::AuthEvent::Failure { msg } => {
+ state.phase = Phase::Prompt;
+ state.status = Some(msg);
+ }
+ auth::AuthEvent::Info { msg } => {
+ state.status = Some(msg);
+ }
+ }
+ })
+ .expect("auth channel");
+
+ calloop_wayland_source::WaylandSource::new(conn.clone(), event_queue)
+ .insert(event_loop.handle())
+ .expect("wayland source");
+
+ while !state.exit {
+ if event_loop
+ .dispatch(std::time::Duration::from_millis(50), &mut state)
+ .is_err()
+ {
+ break;
+ }
+ // Redraw outside the event handlers: an auth result arrives on the
+ // calloop channel with no qh in scope.
+ if !matches!(state.phase, Phase::Unlocking) {
+ let ids: Vec<u32> = state.outputs.keys().copied().collect();
+ for id in ids {
+ state.draw(id);
+ }
+ }
+ }
+
+ // A flush is NOT enough after unlock_and_destroy, and the protocol says
+ // so outright: without a sync the server may terminate this client before
+ // it processes the request, and the session would stay locked with no
+ // locker running. Round-trip, then go.
+ if state.unlocked {
+ if let Err(e) = conn.roundtrip() {
+ log::error!("roundtrip after unlock failed: {}", e);
+ }
+ }
+ let _ = conn.flush();
+ if state.finished {
+ std::process::exit(1);
+ }
+}