// 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::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; /// 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, failed_attempts: u32, on_success: Option>, on_failure: Option>, } impl FingerprintListener { /// Create a new FingerprintListener. /// Connects to fprintd synchronously — call before creating GTK windows. pub fn new() -> Self { let mut listener = FingerprintListener { device_proxy: None, signal_id: None, running: false, failed_attempts: 0, on_success: None, on_failure: None, }; listener.init_device(); listener } /// Connect to fprintd and get the default device. fn init_device(&mut self) { let manager = match gio::DBusProxy::for_bus_sync( gio::BusType::System, gio::DBusProxyFlags::NONE, None, FPRINTD_BUS_NAME, FPRINTD_MANAGER_PATH, FPRINTD_MANAGER_IFACE, gio::Cancellable::NONE, ) { Ok(m) => m, Err(e) => { log::debug!("fprintd manager not available: {e}"); return; } }; // Call GetDefaultDevice let result = match manager.call_sync( "GetDefaultDevice", None, gio::DBusCallFlags::NONE, -1, gio::Cancellable::NONE, ) { Ok(r) => r, Err(e) => { log::debug!("fprintd GetDefaultDevice failed: {e}"); return; } }; // Extract device path from variant tuple let device_path: String = result.child_get::(0); if device_path.is_empty() { return; } match gio::DBusProxy::for_bus_sync( gio::BusType::System, gio::DBusProxyFlags::NONE, None, FPRINTD_BUS_NAME, &device_path, FPRINTD_DEVICE_IFACE, gio::Cancellable::NONE, ) { 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. pub fn is_available(&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_sync( "ListEnrolledFingers", Some(&args), gio::DBusCallFlags::NONE, -1, gio::Cancellable::NONE, ) { Ok(result) => { // Result is a tuple of (array of strings) let fingers: Vec = result.child_get::>(0); !fingers.is_empty() } Err(_) => false, } } /// Start listening for fingerprint verification. /// Connects the D-Bus g-signal handler internally. The `listener` parameter /// must be the same `Rc>` that owns `self`. pub fn start( listener: &Rc>, username: &str, on_success: F, on_failure: G, ) where F: Fn() + 'static, G: Fn() + 'static, { let proxy = { let inner = listener.borrow(); match inner.device_proxy.clone() { Some(p) => p, None => return, } }; { let mut inner = listener.borrow_mut(); inner.on_success = Some(Box::new(on_success)); inner.on_failure = Some(Box::new(on_failure)); } // Claim the device let args = glib::Variant::from((&username,)); if let Err(e) = proxy.call_sync( "Claim", Some(&args), gio::DBusCallFlags::NONE, -1, gio::Cancellable::NONE, ) { log::error!("Failed to claim fingerprint device: {e}"); return; } // Start verification let start_args = glib::Variant::from((&"any",)); if let Err(e) = proxy.call_sync( "VerifyStart", Some(&start_args), gio::DBusCallFlags::NONE, -1, gio::Cancellable::NONE, ) { log::error!("Failed to start fingerprint verification: {e}"); let _ = proxy.call_sync( "Release", None, gio::DBusCallFlags::NONE, -1, gio::Cancellable::NONE, ); return; } // 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 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; } /// Process a VerifyStatus signal from fprintd. pub fn on_verify_status(&mut self, status: &str, done: bool) { if !self.running { return; } if status == "verify-match" { if let Some(ref cb) = self.on_success { cb(); } return; } if RETRY_STATUSES.contains(&status) { if done { self.restart_verify(); } return; } if status == "verify-no-match" { self.failed_attempts += 1; if let Some(ref cb) = self.on_failure { cb(); } if self.failed_attempts >= MAX_FP_ATTEMPTS { log::warn!("Fingerprint max attempts ({MAX_FP_ATTEMPTS}) reached, stopping"); self.stop(); return; } if done { self.restart_verify(); } return; } log::debug!("Unhandled fprintd status: {status}"); } /// Restart fingerprint verification after a completed attempt. fn restart_verify(&self) { if let Some(ref proxy) = self.device_proxy { // VerifyStop before VerifyStart to avoid D-Bus errors let _ = proxy.call_sync( "VerifyStop", None, gio::DBusCallFlags::NONE, -1, gio::Cancellable::NONE, ); let args = glib::Variant::from((&"any",)); if let Err(e) = proxy.call_sync( "VerifyStart", Some(&args), gio::DBusCallFlags::NONE, -1, gio::Cancellable::NONE, ) { log::error!("Failed to restart fingerprint verification: {e}"); } } } /// Stop listening and release the device. pub fn stop(&mut self) { if !self.running { return; } self.running = false; if let Some(ref proxy) = self.device_proxy { if let Some(id) = self.signal_id.take() { proxy.disconnect(id); } let _ = proxy.call_sync( "VerifyStop", None, gio::DBusCallFlags::NONE, -1, gio::Cancellable::NONE, ); let _ = proxy.call_sync( "Release", None, gio::DBusCallFlags::NONE, -1, gio::Cancellable::NONE, ); } } } #[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); } }