git.lucas.co / cce-authenticator
login authentication (PAM + fingerprint)
git clone https://git.lucas.co/cce-authenticator.git

commit1ff38f92420535a70e6b95b92810e7edf6d2aeb1
parent7978f796f0
authorLucas Galante <[email protected]>
date2026-08-22 11:54
fix: stop printing the PAM finger prompt twice, once cut mid-word

Both defects surfaced by live-verifying the fingerprint path against a real polkit
request, which is the only way to see this dialog in polkit mode.

pam_fprintd runs first in /etc/pam.d/polkit-1 (auth sufficient), so a prompt opens
straight into "Place your right middle finger on the fingerprint reader". That
sentence was written to both the wide status line and the 220px fingerprint
column, where the paint clip rect cut it to "...on the fingerprint read" — the
same words twice, one copy broken. The column now reports state ("Waiting for
finger...") and the full wording stays on the status line that fits it. Captions
that are long anyway — fprintd and D-Bus errors interpolate freely — go through
fit_column, which breaks at a word boundary and marks the cut, because a clip rect
is not a layout strategy.

Pointer motion was routed to `bg`, the one root this dialog paints without
registering, so every motion event logged "unregistered/stale root ... event
dropped" for the life of the daemon. ContentBg::hit is unconditionally false and
can never consume a pointer event, so the route was dead: dropped.

Verified live against a real prompt: the column reads "Waiting for finger..." with
the full sentence below it once, and eleven injected motion events across the
dialog produce zero dropped-event lines. That harness needed fixing first — the
initial run called `ccectl pointer-move`, which does not exist, and passed
vacuously with its error in /dev/null; the real check confirms hover changes the
frame, proving the events land before counting what they log.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/main.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 53 insertions(+), 3 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index ca894f4..129412e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -59,6 +59,25 @@ static COOKIES: Mutex<CookieState> = Mutex::new(CookieState {
     cancelled: Vec::new(),
 });
 
+/// Shorten a caption to what the fingerprint column can show, breaking at a word
+/// boundary.
+///
+/// The column is 220 logical px, and its captions are arbitrary-length strings from
+/// PAM, fprintd and D-Bus errors (`No reader: <zbus error>`). The paint API clips to a
+/// rect, and a clip rect is not a layout strategy — it cuts mid-word and gives no hint
+/// that anything is missing. There is no cheap shaping call here to measure exactly, so
+/// the budget comes from the advance observed at this size (~4.15 px/char at 9pt) and
+/// is deliberately a few characters short: erring low only moves the ellipsis earlier.
+fn fit_column(text: &str) -> String {
+    const MAX_CHARS: usize = 50;
+    if text.chars().count() <= MAX_CHARS {
+        return text.to_string();
+    }
+    let head: String = text.chars().take(MAX_CHARS - 1).collect();
+    let cut = head.rfind(' ').unwrap_or(head.len());
+    format!("{}…", head[..cut].trim_end())
+}
+
 /// PAM service backing the standalone password check. Polkit mode never reaches it:
 /// `polkit-agent-helper-1` runs its own `polkit-1` service inside the helper process.
 const PAM_SERVICE: &str = "system-local-login";
@@ -454,8 +473,13 @@ impl Application for AuthenticatorApp {
                 self.status_is_error = is_error;
                 self.status_is_success = false;
                 if msg.to_lowercase().contains("finger") {
+                    // PAM's wording is a whole sentence naming the finger and the
+                    // reader, and the wide status line above already carries it
+                    // verbatim. Repeating it inside the narrow column printed it
+                    // twice and cut the copy mid-word ("…on the fingerprint read"),
+                    // so the column reports the state instead.
                     self.fingerprint_active = true;
-                    self.fingerprint_msg = msg;
+                    self.fingerprint_msg = "Waiting for finger…".to_string();
                 }
             }
             AppMessage::AuthDone(res) => {
@@ -666,7 +690,7 @@ impl Application for AuthenticatorApp {
             [0x83, 0x83, 0x8a]
         };
         pc.text_with(
-            self.fingerprint_msg.clone(),
+            fit_column(&self.fingerprint_msg),
             fp_col_x,
             fp_btn_y + fp_btn_h + 12.0,
             9.0,
@@ -708,7 +732,10 @@ impl Application for AuthenticatorApp {
         // Routed dispatch (6bd shrink): one Event per widget root through the router.
         let mv = cce_ui::widget::Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
         let ctx = &mut self.ui_context;
-        if ctx.propagate_event(&mv, self.bg.id()) { *needs_rebuild = true; }
+        // `bg` is deliberately absent: `ContentBg::hit` is unconditionally false, so it
+        // can never consume a pointer event, and it is the one root this dialog paints
+        // without registering — routing to it logged "unregistered/stale root … event
+        // dropped" on every motion event for the life of the daemon.
         if ctx.propagate_event(&mv, self.password_box.id()) { *needs_rebuild = true; }
         if ctx.propagate_event(&mv, self.verify_btn.id()) { *needs_rebuild = true; }
         if ctx.propagate_event(&mv, self.cancel_btn.id()) { *needs_rebuild = true; }
@@ -1127,6 +1154,29 @@ mod tests {
         st.cancelled.retain(|c| c != cookie);
     }
 
+    #[test]
+    fn column_captions_never_cut_mid_word() {
+        // The message that exposed this: clipping rendered "…on the fingerprint read".
+        let pam = "Place your right middle finger on the fingerprint reader";
+        let fitted = fit_column(pam);
+        assert!(fitted.ends_with('…'), "long captions must show they were cut");
+        assert!(
+            !fitted.contains("read…"),
+            "cut fell mid-word: {fitted}"
+        );
+        assert!(pam.starts_with(fitted.trim_end_matches('…').trim_end()));
+
+        // Short enough to stand as-is, ellipsis included or not.
+        assert_eq!(fit_column("Waiting for finger…"), "Waiting for finger…");
+        assert_eq!(fit_column(""), "");
+
+        // No spaces to break on, and multi-byte characters: must not panic or slice
+        // through a char boundary.
+        let unbroken = "x".repeat(80);
+        assert!(fit_column(&unbroken).ends_with('…'));
+        assert!(fit_column(&"é".repeat(80)).ends_with('…'));
+    }
+
     /// The orderings that a single active-cookie slot got wrong. One test, run in
     /// sequence, because COOKIES is process-global.
     #[test]