// ABOUTME: fprintd D-Bus integration for fingerprint authentication. // ABOUTME: Provides FingerprintListener that connects to fprintd via Gio.DBusProxy. use gio::prelude::*; use gtk4::gio; use std::cell::{Cell, RefCell}; use std::rc::Rc; const FPRINTD_BUS_NAME: &str = "net.reactivated.Fprint"; const FPRINTD_MANAGER_PATH: &str = "/net/reactivated/Fprint/Manager"; const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager"; const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device"; const MAX_FP_ATTEMPTS: u32 = 10; const DBUS_TIMEOUT_MS: i32 = 3000; const FPRINTD_DEVICE_PREFIX: &str = "/net/reactivated/Fprint/Device/"; /// Retry-able statuses — finger not read properly, try again. const RETRY_STATUSES: &[&str] = &[ "verify-swipe-too-short", "verify-finger-not-centered", "verify-remove-and-retry", "verify-retry-scan", ]; /// Fingerprint listener state. pub struct FingerprintListener { device_proxy: Option, signal_id: Option, running: bool, /// Shared flag for async tasks to detect stop() between awaits. running_flag: Rc>, failed_attempts: u32, on_success: Option>, on_failure: Option>, on_exhausted: Option>, } impl Default for FingerprintListener { fn default() -> Self { FingerprintListener { device_proxy: None, signal_id: None, running: false, running_flag: Rc::new(Cell::new(false)), failed_attempts: 0, on_success: None, on_failure: None, on_exhausted: None, } } } impl FingerprintListener { /// Create a lightweight FingerprintListener without any D-Bus calls. /// Call `init_async().await` afterwards to connect to fprintd. pub fn new() -> Self { Self::default() } /// Connect to fprintd and get the default device asynchronously. pub async fn init_async(&mut self) { let manager = match gio::DBusProxy::for_bus_future( gio::BusType::System, gio::DBusProxyFlags::NONE, None, FPRINTD_BUS_NAME, FPRINTD_MANAGER_PATH, FPRINTD_MANAGER_IFACE, ) .await { Ok(m) => m, Err(e) => { log::debug!("fprintd manager not available: {e}"); return; } }; // Call GetDefaultDevice let result = match manager .call_future("GetDefaultDevice", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS) .await { Ok(r) => r, Err(e) => { log::debug!("fprintd GetDefaultDevice failed: {e}"); return; } }; // Extract device path from variant tuple let device_path = match result.child_value(0).get::() { Some(p) => p, None => { log::debug!("fprintd: unexpected GetDefaultDevice response type"); return; } }; if device_path.is_empty() { return; } if !device_path.starts_with(FPRINTD_DEVICE_PREFIX) { log::warn!("Unexpected fprintd device path: {device_path}"); return; } match gio::DBusProxy::for_bus_future( gio::BusType::System, gio::DBusProxyFlags::NONE, None, FPRINTD_BUS_NAME, &device_path, FPRINTD_DEVICE_IFACE, ) .await { Ok(proxy) => { self.device_proxy = Some(proxy); } Err(e) => { log::debug!("fprintd device proxy failed: {e}"); } } } /// Check if fprintd is available and the user has enrolled fingerprints (async). pub async fn is_available_async(&self, username: &str) -> bool { let proxy = match &self.device_proxy { Some(p) => p, None => return false, }; let args = glib::Variant::from((&username,)); match proxy .call_future("ListEnrolledFingers", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS) .await { Ok(result) => { // Result is a tuple of (array of strings) match result.child_value(0).get::>() { Some(fingers) => !fingers.is_empty(), None => { log::debug!("fprintd: unexpected ListEnrolledFingers response type"); false } } } Err(_) => false, } } /// Start listening for fingerprint verification. /// Claims the device and starts verification using async D-Bus calls. /// Connects the D-Bus g-signal handler internally. The `listener` parameter /// must be the same `Rc>` that owns `self`. pub async fn start_async( listener: &Rc>, username: &str, on_success: F, on_failure: G, on_exhausted: H, ) where F: Fn() + 'static, G: Fn() + 'static, H: Fn() + 'static, { { let mut inner = listener.borrow_mut(); inner.on_success = Some(Box::new(on_success)); inner.on_failure = Some(Box::new(on_failure)); inner.on_exhausted = Some(Box::new(on_exhausted)); } Self::begin_verification(listener, username).await; } /// Resume fingerprint verification after a transient interruption (e.g. failed /// PAM account check). Reuses previously stored callbacks. Re-claims the device /// and restarts verification from scratch. pub async fn resume_async( listener: &Rc>, username: &str, ) { listener.borrow_mut().failed_attempts = 0; Self::begin_verification(listener, username).await; } /// Claim device, start verification, and connect D-Bus signal handler. /// Assumes device_proxy is set and callbacks are already stored. async fn begin_verification( listener: &Rc>, username: &str, ) { let proxy = { let mut inner = listener.borrow_mut(); let proxy = match inner.device_proxy.clone() { Some(p) => p, None => return, }; // Disconnect any previous signal handler to prevent duplicates on resume if let Some(old_id) = inner.signal_id.take() { proxy.disconnect(old_id); } proxy }; // Claim the device let args = glib::Variant::from((&username,)); if let Err(e) = proxy .call_future("Claim", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS) .await { log::error!("Failed to claim fingerprint device: {e}"); return; } // Start verification let start_args = glib::Variant::from((&"any",)); if let Err(e) = proxy .call_future("VerifyStart", Some(&start_args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS) .await { log::error!("Failed to start fingerprint verification: {e}"); let _ = proxy .call_future("Release", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS) .await; return; } // Capture the unique bus name of fprintd for sender validation. // D-Bus signals carry the sender's unique name (e.g. ":1.42"), not the // well-known name. We validate this to prevent signal spoofing. let expected_sender = proxy.name_owner(); // Connect the g-signal handler on the proxy to dispatch VerifyStatus let listener_weak = Rc::downgrade(listener); let signal_id = proxy.connect_local("g-signal", false, move |values| { // g-signal arguments: (proxy, sender_name, signal_name, parameters) let sender: String = match values[1].get() { Ok(s) => s, Err(_) => return None, }; if expected_sender.as_ref().map(|s| s.as_str()) != Some(sender.as_str()) { log::warn!("Ignoring D-Bus signal from unexpected sender: {sender}"); return None; } let signal_name: String = match values[2].get() { Ok(v) => v, Err(_) => return None, }; if signal_name.as_str() != "VerifyStatus" { return None; } let params = match values[3].get::() { Ok(v) => v, Err(_) => return None, }; let status = params .child_value(0) .get::() .unwrap_or_default(); let done = params .child_value(1) .get::() .unwrap_or(false); if let Some(listener_rc) = listener_weak.upgrade() { listener_rc.borrow_mut().on_verify_status(&status, done); } None }); let mut inner = listener.borrow_mut(); inner.signal_id = Some(signal_id); inner.running = true; inner.running_flag.set(true); } /// Process a VerifyStatus signal from fprintd. pub(crate) fn on_verify_status(&mut self, status: &str, done: bool) { if !self.running { return; } if status == "verify-match" { self.cleanup_dbus(); if let Some(ref cb) = self.on_success { cb(); } return; } if RETRY_STATUSES.contains(&status) { if done { self.restart_verify_async(); } return; } if status == "verify-no-match" { self.failed_attempts += 1; if self.failed_attempts >= MAX_FP_ATTEMPTS { log::warn!("Fingerprint max attempts ({MAX_FP_ATTEMPTS}) reached, stopping"); if let Some(ref cb) = self.on_exhausted { cb(); } self.stop(); return; } if let Some(ref cb) = self.on_failure { cb(); } if done { self.restart_verify_async(); } return; } log::debug!("Unhandled fprintd status: {status}"); if done { self.restart_verify_async(); } } /// Restart fingerprint verification asynchronously after a completed attempt. /// Checks running_flag after VerifyStop to avoid restarting on a released device. fn restart_verify_async(&self) { if let Some(ref proxy) = self.device_proxy { let proxy = proxy.clone(); let running = self.running_flag.clone(); glib::spawn_future_local(async move { // VerifyStop before VerifyStart to avoid D-Bus errors let _ = proxy .call_future("VerifyStop", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS) .await; if !running.get() { return; } let args = glib::Variant::from((&"any",)); if let Err(e) = proxy .call_future("VerifyStart", Some(&args), gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS) .await { log::error!("Failed to restart fingerprint verification: {e}"); } }); } } /// Disconnect the signal handler and send VerifyStop + Release to fprintd. /// Signal disconnect is synchronous to prevent further callbacks. /// D-Bus cleanup is fire-and-forget to avoid blocking the UI. fn cleanup_dbus(&mut self) { self.running = false; self.running_flag.set(false); if let Some(ref proxy) = self.device_proxy { if let Some(id) = self.signal_id.take() { proxy.disconnect(id); } let proxy = proxy.clone(); glib::spawn_future_local(async move { let _ = proxy .call_future("VerifyStop", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS) .await; let _ = proxy .call_future("Release", None, gio::DBusCallFlags::NONE, DBUS_TIMEOUT_MS) .await; }); } } /// Stop listening and release the device. Idempotent — safe to call multiple times. pub fn stop(&mut self) { if !self.running { return; } self.cleanup_dbus(); } } #[cfg(test)] mod tests { use super::*; #[test] fn retry_statuses_are_defined() { assert!(RETRY_STATUSES.contains(&"verify-swipe-too-short")); assert!(RETRY_STATUSES.contains(&"verify-finger-not-centered")); assert!(!RETRY_STATUSES.contains(&"verify-match")); assert!(!RETRY_STATUSES.contains(&"verify-no-match")); } #[test] fn max_attempts_constant() { assert_eq!(MAX_FP_ATTEMPTS, 10); } #[test] fn verify_match_sets_running_false_and_calls_success() { let called = Rc::new(Cell::new(false)); let called_clone = called.clone(); let mut listener = FingerprintListener::new(); listener.running = true; listener.running_flag.set(true); listener.on_success = Some(Box::new(move || { called_clone.set(true); })); listener.on_verify_status("verify-match", false); assert!(called.get()); assert!(!listener.running); assert!(!listener.running_flag.get()); } #[test] fn verify_no_match_calls_failure_and_stays_running() { let called = Rc::new(Cell::new(false)); let called_clone = called.clone(); let mut listener = FingerprintListener::new(); listener.running = true; listener.running_flag.set(true); listener.on_failure = Some(Box::new(move || { called_clone.set(true); })); listener.on_verify_status("verify-no-match", false); assert!(called.get()); assert!(listener.running); assert!(listener.running_flag.get()); assert_eq!(listener.failed_attempts, 1); } #[test] fn max_attempts_stops_listener_and_calls_exhausted() { let exhausted = Rc::new(Cell::new(false)); let exhausted_clone = exhausted.clone(); let mut listener = FingerprintListener::new(); listener.running = true; listener.on_failure = Some(Box::new(|| {})); listener.on_exhausted = Some(Box::new(move || { exhausted_clone.set(true); })); for _ in 0..MAX_FP_ATTEMPTS { listener.on_verify_status("verify-no-match", true); } assert!(!listener.running); assert!(exhausted.get()); assert_eq!(listener.failed_attempts, MAX_FP_ATTEMPTS); } #[test] fn not_running_ignores_signals() { let called = Rc::new(Cell::new(false)); let called_clone = called.clone(); let mut listener = FingerprintListener::new(); listener.running = false; listener.on_success = Some(Box::new(move || { called_clone.set(true); })); listener.on_verify_status("verify-match", false); assert!(!called.get()); } }