From d613c276d06be543499082a26c1dc911d0d9f0c0 Mon Sep 17 00:00:00 2001 From: Vaishakh S Nair <36193436+vaishakhsnair@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:18:07 +0530 Subject: [PATCH] Add fingerprint management to PicoForge --- src/device/fido/constants.rs | 45 ++ src/device/fido/hid.rs | 339 +++++++++++++ src/device/fido/mod.rs | 125 ++++- src/device/io.rs | 23 + src/device/types.rs | 36 ++ src/ui/components/dialog.rs | 231 +++++++++ src/ui/rootview.rs | 13 +- src/ui/types.rs | 4 +- src/ui/views/security.rs | 959 +++++++++++++++++++++++++++++------ 9 files changed, 1618 insertions(+), 157 deletions(-) diff --git a/src/device/fido/constants.rs b/src/device/fido/constants.rs index d2797f3..b3bf848 100644 --- a/src/device/fido/constants.rs +++ b/src/device/fido/constants.rs @@ -12,6 +12,7 @@ pub enum CtapCommand { ClientPin = 0x06, Reset = 0x07, GetNextAssertion = 0x08, + BioEnroll = 0x09, CredentialMgmt = 0x0A, Selection = 0x0B, LargeBlobs = 0x0C, @@ -176,6 +177,50 @@ pub enum CredentialMgmtResponseParam { TotalCredentials = 0x09, } +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BioEnrollmentParam { + Modality = 0x01, + SubCommand = 0x02, + SubCommandParams = 0x03, + PinUvAuthProtocol = 0x04, + PinUvAuthParam = 0x05, + GetModality = 0x06, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BioEnrollmentSubCommand { + EnrollBegin = 0x01, + EnrollCaptureNextSample = 0x02, + CancelCurrentEnrollment = 0x03, + EnumerateEnrollments = 0x04, + SetFriendlyName = 0x05, + RemoveEnrollment = 0x06, + GetFingerprintSensorInfo = 0x07, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BioEnrollmentSubParam { + TemplateId = 0x01, + FriendlyName = 0x02, + TimeoutMilliseconds = 0x03, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BioEnrollmentResponseParam { + Modality = 0x01, + FingerprintKind = 0x02, + MaxCaptureSamplesRequiredForEnroll = 0x03, + TemplateId = 0x04, + LastEnrollSampleStatus = 0x05, + RemainingSamples = 0x06, + TemplateInfos = 0x07, + MaxTemplateFriendlyName = 0x08, +} + #[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConfigSubCommandParam { diff --git a/src/device/fido/hid.rs b/src/device/fido/hid.rs index 509d0bb..c34513c 100644 --- a/src/device/fido/hid.rs +++ b/src/device/fido/hid.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; use std::time::Duration; use crate::device::fido::constants::*; +use crate::device::types::{FingerprintSensorInfo, FingerprintTemplate}; use crate::error::PFError; // HID Transport Constants @@ -51,6 +52,13 @@ pub struct EnumerateCredentialResponse { pub total_credentials: Option, } +#[derive(Debug, Clone)] +pub(crate) struct BioEnrollmentResponse { + pub template_id: Option>, + pub status: Option, + pub remaining_samples: Option, +} + impl HidTransport { pub fn open() -> Result { log::info!("Attempting to open HID transport for FIDO device..."); @@ -1493,6 +1501,337 @@ impl HidTransport { Ok(()) } + pub fn bio_enrollment_get_fingerprint_sensor_info( + &self, + ) -> Result { + let response = self.send_bio_enrollment( + None, + BioEnrollmentSubCommand::GetFingerprintSensorInfo, + None, + )?; + + let map = match response { + Value::Map(m) => m, + _ => { + return Err(PFError::Device( + "Unexpected bio sensor info response format".into(), + )); + } + }; + + let fingerprint_kind = match map.get(&Value::Integer( + BioEnrollmentResponseParam::FingerprintKind as i128, + )) { + Some(Value::Integer(1)) => "touch".to_string(), + Some(Value::Integer(2)) => "swipe".to_string(), + Some(Value::Integer(v)) => format!("unknown ({})", v), + _ => "unknown".to_string(), + }; + + let max_capture_samples_required_for_enroll = match map.get(&Value::Integer( + BioEnrollmentResponseParam::MaxCaptureSamplesRequiredForEnroll as i128, + )) { + Some(Value::Integer(v)) => *v as u32, + _ => 0, + }; + + let max_template_friendly_name = match map.get(&Value::Integer( + BioEnrollmentResponseParam::MaxTemplateFriendlyName as i128, + )) { + Some(Value::Integer(v)) => *v as u32, + _ => 0, + }; + + Ok(FingerprintSensorInfo { + modality: "fingerprint".to_string(), + fingerprint_kind, + max_capture_samples_required_for_enroll, + max_template_friendly_name, + }) + } + + pub fn bio_enrollment_enumerate_enrollments( + &self, + pin: &str, + ) -> Result, PFError> { + let pin_token = self.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::BIO_ENROLLMENT, + None, + )?; + + let response = self.send_bio_enrollment( + Some(&pin_token), + BioEnrollmentSubCommand::EnumerateEnrollments, + None, + )?; + + let map = match response { + Value::Map(m) => m, + _ => { + return Err(PFError::Device( + "Unexpected fingerprint enumeration response format".into(), + )); + } + }; + + let templates = match map.get(&Value::Integer( + BioEnrollmentResponseParam::TemplateInfos as i128, + )) { + Some(Value::Array(items)) => items + .iter() + .filter_map(|item| match item { + Value::Map(template_map) => { + let template_id = match template_map + .get(&Value::Integer(BioEnrollmentSubParam::TemplateId as i128)) + { + Some(Value::Bytes(bytes)) => hex::encode_upper(bytes), + _ => return None, + }; + + let friendly_name = match template_map.get(&Value::Integer( + BioEnrollmentSubParam::FriendlyName as i128, + )) { + Some(Value::Text(name)) if !name.is_empty() => Some(name.clone()), + _ => None, + }; + + Some(FingerprintTemplate { + template_id, + friendly_name, + }) + } + _ => None, + }) + .collect(), + _ => Vec::new(), + }; + + Ok(templates) + } + + pub fn bio_enrollment_begin( + &self, + pin: &str, + timeout_ms: Option, + ) -> Result<(Vec, BioEnrollmentResponse), PFError> { + let pin_token = self.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::BIO_ENROLLMENT, + None, + )?; + + let begin_sub_params = timeout_ms.map(|timeout| { + let mut sub_params = BTreeMap::new(); + sub_params.insert( + Value::Integer(BioEnrollmentSubParam::TimeoutMilliseconds as i128), + Value::Integer(timeout as i128), + ); + Value::Map(sub_params) + }); + + let response = self.send_bio_enrollment( + Some(&pin_token), + BioEnrollmentSubCommand::EnrollBegin, + begin_sub_params, + )?; + + let parsed = self.parse_bio_enrollment_response(&response)?; + let template_id = parsed + .template_id + .clone() + .ok_or_else(|| PFError::Device("Enrollment did not return a template id".into()))?; + + Ok((template_id, parsed)) + } + + pub fn bio_enrollment_next( + &self, + pin: &str, + timeout_ms: Option, + ) -> Result { + let pin_token = self.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::BIO_ENROLLMENT, + None, + )?; + + let next_sub_params = timeout_ms.map(|timeout| { + let mut sub_params = BTreeMap::new(); + sub_params.insert( + Value::Integer(BioEnrollmentSubParam::TimeoutMilliseconds as i128), + Value::Integer(timeout as i128), + ); + Value::Map(sub_params) + }); + + let response = self.send_bio_enrollment( + Some(&pin_token), + BioEnrollmentSubCommand::EnrollCaptureNextSample, + next_sub_params, + )?; + + self.parse_bio_enrollment_response(&response) + } + + pub fn bio_enrollment_set_friendly_name( + &self, + pin: &str, + template_id: &[u8], + friendly_name: &str, + ) -> Result<(), PFError> { + let pin_token = self.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::BIO_ENROLLMENT, + None, + )?; + + let mut sub_params = BTreeMap::new(); + sub_params.insert( + Value::Integer(BioEnrollmentSubParam::TemplateId as i128), + Value::Bytes(template_id.to_vec()), + ); + sub_params.insert( + Value::Integer(BioEnrollmentSubParam::FriendlyName as i128), + Value::Text(friendly_name.to_string()), + ); + + self.send_bio_enrollment( + Some(&pin_token), + BioEnrollmentSubCommand::SetFriendlyName, + Some(Value::Map(sub_params)), + )?; + + Ok(()) + } + + pub fn bio_enrollment_remove(&self, pin: &str, template_id: &[u8]) -> Result<(), PFError> { + let pin_token = self.get_pin_token_with_permission( + pin, + PinUvAuthTokenPermissions::BIO_ENROLLMENT, + None, + )?; + + let mut sub_params = BTreeMap::new(); + sub_params.insert( + Value::Integer(BioEnrollmentSubParam::TemplateId as i128), + Value::Bytes(template_id.to_vec()), + ); + + self.send_bio_enrollment( + Some(&pin_token), + BioEnrollmentSubCommand::RemoveEnrollment, + Some(Value::Map(sub_params)), + )?; + + Ok(()) + } + + fn send_bio_enrollment( + &self, + pin_token: Option<&[u8]>, + sub_cmd: BioEnrollmentSubCommand, + sub_params: Option, + ) -> Result { + let sub_params_bytes = sub_params + .as_ref() + .map(|params| to_vec(params).map_err(|e| PFError::Io(e.to_string()))) + .transpose()?; + + let mut map = BTreeMap::new(); + map.insert( + Value::Integer(BioEnrollmentParam::Modality as i128), + Value::Integer(1), + ); + map.insert( + Value::Integer(BioEnrollmentParam::SubCommand as i128), + Value::Integer(sub_cmd as i128), + ); + + if let Some(params) = sub_params { + map.insert( + Value::Integer(BioEnrollmentParam::SubCommandParams as i128), + params, + ); + } + + if let Some(token) = pin_token { + map.insert( + Value::Integer(BioEnrollmentParam::PinUvAuthProtocol as i128), + Value::Integer(1), + ); + map.insert( + Value::Integer(BioEnrollmentParam::PinUvAuthParam as i128), + Value::Bytes( + self.sign_bio_enrollment_command(token, 1, sub_cmd as u8, sub_params_bytes.as_deref()), + ), + ); + } + + let mut payload = vec![CtapCommand::BioEnroll as u8]; + payload.extend(to_vec(&Value::Map(map)).map_err(|e| PFError::Io(e.to_string()))?); + + let resp = self.send_cbor(CTAPHID_CBOR, &payload)?; + from_slice(&resp).map_err(|e| PFError::Io(e.to_string())) + } + + fn sign_bio_enrollment_command( + &self, + pin_token: &[u8], + modality: u8, + sub_cmd: u8, + sub_params_bytes: Option<&[u8]>, + ) -> Vec { + let mut message = vec![modality, sub_cmd]; + if let Some(bytes) = sub_params_bytes { + message.extend_from_slice(bytes); + } + + let hmac_key = hmac::Key::new(hmac::HMAC_SHA256, pin_token); + let sig = hmac::sign(&hmac_key, &message); + sig.as_ref()[0..16].to_vec() + } + + fn parse_bio_enrollment_response( + &self, + response: &Value, + ) -> Result { + let map = match response { + Value::Map(m) => m, + _ => { + return Err(PFError::Device( + "Unexpected fingerprint enrollment response format".into(), + )); + } + }; + + let template_id = + match map.get(&Value::Integer(BioEnrollmentResponseParam::TemplateId as i128)) { + Some(Value::Bytes(bytes)) => Some(bytes.clone()), + _ => None, + }; + + let status = match map.get(&Value::Integer( + BioEnrollmentResponseParam::LastEnrollSampleStatus as i128, + )) { + Some(Value::Integer(v)) => Some(*v as u8), + _ => None, + }; + + let remaining_samples = match map.get(&Value::Integer( + BioEnrollmentResponseParam::RemainingSamples as i128, + )) { + Some(Value::Integer(v)) => Some(*v as u32), + _ => None, + }; + + Ok(BioEnrollmentResponse { + template_id, + status, + remaining_samples, + }) + } + fn sign_credential_mgmt_command( &self, pin_token: &[u8], diff --git a/src/device/fido/mod.rs b/src/device/fido/mod.rs index d8cb81b..5e853ae 100644 --- a/src/device/fido/mod.rs +++ b/src/device/fido/mod.rs @@ -3,15 +3,15 @@ pub mod hid; use crate::{ device::types::{ - AppConfig, AppConfigInput, DeviceInfo, DeviceMethod, FidoDeviceInfo, FullDeviceStatus, - StoredCredential, + AppConfig, AppConfigInput, DeviceInfo, DeviceMethod, FidoDeviceInfo, + FingerprintEnrollResult, FingerprintStatus, FullDeviceStatus, StoredCredential, }, error::PFError, }; use constants::*; use hid::*; use serde_cbor_2::{Value, from_slice, to_vec}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; // Fido functions that require pin: @@ -367,6 +367,125 @@ pub(crate) fn set_min_pin_length( )) } +pub(crate) fn get_fingerprint_status(pin: Option) -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + + let info = get_fido_info()?; + let options: HashMap = info.options.into_iter().collect(); + let supported = options.get("bioEnroll").copied().unwrap_or(false); + let pin_configured = options.get("clientPin").copied().unwrap_or(false); + let templates_loaded = pin.is_some(); + + if !supported { + return Ok(FingerprintStatus { + supported: false, + pin_configured, + templates_loaded: false, + sensor: None, + templates: Vec::new(), + }); + } + + let sensor = transport + .bio_enrollment_get_fingerprint_sensor_info() + .map_err(|e| format!("Failed to read fingerprint sensor info: {}", e))?; + + let templates = if let Some(pin) = pin { + transport + .bio_enrollment_enumerate_enrollments(&pin) + .map_err(|e| format!("Failed to enumerate fingerprints: {}", e))? + } else { + Vec::new() + }; + + Ok(FingerprintStatus { + supported, + pin_configured, + templates_loaded, + sensor: Some(sensor), + templates, + }) +} + +pub(crate) fn enroll_fingerprint( + pin: String, + timeout_ms: Option, +) -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + + let sensor = transport + .bio_enrollment_get_fingerprint_sensor_info() + .map_err(|e| format!("Failed to read fingerprint sensor info: {}", e))?; + + let (template_id, mut step) = transport + .bio_enrollment_begin(&pin, timeout_ms) + .map_err(|e| format!("Failed to start fingerprint enrollment: {}", e))?; + + let max_steps = sensor.max_capture_samples_required_for_enroll.max(1) + 4; + let mut attempts = 0; + + while step.remaining_samples.unwrap_or(0) > 0 && attempts < max_steps { + step = transport + .bio_enrollment_next(&pin, timeout_ms) + .map_err(|e| format!("Fingerprint enrollment failed: {}", e))?; + attempts += 1; + } + + let remaining_samples = step.remaining_samples.unwrap_or(0); + let status = step.status.unwrap_or(0xff); + + if remaining_samples > 0 { + return Err(format!( + "Fingerprint enrollment did not complete after {} capture steps", + attempts + )); + } + + Ok(FingerprintEnrollResult { + template_id: hex::encode_upper(template_id), + status, + message: if status == 0 { + "Enrollment complete".to_string() + } else { + format!("Enrollment status 0x{:02X}", status) + }, + remaining_samples, + finished: remaining_samples == 0 && status == 0, + }) +} + +pub(crate) fn rename_fingerprint( + pin: String, + template_id: String, + friendly_name: String, +) -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let template_id = hex::decode(&template_id) + .map_err(|e| format!("Invalid template id '{}': {}", template_id, e))?; + + transport + .bio_enrollment_set_friendly_name(&pin, &template_id, &friendly_name) + .map_err(|e| format!("Failed to update fingerprint name: {}", e))?; + + Ok("Fingerprint name updated successfully".to_string()) +} + +pub(crate) fn remove_fingerprint(pin: String, template_id: String) -> Result { + let transport = + HidTransport::open().map_err(|e| format!("Could not open HID transport: {}", e))?; + let template_id = hex::decode(&template_id) + .map_err(|e| format!("Invalid template id '{}': {}", template_id, e))?; + + transport + .bio_enrollment_remove(&pin, &template_id) + .map_err(|e| format!("Failed to remove fingerprint: {}", e))?; + + Ok("Fingerprint removed successfully".to_string()) +} + pub(crate) fn get_credentials(pin: String) -> Result, String> { log::info!("Listing FIDO credentials via custom implementation..."); diff --git a/src/device/io.rs b/src/device/io.rs index fe5b111..4af25f4 100644 --- a/src/device/io.rs +++ b/src/device/io.rs @@ -47,6 +47,29 @@ pub(crate) fn set_min_pin_length( fido::set_min_pin_length(current_pin, min_pin_length) } +pub(crate) fn get_fingerprint_status(pin: Option) -> Result { + fido::get_fingerprint_status(pin) +} + +pub(crate) fn enroll_fingerprint( + pin: String, + timeout_ms: Option, +) -> Result { + fido::enroll_fingerprint(pin, timeout_ms) +} + +pub(crate) fn rename_fingerprint( + pin: String, + template_id: String, + friendly_name: String, +) -> Result { + fido::rename_fingerprint(pin, template_id, friendly_name) +} + +pub(crate) fn remove_fingerprint(pin: String, template_id: String) -> Result { + fido::remove_fingerprint(pin, template_id) +} + pub fn reboot(to_bootsel: bool) -> Result { rescue::reboot_device(to_bootsel) } diff --git a/src/device/types.rs b/src/device/types.rs index e14334e..1391f7f 100644 --- a/src/device/types.rs +++ b/src/device/types.rs @@ -101,3 +101,39 @@ pub struct StoredCredential { pub user_id: String, pub credential_id: String, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FingerprintTemplate { + pub template_id: String, + pub friendly_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FingerprintSensorInfo { + pub modality: String, + pub fingerprint_kind: String, + pub max_capture_samples_required_for_enroll: u32, + pub max_template_friendly_name: u32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FingerprintStatus { + pub supported: bool, + pub pin_configured: bool, + pub templates_loaded: bool, + pub sensor: Option, + pub templates: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FingerprintEnrollResult { + pub template_id: String, + pub status: u8, + pub message: String, + pub remaining_samples: u32, + pub finished: bool, +} diff --git a/src/ui/components/dialog.rs b/src/ui/components/dialog.rs index ec0aaa3..1de59cf 100644 --- a/src/ui/components/dialog.rs +++ b/src/ui/components/dialog.rs @@ -8,6 +8,7 @@ use gpui_component::{ }; type PinPromptCallback = std::rc::Rc, &mut App)>; +type TextPromptCallback = std::rc::Rc, &mut App)>; type ConfirmCallback = std::rc::Rc, &mut App)>; type ChangePinCallback = std::rc::Rc, &mut App)>; @@ -244,6 +245,236 @@ pub fn open_pin_prompt( }); } +pub struct TextPromptContent { + phase: DialogPhase, + title: SharedString, + description: SharedString, + confirm_label: SharedString, + text_input: Entity, + on_confirm: TextPromptCallback, + _subscription: Subscription, +} + +impl TextPromptContent { + fn set_loading(&mut self, cx: &mut Context) { + self.phase = DialogPhase::Loading; + cx.notify(); + } + + pub fn set_success(&mut self, msg: String, cx: &mut Context) { + self.phase = DialogPhase::Success(msg); + cx.notify(); + } + + pub fn set_error(&mut self, msg: String, cx: &mut Context) { + self.phase = DialogPhase::Error(msg); + cx.notify(); + } + + fn trigger_confirm(&mut self, cx: &mut Context) { + if matches!(self.phase, DialogPhase::Loading | DialogPhase::Success(_)) { + return; + } + let text = self.text_input.read(cx).text().to_string(); + let text = text.trim().to_string(); + if !text.is_empty() { + let handle = cx.entity().downgrade(); + self.set_loading(cx); + (self.on_confirm)(text, handle, cx); + } + } +} + +impl Render for TextPromptContent { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let phase = self.phase.clone(); + + match &phase { + DialogPhase::Success(msg) => v_flex() + .gap_4() + .child( + h_flex() + .gap_2() + .items_center() + .child( + gpui_component::Icon::new(gpui_component::IconName::CircleCheck) + .text_color(cx.theme().green) + .with_size(gpui_component::Size::Large), + ) + .child(self.title.clone()), + ) + .child(msg.clone()) + .child( + h_flex().justify_end().child( + Button::new("done") + .primary() + .label("Done") + .on_click(|_, window, cx| { + window.close_dialog(cx); + }), + ), + ) + .into_any_element(), + + DialogPhase::Loading => v_flex() + .gap_4() + .child(self.description.clone()) + .child(Input::new(&self.text_input).disabled(true)) + .child( + h_flex() + .justify_end() + .gap_2() + .child(Button::new("cancel").label("Cancel").disabled(true)) + .child( + Button::new("confirm") + .primary() + .label("Loading...") + .loading(true), + ), + ) + .into_any_element(), + + DialogPhase::Error(err_msg) => { + let text_input = self.text_input.clone(); + let confirm_label = self.confirm_label.clone(); + let on_confirm = self.on_confirm.clone(); + let handle = cx.entity().downgrade(); + + v_flex() + .gap_4() + .child(self.description.clone()) + .child( + div() + .px_3() + .py_2() + .rounded_md() + .bg(rgb(0x18181b)) + .text_color(rgb(0xef4444)) + .text_sm() + .child(render_error_message(err_msg.clone())), + ) + .child(Input::new(&text_input)) + .child( + h_flex() + .justify_end() + .gap_2() + .child(Button::new("cancel").label("Cancel").on_click( + |_, window, cx| { + window.close_dialog(cx); + }, + )) + .child( + Button::new("confirm") + .primary() + .label(confirm_label) + .on_click(move |_, _, cx| { + let text = text_input.read(cx).text().to_string(); + let text = text.trim().to_string(); + if !text.is_empty() { + if let Some(h) = handle.upgrade() { + h.update(cx, |this, cx| this.set_loading(cx)); + } + on_confirm(text, handle.clone(), cx); + } + }), + ), + ) + .into_any_element() + } + + DialogPhase::Input => { + let text_input = self.text_input.clone(); + let confirm_label = self.confirm_label.clone(); + let on_confirm = self.on_confirm.clone(); + let handle = cx.entity().downgrade(); + + v_flex() + .gap_4() + .child(self.description.clone()) + .child(Input::new(&text_input)) + .child( + h_flex() + .justify_end() + .gap_2() + .child(Button::new("cancel").label("Cancel").on_click( + |_, window, cx| { + window.close_dialog(cx); + }, + )) + .child( + Button::new("confirm") + .primary() + .label(confirm_label) + .on_click(move |_, _, cx| { + let text = text_input.read(cx).text().to_string(); + let text = text.trim().to_string(); + if !text.is_empty() { + if let Some(h) = handle.upgrade() { + h.update(cx, |this, cx| this.set_loading(cx)); + } + on_confirm(text, handle.clone(), cx); + } + }), + ), + ) + .into_any_element() + } + } + } +} + +pub fn open_text_prompt( + title: &str, + description: &str, + placeholder: &str, + confirm_label: &str, + initial_value: Option, + window: &mut Window, + cx: &mut App, + on_confirm: impl Fn(String, WeakEntity, &mut App) + 'static, +) { + let title_str = SharedString::from(title.to_string()); + let description = SharedString::from(description.to_string()); + let confirm_label = SharedString::from(confirm_label.to_string()); + + let initial_text = initial_value.unwrap_or_default(); + let placeholder = SharedString::from(placeholder.to_string()); + let text_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(placeholder.clone()) + .default_value(initial_text.clone()) + }); + + let dialog_title = title_str.clone(); + let text_for_sub = text_input.clone(); + + let content = cx.new(|cx| { + let sub = cx.subscribe(&text_for_sub, |this: &mut TextPromptContent, _, event, cx| { + if matches!(event, InputEvent::PressEnter { .. }) { + this.trigger_confirm(cx); + } + }); + + TextPromptContent { + phase: DialogPhase::Input, + title: title_str, + description, + confirm_label, + text_input: text_for_sub, + on_confirm: std::rc::Rc::new(on_confirm), + _subscription: sub, + } + }); + + window.open_dialog(cx, move |dialog, _, _| { + dialog + .title(dialog_title.clone()) + .child(content.clone()) + .overlay_closable(false) + .close_button(false) + }); +} + pub struct ConfirmContent { phase: DialogPhase, title: SharedString, diff --git a/src/ui/rootview.rs b/src/ui/rootview.rs index 90d1f93..7a8e152 100644 --- a/src/ui/rootview.rs +++ b/src/ui/rootview.rs @@ -60,6 +60,7 @@ impl ApplicationRoot { if device_changed { self.views.passkeys = None; + self.views.security = None; } match io::get_fido_info() { @@ -172,7 +173,17 @@ impl Render for ApplicationRoot { } self.views.config.clone().unwrap().into_any_element() } - ActiveView::Security => SecurityView::build(cx).into_any_element(), + ActiveView::Security => { + if self.views.security.is_none() { + let root = cx.entity().downgrade(); + let view = cx.new(|cx| SecurityView::new(window, cx, root)); + view.update(cx, |view, cx| { + view.refresh_status(None, cx); + }); + self.views.security = Some(view); + } + self.views.security.clone().unwrap().into_any_element() + } ActiveView::About => AboutView::build(cx.theme()).into_any_element(), }); diff --git a/src/ui/types.rs b/src/ui/types.rs index 962e6f1..773c40b 100644 --- a/src/ui/types.rs +++ b/src/ui/types.rs @@ -1,6 +1,6 @@ use crate::{ device::types::{FidoDeviceInfo, FullDeviceStatus}, - ui::views::{config::ConfigView, passkeys::PasskeysView}, + ui::views::{config::ConfigView, passkeys::PasskeysView, security::SecurityView}, }; use gpui::{Entity, Pixels, SharedString, px}; @@ -54,6 +54,7 @@ impl LayoutState { pub struct ViewCache { pub passkeys: Option>, pub config: Option>, + pub security: Option>, } impl ViewCache { @@ -61,6 +62,7 @@ impl ViewCache { Self { passkeys: None, config: None, + security: None, } } } diff --git a/src/ui/views/security.rs b/src/ui/views/security.rs index 00eee09..e55edfc 100644 --- a/src/ui/views/security.rs +++ b/src/ui/views/security.rs @@ -1,193 +1,848 @@ -use crate::ui::components::page_view::PageView; +use crate::device::io; +use crate::device::types::{FingerprintStatus, FingerprintTemplate}; +use crate::ui::components::{ + button::{PFButton, PFIconButton}, + card::Card, + dialog, + dialog::{ConfirmContent, PinPromptContent, StatusContent, TextPromptContent}, + page_view::PageView, + tag::Tag, +}; +use crate::ui::rootview::ApplicationRoot; +use crate::ui::types::DeviceConnectionState; use gpui::*; +use gpui_component::button::ButtonVariant; use gpui_component::{ - ActiveTheme, Disableable, Icon, StyledExt, - button::{Button, ButtonCustomVariant, ButtonVariants}, - h_flex, - switch::Switch, - v_flex, + ActiveTheme, Icon, StyledExt, Theme, WindowExt, badge::Badge, h_flex, v_flex, }; -pub struct SecurityView; +pub struct SecurityView { + root: WeakEntity, + fingerprint_status: Option, + cached_pin: Option, + loading: bool, + _task: Option>, +} impl SecurityView { - pub fn build(cx: &mut Context) -> impl IntoElement { - let (fg, muted_fg, border, card_bg) = { - let theme = cx.theme(); - ( - theme.foreground, - theme.muted_foreground, - theme.border, - theme.secondary, - ) + pub fn new( + _window: &mut Window, + _cx: &mut Context, + root: WeakEntity, + ) -> Self { + Self { + root, + fingerprint_status: None, + cached_pin: None, + loading: false, + _task: None, + } + } + + pub fn refresh_status(&mut self, pin: Option, cx: &mut Context) { + if self.loading { + return; + } + + self.loading = true; + cx.notify(); + + let entity = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let pin_for_bg = pin.clone(); + let result = cx + .background_executor() + .spawn(async move { io::get_fingerprint_status(pin_for_bg) }) + .await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(status) => { + this.cached_pin = pin; + this.fingerprint_status = Some(status); + } + Err(err) => { + log::error!("Failed to refresh fingerprint status: {}", err); + this.cached_pin = None; + this.fingerprint_status = None; + } + } + cx.notify(); + }); + })); + } + + fn lock_session(&mut self, cx: &mut Context) { + self.cached_pin = None; + if let Some(status) = &mut self.fingerprint_status { + status.templates_loaded = false; + status.templates.clear(); + } + cx.notify(); + } + + fn open_unlock_dialog(&mut self, window: &mut Window, cx: &mut Context) { + if self.loading { + return; + } + + let view_handle = cx.entity().downgrade(); + dialog::open_pin_prompt( + "Unlock Fingerprint Management", + "Enter your device PIN to enumerate fingerprints stored on the key.", + "Load", + window, + cx, + move |pin, dialog_handle, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.unlock_with_pin(pin, dialog_handle, cx); + }); + }, + ); + } + + fn unlock_with_pin( + &mut self, + pin: String, + dialog_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + + self.loading = true; + cx.notify(); + + let entity = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let pin_for_bg = pin.clone(); + let result = cx + .background_executor() + .spawn(async move { io::get_fingerprint_status(Some(pin_for_bg)) }) + .await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(status) => { + this.cached_pin = Some(pin); + this.fingerprint_status = Some(status); + let _ = dialog_handle.update(cx, |d, cx| { + d.set_success("Fingerprint list refreshed.".to_string(), cx); + }); + } + Err(err) => { + log::error!("Failed to unlock fingerprint management: {}", err); + let _ = dialog_handle.update(cx, |d, cx| { + d.set_error(format!("Failed to load fingerprints: {}", err), cx); + }); + } + } + cx.notify(); + }); + })); + } + + fn open_enroll_flow(&mut self, window: &mut Window, cx: &mut Context) { + let Some(pin) = self.cached_pin.clone() else { + window.push_notification("Unlock fingerprint management first.", cx); + return; }; - let destructive_red = rgb(0xef4444); - let destructive_red_hover = rgb(0xdc2626); - let destructive_red_active = rgb(0xb91c1c); - let destructive_border = rgba(0xef44444d); - let destructive_bg_muted = rgba(0xef44441a); + if self.loading { + return; + } - let content = v_flex() - .gap_6() - .w_full() + let status_handle = dialog::open_status_dialog("Enroll Fingerprint", window, cx); + self.enroll_with_pin(pin, status_handle, cx); + } + + fn enroll_with_pin( + &mut self, + pin: String, + status_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + + self.loading = true; + cx.notify(); + + let entity = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let pin_for_bg = pin.clone(); + let result = cx + .background_executor() + .spawn(async move { io::enroll_fingerprint(pin_for_bg, Some(20_000)) }) + .await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(result) => { + this.cached_pin = Some(pin.clone()); + let _ = status_handle.update(cx, |status, cx| { + status.set_success( + format!( + "Fingerprint enrolled as template {}. Touch the sensor twice during capture.", + result.template_id + ), + cx, + ); + }); + this.refresh_status(Some(pin), cx); + } + Err(err) => { + log::error!("Fingerprint enrollment failed: {}", err); + let _ = status_handle.update(cx, |status, cx| { + status.set_error(format!("Enrollment failed: {}", err), cx); + }); + cx.notify(); + } + } + }); + })); + } + + fn open_rename_dialog( + &mut self, + template: FingerprintTemplate, + window: &mut Window, + cx: &mut Context, + ) { + let Some(pin) = self.cached_pin.clone() else { + window.push_notification("Unlock fingerprint management first.", cx); + return; + }; + + let template_id = template.template_id.clone(); + let current_name = template.friendly_name.clone(); + let view_handle = cx.entity().downgrade(); + + dialog::open_text_prompt( + "Rename Fingerprint", + "Provide a friendly name for this fingerprint template.", + "Friendly name", + "Save", + current_name, + window, + cx, + move |friendly_name, dialog_handle, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.rename_fingerprint( + pin.clone(), + template_id.clone(), + friendly_name, + dialog_handle, + cx, + ); + }); + }, + ); + } + + fn rename_fingerprint( + &mut self, + pin: String, + template_id: String, + friendly_name: String, + dialog_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + + self.loading = true; + cx.notify(); + + let entity = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let pin_for_bg = pin.clone(); + let template_id_for_bg = template_id.clone(); + let friendly_name_for_bg = friendly_name.clone(); + let result = cx.background_executor().spawn(async move { + io::rename_fingerprint(pin_for_bg, template_id_for_bg, friendly_name_for_bg) + }); + + let result = result.await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(_) => { + this.cached_pin = Some(pin.clone()); + let _ = dialog_handle.update(cx, |dialog, cx| { + dialog.set_success("Fingerprint name updated.".to_string(), cx); + }); + this.refresh_status(Some(pin), cx); + } + Err(err) => { + log::error!("Failed to rename fingerprint: {}", err); + let _ = dialog_handle.update(cx, |dialog, cx| { + dialog.set_error(format!("Rename failed: {}", err), cx); + }); + cx.notify(); + } + } + }); + })); + } + + fn open_delete_dialog( + &mut self, + template: FingerprintTemplate, + window: &mut Window, + cx: &mut Context, + ) { + let Some(pin) = self.cached_pin.clone() else { + window.push_notification("Unlock fingerprint management first.", cx); + return; + }; + + let template_id = template.template_id.clone(); + let label = template + .friendly_name + .clone() + .unwrap_or_else(|| template_id.clone()); + let view_handle = cx.entity().downgrade(); + + dialog::open_confirm( + "Delete Fingerprint", + format!("Delete fingerprint template {}?", label), + "Delete", + ButtonVariant::Danger, + window, + cx, + move |dialog_handle, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.delete_fingerprint(pin.clone(), template_id.clone(), dialog_handle, cx); + }); + }, + ); + } + + fn delete_fingerprint( + &mut self, + pin: String, + template_id: String, + dialog_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + + self.loading = true; + cx.notify(); + + let entity = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let pin_for_bg = pin.clone(); + let template_id_for_bg = template_id.clone(); + let result = cx + .background_executor() + .spawn(async move { io::remove_fingerprint(pin_for_bg, template_id_for_bg) }) + .await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(_) => { + this.cached_pin = Some(pin.clone()); + let _ = dialog_handle.update(cx, |dialog, cx| { + dialog.set_success("Fingerprint removed successfully.".to_string(), cx); + }); + this.refresh_status(Some(pin), cx); + } + Err(err) => { + log::error!("Failed to delete fingerprint: {}", err); + let _ = dialog_handle.update(cx, |dialog, cx| { + dialog.set_error(format!("Delete failed: {}", err), cx); + }); + cx.notify(); + } + } + }); + })); + } + + fn render_no_device(&self, theme: &Theme) -> impl IntoElement { + div() + .flex() + .items_center() + .justify_center() + .h_64() + .border_1() + .border_color(theme.border) + .rounded_xl() .child( - v_flex() - .w_full() - .p_4() - .gap_2() - .border_1() - .border_color(destructive_border) - .bg(card_bg) - .rounded_md() + div() + .text_color(theme.muted_foreground) + .child("Connect your pico-key to manage biometric security."), + ) + .into_any_element() + } + + fn render_not_supported(&self, theme: &Theme) -> impl IntoElement { + div() + .flex() + .items_center() + .justify_center() + .h_64() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child( + div() + .text_color(theme.muted_foreground) + .child("Biometric management is not supported on this device."), + ) + .into_any_element() + } + + fn render_fingerprint_card(&self, cx: &mut Context) -> impl IntoElement { + let status = self.fingerprint_status.clone(); + let unlocked = self.cached_pin.is_some() + && status + .as_ref() + .map(|s| s.templates_loaded) + .unwrap_or(false); + let muted_fg = cx.theme().muted_foreground; + + let header_right = if unlocked { + Badge::new() + .child( + h_flex() + .gap_1() + .items_center() + .child(Icon::default().path("icons/lock-open.svg").size_3p5()) + .child("Unlocked"), + ) + .color(gpui::green()) + .into_any_element() + } else { + Tag::new("PIN required").into_any_element() + }; + + let sensor_chips = if let Some(sensor) = status.as_ref().and_then(|s| s.sensor.clone()) { + h_flex() + .gap_2() + .flex_wrap() + .child(Tag::new(sensor.modality)) + .child(Tag::new(format!("{} sensor", sensor.fingerprint_kind))) + .child(Tag::new(format!( + "{} samples / enroll", + sensor.max_capture_samples_required_for_enroll + ))) + .child(Tag::new(format!( + "{} byte names", + sensor.max_template_friendly_name + ))) + .into_any_element() + } else { + div() + .text_sm() + .text_color(muted_fg) + .child("Sensor information unavailable.") + .into_any_element() + }; + + let body = if !unlocked { + self.render_locked_state(cx).into_any_element() + } else { + self.render_unlocked_state(cx).into_any_element() + }; + + Card::new() + .title("Fingerprints") + .description("Enumerate, enroll, rename, and delete fingerprints stored on the key.") + .icon(Icon::default().path("icons/shield.svg")) + .header_right(header_right) + .child(v_flex().gap_4().child(sensor_chips).child(body)) + } + + fn render_locked_state(&self, cx: &mut Context) -> impl IntoElement { + let muted = cx.theme().muted; + let muted_fg = cx.theme().muted_foreground; + let pin_set = self + .root + .upgrade() + .and_then(|r| r.read(cx).device.fido_info.clone()) + .and_then(|info| info.options.get("clientPin").copied()) + .unwrap_or(false); + + if !pin_set { + return v_flex() + .items_center() + .justify_center() + .gap_3() + .py_3() + .child( + div().rounded_full().bg(muted).p_4().child( + Icon::default() + .path("icons/key.svg") + .size_12() + .text_color(muted_fg), + ), + ) + .child(div().text_lg().font_semibold().child("PIN Required")) + .child( + div() + .text_color(muted_fg) + .text_sm() + .child("Set a FIDO PIN in the Passkeys view before managing fingerprints."), + ) + .into_any_element(); + } + + let listener = cx.listener(|this, _, window, cx| { + this.open_unlock_dialog(window, cx); + }); + + v_flex() + .items_center() + .justify_center() + .gap_3() + .py_3() + .child( + div().rounded_full().bg(muted).p_4().child( + Icon::default() + .path("icons/lock.svg") + .size_12() + .text_color(muted_fg), + ), + ) + .child( + div() + .text_lg() + .font_semibold() + .child("Authentication Required"), + ) + .child( + div() + .text_color(muted_fg) + .text_sm() + .child("Unlock with your device PIN to view and manage enrolled fingerprints."), + ) + .child( + PFIconButton::new(Icon::default().path("icons/lock-open.svg"), "Unlock Fingerprints") + .on_click(listener) + .with_colors(rgb(0xe4e4e7), rgb(0xd0d0d3), rgb(0xe4e4e7)) + .with_text_color(rgb(0x18181b)) + .loading(self.loading), + ) + .into_any_element() + } + + fn render_unlocked_state(&self, cx: &mut Context) -> impl IntoElement { + let templates = self + .fingerprint_status + .as_ref() + .map(|status| status.templates.clone()) + .unwrap_or_default(); + let border = cx.theme().border; + let muted = cx.theme().muted; + let muted_fg = cx.theme().muted_foreground; + let count = templates.len(); + let lock_listener = cx.listener(|this, _, _, cx| { + this.lock_session(cx); + }); + let enroll_listener = cx.listener(|this, _, window, cx| { + this.open_enroll_flow(window, cx); + }); + + let mut rows = Vec::new(); + for template in templates { + rows.push(self.render_template_card(template, cx).into_any_element()); + } + + let content = if rows.is_empty() { + v_flex() + .items_center() + .justify_center() + .py_12() + .border_1() + .border_color(border) + .rounded_xl() + .gap_4() + .child( + div().rounded_full().bg(muted).p_4().child( + Icon::default() + .path("icons/shield.svg") + .size_8() + .text_color(muted_fg), + ), + ) + .child(div().text_lg().font_semibold().child("No Fingerprints Enrolled")) + .child( + div() + .text_sm() + .text_color(muted_fg) + .child("Enroll a fingerprint to use biometric verification on this key."), + ) + .into_any_element() + } else { + v_flex().gap_4().children(rows).into_any_element() + }; + + v_flex() + .gap_6() + .child( + h_flex() + .justify_between() + .items_center() + .child( + h_flex() + .gap_4() + .items_center() + .child( + div() + .text_sm() + .text_color(muted_fg) + .child(format!("{} fingerprint template(s) loaded", count)), + ), + ) .child( h_flex() .gap_2() - .items_center() .child( - Icon::default() - .path("icons/triangle-alert.svg") - .text_color(destructive_red), + PFIconButton::new( + Icon::default().path("icons/plus.svg").size_3p5(), + "Enroll", + ) + .small() + .loading(self.loading) + .on_click(enroll_listener), ) .child( - div() - .font_bold() - .text_color(destructive_red) - .child("Feature Unstable"), + PFIconButton::new( + Icon::default().path("icons/lock.svg").size_3p5(), + "Lock", + ) + .small() + .disabled(self.loading) + .on_click(lock_listener), + ), + ), + ) + .child(content) + } + + fn render_template_card( + &self, + template: FingerprintTemplate, + cx: &mut Context, + ) -> impl IntoElement { + let border = cx.theme().border; + let muted_fg = cx.theme().muted_foreground; + let rename_template = template.clone(); + let delete_template = template.clone(); + + div() + .w_full() + .border_1() + .border_color(border) + .rounded_xl() + .p_4() + .child( + v_flex() + .gap_4() + .child( + h_flex() + .justify_between() + .items_start() + .child( + v_flex() + .gap_1() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child( + template + .friendly_name + .clone() + .unwrap_or_else(|| "Unnamed fingerprint".to_string()), + ), + ) + .child( + div() + .text_sm() + .text_color(muted_fg) + .font_family("Mono") + .child(template.template_id.clone()), + ), + ) + .child( + h_flex() + .gap_2() + .child( + PFButton::new("Rename") + .small() + .disabled(self.loading) + .with_colors( + rgb(0x222225), + rgb(0x2a2a2d), + rgb(0x333336), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_rename_dialog( + rename_template.clone(), + window, + cx, + ); + })), + ) + .child( + PFButton::new("Delete") + .small() + .disabled(self.loading) + .with_colors( + rgb(0x7f1d1d), + rgb(0x991b1b), + rgb(0xb91c1c), + ) + .with_text_color(rgb(0xfef2f2)) + .on_click(cx.listener(move |this, _, window, cx| { + this.open_delete_dialog( + delete_template.clone(), + window, + cx, + ); + })), + ), ), ) .child( div() .text_sm() - .text_color(destructive_red) - .child("This feature is currently under work and disabled for safety."), + .text_color(muted_fg) + .child("Rename or delete this template. Enrollment requires two captures on the sensor."), ), ) + } + + fn render_secure_boot_card(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let destructive_red = rgb(0xef4444); + let destructive_border = rgba(0xef44444d); + let destructive_bg_muted = rgba(0xef44441a); + + div() + .w_full() + .border_1() + .border_color(destructive_border) + .bg(theme.secondary) + .rounded_xl() + .overflow_hidden() + .child( + div() + .p_6() + .child(div().text_lg().font_bold().child("Secure Boot")), + ) .child( v_flex() - .w_full() - .border_1() - .border_color(destructive_border) - .bg(card_bg) - .rounded_xl() - .overflow_hidden() - .child( - div().p_6().child( - div() - .text_lg() - .font_bold() - .text_color(fg) - .child("Lock Settings"), - ), - ) - // Card Content - .child( - v_flex() - .px_6() - .pb_6() - .gap_6() - .child( - h_flex() - .justify_between() - .items_center() - .child( - v_flex() - .gap_1() - .child( - div() - .text_sm() - .font_medium() - .child("Enable Secure Boot"), - ) - .child( - div().text_xs().text_color(muted_fg).child( - "Verifies firmware signature on startup", - ), - ), - ) - .child( - Switch::new("secure-boot-switch") - .checked(false) - .disabled(true), - ), - ) - .child( - // Secure Lock Row (Disabled) - h_flex() - .justify_between() - .items_center() - .child( - v_flex() - .gap_1() - .child( - div().text_sm().font_medium().child("Secure Lock"), - ) - .child(div().text_xs().text_color(muted_fg).child( - "Prevents reading key material via debug ports", - )), - ) - .child( - Switch::new("secure-lock-switch") - .checked(false) - .disabled(true), - ), - ) - .child(div().h_px().bg(border)) - .child( - h_flex() - .items_center() - .gap_4() - .p_4() - .rounded_md() - .bg(destructive_bg_muted) - .border_1() - .border_color(destructive_border) - .child( - Switch::new("confirm-switch").checked(false).disabled(true), - ) - .child( - div() - .font_medium() - .text_color(destructive_red) - .child("I understand the risks of bricking my device."), - ), - ), - ) - // Card Footer + .px_6() + .pb_6() + .gap_6() .child( div() - .border_t_1() - .border_color(border) - .bg(gpui::rgba(0x00000033)) - .px_6() - .py_4() - .flex() - .justify_end() + .p_4() + .border_1() + .border_color(destructive_border) + .rounded_md() + .bg(destructive_bg_muted) .child( - Button::new("lock-device-btn") - .custom( - ButtonCustomVariant::new(cx) - .color(destructive_red.into()) - .hover(destructive_red_hover.into()) - .active(destructive_red_active.into()), - ) - .disabled(true) + v_flex() + .gap_2() .child( h_flex() .gap_2() .items_center() - .child(Icon::default().path("icons/lock.svg").size_4()) - .child("Permanently Lock Device"), + .child( + Icon::default() + .path("icons/triangle-alert.svg") + .text_color(destructive_red), + ) + .child( + div() + .font_bold() + .text_color(destructive_red) + .child("Feature Unstable"), + ), + ) + .child( + div() + .text_sm() + .text_color(destructive_red) + .child("This feature is currently disabled for safety."), ), ), + ) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Permanently lock this device to the current firmware vendor."), ), - ); + ) + } +} +impl Render for SecurityView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let device = self + .root + .upgrade() + .map(|r| r.read(cx).device.clone()) + .unwrap_or_else(DeviceConnectionState::new); + + if device.status.is_none() { + let theme = cx.theme(); + return PageView::build( + "Security", + "Manage FIDO2 security controls and biometric enrollments.", + self.render_no_device(theme).into_any_element(), + theme, + ) + .into_any_element(); + } + + let bio_supported = device + .fido_info + .as_ref() + .and_then(|info| info.options.get("bioEnroll").copied()) + .unwrap_or(false); + + if !bio_supported { + let theme = cx.theme(); + return PageView::build( + "Security", + "Manage FIDO2 security controls and biometric enrollments.", + self.render_not_supported(theme).into_any_element(), + theme, + ) + .into_any_element(); + } + + let is_wide = window.bounds().size.width > px(1100.0); + let columns = if is_wide { 2 } else { 1 }; + PageView::build( - "Secure Boot", - "Permanently lock this device to the current firmware vendor.", - content, + "Security", + "Manage FIDO2 security controls and biometric enrollments.", + div() + .grid() + .grid_cols(columns) + .gap_6() + .child(self.render_fingerprint_card(cx)) + .child(self.render_secure_boot_card(cx)), cx.theme(), ) + .into_any_element() } }