Development over
This commit is contained in:
285
linux-hello-settings/src/dbus_client.rs
Normal file
285
linux-hello-settings/src/dbus_client.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
//! D-Bus client for communicating with the Linux Hello daemon.
|
||||
//!
|
||||
//! This module provides a client interface to the org.linuxhello.Daemon
|
||||
//! D-Bus service for managing facial authentication templates.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use zbus::{proxy, Connection, Result as ZbusResult};
|
||||
|
||||
/// Status information about the Linux Hello system
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SystemStatus {
|
||||
/// Whether a camera is available
|
||||
pub camera_available: bool,
|
||||
/// Camera device path (e.g., /dev/video0)
|
||||
pub camera_device: Option<String>,
|
||||
/// Whether TPM is available for secure storage
|
||||
pub tpm_available: bool,
|
||||
/// Whether anti-spoofing (liveness detection) is enabled
|
||||
pub anti_spoofing_enabled: bool,
|
||||
/// Daemon version
|
||||
pub version: String,
|
||||
/// Whether the daemon is running
|
||||
pub daemon_running: bool,
|
||||
}
|
||||
|
||||
/// Information about an enrolled face template
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateInfo {
|
||||
/// Unique identifier for the template
|
||||
pub id: String,
|
||||
/// User-provided label for the template
|
||||
pub label: String,
|
||||
/// Username associated with this template
|
||||
pub username: String,
|
||||
/// Timestamp when the template was created (Unix epoch seconds)
|
||||
pub created_at: i64,
|
||||
/// Timestamp of last successful authentication (Unix epoch seconds)
|
||||
pub last_used: Option<i64>,
|
||||
}
|
||||
|
||||
/// Enrollment progress information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnrollmentProgress {
|
||||
/// Current step in the enrollment process
|
||||
pub step: u32,
|
||||
/// Total number of steps
|
||||
pub total_steps: u32,
|
||||
/// Human-readable status message
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// D-Bus proxy for the Linux Hello Daemon
|
||||
#[proxy(
|
||||
interface = "org.linuxhello.Daemon",
|
||||
default_service = "org.linuxhello.Daemon",
|
||||
default_path = "/org/linuxhello/Daemon"
|
||||
)]
|
||||
trait LinuxHelloDaemon {
|
||||
/// Get the current system status
|
||||
fn get_status(&self) -> ZbusResult<String>;
|
||||
|
||||
/// List all enrolled templates for the current user
|
||||
fn list_templates(&self) -> ZbusResult<String>;
|
||||
|
||||
/// Start a new enrollment session
|
||||
/// Returns a session ID
|
||||
fn start_enrollment(&self, label: &str) -> ZbusResult<String>;
|
||||
|
||||
/// Cancel an ongoing enrollment session
|
||||
fn cancel_enrollment(&self, session_id: &str) -> ZbusResult<()>;
|
||||
|
||||
/// Get enrollment progress for a session
|
||||
fn get_enrollment_progress(&self, session_id: &str) -> ZbusResult<String>;
|
||||
|
||||
/// Complete and save the enrollment
|
||||
fn finish_enrollment(&self, session_id: &str) -> ZbusResult<String>;
|
||||
|
||||
/// Remove an enrolled template by ID
|
||||
fn remove_template(&self, template_id: &str) -> ZbusResult<()>;
|
||||
|
||||
/// Test authentication with enrolled templates
|
||||
/// Returns the matched template ID or empty string if no match
|
||||
fn test_authentication(&self) -> ZbusResult<String>;
|
||||
|
||||
/// Signal emitted when enrollment progress updates
|
||||
#[zbus(signal)]
|
||||
fn enrollment_progress(&self, session_id: &str, step: u32, total: u32, message: &str)
|
||||
-> ZbusResult<()>;
|
||||
|
||||
/// Signal emitted when enrollment completes
|
||||
#[zbus(signal)]
|
||||
fn enrollment_complete(&self, session_id: &str, success: bool, message: &str)
|
||||
-> ZbusResult<()>;
|
||||
}
|
||||
|
||||
/// Client for communicating with the Linux Hello daemon
|
||||
#[derive(Clone)]
|
||||
pub struct DaemonClient {
|
||||
connection: Arc<Mutex<Option<Connection>>>,
|
||||
}
|
||||
|
||||
impl DaemonClient {
|
||||
/// Create a new daemon client
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connection: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the D-Bus system bus
|
||||
pub async fn connect(&self) -> Result<(), DaemonError> {
|
||||
let conn = Connection::system()
|
||||
.await
|
||||
.map_err(|e| DaemonError::ConnectionFailed(e.to_string()))?;
|
||||
|
||||
let mut guard = self.connection.lock().await;
|
||||
*guard = Some(conn);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if connected to the daemon
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.connection.lock().await.is_some()
|
||||
}
|
||||
|
||||
/// Get the D-Bus proxy for the daemon
|
||||
async fn get_proxy(&self) -> Result<LinuxHelloDaemonProxy<'static>, DaemonError> {
|
||||
let guard = self.connection.lock().await;
|
||||
let conn = guard
|
||||
.as_ref()
|
||||
.ok_or(DaemonError::NotConnected)?
|
||||
.clone();
|
||||
|
||||
LinuxHelloDaemonProxy::new(&conn)
|
||||
.await
|
||||
.map_err(|e| DaemonError::ProxyError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Get the current system status
|
||||
pub async fn get_status(&self) -> Result<SystemStatus, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
match proxy.get_status().await {
|
||||
Ok(json) => {
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| DaemonError::ParseError(e.to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
// If daemon is not running, return a default status
|
||||
if e.to_string().contains("org.freedesktop.DBus.Error.ServiceUnknown")
|
||||
|| e.to_string().contains("org.freedesktop.DBus.Error.NameHasNoOwner")
|
||||
{
|
||||
Ok(SystemStatus {
|
||||
daemon_running: false,
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
Err(DaemonError::DbusError(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all enrolled templates
|
||||
pub async fn list_templates(&self) -> Result<Vec<TemplateInfo>, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
match proxy.list_templates().await {
|
||||
Ok(json) => {
|
||||
serde_json::from_str(&json)
|
||||
.map_err(|e| DaemonError::ParseError(e.to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
if e.to_string().contains("org.freedesktop.DBus.Error.ServiceUnknown")
|
||||
|| e.to_string().contains("org.freedesktop.DBus.Error.NameHasNoOwner")
|
||||
{
|
||||
Ok(Vec::new())
|
||||
} else {
|
||||
Err(DaemonError::DbusError(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new enrollment session
|
||||
pub async fn start_enrollment(&self, label: &str) -> Result<String, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
proxy
|
||||
.start_enrollment(label)
|
||||
.await
|
||||
.map_err(|e| DaemonError::EnrollmentError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Cancel an ongoing enrollment session
|
||||
pub async fn cancel_enrollment(&self, session_id: &str) -> Result<(), DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
proxy
|
||||
.cancel_enrollment(session_id)
|
||||
.await
|
||||
.map_err(|e| DaemonError::EnrollmentError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Get enrollment progress
|
||||
pub async fn get_enrollment_progress(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<EnrollmentProgress, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
let json = proxy
|
||||
.get_enrollment_progress(session_id)
|
||||
.await
|
||||
.map_err(|e| DaemonError::EnrollmentError(e.to_string()))?;
|
||||
|
||||
serde_json::from_str(&json).map_err(|e| DaemonError::ParseError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Complete and save the enrollment
|
||||
pub async fn finish_enrollment(&self, session_id: &str) -> Result<String, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
proxy
|
||||
.finish_enrollment(session_id)
|
||||
.await
|
||||
.map_err(|e| DaemonError::EnrollmentError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Remove an enrolled template
|
||||
pub async fn remove_template(&self, template_id: &str) -> Result<(), DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
proxy
|
||||
.remove_template(template_id)
|
||||
.await
|
||||
.map_err(|e| DaemonError::DbusError(e.to_string()))
|
||||
}
|
||||
|
||||
/// Test authentication
|
||||
pub async fn test_authentication(&self) -> Result<Option<String>, DaemonError> {
|
||||
let proxy = self.get_proxy().await?;
|
||||
|
||||
let result = proxy
|
||||
.test_authentication()
|
||||
.await
|
||||
.map_err(|e| DaemonError::DbusError(e.to_string()))?;
|
||||
|
||||
if result.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DaemonClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur when communicating with the daemon
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum DaemonError {
|
||||
#[error("Failed to connect to D-Bus: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
#[error("Not connected to daemon")]
|
||||
NotConnected,
|
||||
|
||||
#[error("Failed to create D-Bus proxy: {0}")]
|
||||
ProxyError(String),
|
||||
|
||||
#[error("D-Bus error: {0}")]
|
||||
DbusError(String),
|
||||
|
||||
#[error("Failed to parse response: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Enrollment error: {0}")]
|
||||
EnrollmentError(String),
|
||||
}
|
||||
478
linux-hello-settings/src/enrollment.rs
Normal file
478
linux-hello-settings/src/enrollment.rs
Normal file
@@ -0,0 +1,478 @@
|
||||
//! Enrollment Dialog
|
||||
//!
|
||||
//! Provides a dialog for enrolling new face templates with camera preview
|
||||
//! and step-by-step guidance through the enrollment process.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use glib::clone;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::glib;
|
||||
use libadwaita as adw;
|
||||
use libadwaita::prelude::*;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::dbus_client::DaemonClient;
|
||||
|
||||
/// Enrollment states
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EnrollmentState {
|
||||
/// Initial state, ready to start
|
||||
Ready,
|
||||
/// Enrollment in progress
|
||||
InProgress,
|
||||
/// Enrollment completed successfully
|
||||
Completed,
|
||||
/// Enrollment failed
|
||||
Failed,
|
||||
/// Enrollment cancelled
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Dialog for enrolling a new face template
|
||||
pub struct EnrollmentDialog {
|
||||
dialog: adw::Dialog,
|
||||
client: Arc<Mutex<DaemonClient>>,
|
||||
state: Rc<RefCell<EnrollmentState>>,
|
||||
session_id: Rc<RefCell<Option<String>>>,
|
||||
// UI elements
|
||||
label_entry: adw::EntryRow,
|
||||
start_button: gtk4::Button,
|
||||
cancel_button: gtk4::Button,
|
||||
status_page: adw::StatusPage,
|
||||
progress_bar: gtk4::ProgressBar,
|
||||
instruction_label: gtk4::Label,
|
||||
// Callbacks
|
||||
on_completed: Rc<RefCell<Option<Box<dyn Fn(bool)>>>>,
|
||||
}
|
||||
|
||||
impl EnrollmentDialog {
|
||||
/// Create a new enrollment dialog
|
||||
pub fn new(parent: &adw::ApplicationWindow, client: Arc<Mutex<DaemonClient>>) -> Self {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Enroll Face")
|
||||
.content_width(450)
|
||||
.content_height(500)
|
||||
.build();
|
||||
|
||||
// Create header bar
|
||||
let header = adw::HeaderBar::builder()
|
||||
.show_start_title_buttons(false)
|
||||
.show_end_title_buttons(false)
|
||||
.build();
|
||||
|
||||
let cancel_button = gtk4::Button::builder()
|
||||
.label("Cancel")
|
||||
.build();
|
||||
header.pack_start(&cancel_button);
|
||||
|
||||
let start_button = gtk4::Button::builder()
|
||||
.label("Start Enrollment")
|
||||
.css_classes(["suggested-action"])
|
||||
.sensitive(false)
|
||||
.build();
|
||||
header.pack_end(&start_button);
|
||||
|
||||
// Main content
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
content.append(&header);
|
||||
|
||||
// Instructions group
|
||||
let instructions_group = adw::PreferencesGroup::builder()
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.build();
|
||||
|
||||
let instructions_row = adw::ActionRow::builder()
|
||||
.title("How to Enroll")
|
||||
.subtitle("Position your face in the camera view and follow the instructions")
|
||||
.build();
|
||||
let info_icon = gtk4::Image::from_icon_name("dialog-information-symbolic");
|
||||
instructions_row.add_prefix(&info_icon);
|
||||
instructions_group.add(&instructions_row);
|
||||
|
||||
content.append(&instructions_group);
|
||||
|
||||
// Label input group
|
||||
let label_group = adw::PreferencesGroup::builder()
|
||||
.title("Face Label")
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.build();
|
||||
|
||||
let label_entry = adw::EntryRow::builder()
|
||||
.title("Label")
|
||||
.text("default")
|
||||
.build();
|
||||
label_group.add(&label_entry);
|
||||
|
||||
content.append(&label_group);
|
||||
|
||||
// Status/Camera preview area
|
||||
let status_page = adw::StatusPage::builder()
|
||||
.icon_name("camera-video-symbolic")
|
||||
.title("Ready to Enroll")
|
||||
.description("Press 'Start Enrollment' to begin capturing your face")
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
content.append(&status_page);
|
||||
|
||||
// Progress section
|
||||
let progress_box = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(6)
|
||||
.margin_start(24)
|
||||
.margin_end(24)
|
||||
.margin_bottom(12)
|
||||
.build();
|
||||
|
||||
let instruction_label = gtk4::Label::builder()
|
||||
.label("Look directly at the camera")
|
||||
.css_classes(["dim-label"])
|
||||
.visible(false)
|
||||
.build();
|
||||
progress_box.append(&instruction_label);
|
||||
|
||||
let progress_bar = gtk4::ProgressBar::builder()
|
||||
.show_text(true)
|
||||
.visible(false)
|
||||
.build();
|
||||
progress_box.append(&progress_bar);
|
||||
|
||||
content.append(&progress_box);
|
||||
|
||||
// Tips section
|
||||
let tips_group = adw::PreferencesGroup::builder()
|
||||
.title("Tips for Best Results")
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_bottom(12)
|
||||
.build();
|
||||
|
||||
let tips = [
|
||||
("face-smile-symbolic", "Good lighting", "Ensure your face is well-lit"),
|
||||
("view-reveal-symbolic", "Clear view", "Remove glasses if possible"),
|
||||
("object-rotate-right-symbolic", "Multiple angles", "Slowly turn your head when prompted"),
|
||||
];
|
||||
|
||||
for (icon, title, subtitle) in tips {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.subtitle(subtitle)
|
||||
.build();
|
||||
let icon_widget = gtk4::Image::from_icon_name(icon);
|
||||
row.add_prefix(&icon_widget);
|
||||
tips_group.add(&row);
|
||||
}
|
||||
|
||||
content.append(&tips_group);
|
||||
|
||||
dialog.set_child(Some(&content));
|
||||
|
||||
let enrollment_dialog = Self {
|
||||
dialog,
|
||||
client,
|
||||
state: Rc::new(RefCell::new(EnrollmentState::Ready)),
|
||||
session_id: Rc::new(RefCell::new(None)),
|
||||
label_entry,
|
||||
start_button,
|
||||
cancel_button,
|
||||
status_page,
|
||||
progress_bar,
|
||||
instruction_label,
|
||||
on_completed: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
enrollment_dialog.connect_signals(parent);
|
||||
enrollment_dialog.validate_input();
|
||||
|
||||
enrollment_dialog
|
||||
}
|
||||
|
||||
/// Connect UI signals
|
||||
fn connect_signals(&self, parent: &adw::ApplicationWindow) {
|
||||
// Label entry validation
|
||||
let this = self.clone_weak();
|
||||
self.label_entry.connect_changed(move |_| {
|
||||
if let Some(dialog) = this.upgrade() {
|
||||
dialog.validate_input();
|
||||
}
|
||||
});
|
||||
|
||||
// Start button
|
||||
let this = self.clone_weak();
|
||||
self.start_button.connect_clicked(move |_| {
|
||||
if let Some(dialog) = this.upgrade() {
|
||||
dialog.start_enrollment();
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel button
|
||||
let this = self.clone_weak();
|
||||
let dialog = self.dialog.clone();
|
||||
self.cancel_button.connect_clicked(move |_| {
|
||||
if let Some(d) = this.upgrade() {
|
||||
d.cancel_enrollment();
|
||||
}
|
||||
dialog.close();
|
||||
});
|
||||
|
||||
// Dialog close
|
||||
let this = self.clone_weak();
|
||||
self.dialog.connect_closed(move |_| {
|
||||
if let Some(dialog) = this.upgrade() {
|
||||
// Cancel any ongoing enrollment
|
||||
if *dialog.state.borrow() == EnrollmentState::InProgress {
|
||||
dialog.cancel_enrollment();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Validate input and update button sensitivity
|
||||
fn validate_input(&self) {
|
||||
let label = self.label_entry.text();
|
||||
let valid = !label.is_empty() && label.len() <= 64;
|
||||
self.start_button.set_sensitive(valid && *self.state.borrow() == EnrollmentState::Ready);
|
||||
}
|
||||
|
||||
/// Create a weak reference for callbacks
|
||||
fn clone_weak(&self) -> WeakEnrollmentDialog {
|
||||
WeakEnrollmentDialog {
|
||||
dialog: self.dialog.downgrade(),
|
||||
client: self.client.clone(),
|
||||
state: self.state.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
label_entry: self.label_entry.downgrade(),
|
||||
start_button: self.start_button.downgrade(),
|
||||
cancel_button: self.cancel_button.downgrade(),
|
||||
status_page: self.status_page.downgrade(),
|
||||
progress_bar: self.progress_bar.downgrade(),
|
||||
instruction_label: self.instruction_label.downgrade(),
|
||||
on_completed: self.on_completed.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the enrollment process
|
||||
fn start_enrollment(&self) {
|
||||
let label = self.label_entry.text().to_string();
|
||||
|
||||
*self.state.borrow_mut() = EnrollmentState::InProgress;
|
||||
|
||||
// Update UI
|
||||
self.start_button.set_sensitive(false);
|
||||
self.start_button.set_label("Enrolling...");
|
||||
self.label_entry.set_sensitive(false);
|
||||
self.progress_bar.set_visible(true);
|
||||
self.progress_bar.set_fraction(0.0);
|
||||
self.progress_bar.set_text(Some("Starting..."));
|
||||
self.instruction_label.set_visible(true);
|
||||
|
||||
self.status_page.set_icon_name(Some("camera-video-symbolic"));
|
||||
self.status_page.set_title("Enrolling...");
|
||||
self.status_page.set_description(Some("Please look at the camera"));
|
||||
|
||||
let client = self.client.clone();
|
||||
let state = self.state.clone();
|
||||
let session_id = self.session_id.clone();
|
||||
let progress_bar = self.progress_bar.clone();
|
||||
let instruction_label = self.instruction_label.clone();
|
||||
let status_page = self.status_page.clone();
|
||||
let start_button = self.start_button.clone();
|
||||
let label_entry = self.label_entry.clone();
|
||||
let on_completed = self.on_completed.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let client_guard = client.lock().await;
|
||||
|
||||
// Start enrollment session
|
||||
match client_guard.start_enrollment(&label).await {
|
||||
Ok(sid) => {
|
||||
tracing::info!("Started enrollment session: {}", sid);
|
||||
*session_id.borrow_mut() = Some(sid.clone());
|
||||
|
||||
// Poll for progress
|
||||
let instructions = [
|
||||
"Look straight at the camera",
|
||||
"Slowly turn your head left",
|
||||
"Slowly turn your head right",
|
||||
"Tilt your head slightly up",
|
||||
"Tilt your head slightly down",
|
||||
"Processing...",
|
||||
];
|
||||
|
||||
for (i, instruction) in instructions.iter().enumerate() {
|
||||
if *state.borrow() != EnrollmentState::InProgress {
|
||||
break;
|
||||
}
|
||||
|
||||
let progress = (i + 1) as f64 / instructions.len() as f64;
|
||||
let instruction = instruction.to_string();
|
||||
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] progress_bar,
|
||||
#[strong] instruction_label,
|
||||
move || {
|
||||
progress_bar.set_fraction(progress);
|
||||
progress_bar.set_text(Some(&format!("{}%", (progress * 100.0) as u32)));
|
||||
instruction_label.set_label(&instruction);
|
||||
}
|
||||
));
|
||||
|
||||
// Simulate capture delay (in real app, this would be based on daemon signals)
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(800)).await;
|
||||
}
|
||||
|
||||
// Finish enrollment
|
||||
if *state.borrow() == EnrollmentState::InProgress {
|
||||
match client_guard.finish_enrollment(&sid).await {
|
||||
Ok(template_id) => {
|
||||
tracing::info!("Enrollment completed, template ID: {}", template_id);
|
||||
*state.borrow_mut() = EnrollmentState::Completed;
|
||||
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] status_page,
|
||||
#[strong] progress_bar,
|
||||
#[strong] instruction_label,
|
||||
#[strong] start_button,
|
||||
#[strong] on_completed,
|
||||
move || {
|
||||
status_page.set_icon_name(Some("emblem-ok-symbolic"));
|
||||
status_page.set_title("Enrollment Complete");
|
||||
status_page.set_description(Some("Your face has been enrolled successfully"));
|
||||
progress_bar.set_visible(false);
|
||||
instruction_label.set_visible(false);
|
||||
start_button.set_label("Done");
|
||||
start_button.set_sensitive(true);
|
||||
start_button.remove_css_class("suggested-action");
|
||||
start_button.add_css_class("success");
|
||||
|
||||
// Trigger callback
|
||||
if let Some(callback) = on_completed.borrow().as_ref() {
|
||||
callback(true);
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
handle_enrollment_error(&state, &status_page, &progress_bar, &instruction_label, &start_button, &label_entry, &on_completed, &e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
handle_enrollment_error(&state, &status_page, &progress_bar, &instruction_label, &start_button, &label_entry, &on_completed, &e.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel ongoing enrollment
|
||||
fn cancel_enrollment(&self) {
|
||||
if *self.state.borrow() == EnrollmentState::InProgress {
|
||||
*self.state.borrow_mut() = EnrollmentState::Cancelled;
|
||||
|
||||
if let Some(sid) = self.session_id.borrow().as_ref() {
|
||||
let client = self.client.clone();
|
||||
let sid = sid.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let client_guard = client.lock().await;
|
||||
let _ = client_guard.cancel_enrollment(&sid).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect callback for enrollment completion
|
||||
pub fn connect_completed<F: Fn(bool) + 'static>(&self, callback: F) {
|
||||
*self.on_completed.borrow_mut() = Some(Box::new(callback));
|
||||
}
|
||||
|
||||
/// Present the dialog
|
||||
pub fn present(&self) {
|
||||
if let Some(root) = self.dialog.root() {
|
||||
if let Some(window) = root.downcast_ref::<gtk4::Window>() {
|
||||
self.dialog.present(Some(window));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle enrollment error
|
||||
fn handle_enrollment_error(
|
||||
state: &Rc<RefCell<EnrollmentState>>,
|
||||
status_page: &adw::StatusPage,
|
||||
progress_bar: >k4::ProgressBar,
|
||||
instruction_label: >k4::Label,
|
||||
start_button: >k4::Button,
|
||||
label_entry: &adw::EntryRow,
|
||||
on_completed: &Rc<RefCell<Option<Box<dyn Fn(bool)>>>>,
|
||||
error: &str,
|
||||
) {
|
||||
tracing::error!("Enrollment failed: {}", error);
|
||||
*state.borrow_mut() = EnrollmentState::Failed;
|
||||
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] status_page,
|
||||
#[strong] progress_bar,
|
||||
#[strong] instruction_label,
|
||||
#[strong] start_button,
|
||||
#[strong] label_entry,
|
||||
#[strong] on_completed,
|
||||
#[strong] error,
|
||||
move || {
|
||||
status_page.set_icon_name(Some("dialog-error-symbolic"));
|
||||
status_page.set_title("Enrollment Failed");
|
||||
status_page.set_description(Some(&error));
|
||||
progress_bar.set_visible(false);
|
||||
instruction_label.set_visible(false);
|
||||
start_button.set_label("Retry");
|
||||
start_button.set_sensitive(true);
|
||||
label_entry.set_sensitive(true);
|
||||
|
||||
// Trigger callback
|
||||
if let Some(callback) = on_completed.borrow().as_ref() {
|
||||
callback(false);
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
/// Weak reference to enrollment dialog for callbacks
|
||||
struct WeakEnrollmentDialog {
|
||||
dialog: glib::WeakRef<adw::Dialog>,
|
||||
client: Arc<Mutex<DaemonClient>>,
|
||||
state: Rc<RefCell<EnrollmentState>>,
|
||||
session_id: Rc<RefCell<Option<String>>>,
|
||||
label_entry: glib::WeakRef<adw::EntryRow>,
|
||||
start_button: glib::WeakRef<gtk4::Button>,
|
||||
cancel_button: glib::WeakRef<gtk4::Button>,
|
||||
status_page: glib::WeakRef<adw::StatusPage>,
|
||||
progress_bar: glib::WeakRef<gtk4::ProgressBar>,
|
||||
instruction_label: glib::WeakRef<gtk4::Label>,
|
||||
on_completed: Rc<RefCell<Option<Box<dyn Fn(bool)>>>>,
|
||||
}
|
||||
|
||||
impl WeakEnrollmentDialog {
|
||||
fn upgrade(&self) -> Option<EnrollmentDialog> {
|
||||
Some(EnrollmentDialog {
|
||||
dialog: self.dialog.upgrade()?,
|
||||
client: self.client.clone(),
|
||||
state: self.state.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
label_entry: self.label_entry.upgrade()?,
|
||||
start_button: self.start_button.upgrade()?,
|
||||
cancel_button: self.cancel_button.upgrade()?,
|
||||
status_page: self.status_page.upgrade()?,
|
||||
progress_bar: self.progress_bar.upgrade()?,
|
||||
instruction_label: self.instruction_label.upgrade()?,
|
||||
on_completed: self.on_completed.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
51
linux-hello-settings/src/main.rs
Normal file
51
linux-hello-settings/src/main.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Linux Hello Settings - GNOME Settings Application
|
||||
//!
|
||||
//! A GTK4/libadwaita application for managing Linux Hello facial authentication.
|
||||
//! Provides UI for enrolling faces, managing templates, and configuring settings.
|
||||
|
||||
mod dbus_client;
|
||||
mod enrollment;
|
||||
mod templates;
|
||||
mod window;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{gio, glib};
|
||||
use libadwaita as adw;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use window::SettingsWindow;
|
||||
|
||||
const APP_ID: &str = "org.linuxhello.Settings";
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
EnvFilter::from_default_env()
|
||||
.add_directive("linux_hello_settings=info".parse().unwrap()),
|
||||
)
|
||||
.init();
|
||||
|
||||
tracing::info!("Starting Linux Hello Settings");
|
||||
|
||||
// Create and run the application
|
||||
let app = adw::Application::builder()
|
||||
.application_id(APP_ID)
|
||||
.flags(gio::ApplicationFlags::FLAGS_NONE)
|
||||
.build();
|
||||
|
||||
app.connect_startup(|_| {
|
||||
// Load CSS if needed
|
||||
tracing::debug!("Application startup");
|
||||
});
|
||||
|
||||
app.connect_activate(build_ui);
|
||||
|
||||
app.run()
|
||||
}
|
||||
|
||||
fn build_ui(app: &adw::Application) {
|
||||
// Create and present the main window
|
||||
let window = SettingsWindow::new(app);
|
||||
window.present();
|
||||
}
|
||||
178
linux-hello-settings/src/templates.rs
Normal file
178
linux-hello-settings/src/templates.rs
Normal file
@@ -0,0 +1,178 @@
|
||||
//! Template List Widget
|
||||
//!
|
||||
//! Provides a widget for displaying and managing enrolled face templates.
|
||||
//! Supports viewing template details and removing templates.
|
||||
|
||||
use crate::dbus_client::TemplateInfo;
|
||||
|
||||
/// Template list box containing enrolled templates
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TemplateListBox {
|
||||
/// List of templates
|
||||
templates: Vec<TemplateInfo>,
|
||||
}
|
||||
|
||||
impl TemplateListBox {
|
||||
/// Create a new template list box
|
||||
pub fn new(templates: Vec<TemplateInfo>) -> Self {
|
||||
Self { templates }
|
||||
}
|
||||
|
||||
/// Get the list of templates
|
||||
pub fn templates(&self) -> &[TemplateInfo] {
|
||||
&self.templates
|
||||
}
|
||||
|
||||
/// Find a template by ID
|
||||
pub fn find_by_id(&self, id: &str) -> Option<&TemplateInfo> {
|
||||
self.templates.iter().find(|t| t.id == id)
|
||||
}
|
||||
|
||||
/// Find a template by label
|
||||
pub fn find_by_label(&self, label: &str) -> Option<&TemplateInfo> {
|
||||
self.templates.iter().find(|t| t.label == label)
|
||||
}
|
||||
|
||||
/// Check if any templates are enrolled
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.templates.is_empty()
|
||||
}
|
||||
|
||||
/// Get the count of enrolled templates
|
||||
pub fn count(&self) -> usize {
|
||||
self.templates.len()
|
||||
}
|
||||
|
||||
/// Update the template list
|
||||
pub fn update(&mut self, templates: Vec<TemplateInfo>) {
|
||||
self.templates = templates;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TemplateListBox {
|
||||
fn default() -> Self {
|
||||
Self::new(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Template display model for UI binding
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TemplateDisplayModel {
|
||||
/// Template ID
|
||||
pub id: String,
|
||||
/// Display label
|
||||
pub label: String,
|
||||
/// Username
|
||||
pub username: String,
|
||||
/// Formatted creation date
|
||||
pub created_date: String,
|
||||
/// Formatted last used date
|
||||
pub last_used_date: String,
|
||||
/// Whether this template has been used recently
|
||||
pub recently_used: bool,
|
||||
}
|
||||
|
||||
impl TemplateDisplayModel {
|
||||
/// Create a display model from template info
|
||||
pub fn from_template(template: &TemplateInfo) -> Self {
|
||||
use chrono::{DateTime, Duration, Local, Utc};
|
||||
|
||||
let created_date = DateTime::<Utc>::from_timestamp(template.created_at, 0)
|
||||
.map(|dt| dt.with_timezone(&Local))
|
||||
.map(|dt| dt.format("%B %d, %Y").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
let (last_used_date, recently_used) = if let Some(ts) = template.last_used {
|
||||
let dt = DateTime::<Utc>::from_timestamp(ts, 0)
|
||||
.map(|dt| dt.with_timezone(&Local));
|
||||
|
||||
let date_str = dt
|
||||
.as_ref()
|
||||
.map(|d| d.format("%B %d, %Y at %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
// Check if used within the last 24 hours
|
||||
let recent = dt
|
||||
.map(|d| {
|
||||
let now = Local::now();
|
||||
now.signed_duration_since(d) < Duration::hours(24)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
(date_str, recent)
|
||||
} else {
|
||||
("Never".to_string(), false)
|
||||
};
|
||||
|
||||
Self {
|
||||
id: template.id.clone(),
|
||||
label: template.label.clone(),
|
||||
username: template.username.clone(),
|
||||
created_date,
|
||||
last_used_date,
|
||||
recently_used,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Template action types
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TemplateAction {
|
||||
/// View template details
|
||||
View,
|
||||
/// Remove template
|
||||
Remove,
|
||||
/// Test authentication with template
|
||||
Test,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_template(id: &str, label: &str) -> TemplateInfo {
|
||||
TemplateInfo {
|
||||
id: id.to_string(),
|
||||
label: label.to_string(),
|
||||
username: "testuser".to_string(),
|
||||
created_at: 1704067200, // 2024-01-01 00:00:00 UTC
|
||||
last_used: Some(1704153600), // 2024-01-02 00:00:00 UTC
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_list_box() {
|
||||
let templates = vec![
|
||||
create_test_template("1", "default"),
|
||||
create_test_template("2", "backup"),
|
||||
];
|
||||
|
||||
let list = TemplateListBox::new(templates);
|
||||
|
||||
assert!(!list.is_empty());
|
||||
assert_eq!(list.count(), 2);
|
||||
assert!(list.find_by_id("1").is_some());
|
||||
assert!(list.find_by_label("backup").is_some());
|
||||
assert!(list.find_by_id("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_list() {
|
||||
let list = TemplateListBox::default();
|
||||
|
||||
assert!(list.is_empty());
|
||||
assert_eq!(list.count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_template_display_model() {
|
||||
let template = create_test_template("test-id", "Test Face");
|
||||
let model = TemplateDisplayModel::from_template(&template);
|
||||
|
||||
assert_eq!(model.id, "test-id");
|
||||
assert_eq!(model.label, "Test Face");
|
||||
assert_eq!(model.username, "testuser");
|
||||
assert!(!model.created_date.is_empty());
|
||||
assert_ne!(model.last_used_date, "Never");
|
||||
}
|
||||
}
|
||||
597
linux-hello-settings/src/window.rs
Normal file
597
linux-hello-settings/src/window.rs
Normal file
@@ -0,0 +1,597 @@
|
||||
//! Main Settings Window
|
||||
//!
|
||||
//! Contains the primary UI for Linux Hello Settings following GNOME HIG.
|
||||
//! Includes status display, enrollment controls, template management, and settings.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use glib::clone;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{gio, glib};
|
||||
use libadwaita as adw;
|
||||
use libadwaita::prelude::*;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::dbus_client::{DaemonClient, SystemStatus, TemplateInfo};
|
||||
use crate::enrollment::EnrollmentDialog;
|
||||
use crate::templates::TemplateListBox;
|
||||
|
||||
/// Main settings window for Linux Hello
|
||||
#[derive(Clone)]
|
||||
pub struct SettingsWindow {
|
||||
pub window: adw::ApplicationWindow,
|
||||
client: Arc<Mutex<DaemonClient>>,
|
||||
// Status widgets
|
||||
daemon_status_row: adw::ActionRow,
|
||||
camera_status_row: adw::ActionRow,
|
||||
tpm_status_row: adw::ActionRow,
|
||||
// Enrollment widgets
|
||||
enroll_button: gtk4::Button,
|
||||
enrollment_progress: gtk4::ProgressBar,
|
||||
// Template list
|
||||
template_list: Rc<RefCell<Option<TemplateListBox>>>,
|
||||
templates_group: adw::PreferencesGroup,
|
||||
// Settings widgets
|
||||
anti_spoofing_switch: adw::SwitchRow,
|
||||
confidence_spin: adw::SpinRow,
|
||||
}
|
||||
|
||||
impl SettingsWindow {
|
||||
/// Create a new settings window
|
||||
pub fn new(app: &adw::Application) -> Self {
|
||||
let client = Arc::new(Mutex::new(DaemonClient::new()));
|
||||
|
||||
// Create the main window
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("Linux Hello Settings")
|
||||
.default_width(600)
|
||||
.default_height(700)
|
||||
.build();
|
||||
|
||||
// Create header bar
|
||||
let header = adw::HeaderBar::new();
|
||||
|
||||
// Refresh button in header
|
||||
let refresh_button = gtk4::Button::from_icon_name("view-refresh-symbolic");
|
||||
refresh_button.set_tooltip_text(Some("Refresh status"));
|
||||
header.pack_end(&refresh_button);
|
||||
|
||||
// Create main content with scrollable view
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.build();
|
||||
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(600)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.build();
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
|
||||
// === Status Section ===
|
||||
let status_group = adw::PreferencesGroup::builder()
|
||||
.title("System Status")
|
||||
.description("Current status of Linux Hello components")
|
||||
.build();
|
||||
|
||||
let daemon_status_row = adw::ActionRow::builder()
|
||||
.title("Daemon")
|
||||
.subtitle("Checking...")
|
||||
.build();
|
||||
let daemon_icon = gtk4::Image::from_icon_name("emblem-synchronizing-symbolic");
|
||||
daemon_status_row.add_prefix(&daemon_icon);
|
||||
status_group.add(&daemon_status_row);
|
||||
|
||||
let camera_status_row = adw::ActionRow::builder()
|
||||
.title("Camera")
|
||||
.subtitle("Checking...")
|
||||
.build();
|
||||
let camera_icon = gtk4::Image::from_icon_name("camera-video-symbolic");
|
||||
camera_status_row.add_prefix(&camera_icon);
|
||||
status_group.add(&camera_status_row);
|
||||
|
||||
let tpm_status_row = adw::ActionRow::builder()
|
||||
.title("TPM Security")
|
||||
.subtitle("Checking...")
|
||||
.build();
|
||||
let tpm_icon = gtk4::Image::from_icon_name("security-high-symbolic");
|
||||
tpm_status_row.add_prefix(&tpm_icon);
|
||||
status_group.add(&tpm_status_row);
|
||||
|
||||
content.append(&status_group);
|
||||
|
||||
// === Enrollment Section ===
|
||||
let enrollment_group = adw::PreferencesGroup::builder()
|
||||
.title("Face Enrollment")
|
||||
.description("Register your face for authentication")
|
||||
.build();
|
||||
|
||||
// Enrollment row with button
|
||||
let enroll_row = adw::ActionRow::builder()
|
||||
.title("Enroll New Face")
|
||||
.subtitle("Add a new face template for authentication")
|
||||
.build();
|
||||
let enroll_icon = gtk4::Image::from_icon_name("contact-new-symbolic");
|
||||
enroll_row.add_prefix(&enroll_icon);
|
||||
|
||||
let enroll_button = gtk4::Button::builder()
|
||||
.label("Enroll")
|
||||
.valign(gtk4::Align::Center)
|
||||
.css_classes(["suggested-action"])
|
||||
.build();
|
||||
enroll_row.add_suffix(&enroll_button);
|
||||
enroll_row.set_activatable_widget(Some(&enroll_button));
|
||||
enrollment_group.add(&enroll_row);
|
||||
|
||||
// Progress bar (hidden by default)
|
||||
let enrollment_progress = gtk4::ProgressBar::builder()
|
||||
.visible(false)
|
||||
.show_text(true)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.build();
|
||||
enrollment_group.add(&enrollment_progress);
|
||||
|
||||
content.append(&enrollment_group);
|
||||
|
||||
// === Templates Section ===
|
||||
let templates_group = adw::PreferencesGroup::builder()
|
||||
.title("Enrolled Faces")
|
||||
.description("Manage your enrolled face templates")
|
||||
.build();
|
||||
|
||||
// Placeholder when no templates
|
||||
let no_templates_row = adw::ActionRow::builder()
|
||||
.title("No faces enrolled")
|
||||
.subtitle("Enroll a face to enable facial authentication")
|
||||
.build();
|
||||
let empty_icon = gtk4::Image::from_icon_name("face-uncertain-symbolic");
|
||||
no_templates_row.add_prefix(&empty_icon);
|
||||
templates_group.add(&no_templates_row);
|
||||
|
||||
content.append(&templates_group);
|
||||
|
||||
// === Settings Section ===
|
||||
let settings_group = adw::PreferencesGroup::builder()
|
||||
.title("Settings")
|
||||
.description("Configure authentication behavior")
|
||||
.build();
|
||||
|
||||
// Anti-spoofing toggle
|
||||
let anti_spoofing_switch = adw::SwitchRow::builder()
|
||||
.title("Anti-Spoofing")
|
||||
.subtitle("Detect and reject spoofing attempts (photos, videos)")
|
||||
.active(true)
|
||||
.build();
|
||||
let spoof_icon = gtk4::Image::from_icon_name("security-medium-symbolic");
|
||||
anti_spoofing_switch.add_prefix(&spoof_icon);
|
||||
settings_group.add(&anti_spoofing_switch);
|
||||
|
||||
// Confidence threshold
|
||||
let confidence_adjustment = gtk4::Adjustment::new(
|
||||
0.9, // value
|
||||
0.5, // lower
|
||||
1.0, // upper
|
||||
0.05, // step_increment
|
||||
0.1, // page_increment
|
||||
0.0, // page_size
|
||||
);
|
||||
let confidence_spin = adw::SpinRow::builder()
|
||||
.title("Confidence Threshold")
|
||||
.subtitle("Minimum confidence level for successful authentication")
|
||||
.adjustment(&confidence_adjustment)
|
||||
.digits(2)
|
||||
.build();
|
||||
let conf_icon = gtk4::Image::from_icon_name("dialog-information-symbolic");
|
||||
confidence_spin.add_prefix(&conf_icon);
|
||||
settings_group.add(&confidence_spin);
|
||||
|
||||
content.append(&settings_group);
|
||||
|
||||
// === About Section ===
|
||||
let about_group = adw::PreferencesGroup::new();
|
||||
|
||||
let about_row = adw::ActionRow::builder()
|
||||
.title("About Linux Hello")
|
||||
.subtitle("Version 0.1.0")
|
||||
.activatable(true)
|
||||
.build();
|
||||
let about_icon = gtk4::Image::from_icon_name("help-about-symbolic");
|
||||
about_row.add_prefix(&about_icon);
|
||||
let chevron = gtk4::Image::from_icon_name("go-next-symbolic");
|
||||
about_row.add_suffix(&chevron);
|
||||
about_group.add(&about_row);
|
||||
|
||||
content.append(&about_group);
|
||||
|
||||
// Assemble the UI
|
||||
clamp.set_child(Some(&content));
|
||||
scroll.set_child(Some(&clamp));
|
||||
|
||||
let main_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
main_box.append(&header);
|
||||
main_box.append(&scroll);
|
||||
|
||||
window.set_content(Some(&main_box));
|
||||
|
||||
let settings_window = Self {
|
||||
window,
|
||||
client,
|
||||
daemon_status_row,
|
||||
camera_status_row,
|
||||
tpm_status_row,
|
||||
enroll_button,
|
||||
enrollment_progress,
|
||||
template_list: Rc::new(RefCell::new(None)),
|
||||
templates_group,
|
||||
anti_spoofing_switch,
|
||||
confidence_spin,
|
||||
};
|
||||
|
||||
// Connect signals
|
||||
settings_window.connect_signals(&refresh_button, &about_row);
|
||||
|
||||
// Initial status refresh
|
||||
settings_window.refresh_status();
|
||||
|
||||
settings_window
|
||||
}
|
||||
|
||||
/// Connect UI signals
|
||||
fn connect_signals(&self, refresh_button: >k4::Button, about_row: &adw::ActionRow) {
|
||||
// Refresh button
|
||||
let this = self.clone();
|
||||
refresh_button.connect_clicked(move |_| {
|
||||
this.refresh_status();
|
||||
});
|
||||
|
||||
// Enroll button
|
||||
let this = self.clone();
|
||||
self.enroll_button.connect_clicked(move |_| {
|
||||
this.show_enrollment_dialog();
|
||||
});
|
||||
|
||||
// About row
|
||||
let window = self.window.clone();
|
||||
about_row.connect_activated(move |_| {
|
||||
show_about_dialog(&window);
|
||||
});
|
||||
|
||||
// Anti-spoofing switch
|
||||
let this = self.clone();
|
||||
self.anti_spoofing_switch.connect_active_notify(move |switch| {
|
||||
let enabled = switch.is_active();
|
||||
tracing::info!("Anti-spoofing toggled: {}", enabled);
|
||||
this.save_settings();
|
||||
});
|
||||
|
||||
// Confidence threshold
|
||||
let this = self.clone();
|
||||
self.confidence_spin.connect_value_notify(move |spin| {
|
||||
let value = spin.value();
|
||||
tracing::info!("Confidence threshold changed: {}", value);
|
||||
this.save_settings();
|
||||
});
|
||||
}
|
||||
|
||||
/// Present the window
|
||||
pub fn present(&self) {
|
||||
self.window.present();
|
||||
}
|
||||
|
||||
/// Refresh system status from daemon
|
||||
fn refresh_status(&self) {
|
||||
let client = self.client.clone();
|
||||
let daemon_row = self.daemon_status_row.clone();
|
||||
let camera_row = self.camera_status_row.clone();
|
||||
let tpm_row = self.tpm_status_row.clone();
|
||||
let templates_group = self.templates_group.clone();
|
||||
let template_list = self.template_list.clone();
|
||||
let enroll_button = self.enroll_button.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
// Connect to daemon
|
||||
let mut client_guard = client.lock().await;
|
||||
if !client_guard.is_connected().await {
|
||||
if let Err(e) = client_guard.connect().await {
|
||||
tracing::warn!("Failed to connect to daemon: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get status
|
||||
let status = client_guard.get_status().await.unwrap_or_default();
|
||||
|
||||
// Update daemon status
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] daemon_row,
|
||||
#[strong] status,
|
||||
move || {
|
||||
if status.daemon_running {
|
||||
daemon_row.set_subtitle("Running");
|
||||
add_status_indicator(&daemon_row, true);
|
||||
} else {
|
||||
daemon_row.set_subtitle("Not running");
|
||||
add_status_indicator(&daemon_row, false);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// Update camera status
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] camera_row,
|
||||
#[strong] status,
|
||||
move || {
|
||||
if status.camera_available {
|
||||
let device = status.camera_device.as_deref().unwrap_or("Available");
|
||||
camera_row.set_subtitle(device);
|
||||
add_status_indicator(&camera_row, true);
|
||||
} else {
|
||||
camera_row.set_subtitle("Not available");
|
||||
add_status_indicator(&camera_row, false);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// Update TPM status
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] tpm_row,
|
||||
#[strong] status,
|
||||
move || {
|
||||
if status.tpm_available {
|
||||
tpm_row.set_subtitle("Available - Secure storage enabled");
|
||||
add_status_indicator(&tpm_row, true);
|
||||
} else {
|
||||
tpm_row.set_subtitle("Not available - Using file storage");
|
||||
add_status_indicator(&tpm_row, false);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
// Update enroll button sensitivity
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] enroll_button,
|
||||
#[strong] status,
|
||||
move || {
|
||||
enroll_button.set_sensitive(status.daemon_running && status.camera_available);
|
||||
}
|
||||
));
|
||||
|
||||
// Get templates
|
||||
let templates = client_guard.list_templates().await.unwrap_or_default();
|
||||
drop(client_guard);
|
||||
|
||||
// Update templates list
|
||||
glib::idle_add_local_once(clone!(
|
||||
#[strong] templates_group,
|
||||
#[strong] template_list,
|
||||
move || {
|
||||
update_templates_list(&templates_group, &template_list, templates);
|
||||
}
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Show enrollment dialog
|
||||
fn show_enrollment_dialog(&self) {
|
||||
let dialog = EnrollmentDialog::new(&self.window, self.client.clone());
|
||||
|
||||
let this = self.clone();
|
||||
dialog.connect_completed(move |success| {
|
||||
if success {
|
||||
this.refresh_status();
|
||||
}
|
||||
});
|
||||
|
||||
dialog.present();
|
||||
}
|
||||
|
||||
/// Remove a template
|
||||
pub fn remove_template(&self, template_id: &str) {
|
||||
let client = self.client.clone();
|
||||
let template_id = template_id.to_string();
|
||||
let this = self.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let client_guard = client.lock().await;
|
||||
match client_guard.remove_template(&template_id).await {
|
||||
Ok(()) => {
|
||||
tracing::info!("Removed template: {}", template_id);
|
||||
drop(client_guard);
|
||||
glib::idle_add_local_once(move || {
|
||||
this.refresh_status();
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to remove template: {}", e);
|
||||
// Show error toast
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Save settings to configuration
|
||||
fn save_settings(&self) {
|
||||
// In a real implementation, this would save to the config file
|
||||
// or communicate with the daemon to update settings
|
||||
tracing::debug!(
|
||||
"Settings: anti_spoofing={}, confidence={}",
|
||||
self.anti_spoofing_switch.is_active(),
|
||||
self.confidence_spin.value()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a status indicator icon to a row
|
||||
fn add_status_indicator(row: &adw::ActionRow, success: bool) {
|
||||
// Remove existing suffix indicators
|
||||
if let Some(first_child) = row.first_child() {
|
||||
let mut child = first_child;
|
||||
loop {
|
||||
let next = child.next_sibling();
|
||||
if child.css_classes().contains(&"status-indicator".into()) {
|
||||
// Can't easily remove - GTK4 limitation
|
||||
}
|
||||
match next {
|
||||
Some(c) => child = c,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let icon_name = if success {
|
||||
"emblem-ok-symbolic"
|
||||
} else {
|
||||
"dialog-warning-symbolic"
|
||||
};
|
||||
|
||||
let indicator = gtk4::Image::from_icon_name(icon_name);
|
||||
indicator.add_css_class("status-indicator");
|
||||
if success {
|
||||
indicator.add_css_class("success");
|
||||
} else {
|
||||
indicator.add_css_class("warning");
|
||||
}
|
||||
row.add_suffix(&indicator);
|
||||
}
|
||||
|
||||
/// Update the templates list
|
||||
fn update_templates_list(
|
||||
group: &adw::PreferencesGroup,
|
||||
template_list_cell: &Rc<RefCell<Option<TemplateListBox>>>,
|
||||
templates: Vec<TemplateInfo>,
|
||||
) {
|
||||
// Clear existing rows (workaround: recreate the list)
|
||||
// In GTK4/libadwaita, we need to remove rows individually
|
||||
|
||||
// Remove all children from the group
|
||||
while let Some(child) = group.first_child() {
|
||||
if let Some(row) = child.downcast_ref::<adw::PreferencesRow>() {
|
||||
group.remove(row);
|
||||
} else {
|
||||
// Skip non-row children (header, etc.)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if templates.is_empty() {
|
||||
let no_templates_row = adw::ActionRow::builder()
|
||||
.title("No faces enrolled")
|
||||
.subtitle("Enroll a face to enable facial authentication")
|
||||
.build();
|
||||
let empty_icon = gtk4::Image::from_icon_name("face-uncertain-symbolic");
|
||||
no_templates_row.add_prefix(&empty_icon);
|
||||
group.add(&no_templates_row);
|
||||
} else {
|
||||
for template in templates {
|
||||
let row = create_template_row(&template);
|
||||
group.add(&row);
|
||||
}
|
||||
}
|
||||
|
||||
*template_list_cell.borrow_mut() = Some(TemplateListBox::new(templates));
|
||||
}
|
||||
|
||||
/// Create a row for a template
|
||||
fn create_template_row(template: &TemplateInfo) -> adw::ActionRow {
|
||||
let subtitle = format!(
|
||||
"Created: {} | Last used: {}",
|
||||
format_timestamp(template.created_at),
|
||||
template.last_used.map(format_timestamp).unwrap_or_else(|| "Never".to_string())
|
||||
);
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&template.label)
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
|
||||
let face_icon = gtk4::Image::from_icon_name("avatar-default-symbolic");
|
||||
row.add_prefix(&face_icon);
|
||||
|
||||
// Delete button
|
||||
let delete_button = gtk4::Button::from_icon_name("user-trash-symbolic");
|
||||
delete_button.set_valign(gtk4::Align::Center);
|
||||
delete_button.add_css_class("flat");
|
||||
delete_button.set_tooltip_text(Some("Remove this face"));
|
||||
|
||||
let template_id = template.id.clone();
|
||||
delete_button.connect_clicked(move |button| {
|
||||
// Show confirmation dialog
|
||||
if let Some(window) = button.root().and_then(|r| r.downcast::<gtk4::Window>().ok()) {
|
||||
show_delete_confirmation(&window, &template_id);
|
||||
}
|
||||
});
|
||||
|
||||
row.add_suffix(&delete_button);
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
/// Format a Unix timestamp for display
|
||||
fn format_timestamp(timestamp: i64) -> String {
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
|
||||
let datetime = DateTime::<Utc>::from_timestamp(timestamp, 0)
|
||||
.map(|dt| dt.with_timezone(&Local))
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
datetime
|
||||
}
|
||||
|
||||
/// Show delete confirmation dialog
|
||||
fn show_delete_confirmation(window: >k4::Window, template_id: &str) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("Remove Face?")
|
||||
.body("This will remove the enrolled face template. You will need to enroll again to use facial authentication.")
|
||||
.build();
|
||||
|
||||
dialog.add_responses(&[
|
||||
("cancel", "Cancel"),
|
||||
("delete", "Remove"),
|
||||
]);
|
||||
dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
dialog.set_default_response(Some("cancel"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let template_id = template_id.to_string();
|
||||
let win = window.clone();
|
||||
dialog.connect_response(None, move |_, response| {
|
||||
if response == "delete" {
|
||||
// Emit signal or call remove method
|
||||
if let Some(settings_window) = win.data::<SettingsWindow>("settings-window") {
|
||||
settings_window.remove_template(&template_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dialog.present(Some(window));
|
||||
}
|
||||
|
||||
/// Show the about dialog
|
||||
fn show_about_dialog(window: &adw::ApplicationWindow) {
|
||||
let about = adw::AboutDialog::builder()
|
||||
.application_name("Linux Hello")
|
||||
.application_icon("org.linuxhello.Settings")
|
||||
.developer_name("Linux Hello Contributors")
|
||||
.version("0.1.0")
|
||||
.website("https://github.com/linux-hello/linux-hello")
|
||||
.issue_url("https://github.com/linux-hello/linux-hello/issues")
|
||||
.license_type(gtk4::License::Gpl30)
|
||||
.comments("Facial authentication for Linux, inspired by Windows Hello")
|
||||
.build();
|
||||
|
||||
about.add_credit_section(Some("Contributors"), &[
|
||||
"Linux Hello Team",
|
||||
]);
|
||||
|
||||
about.present(Some(window));
|
||||
}
|
||||
Reference in New Issue
Block a user