Development over

This commit is contained in:
2026-01-15 22:40:51 +01:00
parent 2f6b16d946
commit 1e7f296635
63 changed files with 12945 additions and 331 deletions

View 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),
}

View 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: &gtk4::ProgressBar,
instruction_label: &gtk4::Label,
start_button: &gtk4::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(),
})
}
}

View 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();
}

View 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");
}
}

View 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: &gtk4::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: &gtk4::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));
}