From 5fede906b0cba12de91cb8655597dbbcc7e24243 Mon Sep 17 00:00:00 2001 From: Suyog Tandel Date: Sun, 5 Jul 2026 16:02:14 +0530 Subject: [PATCH] refactor: extract screens into view/view_model dirs, add AppModels DI, switch to Entity - Decompose flat about.rs/home.rs/security.rs/config.rs/passkeys.rs into per-screen directories with mod.rs, view.rs, view_model.rs - Extract ApplicationRoot, LayoutState, ActiveView, ViewModelStore into new app.rs - Replace DeviceConnectionState (snapshot-based) with reactive Entity - Replace ViewCache with ViewModelStore using uniform get_or_insert_with lazy init - Add AppModels DI bag shared across all view constructors - Move side types (UsbIdentityPreset, LedDriverType, etc.) into local view_model files - Delete src/ui/types.rs --- src/main.rs | 4 +- src/ui/app.rs | 170 ++ src/ui/components/sidebar.rs | 21 +- src/ui/mod.rs | 2 +- src/ui/models/device.rs | 57 +- src/ui/models/mod.rs | 2 +- src/ui/rootview.rs | 177 +- src/ui/screens/about/mod.rs | 3 + src/ui/screens/{about.rs => about/view.rs} | 11 +- src/ui/screens/about/view_model.rs | 10 + src/ui/screens/config.rs | 1354 ------------- src/ui/screens/config/mod.rs | 3 + src/ui/screens/config/view.rs | 657 ++++++ src/ui/screens/config/view_model.rs | 849 ++++++++ src/ui/screens/home/mod.rs | 3 + src/ui/screens/{home.rs => home/view.rs} | 120 +- src/ui/screens/home/view_model.rs | 16 + src/ui/screens/passkeys.rs | 1788 ----------------- src/ui/screens/passkeys/mod.rs | 3 + src/ui/screens/passkeys/view.rs | 743 +++++++ src/ui/screens/passkeys/view_model.rs | 1019 ++++++++++ src/ui/screens/security/mod.rs | 3 + .../screens/{security.rs => security/view.rs} | 26 +- src/ui/screens/security/view_model.rs | 10 + src/ui/types.rs | 217 -- 25 files changed, 3668 insertions(+), 3600 deletions(-) create mode 100644 src/ui/app.rs create mode 100644 src/ui/screens/about/mod.rs rename src/ui/screens/{about.rs => about/view.rs} (95%) create mode 100644 src/ui/screens/about/view_model.rs delete mode 100644 src/ui/screens/config.rs create mode 100644 src/ui/screens/config/mod.rs create mode 100644 src/ui/screens/config/view.rs create mode 100644 src/ui/screens/config/view_model.rs create mode 100644 src/ui/screens/home/mod.rs rename src/ui/screens/{home.rs => home/view.rs} (91%) create mode 100644 src/ui/screens/home/view_model.rs delete mode 100644 src/ui/screens/passkeys.rs create mode 100644 src/ui/screens/passkeys/mod.rs create mode 100644 src/ui/screens/passkeys/view.rs create mode 100644 src/ui/screens/passkeys/view_model.rs create mode 100644 src/ui/screens/security/mod.rs rename src/ui/screens/{security.rs => security/view.rs} (93%) create mode 100644 src/ui/screens/security/view_model.rs delete mode 100644 src/ui/types.rs diff --git a/src/main.rs b/src/main.rs index cc908d4..fc4a56f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -819,7 +819,7 @@ use std::rc::Rc; use gpui::*; use gpui_component::Root; use gpui_component::{Theme, ThemeMode, ThemeSet}; -use ui::rootview::ApplicationRoot; +use ui::app::ApplicationRoot; pub mod error; mod hal; @@ -837,7 +837,7 @@ fn main() { // Register sidebar toggle keybinding cx.bind_keys([gpui::KeyBinding::new( "ctrl-shift-d", - ui::rootview::ToggleSidebar, + ui::app::ToggleSidebar, None, )]); diff --git a/src/ui/app.rs b/src/ui/app.rs new file mode 100644 index 0000000..bab07a4 --- /dev/null +++ b/src/ui/app.rs @@ -0,0 +1,170 @@ +use crate::hal::io; +use crate::hal::types::{DeviceMethod, FirmwareType}; +use crate::ui::models::device::DeviceRepo; +use crate::ui::screens::{ + about::AboutViewModel, config::ConfigView, home::HomeViewModel, passkeys::PasskeysView, + security::SecurityViewModel, +}; +use gpui::*; + +gpui::actions!(picoforge, [ToggleSidebar]); + +pub struct AppModels { + pub device: Entity, +} + +pub struct ViewModelStore { + pub home: Option>, + pub about: Option>, + pub security: Option>, + pub passkeys: Option>, + pub config: Option>, +} + +impl ViewModelStore { + pub fn new() -> Self { + Self { + home: None, + about: None, + security: None, + passkeys: None, + config: None, + } + } +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum ActiveView { + Home, + Passkeys, + Configuration, + Security, + About, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LayoutState { + pub active_view: ActiveView, + pub is_sidebar_collapsed: bool, + pub sidebar_toggle_hovered: bool, + pub sidebar_width: Pixels, +} + +impl LayoutState { + pub fn new() -> Self { + Self { + active_view: ActiveView::Home, + is_sidebar_collapsed: false, + sidebar_toggle_hovered: false, + sidebar_width: px(255.), + } + } +} + +pub struct ApplicationRoot { + pub models: AppModels, + pub view_state: LayoutState, + pub views_store: ViewModelStore, + pub focus_handle: FocusHandle, +} + +impl ApplicationRoot { + pub fn new(cx: &mut Context) -> Self { + let device = cx.new(|_| DeviceRepo::new()); + + let mut this = Self { + models: AppModels { device }, + view_state: LayoutState::new(), + views_store: ViewModelStore::new(), + focus_handle: cx.focus_handle(), + }; + + this.refresh_device_status(None, cx); + this + } + + pub fn focus_handle(&self) -> FocusHandle { + self.focus_handle.clone() + } + + pub(crate) fn refresh_device_status( + &mut self, + window: Option<&mut Window>, + cx: &mut Context, + ) { + if self.models.device.read(cx).is_loading() { + return; + } + + self.models.device.update(cx, |repo, _| repo.begin_load()); + cx.notify(); + + match io::read_device_details() { + Ok(status) => { + let device_changed = self + .models + .device + .update(cx, |repo, _| repo.set_status(status.clone())); + + let firmware_type = status.firmware_type; + let method = status.method; + + if device_changed { + self.views_store.passkeys = None; + } else if let Some(passkeys_view) = &self.views_store.passkeys { + passkeys_view.update(cx, |view, cx| { + view.refresh_if_unlocked(cx); + }); + } + + match io::get_fido_info() { + Ok(fido) => { + self.models + .device + .update(cx, |repo, _| repo.set_fido_info(Some(fido))); + } + Err(e) => { + log::error!("FIDO Info fetch failed: {}", e); + self.models + .device + .update(cx, |repo, _| repo.set_fido_info(None)); + } + } + + if firmware_type == FirmwareType::RSKey && method == DeviceMethod::Rescue { + let led = io::read_led_config().ok(); + let mgmt = io::read_management_config().ok(); + self.models + .device + .update(cx, |repo, _| repo.set_auxiliary_data(led, mgmt)); + } else { + self.models + .device + .update(cx, |repo, _| repo.clear_auxiliary_data()); + } + + if let Some(config_view) = &self.views_store.config + && let Some(window) = window + { + config_view.update(cx, |view, cx| { + view.sync_from_device(window, cx); + }); + } + } + Err(e) => { + self.models + .device + .update(cx, |repo, _| repo.set_error(format!("{}", e))); + self.views_store.passkeys = None; + } + } + + self.models.device.update(cx, |repo, _| repo.end_load()); + cx.notify(); + } + + pub fn toggle_sidebar(&mut self, cx: &mut Context) { + self.view_state.is_sidebar_collapsed = !self.view_state.is_sidebar_collapsed; + cx.notify(); + } +} diff --git a/src/ui/components/sidebar.rs b/src/ui/components/sidebar.rs index a9a63ac..e453118 100644 --- a/src/ui/components/sidebar.rs +++ b/src/ui/components/sidebar.rs @@ -1,6 +1,7 @@ use crate::hal::types::DeviceMethod; +use crate::ui::app::{ActiveView, AppModels}; use crate::ui::components::button::PFIconButton; -use crate::ui::types::{ActiveView, DeviceConnectionState}; +use crate::ui::models::device::DeviceRepo; use gpui::*; use gpui_component::{ ActiveTheme, Icon, IconName, Side, @@ -18,7 +19,7 @@ pub struct AppSidebar { active_view: ActiveView, width: Pixels, collapsed: bool, - state: DeviceConnectionState, + device: Entity, on_select: SelectHandler, on_refresh: RefreshHandler, } @@ -28,13 +29,13 @@ impl AppSidebar { active_view: ActiveView, width: Pixels, collapsed: bool, - state: DeviceConnectionState, + models: &AppModels, ) -> Self { Self { active_view, width, collapsed, - state, + device: models.device.clone(), on_select: None, on_refresh: None, } @@ -59,7 +60,9 @@ impl AppSidebar { pub fn render(self, cx: &mut Context) -> impl IntoElement { let width = self.width; let collapsed = self.collapsed; - let state = self.state.clone(); + let state = self.device.read(cx); + let status_owned = state.status.clone(); + let error_owned = state.error.clone(); let sidebar_bg = cx.theme().sidebar; let sidebar_fg = cx.theme().sidebar_foreground; @@ -191,13 +194,13 @@ impl AppSidebar { .w_full(), ) .child(div().w(px(8.)).h(px(8.)).rounded_full().bg( - if let Some(status) = &state.status { + if let Some(status) = &status_owned { if status.method == DeviceMethod::Fido { rgb(0xf59e0b) } else { rgb(0x22c55e) } - } else if state.error.is_some() { + } else if error_owned.is_some() { rgb(0xf59e0b) } else { rgb(0xef4444) @@ -220,7 +223,7 @@ impl AppSidebar { ) .child({ let (text, color_bg, color_text) = if let Some(status) = - &state.status + &status_owned { let is_rskey = status.firmware_type == crate::hal::types::FirmwareType::RSKey; @@ -240,7 +243,7 @@ impl AppSidebar { rgb(0xffffff), ) } - } else if state.error.is_some() { + } else if error_owned.is_some() { ("Error".to_string(), rgb(0xd97706), rgb(0xffffff)) } else { ("Offline".to_string(), rgb(0xef4444), rgb(0xffffff)) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d152943..a94a446 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,7 +1,7 @@ +pub mod app; pub mod assets; pub mod colors; pub mod components; pub mod models; pub mod rootview; pub mod screens; -pub mod types; diff --git a/src/ui/models/device.rs b/src/ui/models/device.rs index 542b61c..084558c 100644 --- a/src/ui/models/device.rs +++ b/src/ui/models/device.rs @@ -1,14 +1,13 @@ use crate::hal::types::{FidoDeviceInfo, FullDeviceStatus, LedStatusConfig, ManagementAppConfig}; use gpui::*; -// Repo Events: +#[allow(dead_code)] pub enum DeviceEvent { Updated, } impl EventEmitter for DeviceRepo {} -// Repo Definition: pub struct DeviceRepo { pub status: Option, pub fido_info: Option, @@ -29,4 +28,58 @@ impl DeviceRepo { loading: false, } } + + // --- State lifecycle --- + + pub fn begin_load(&mut self) { + self.loading = true; + self.error = None; + } + + pub fn end_load(&mut self) { + self.loading = false; + } + + pub fn is_loading(&self) -> bool { + self.loading + } + + // --- Field setters --- + + pub fn set_status(&mut self, status: FullDeviceStatus) -> bool { + let changed = self + .status + .as_ref() + .map(|s| s.info.serial != status.info.serial) + .unwrap_or(true); + self.status = Some(status); + changed + } + + pub fn set_fido_info(&mut self, fido: Option) { + self.fido_info = fido; + } + + pub fn set_auxiliary_data( + &mut self, + led: Option, + mgmt: Option, + ) { + self.led_status = led; + self.management_apps = mgmt; + } + + pub fn clear_auxiliary_data(&mut self) { + self.led_status = None; + self.management_apps = None; + } + + pub fn set_error(&mut self, error: String) { + self.status = None; + self.fido_info = None; + self.led_status = None; + self.management_apps = None; + self.loading = false; + self.error = Some(error); + } } diff --git a/src/ui/models/mod.rs b/src/ui/models/mod.rs index 52e2328..5458924 100644 --- a/src/ui/models/mod.rs +++ b/src/ui/models/mod.rs @@ -1 +1 @@ -mod device; +pub mod device; diff --git a/src/ui/rootview.rs b/src/ui/rootview.rs index a7cf6fc..7ce53d6 100644 --- a/src/ui/rootview.rs +++ b/src/ui/rootview.rs @@ -1,10 +1,9 @@ -use crate::hal::io; +use crate::ui::app::{ActiveView, ApplicationRoot, ToggleSidebar}; use crate::ui::components::sidebar::AppSidebar; use crate::ui::screens::{ - about::AboutView, config::ConfigView, home::HomeView, passkeys::PasskeysEvent, - passkeys::PasskeysView, security::SecurityView, + about::AboutViewModel, config::ConfigView, home::HomeViewModel, passkeys::PasskeysEvent, + passkeys::PasskeysView, security::SecurityViewModel, }; -use crate::ui::types::{ActiveView, DeviceConnectionState, LayoutState, ViewCache}; use gpui::prelude::*; use gpui::*; use gpui_component::Root; @@ -12,114 +11,11 @@ use gpui_component::{ ActiveTheme, Icon, TitleBar, WindowExt, h_flex, scroll::ScrollableElement, v_flex, }; -gpui::actions!(picoforge, [ToggleSidebar]); - -pub struct ApplicationRoot { - pub device: DeviceConnectionState, - pub layout: LayoutState, - pub views: ViewCache, - pub focus_handle: FocusHandle, -} - -impl ApplicationRoot { - pub fn new(cx: &mut Context) -> Self { - let mut this = Self { - device: DeviceConnectionState::new(), - layout: LayoutState::new(), - views: ViewCache::new(), - focus_handle: cx.focus_handle(), - }; - - this.refresh_device_status(None, cx); - this - } - - pub fn focus_handle(&self) -> FocusHandle { - self.focus_handle.clone() - } - - fn refresh_device_status(&mut self, window: Option<&mut Window>, cx: &mut Context) { - if self.device.loading { - return; - } - self.device.loading = true; - self.device.error = None; - cx.notify(); - - match io::read_device_details() { - Ok(status) => { - let device_changed = self - .device - .status - .as_ref() - .map(|s| s.info.serial != status.info.serial) - .unwrap_or(true); - - self.device.status = Some(status.clone()); - self.device.error = None; - - if device_changed { - self.views.passkeys = None; - } else if let Some(passkeys_view) = &self.views.passkeys { - passkeys_view.update(cx, |view, cx| { - view.refresh_if_unlocked(cx); - }); - } - - match io::get_fido_info() { - Ok(fido) => { - self.device.fido_info = Some(fido); - } - Err(e) => { - log::error!("FIDO Info fetch failed: {}", e); - self.device.fido_info = None; - } - } - - if status.firmware_type == crate::hal::types::FirmwareType::RSKey - && status.method == crate::hal::types::DeviceMethod::Rescue - { - self.device.led_status = io::read_led_config().ok(); - self.device.management_apps = io::read_management_config().ok(); - } else { - self.device.led_status = None; - self.device.management_apps = None; - } - - if let Some(config_view) = &self.views.config - && let Some(window) = window - { - let device = self.device.clone(); - config_view.update(cx, |view, cx| { - view.sync_from_device(&device, window, cx); - }); - } - } - Err(e) => { - self.device.status = None; - self.device.error = Some(format!("{}", e)); - self.device.fido_info = None; - self.device.led_status = None; - self.device.management_apps = None; - // Drop cached passkeys view (and cached PIN) on disconnect. - self.views.passkeys = None; - } - } - self.device.loading = false; - cx.notify(); - } - - pub fn toggle_sidebar(&mut self, cx: &mut Context) { - self.layout.is_sidebar_collapsed = !self.layout.is_sidebar_collapsed; - cx.notify(); - } -} - impl Render for ApplicationRoot { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let window_width = window.bounds().size.width; let is_window_wide = window_width > px(800.0); - let is_sidebar_collapsed = self.layout.is_sidebar_collapsed || !is_window_wide; + let is_sidebar_collapsed = self.view_state.is_sidebar_collapsed || !is_window_wide; let target_width = if is_sidebar_collapsed { px(48.) @@ -127,12 +23,12 @@ impl Render for ApplicationRoot { px(255.) }; - if (self.layout.sidebar_width - target_width).abs() > px(0.1) { - self.layout.sidebar_width = - self.layout.sidebar_width + (target_width - self.layout.sidebar_width) * 0.2; + if (self.view_state.sidebar_width - target_width).abs() > px(0.1) { + self.view_state.sidebar_width = self.view_state.sidebar_width + + (target_width - self.view_state.sidebar_width) * 0.2; window.request_animation_frame(); } else { - self.layout.sidebar_width = target_width; + self.view_state.sidebar_width = target_width; } let dialog_layer = Root::render_dialog_layer(window, cx); @@ -158,15 +54,16 @@ impl Render for ApplicationRoot { .overflow_y_scrollbar() .flex_grow() .bg(cx.theme().background) - .child(match self.layout.active_view { + .child(match self.view_state.active_view { ActiveView::Home => { - HomeView::build(&self.device, cx.theme(), window.bounds().size.width) - .into_any_element() + let view = self.views_store.home.get_or_insert_with(|| { + cx.new(|cx| HomeViewModel::new(window, cx, &self.models)) + }); + view.clone().into_any_element() } ActiveView::Passkeys => { - let view = self.views.passkeys.get_or_insert_with(|| { - let root = cx.entity().downgrade(); - let view = cx.new(|cx| PasskeysView::new(window, cx, root)); + let view = self.views_store.passkeys.get_or_insert_with(|| { + let view = cx.new(|cx| PasskeysView::new(window, cx, &self.models)); cx.subscribe_in( &view, window, @@ -182,38 +79,43 @@ impl Render for ApplicationRoot { view.clone().into_any_element() } ActiveView::Configuration => { - if self.views.config.is_none() { - let root = cx.entity().downgrade(); - let device = self.device.clone(); - self.views.config = - Some(cx.new(|cx| ConfigView::new(window, cx, root, device))); - } - self.views.config.clone().unwrap().into_any_element() + let view = self.views_store.config.get_or_insert_with(|| { + cx.new(|cx| ConfigView::new(window, cx, &self.models)) + }); + view.clone().into_any_element() + } + ActiveView::Security => { + let view = self.views_store.security.get_or_insert_with(|| { + cx.new(|cx| SecurityViewModel::new(window, cx, &self.models)) + }); + view.clone().into_any_element() + } + ActiveView::About => { + let view = self.views_store.about.get_or_insert_with(|| { + cx.new(|cx| AboutViewModel::new(window, cx, &self.models)) + }); + view.clone().into_any_element() } - ActiveView::Security => SecurityView::build(cx).into_any_element(), - ActiveView::About => AboutView::build(cx.theme()).into_any_element(), }); let sidebar = AppSidebar::new( - self.layout.active_view, - self.layout.sidebar_width, + self.view_state.active_view, + self.view_state.sidebar_width, is_sidebar_collapsed, - self.device.clone(), + &self.models, ) .on_select(|this: &mut Self, view, _, _| { - this.layout.active_view = view; + this.view_state.active_view = view; }) .on_refresh(|this, window, cx| { this.refresh_device_status(Some(window), cx); }); - // Toggle button absolutely positioned at the sidebar's right edge. - // It fades in on hover when the sidebar is collapsed. let sidebar_bg = cx.theme().sidebar; let border_color = cx.theme().sidebar_border; let sidebar_fg = cx.theme().sidebar_foreground; - let is_toggle_visible = !is_sidebar_collapsed || self.layout.sidebar_toggle_hovered; - let sidebar_width = self.layout.sidebar_width; + let is_toggle_visible = !is_sidebar_collapsed || self.view_state.sidebar_toggle_hovered; + let sidebar_width = self.view_state.sidebar_width; let toggle_icon = if is_sidebar_collapsed { "icons/chevron-right.svg" } else { @@ -235,7 +137,7 @@ impl Render for ApplicationRoot { .items_center() .justify_center() .on_hover(cx.listener(|this, hovered, _, cx| { - this.layout.sidebar_toggle_hovered = *hovered; + this.view_state.sidebar_toggle_hovered = *hovered; cx.notify(); })) .child( @@ -258,7 +160,8 @@ impl Render for ApplicationRoot { .build(window, cx) }) .on_click(cx.listener(|this, _, _, _| { - this.layout.is_sidebar_collapsed = !this.layout.is_sidebar_collapsed; + this.view_state.is_sidebar_collapsed = + !this.view_state.is_sidebar_collapsed; })) .child(Icon::default().path(toggle_icon).text_color(sidebar_fg)), ); diff --git a/src/ui/screens/about/mod.rs b/src/ui/screens/about/mod.rs new file mode 100644 index 0000000..5b085b2 --- /dev/null +++ b/src/ui/screens/about/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::AboutViewModel; diff --git a/src/ui/screens/about.rs b/src/ui/screens/about/view.rs similarity index 95% rename from src/ui/screens/about.rs rename to src/ui/screens/about/view.rs index 33ed528..b7f2fee 100644 --- a/src/ui/screens/about.rs +++ b/src/ui/screens/about/view.rs @@ -1,12 +1,11 @@ -// src/views/about.rs use crate::ui::components::{card::Card, page_view::PageView, tag::Tag}; +use crate::ui::screens::about::view_model::AboutViewModel; use gpui::*; -use gpui_component::{Icon, StyledExt, Theme, button::Button, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Icon, StyledExt, button::Button, h_flex, v_flex}; -pub struct AboutView; - -impl AboutView { - pub fn build(theme: &Theme) -> impl IntoElement { +impl Render for AboutViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); PageView::build( "About", "Information about the application and its development.", diff --git a/src/ui/screens/about/view_model.rs b/src/ui/screens/about/view_model.rs new file mode 100644 index 0000000..8e6c9c6 --- /dev/null +++ b/src/ui/screens/about/view_model.rs @@ -0,0 +1,10 @@ +use crate::ui::app::AppModels; +use gpui::*; + +pub struct AboutViewModel; + +impl AboutViewModel { + pub fn new(_window: &mut Window, _cx: &mut Context, _models: &AppModels) -> Self { + Self + } +} diff --git a/src/ui/screens/config.rs b/src/ui/screens/config.rs deleted file mode 100644 index 68c21ac..0000000 --- a/src/ui/screens/config.rs +++ /dev/null @@ -1,1354 +0,0 @@ -use crate::hal::rescue::constants::{ - LedColor, LedStatus, USB_CAP_FIDO2, USB_CAP_OATH, USB_CAP_OPENPGP, USB_CAP_OTP, USB_CAP_PIV, - USB_CAP_U2F, -}; -use crate::hal::types::{AppConfigInput, DeviceMethod}; -use crate::hal::{fido, io}; -use crate::ui::components::dialog::PinPromptContent; -use crate::ui::components::{card::Card, dialog, dialog::StatusContent, page_view::PageView}; -use crate::ui::rootview::ApplicationRoot; -use crate::ui::types::{DeviceConnectionState, LedDriverType, UsbIdentityPreset}; -use gpui::*; -use gpui_component::button::{ButtonCustomVariant, ButtonVariants}; -use gpui_component::{ - ActiveTheme, Disableable, Icon, Theme, - button::Button, - input::{Input, InputState}, - select::{Select, SelectItem, SelectState}, - slider::{Slider, SliderState}, - switch::Switch, - v_flex, -}; - -#[derive(Clone, PartialEq)] -struct VendorSelectOption { - preset: UsbIdentityPreset, - label: SharedString, -} - -impl SelectItem for VendorSelectOption { - type Value = UsbIdentityPreset; - - fn title(&self) -> SharedString { - self.label.clone() - } - - fn value(&self) -> &Self::Value { - &self.preset - } -} - -#[derive(Clone, PartialEq)] -struct DriverSelectOption { - driver_type: LedDriverType, - label: SharedString, -} - -impl SelectItem for DriverSelectOption { - type Value = LedDriverType; - - fn title(&self) -> SharedString { - self.label.clone() - } - - fn value(&self) -> &Self::Value { - &self.driver_type - } -} - -enum StatusDialogHandle { - Pin(WeakEntity), - Status(WeakEntity), -} - -pub struct ConfigView { - root: WeakEntity, - vendor_select: Entity>>, - vid_input: Entity, - pid_input: Entity, - product_name_input: Entity, - led_gpio_input: Entity, - led_driver_select: Entity>>, - led_brightness_slider: Entity, - led_dimmable: bool, - led_steady: bool, - touch_timeout_input: Entity, - power_cycle: bool, - enable_secp256k1: bool, - loading: bool, - is_custom_vendor: bool, - - // RS-Key specific state - led_status_steady: bool, - led_status_colors: [u8; 4], - led_status_brightness: [u8; 4], - usb_apps_supported: u16, - usb_apps_enabled: u16, - enabled_usb_itf: Option, - - _task: Option>, -} - -impl ConfigView { - pub fn new( - window: &mut Window, - cx: &mut Context, - root: WeakEntity, - device: DeviceConnectionState, - ) -> Self { - let config = device.status.as_ref().map(|s| &s.config); - - let vendors: Vec = UsbIdentityPreset::all() - .iter() - .map(|preset| { - let (label, _, _) = preset.details(); - VendorSelectOption { - preset: *preset, - label, - } - }) - .collect(); - - let drivers: Vec = LedDriverType::all() - .iter() - .map(|driver| DriverSelectOption { - driver_type: *driver, - label: driver.label(), - }) - .collect(); - - let current_vid: SharedString = config - .map(|c| c.vid.clone().into()) - .unwrap_or_else(|| "CAFE".into()); - let current_pid: SharedString = config - .map(|c| c.pid.clone().into()) - .unwrap_or_else(|| "4242".into()); - let current_product_name: SharedString = config - .map(|c| c.product_name.clone().into()) - .unwrap_or_else(|| "My Key".into()); - let current_led_gpio: SharedString = config - .map(|c| c.led_gpio.to_string().into()) - .unwrap_or_else(|| "25".into()); - let current_touch_timeout: SharedString = config - .map(|c| c.touch_timeout.to_string().into()) - .unwrap_or_else(|| "10".into()); - let current_brightness = config.map(|c| c.led_brightness as f32).unwrap_or(8.0); - - let initial_preset = UsbIdentityPreset::from_vid_pid(¤t_vid, ¤t_pid); - let is_custom_vendor = initial_preset == UsbIdentityPreset::Custom; - - let initial_vendor_idx = UsbIdentityPreset::all() - .iter() - .position(|p| *p == initial_preset) - .unwrap_or(0); - - let vendor_select = cx.new(|cx| { - SelectState::new( - vendors, - Some(gpui_component::IndexPath::default().row(initial_vendor_idx)), - window, - cx, - ) - }); - - let vid_input = cx.new(|cx| InputState::new(window, cx).default_value(current_vid.clone())); - let pid_input = cx.new(|cx| InputState::new(window, cx).default_value(current_pid.clone())); - let product_name_input = - cx.new(|cx| InputState::new(window, cx).default_value(current_product_name.clone())); - - let led_gpio_input = - cx.new(|cx| InputState::new(window, cx).default_value(current_led_gpio.clone())); - - let current_driver_val = config.and_then(|c| c.led_driver).unwrap_or(0); - let initial_driver_idx = LedDriverType::all() - .iter() - .position(|d| d.value() == current_driver_val) - .unwrap_or(0); - - let led_driver_select = cx.new(|cx| { - SelectState::new( - drivers, - Some(gpui_component::IndexPath::default().row(initial_driver_idx)), - window, - cx, - ) - }); - - cx.subscribe_in( - &vendor_select, - window, - |this: &mut Self, _, event, window, cx| { - if let gpui_component::select::SelectEvent::Confirm(Some(preset)) = event { - let (_, vid_opt, pid_opt) = preset.details(); - - if let (Some(vid), Some(pid)) = (vid_opt, pid_opt) { - this.is_custom_vendor = false; - this.vid_input - .update(cx, |input, cx| input.set_value(vid, window, cx)); - this.pid_input - .update(cx, |input, cx| input.set_value(pid, window, cx)); - } else { - this.is_custom_vendor = true; - } - cx.notify(); - } - }, - ) - .detach(); - - let led_brightness_slider = cx.new(|_| { - SliderState::new() - .min(0.0) - .max(15.0) - .step(1.0) - .default_value(current_brightness) - }); - - let touch_timeout_input = - cx.new(|cx| InputState::new(window, cx).default_value(current_touch_timeout.clone())); - - let mut led_status_steady = false; - let mut led_status_colors = [0; 4]; - let mut led_status_brightness = [0; 4]; - if let Some(led) = &device.led_status { - led_status_steady = led.steady; - for i in 0..4 { - led_status_colors[i] = led.statuses[i].0; - led_status_brightness[i] = led.statuses[i].1; - } - } - - let mut usb_apps_supported = 0; - let mut usb_apps_enabled = 0; - if let Some(apps) = &device.management_apps { - usb_apps_supported = apps.usb_supported; - usb_apps_enabled = apps.usb_enabled; - } - - Self { - root, - vendor_select, - vid_input, - pid_input, - product_name_input, - led_gpio_input, - led_driver_select, - led_brightness_slider, - led_dimmable: config.map(|c| c.led_dimmable).unwrap_or(true), - led_steady: config.map(|c| c.led_steady).unwrap_or(false), - touch_timeout_input, - power_cycle: config.map(|c| c.power_cycle_on_reset).unwrap_or(false), - enable_secp256k1: config.map(|c| c.enable_secp256k1).unwrap_or(true), - loading: false, - is_custom_vendor, - led_status_steady, - led_status_colors, - led_status_brightness, - usb_apps_supported, - usb_apps_enabled, - enabled_usb_itf: config.and_then(|c| c.enabled_usb_itf), - _task: None, - } - } - - fn write_config_to_device( - &mut self, - changes: AppConfigInput, - method: crate::hal::types::DeviceMethod, - pin: Option, - dialog_handle: StatusDialogHandle, - cx: &mut Context, - ) { - let expected_serial = self.root.upgrade().and_then(|r| { - r.read(cx) - .device - .status - .as_ref() - .map(|s| s.info.serial.clone()) - }); - - self.loading = true; - cx.notify(); - - let entity = cx.entity().downgrade(); - let method_clone = method.clone(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { io::write_config(changes, method_clone, pin) }) - .await; - - let new_status_result = if result.is_ok() { - Some( - cx.background_executor() - .spawn(async move { io::read_device_details() }) - .await, - ) - } else { - None - }; - - let _ = entity.update(cx, |this, cx| { - this.loading = false; - - match result { - Ok(msg) => { - log::info!("Success: {}", msg); - - if let Some(Ok(new_status)) = new_status_result { - let serial_matches = expected_serial.as_deref() - == Some(new_status.info.serial.as_str()); - - if serial_matches { - log::info!( - "Refreshed device status. LED Steady: {}", - new_status.config.led_steady - ); - - let config = &new_status.config; - this.led_dimmable = config.led_dimmable; - this.led_steady = config.led_steady; - this.power_cycle = config.power_cycle_on_reset; - this.enable_secp256k1 = config.enable_secp256k1; - - let _ = this.root.update(cx, |root, cx| { - root.device.status = Some(new_status); - cx.notify(); - }); - } else { - log::warn!("Device changed during config write, discarding stale status"); - } - } - - match &dialog_handle { - StatusDialogHandle::Pin(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_success( - "Configuration applied successfully.".to_string(), - cx, - ); - }); - } - StatusDialogHandle::Status(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_success( - "Configuration applied successfully.".to_string(), - cx, - ); - }); - } - } - } - Err(e) => { - log::error!("Error saving config: {}", e); - - let mut err_msg = format!("Failed to apply configuration: {}", e); - - // Special case for FIDO 0x3E error (Invalid Subcommand) - // This happens when the firmware is too old to support config over FIDO - if method == DeviceMethod::Fido && err_msg.contains("0x3E") - { - err_msg = "The device firmware does not support being configured in fido only communication mode. \nHave a look at the troubleshooting guide to fix this".to_string(); - } - - match &dialog_handle { - StatusDialogHandle::Pin(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_error(err_msg, cx); - }); - } - StatusDialogHandle::Status(dh) => { - let _ = dh.update(cx, |d, cx| { - d.set_error(err_msg, cx); - }); - } - } - } - } - - cx.notify(); - }); - })); - } - - fn open_pin_dialog( - &mut self, - changes: AppConfigInput, - window: &mut Window, - cx: &mut Context, - ) { - let view_handle = cx.entity().downgrade(); - - dialog::open_pin_prompt( - "Authentication Required", - "Enter your device PIN to apply changes.", - None, - "Confirm", - window, - cx, - move |pin, dialog_handle, cx| { - let _ = view_handle.update(cx, |this, cx| { - this.write_config_to_device( - changes.clone(), - DeviceMethod::Fido, - Some(pin), - StatusDialogHandle::Pin(dialog_handle), - cx, - ); - }); - }, - ); - } - - fn apply_changes(&mut self, window: &mut Window, cx: &mut Context) { - let Some(root) = self.root.upgrade() else { - return; - }; - let device = root.read(cx).device.clone(); - let Some(status) = &device.status else { return }; - - let current_config = &status.config; - let mut has_changes = false; - - let vid = self.vid_input.read(cx).text().to_string(); - if vid != current_config.vid { - has_changes = true; - } - - let pid = self.pid_input.read(cx).text().to_string(); - if pid != current_config.pid { - has_changes = true; - } - - let product_name = self.product_name_input.read(cx).text().to_string(); - if product_name != current_config.product_name { - has_changes = true; - } - - let mut final_led_gpio = current_config.led_gpio; - let led_gpio_str = self.led_gpio_input.read(cx).text().to_string(); - if let Ok(val) = led_gpio_str.parse::() { - if val != current_config.led_gpio { - has_changes = true; - } - final_led_gpio = val; - } - - let mut final_led_driver = current_config.led_driver; - let driver_idx = self.led_driver_select.read(cx).selected_index(cx); - if let Some(idx) = driver_idx - && let Some(driver) = LedDriverType::all().get(idx.row) - { - let val = driver.value(); - let current_val = current_config.led_driver.unwrap_or(1); - if val != current_val { - has_changes = true; - } - final_led_driver = Some(val); - } - - let brightness = self.led_brightness_slider.read(cx).value().start() as u8; - if brightness != current_config.led_brightness { - has_changes = true; - } - - let mut final_touch_timeout = current_config.touch_timeout; - let touch_timeout_str = self.touch_timeout_input.read(cx).text().to_string(); - if let Ok(val) = touch_timeout_str.parse::() { - if val != current_config.touch_timeout { - has_changes = true; - } - final_touch_timeout = val; - } - - if (self.led_dimmable != current_config.led_dimmable) - || (self.led_steady != current_config.led_steady) - || (self.power_cycle != current_config.power_cycle_on_reset) - { - has_changes = true; - } - - if self.enable_secp256k1 != current_config.enable_secp256k1 { - has_changes = true; - } - - let mut final_enabled_usb_itf = current_config.enabled_usb_itf; - if self.enabled_usb_itf != current_config.enabled_usb_itf { - has_changes = true; - final_enabled_usb_itf = self.enabled_usb_itf; - } - - if !has_changes { - log::info!("No changes detected"); - return; - } - - let changes = AppConfigInput { - vid: Some(vid), - pid: Some(pid), - product_name: Some(product_name), - led_gpio: Some(final_led_gpio), - led_brightness: Some(brightness), - touch_timeout: Some(final_touch_timeout), - led_driver: final_led_driver, - led_dimmable: Some(self.led_dimmable), - power_cycle_on_reset: Some(self.power_cycle), - led_steady: Some(self.led_steady), - enable_secp256k1: Some(self.enable_secp256k1), - raw_curves_mask: current_config.raw_curves_mask, - led_order: current_config.led_order, - enabled_usb_itf: final_enabled_usb_itf, - }; - - let method = status.method.clone(); - - if method == DeviceMethod::Fido { - if Self::status_supports_legacy_fido_config(status) { - self.open_pin_dialog(changes, window, cx); - } else { - let handle = - dialog::open_status_dialog("Configuration Requires Rescue Mode", window, cx); - self.write_config_to_device( - changes, - method, - None, - StatusDialogHandle::Status(handle), - cx, - ); - } - } else { - let handle = dialog::open_status_dialog("Applying Configuration", window, cx); - self.write_config_to_device( - changes, - method, - None, - StatusDialogHandle::Status(handle), - cx, - ); - } - } - - fn status_supports_legacy_fido_config(status: &crate::hal::types::FullDeviceStatus) -> bool { - status.method == DeviceMethod::Fido - && fido::firmware_supports_legacy_fido_hardware_config(&status.info.firmware_version) - } - - pub fn sync_from_device( - &mut self, - device: &DeviceConnectionState, - window: &mut Window, - cx: &mut Context, - ) { - let config = device.status.as_ref().map(|s| &s.config); - - let vid = config - .map(|c| c.vid.clone()) - .unwrap_or_else(|| "CAFE".into()); - self.vid_input - .update(cx, |input, cx| input.set_value(vid, window, cx)); - - let pid = config - .map(|c| c.pid.clone()) - .unwrap_or_else(|| "4242".into()); - self.pid_input - .update(cx, |input, cx| input.set_value(pid, window, cx)); - - let product = config - .map(|c| c.product_name.clone()) - .unwrap_or_else(|| "My Key".into()); - self.product_name_input - .update(cx, |input, cx| input.set_value(product, window, cx)); - - let gpio = config - .map(|c| c.led_gpio.to_string()) - .unwrap_or_else(|| "25".into()); - self.led_gpio_input - .update(cx, |input, cx| input.set_value(gpio, window, cx)); - - let timeout = config - .map(|c| c.touch_timeout.to_string()) - .unwrap_or_else(|| "10".into()); - self.touch_timeout_input - .update(cx, |input, cx| input.set_value(timeout, window, cx)); - - self.led_dimmable = config.map(|c| c.led_dimmable).unwrap_or(true); - self.led_steady = config.map(|c| c.led_steady).unwrap_or(false); - self.power_cycle = config.map(|c| c.power_cycle_on_reset).unwrap_or(false); - self.enable_secp256k1 = config.map(|c| c.enable_secp256k1).unwrap_or(true); - - let brightness = config.map(|c| c.led_brightness as f32).unwrap_or(8.0); - self.led_brightness_slider - .update(cx, |slider, cx| slider.set_value(brightness, window, cx)); - - let new_driver_val = config.and_then(|c| c.led_driver).unwrap_or(1); - let new_driver_idx = LedDriverType::all() - .iter() - .position(|d| d.value() == new_driver_val) - .unwrap_or(0); - self.led_driver_select.update(cx, |select, cx| { - select.set_selected_index( - Some(gpui_component::IndexPath::default().row(new_driver_idx)), - window, - cx, - ); - }); - - if let Some(led) = &device.led_status { - self.led_status_steady = led.steady; - for i in 0..4 { - self.led_status_colors[i] = led.statuses[i].0; - self.led_status_brightness[i] = led.statuses[i].1; - } - } - - if let Some(apps) = &device.management_apps { - self.usb_apps_supported = apps.usb_supported; - self.usb_apps_enabled = apps.usb_enabled; - } - - self.enabled_usb_itf = config.and_then(|c| c.enabled_usb_itf); - - cx.notify(); - } - - fn render_identity_card( - &self, - theme: &Theme, - is_fido: bool, - hardware_config_disabled: bool, - ) -> impl IntoElement { - let content = v_flex() - .gap_4() - .child( - v_flex().gap_2().child("Vendor Preset").child( - Select::new(&self.vendor_select) - .bg(rgb(0x222225)) - .w_full() - .disabled(hardware_config_disabled), - ), - ) - .child( - div() - .grid() - .grid_cols(2) - .gap_4() - .child( - v_flex().gap_2().child("Vendor ID (HEX)").child( - Input::new(&self.vid_input) - .font_family("Mono") - .bg(rgb(0x222225)) - .disabled(hardware_config_disabled || !self.is_custom_vendor), - ), - ) - .child( - v_flex().gap_2().child("Product ID (HEX)").child( - Input::new(&self.pid_input) - .font_family("Mono") - .bg(rgb(0x222225)) - .disabled(hardware_config_disabled || !self.is_custom_vendor), - ), - ), - ) - .child(div().h_px().bg(theme.border)) - .child( - v_flex().gap_2().child("Product Name").child( - Input::new(&self.product_name_input) - .bg(rgb(0x222225)) - .disabled(is_fido), - ), - ); - - Card::new() - .title("Identity") - .description("USB Identification settings") - .icon(Icon::default().path("icons/tag.svg")) - .child(content) - } - - fn render_led_card( - &mut self, - cx: &mut Context, - is_fido: bool, - hardware_config_disabled: bool, - ) -> impl IntoElement { - let dim_listener = cx.listener(|this, checked, _, cx| { - this.led_dimmable = *checked; - cx.notify(); - }); - - let steady_listener = cx.listener(|this, checked, _, cx| { - this.led_steady = *checked; - cx.notify(); - }); - - let theme = cx.theme(); - - let brightness = self.led_brightness_slider.read(cx).value().start() as i32; - - let content = v_flex() - .gap_4() - .child( - v_flex().gap_2().child("LED GPIO Pin").child( - Input::new(&self.led_gpio_input) - .bg(rgb(0x222225)) - .disabled(hardware_config_disabled), - ), - ) - .child( - v_flex().gap_2().child("LED Driver").child( - Select::new(&self.led_driver_select) - .w_full() - .bg(rgb(0x222225)) - .disabled(is_fido), - ), - ) - .child(div().h_px().bg(theme.border)) - .child( - v_flex().gap_2().child("Brightness (0-15)").child( - gpui_component::h_flex() - .items_center() - .gap_4() - .child( - Slider::new(&self.led_brightness_slider) - .flex_1() - .disabled(hardware_config_disabled), - ) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(format!("Level {}", brightness)), - ), - ), - ) - .child( - gpui_component::h_flex() - .items_center() - .justify_between() - .child( - v_flex().gap_0p5().child("LED Dimmable").child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Allow brightness adjustment"), - ), - ) - .child( - Switch::new("led-dimmable") - .checked(self.led_dimmable) - .disabled(hardware_config_disabled) - .on_click(dim_listener), - ), - ) - .child( - gpui_component::h_flex() - .items_center() - .justify_between() - .child( - v_flex().gap_0p5().child("LED Steady Mode").child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Keep LED on constantly"), - ), - ) - .child( - Switch::new("led-steady") - .checked(self.led_steady) - .disabled(hardware_config_disabled) - .on_click(steady_listener), - ), - ); - - Card::new() - .title("LED Settings") - .description("Adjust visual feedback behavior") - .icon(Icon::default().path("icons/microchip.svg")) - .child(content) - } - - fn render_touch_card(&self, _theme: &Theme, is_fido: bool) -> impl IntoElement { - let content = v_flex().gap_4().child( - v_flex().gap_2().child("Touch Timeout (seconds)").child( - Input::new(&self.touch_timeout_input) - .bg(rgb(0x222225)) - .disabled(is_fido), - ), - ); - - Card::new() - .title("Touch & Timing") - .description("Configure interaction timeouts") - .icon(Icon::default().path("icons/settings.svg")) - .child(content) - } - - fn render_options_card( - &mut self, - cx: &mut Context, - is_fido: bool, - hardware_config_disabled: bool, - ) -> impl IntoElement { - let power_cycle_listener = cx.listener(|this, checked, _, cx| { - this.power_cycle = *checked; - cx.notify(); - }); - - let secp_listener = cx.listener(|this, checked, _, cx| { - this.enable_secp256k1 = *checked; - cx.notify(); - }); - - let theme = cx.theme(); - - let content = v_flex() - .gap_4() - .child( - gpui_component::h_flex() - .items_center() - .justify_between() - .child( - v_flex().gap_0p5().child("Power Cycle on Reset").child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Restart device on reset"), - ), - ) - .child( - Switch::new("power-cycle") - .checked(self.power_cycle) - .disabled(hardware_config_disabled) - .on_click(power_cycle_listener), - ), - ) - .child( - gpui_component::h_flex() - .items_center() - .justify_between() - .child( - v_flex().gap_0p5().child("Enable Secp256k1").child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Does not work on Android!"), - ), - ) - .child( - Switch::new("enable-secp") - .checked(self.enable_secp256k1) - .disabled(is_fido) - .on_click(secp_listener), - ), - ); - - Card::new() - .title("Device Options") - .description("Toggle advanced features") - .icon(Icon::default().path("icons/settings.svg")) - .child(content) - } - - /// Renders the RS-Key-specific LED configuration card. - /// - /// This dynamic panel iterates through the device's LED operating statuses (Idle, Processing, - /// Touch, Boot) and provides interactive widgets to customize the active color and brightness - /// level for each. Only displayed when an RS-Key firmware is detected. - fn render_rskey_led_card(&mut self, cx: &mut Context, is_fido: bool) -> impl IntoElement { - let theme = cx.theme(); - let mut rows = v_flex().gap_4(); - - // Steady switch - let steady_listener = cx.listener(|this, checked, _, cx| { - this.led_status_steady = *checked; - cx.notify(); - }); - - rows = rows.child( - gpui_component::h_flex() - .items_center() - .justify_between() - .child( - v_flex().gap_0p5().child("Global Steady Mode").child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Keep status LEDs on constantly"), - ), - ) - .child( - Switch::new("rskey-led-steady") - .checked(self.led_status_steady) - .disabled(is_fido) - .on_click(steady_listener), - ), - ); - - rows = rows.child(div().h_px().bg(theme.border)); - - // Create rows for each status - for (i, status) in LedStatus::all().iter().enumerate() { - let color_val = self.led_status_colors[i]; - let brightness_val = self.led_status_brightness[i]; - - let cycle_color_listener = cx.listener(move |this, _, _, cx| { - let mut c = this.led_status_colors[i]; - c = (c + 1) % LedColor::all().len() as u8; - this.led_status_colors[i] = c; - cx.notify(); - }); - - let dec_bright_listener = cx.listener(move |this, _, _, cx| { - let mut b = this.led_status_brightness[i]; - b = b.saturating_sub(1); - this.led_status_brightness[i] = b; - cx.notify(); - }); - - let inc_bright_listener = cx.listener(move |this, _, _, cx| { - let mut b = this.led_status_brightness[i]; - if b < 15 { - b += 1; - } - this.led_status_brightness[i] = b; - cx.notify(); - }); - - let color_name = LedColor::from_u8(color_val) - .map(|c| c.label()) - .unwrap_or("Unknown"); - - rows = rows.child( - gpui_component::h_flex() - .items_center() - .justify_between() - .child(div().w_24().child(status.label())) - .child( - gpui_component::h_flex() - .gap_2() - .items_center() - .child( - Button::new(gpui::SharedString::from(format!("color-btn-{}", i))) - .child(color_name) - .custom( - ButtonCustomVariant::new(cx) - .color(rgb(0x27272a).into()) - .hover(rgb(0x3f3f46).into()) - .active(rgb(0x52525b).into()) - .border(theme.border), - ) - .disabled(is_fido) - .on_click(cycle_color_listener), - ) - .child(div().w_4()) - .child( - Button::new(gpui::SharedString::from(format!("bdec-btn-{}", i))) - .child("-") - .custom( - ButtonCustomVariant::new(cx) - .color(rgb(0x1b1b1d).into()) - .hover(rgb(0x232325).into()) - .active(rgb(0x3f3f46).into()) - .border(theme.border), - ) - .disabled(is_fido || brightness_val == 0) - .on_click(dec_bright_listener), - ) - .child( - div() - .w_8() - .flex() - .justify_center() - .child(brightness_val.to_string()), - ) - .child( - Button::new(gpui::SharedString::from(format!("binc-btn-{}", i))) - .child("+") - .custom( - ButtonCustomVariant::new(cx) - .color(rgb(0x1b1b1d).into()) - .hover(rgb(0x232325).into()) - .active(rgb(0x3f3f46).into()) - .border(theme.border), - ) - .disabled(is_fido || brightness_val >= 15) - .on_click(inc_bright_listener), - ), - ), - ); - } - - // Add a save button for LED status - rows = rows.child(div().h_px().bg(theme.border)); - rows = rows.child( - gpui_component::h_flex().justify_end().child( - Button::new("apply-rskey-leds") - .child("Save LED Status") - .custom( - ButtonCustomVariant::new(cx) - .color(rgb(0xe3e3e6).into()) - .hover(rgb(0xcfcfd1).into()) - .active(rgb(0xe3e3e6).into()) - .foreground(rgb(0x4b4b4e).into()), - ) - .disabled(is_fido || self.loading) - .on_click(cx.listener(|this, _, window, cx| { - this.apply_rskey_led_settings(window, cx); - })), - ), - ); - - Card::new() - .title("Status LED Colors") - .description("Configure LED colors and brightness per device state") - .icon(Icon::default().path("icons/palette.svg")) - .child(rows) - } - - fn apply_rskey_led_settings(&mut self, window: &mut Window, cx: &mut Context) { - let steady = self.led_status_steady; - let colors = self.led_status_colors; - let brightnesses = self.led_status_brightness; - - self.loading = true; - let handle = dialog::open_status_dialog("Applying LED Configuration...", window, cx); - let entity = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { - for i in 0..4 { - io::write_led_status(i as u8, colors[i], brightnesses[i], steady)?; - } - Ok::<_, crate::error::PFError>(()) - }) - .await; - - let _ = entity.update(cx, |this, cx| { - this.loading = false; - match result { - Ok(_) => { - let _ = handle.update(cx, |d, cx| { - d.set_success( - "LED configuration applied successfully.".to_string(), - cx, - ); - }); - } - Err(e) => { - let _ = handle.update(cx, |d, cx| { - d.set_error(format!("Failed to apply LED config: {}", e), cx); - }); - } - } - cx.notify(); - }); - })); - } - - /// Renders the RS-Key-specific USB Applications management card. - /// - /// Provides toggles to enable or disable USB endpoints such as U2F, OATH, PIV, and OpenPGP. - /// Safely computes the bitmasks and writes to the Management applet. Gated by hardware support. - fn render_rskey_apps_card( - &mut self, - cx: &mut Context, - is_fido: bool, - ) -> impl IntoElement { - let theme = cx.theme(); - let mut rows = v_flex().gap_4(); - - let apps = [ - ("FIDO2", USB_CAP_FIDO2), - ("OATH", USB_CAP_OATH), - ("PIV", USB_CAP_PIV), - ("OpenPGP", USB_CAP_OPENPGP), - ("U2F", USB_CAP_U2F), - ("OTP", USB_CAP_OTP), - ]; - - for (name, cap) in apps { - let is_supported = (self.usb_apps_supported & cap) != 0; - let is_enabled = (self.usb_apps_enabled & cap) != 0; - - let toggle_listener = cx.listener(move |this, checked, _, cx| { - if *checked { - this.usb_apps_enabled |= cap; - } else { - this.usb_apps_enabled &= !cap; - } - cx.notify(); - }); - - rows = - rows.child( - gpui_component::h_flex() - .items_center() - .justify_between() - .child(v_flex().gap_0p5().child(name).child( - div().text_sm().text_color(theme.muted_foreground).child( - if is_supported { - "Supported" - } else { - "Not Supported by Firmware" - }, - ), - )) - .child( - Switch::new(gpui::SharedString::from(format!("app-toggle-{}", cap))) - .checked(is_enabled) - .disabled(is_fido || !is_supported) - .on_click(toggle_listener), - ), - ); - } - - rows = rows.child(div().h_px().bg(theme.border)); - rows = rows.child( - gpui_component::h_flex().justify_end().child( - Button::new("apply-rskey-apps") - .child("Save USB Applications") - .custom( - ButtonCustomVariant::new(cx) - .color(rgb(0xe3e3e6).into()) - .hover(rgb(0xcfcfd1).into()) - .active(rgb(0xe3e3e6).into()) - .foreground(rgb(0x4b4b4e).into()), - ) - .disabled(is_fido || self.loading) - .on_click(cx.listener(|this, _, window, cx| { - this.apply_rskey_apps_settings(window, cx); - })), - ), - ); - - Card::new() - .title("USB Applications") - .description("Enable or disable specific USB features") - .icon(Icon::default().path("icons/microchip.svg")) - .child(rows) - } - - fn apply_rskey_apps_settings(&mut self, window: &mut Window, cx: &mut Context) { - let mask = self.usb_apps_enabled; - - self.loading = true; - let handle = dialog::open_status_dialog("Applying USB Applications...", window, cx); - let entity = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { io::write_management_config(mask) }) - .await; - - let _ = entity.update(cx, |this, cx| { - this.loading = false; - match result { - Ok(_) => { - let _ = handle.update(cx, |d, cx| { - d.set_success( - "USB applications updated successfully. Please re-plug the device." - .to_string(), - cx, - ); - }); - } - Err(e) => { - let _ = handle.update(cx, |d, cx| { - d.set_error(format!("Failed to apply USB applications: {}", e), cx); - }); - } - } - cx.notify(); - }); - })); - } - - fn render_rskey_usb_itf_card( - &mut self, - cx: &mut Context, - is_fido: bool, - ) -> impl IntoElement { - let theme = cx.theme(); - let mut rows = v_flex().gap_4(); - - // 0x01: CCID, 0x02: WCID, 0x04: HID, 0x08: KB, 0x10: LWIP - let interfaces = [ - ("CCID (Smart Card)", 0x01u8), - ("WCID (WebUSB)", 0x02u8), - ("HID (FIDO)", 0x04u8), - ("KB (Keyboard)", 0x08u8), - ("LWIP", 0x10u8), - ]; - - let current_mask = self.enabled_usb_itf.unwrap_or(0x1F); // Default to all on if missing - - for (name, bit) in interfaces { - let is_enabled = (current_mask & bit) != 0; - let is_ccid = bit == 0x01; - - let toggle_listener = cx.listener(move |this, checked, _, cx| { - let mut mask = this.enabled_usb_itf.unwrap_or(0x1F); - if *checked { - mask |= bit; - } else { - mask &= !bit; - } - - if bit == 0x01 { - // Force CCID on to prevent bricking - mask |= 0x01; - } - - this.enabled_usb_itf = Some(mask); - cx.notify(); - }); - - rows = rows.child( - gpui_component::h_flex() - .items_center() - .justify_between() - .child( - v_flex().gap_0p5().child(name).child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child(if is_ccid { - "Required for Rescue Applet" - } else { - "USB Endpoint" - }), - ), - ) - .child( - Switch::new(gpui::SharedString::from(format!("usb-itf-toggle-{}", bit))) - .checked(is_enabled || is_ccid) // CCID always looks checked - .disabled(is_fido || is_ccid) // Disable toggling CCID entirely! - .on_click(toggle_listener), - ), - ); - } - - Card::new() - .title("Hardware Endpoints") - .description("Toggle low-level USB interfaces") - .icon(Icon::default().path("icons/cpu.svg")) - .child(rows) - } -} - -impl Render for ConfigView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let has_device = self - .root - .upgrade() - .map(|r| r.read(cx).device.status.is_some()) - .unwrap_or(false); - - if !has_device { - return PageView::build( - "Configuration", - "Customize device settings and behavior.", - div() - .flex() - .items_center() - .justify_center() - .h_64() - .border_1() - .border_color(theme.border) - .rounded_xl() - .child( - div() - .text_color(theme.muted_foreground) - .child("No Device Connected"), - ), - theme, - ) - .into_any_element(); - } - - let status = self - .root - .upgrade() - .and_then(|r| r.read(cx).device.status.clone()); - let is_fido = status.as_ref().map(|s| s.method.clone()) == Some(DeviceMethod::Fido); - let supports_legacy_fido_config = status - .as_ref() - .map(Self::status_supports_legacy_fido_config) - .unwrap_or(false); - let hardware_config_disabled = is_fido && !supports_legacy_fido_config; - - let led_card = self - .render_led_card(cx, is_fido, hardware_config_disabled) - .into_any_element(); - let options_card = self - .render_options_card(cx, is_fido, hardware_config_disabled) - .into_any_element(); - - let identity_card = self - .render_identity_card(cx.theme(), is_fido, hardware_config_disabled) - .into_any_element(); - let touch_card = self - .render_touch_card(cx.theme(), is_fido) - .into_any_element(); - - let is_wide = window.bounds().size.width > px(1100.0); - let columns = if is_wide { 2 } else { 1 }; - - let is_rskey = status.as_ref().map(|s| &s.firmware_type) - == Some(&crate::hal::types::FirmwareType::RSKey); - - let mut grid_children = vec![identity_card, led_card, touch_card, options_card]; - - if is_rskey { - let rskey_led = self.render_rskey_led_card(cx, is_fido).into_any_element(); - let rskey_apps = self.render_rskey_apps_card(cx, is_fido).into_any_element(); - let rskey_usb_itf = self - .render_rskey_usb_itf_card(cx, is_fido) - .into_any_element(); - grid_children.push(rskey_led); - grid_children.push(rskey_apps); - grid_children.push(rskey_usb_itf); - } - - let theme = cx.theme(); - - PageView::build( - "Configuration", - "Customize device settings and behavior.", - v_flex() - .gap_6() - .child( - div() - .grid() - .grid_cols(columns) - .gap_6() - .children(grid_children), - ) - .child( - gpui_component::h_flex().justify_end().pt_4().child( - Button::new("apply-changes") - .icon(Icon::default().path("icons/save.svg")) - .child("Apply Changes") - .disabled(self.loading || hardware_config_disabled) - .custom( - ButtonCustomVariant::new(cx) - .color(rgb(0xe3e3e6).into()) - .hover(rgb(0xcfcfd1).into()) - .active(rgb(0xe3e3e6).into()) - .foreground(rgb(0x4b4b4e).into()), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.apply_changes(window, cx); - })), - ), - ), - theme, - ) - .into_any_element() - } -} diff --git a/src/ui/screens/config/mod.rs b/src/ui/screens/config/mod.rs new file mode 100644 index 0000000..88011ad --- /dev/null +++ b/src/ui/screens/config/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::ConfigView; diff --git a/src/ui/screens/config/view.rs b/src/ui/screens/config/view.rs new file mode 100644 index 0000000..e278b88 --- /dev/null +++ b/src/ui/screens/config/view.rs @@ -0,0 +1,657 @@ +use crate::hal::rescue::constants::{ + LedColor, LedStatus, USB_CAP_FIDO2, USB_CAP_OATH, USB_CAP_OPENPGP, USB_CAP_OTP, USB_CAP_PIV, + USB_CAP_U2F, +}; +use crate::hal::types::DeviceMethod; +use crate::ui::components::{card::Card, page_view::PageView}; +use crate::ui::screens::config::view_model::ConfigView; +use gpui::*; +use gpui_component::button::{ButtonCustomVariant, ButtonVariants}; +use gpui_component::{ + ActiveTheme, Disableable, Icon, Theme, button::Button, input::Input, select::Select, + slider::Slider, switch::Switch, v_flex, +}; + +impl ConfigView { + fn render_identity_card( + &self, + theme: &Theme, + is_fido: bool, + hardware_config_disabled: bool, + ) -> impl IntoElement { + let content = v_flex() + .gap_4() + .child( + v_flex().gap_2().child("Vendor Preset").child( + Select::new(&self.vendor_select) + .bg(rgb(0x222225)) + .w_full() + .disabled(hardware_config_disabled), + ), + ) + .child( + div() + .grid() + .grid_cols(2) + .gap_4() + .child( + v_flex().gap_2().child("Vendor ID (HEX)").child( + Input::new(&self.vid_input) + .font_family("Mono") + .bg(rgb(0x222225)) + .disabled(hardware_config_disabled || !self.is_custom_vendor), + ), + ) + .child( + v_flex().gap_2().child("Product ID (HEX)").child( + Input::new(&self.pid_input) + .font_family("Mono") + .bg(rgb(0x222225)) + .disabled(hardware_config_disabled || !self.is_custom_vendor), + ), + ), + ) + .child(div().h_px().bg(theme.border)) + .child( + v_flex().gap_2().child("Product Name").child( + Input::new(&self.product_name_input) + .bg(rgb(0x222225)) + .disabled(is_fido), + ), + ); + + Card::new() + .title("Identity") + .description("USB Identification settings") + .icon(Icon::default().path("icons/tag.svg")) + .child(content) + } + + fn render_led_card( + &mut self, + cx: &mut Context, + is_fido: bool, + hardware_config_disabled: bool, + ) -> impl IntoElement { + let dim_listener = cx.listener(|this, checked, _, cx| { + this.led_dimmable = *checked; + cx.notify(); + }); + + let steady_listener = cx.listener(|this, checked, _, cx| { + this.led_steady = *checked; + cx.notify(); + }); + + let theme = cx.theme(); + + let brightness = self.led_brightness_slider.read(cx).value().start() as i32; + + let content = v_flex() + .gap_4() + .child( + v_flex().gap_2().child("LED GPIO Pin").child( + Input::new(&self.led_gpio_input) + .bg(rgb(0x222225)) + .disabled(hardware_config_disabled), + ), + ) + .child( + v_flex().gap_2().child("LED Driver").child( + Select::new(&self.led_driver_select) + .w_full() + .bg(rgb(0x222225)) + .disabled(is_fido), + ), + ) + .child(div().h_px().bg(theme.border)) + .child( + v_flex().gap_2().child("Brightness (0-15)").child( + gpui_component::h_flex() + .items_center() + .gap_4() + .child( + Slider::new(&self.led_brightness_slider) + .flex_1() + .disabled(hardware_config_disabled), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(format!("Level {}", brightness)), + ), + ), + ) + .child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child( + v_flex().gap_0p5().child("LED Dimmable").child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Allow brightness adjustment"), + ), + ) + .child( + Switch::new("led-dimmable") + .checked(self.led_dimmable) + .disabled(hardware_config_disabled) + .on_click(dim_listener), + ), + ) + .child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child( + v_flex().gap_0p5().child("LED Steady Mode").child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Keep LED on constantly"), + ), + ) + .child( + Switch::new("led-steady") + .checked(self.led_steady) + .disabled(hardware_config_disabled) + .on_click(steady_listener), + ), + ); + + Card::new() + .title("LED Settings") + .description("Adjust visual feedback behavior") + .icon(Icon::default().path("icons/microchip.svg")) + .child(content) + } + + fn render_touch_card(&self, _theme: &Theme, is_fido: bool) -> impl IntoElement { + let content = v_flex().gap_4().child( + v_flex().gap_2().child("Touch Timeout (seconds)").child( + Input::new(&self.touch_timeout_input) + .bg(rgb(0x222225)) + .disabled(is_fido), + ), + ); + + Card::new() + .title("Touch & Timing") + .description("Configure interaction timeouts") + .icon(Icon::default().path("icons/settings.svg")) + .child(content) + } + + fn render_options_card( + &mut self, + cx: &mut Context, + is_fido: bool, + hardware_config_disabled: bool, + ) -> impl IntoElement { + let power_cycle_listener = cx.listener(|this, checked, _, cx| { + this.power_cycle = *checked; + cx.notify(); + }); + + let secp_listener = cx.listener(|this, checked, _, cx| { + this.enable_secp256k1 = *checked; + cx.notify(); + }); + + let theme = cx.theme(); + + let content = v_flex() + .gap_4() + .child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child( + v_flex().gap_0p5().child("Power Cycle on Reset").child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Restart device on reset"), + ), + ) + .child( + Switch::new("power-cycle") + .checked(self.power_cycle) + .disabled(hardware_config_disabled) + .on_click(power_cycle_listener), + ), + ) + .child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child( + v_flex().gap_0p5().child("Enable Secp256k1").child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Does not work on Android!"), + ), + ) + .child( + Switch::new("enable-secp") + .checked(self.enable_secp256k1) + .disabled(is_fido) + .on_click(secp_listener), + ), + ); + + Card::new() + .title("Device Options") + .description("Toggle advanced features") + .icon(Icon::default().path("icons/settings.svg")) + .child(content) + } + + fn render_rskey_led_card(&mut self, cx: &mut Context, is_fido: bool) -> impl IntoElement { + let theme = cx.theme(); + let mut rows = v_flex().gap_4(); + + let steady_listener = cx.listener(|this, checked, _, cx| { + this.led_status_steady = *checked; + cx.notify(); + }); + + rows = rows.child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child( + v_flex().gap_0p5().child("Global Steady Mode").child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Keep status LEDs on constantly"), + ), + ) + .child( + Switch::new("rskey-led-steady") + .checked(self.led_status_steady) + .disabled(is_fido) + .on_click(steady_listener), + ), + ); + + rows = rows.child(div().h_px().bg(theme.border)); + + for (i, status) in LedStatus::all().iter().enumerate() { + let color_val = self.led_status_colors[i]; + let brightness_val = self.led_status_brightness[i]; + + let cycle_color_listener = cx.listener(move |this, _, _, cx| { + let mut c = this.led_status_colors[i]; + c = (c + 1) % LedColor::all().len() as u8; + this.led_status_colors[i] = c; + cx.notify(); + }); + + let dec_bright_listener = cx.listener(move |this, _, _, cx| { + let mut b = this.led_status_brightness[i]; + b = b.saturating_sub(1); + this.led_status_brightness[i] = b; + cx.notify(); + }); + + let inc_bright_listener = cx.listener(move |this, _, _, cx| { + let mut b = this.led_status_brightness[i]; + if b < 15 { + b += 1; + } + this.led_status_brightness[i] = b; + cx.notify(); + }); + + let color_name = LedColor::from_u8(color_val) + .map(|c| c.label()) + .unwrap_or("Unknown"); + + rows = rows.child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child(div().w_24().child(status.label())) + .child( + gpui_component::h_flex() + .gap_2() + .items_center() + .child( + Button::new(gpui::SharedString::from(format!("color-btn-{}", i))) + .child(color_name) + .custom( + ButtonCustomVariant::new(cx) + .color(rgb(0x27272a).into()) + .hover(rgb(0x3f3f46).into()) + .active(rgb(0x52525b).into()) + .border(theme.border), + ) + .disabled(is_fido) + .on_click(cycle_color_listener), + ) + .child(div().w_4()) + .child( + Button::new(gpui::SharedString::from(format!("bdec-btn-{}", i))) + .child("-") + .custom( + ButtonCustomVariant::new(cx) + .color(rgb(0x1b1b1d).into()) + .hover(rgb(0x232325).into()) + .active(rgb(0x3f3f46).into()) + .border(theme.border), + ) + .disabled(is_fido || brightness_val == 0) + .on_click(dec_bright_listener), + ) + .child( + div() + .w_8() + .flex() + .justify_center() + .child(brightness_val.to_string()), + ) + .child( + Button::new(gpui::SharedString::from(format!("binc-btn-{}", i))) + .child("+") + .custom( + ButtonCustomVariant::new(cx) + .color(rgb(0x1b1b1d).into()) + .hover(rgb(0x232325).into()) + .active(rgb(0x3f3f46).into()) + .border(theme.border), + ) + .disabled(is_fido || brightness_val >= 15) + .on_click(inc_bright_listener), + ), + ), + ); + } + + rows = rows.child(div().h_px().bg(theme.border)); + rows = rows.child( + gpui_component::h_flex().justify_end().child( + Button::new("apply-rskey-leds") + .child("Save LED Status") + .custom( + ButtonCustomVariant::new(cx) + .color(rgb(0xe3e3e6).into()) + .hover(rgb(0xcfcfd1).into()) + .active(rgb(0xe3e3e6).into()) + .foreground(rgb(0x4b4b4e).into()), + ) + .disabled(is_fido || self.loading) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_rskey_led_settings(window, cx); + })), + ), + ); + + Card::new() + .title("Status LED Colors") + .description("Configure LED colors and brightness per device state") + .icon(Icon::default().path("icons/palette.svg")) + .child(rows) + } + + fn render_rskey_apps_card( + &mut self, + cx: &mut Context, + is_fido: bool, + ) -> impl IntoElement { + let theme = cx.theme(); + let mut rows = v_flex().gap_4(); + + let apps = [ + ("FIDO2", USB_CAP_FIDO2), + ("OATH", USB_CAP_OATH), + ("PIV", USB_CAP_PIV), + ("OpenPGP", USB_CAP_OPENPGP), + ("U2F", USB_CAP_U2F), + ("OTP", USB_CAP_OTP), + ]; + + for (name, cap) in apps { + let is_supported = (self.usb_apps_supported & cap) != 0; + let is_enabled = (self.usb_apps_enabled & cap) != 0; + + let toggle_listener = cx.listener(move |this, checked, _, cx| { + if *checked { + this.usb_apps_enabled |= cap; + } else { + this.usb_apps_enabled &= !cap; + } + cx.notify(); + }); + + rows = + rows.child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child(v_flex().gap_0p5().child(name).child( + div().text_sm().text_color(theme.muted_foreground).child( + if is_supported { + "Supported" + } else { + "Not Supported by Firmware" + }, + ), + )) + .child( + Switch::new(gpui::SharedString::from(format!("app-toggle-{}", cap))) + .checked(is_enabled) + .disabled(is_fido || !is_supported) + .on_click(toggle_listener), + ), + ); + } + + rows = rows.child(div().h_px().bg(theme.border)); + rows = rows.child( + gpui_component::h_flex().justify_end().child( + Button::new("apply-rskey-apps") + .child("Save USB Applications") + .custom( + ButtonCustomVariant::new(cx) + .color(rgb(0xe3e3e6).into()) + .hover(rgb(0xcfcfd1).into()) + .active(rgb(0xe3e3e6).into()) + .foreground(rgb(0x4b4b4e).into()), + ) + .disabled(is_fido || self.loading) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_rskey_apps_settings(window, cx); + })), + ), + ); + + Card::new() + .title("USB Applications") + .description("Enable or disable specific USB features") + .icon(Icon::default().path("icons/microchip.svg")) + .child(rows) + } + + fn render_rskey_usb_itf_card( + &mut self, + cx: &mut Context, + is_fido: bool, + ) -> impl IntoElement { + let theme = cx.theme(); + let mut rows = v_flex().gap_4(); + + let interfaces = [ + ("CCID (Smart Card)", 0x01u8), + ("WCID (WebUSB)", 0x02u8), + ("HID (FIDO)", 0x04u8), + ("KB (Keyboard)", 0x08u8), + ("LWIP", 0x10u8), + ]; + + let current_mask = self.enabled_usb_itf.unwrap_or(0x1F); + + for (name, bit) in interfaces { + let is_enabled = (current_mask & bit) != 0; + let is_ccid = bit == 0x01; + + let toggle_listener = cx.listener(move |this, checked, _, cx| { + let mut mask = this.enabled_usb_itf.unwrap_or(0x1F); + if *checked { + mask |= bit; + } else { + mask &= !bit; + } + + if bit == 0x01 { + mask |= 0x01; + } + + this.enabled_usb_itf = Some(mask); + cx.notify(); + }); + + rows = rows.child( + gpui_component::h_flex() + .items_center() + .justify_between() + .child( + v_flex().gap_0p5().child(name).child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(if is_ccid { + "Required for Rescue Applet" + } else { + "USB Endpoint" + }), + ), + ) + .child( + Switch::new(gpui::SharedString::from(format!("usb-itf-toggle-{}", bit))) + .checked(is_enabled || is_ccid) + .disabled(is_fido || is_ccid) + .on_click(toggle_listener), + ), + ); + } + + Card::new() + .title("Hardware Endpoints") + .description("Toggle low-level USB interfaces") + .icon(Icon::default().path("icons/cpu.svg")) + .child(rows) + } +} + +impl Render for ConfigView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let has_device = self.device.read(cx).status.is_some(); + + if !has_device { + return PageView::build( + "Configuration", + "Customize device settings and behavior.", + div() + .flex() + .items_center() + .justify_center() + .h_64() + .border_1() + .border_color(theme.border) + .rounded_xl() + .child( + div() + .text_color(theme.muted_foreground) + .child("No Device Connected"), + ), + theme, + ) + .into_any_element(); + } + + let device = self.device.read(cx); + let status = device.status.clone(); + let is_fido = status.as_ref().map(|s| s.method.clone()) == Some(DeviceMethod::Fido); + let supports_legacy_fido_config = status + .as_ref() + .map(ConfigView::status_supports_legacy_fido_config) + .unwrap_or(false); + let hardware_config_disabled = is_fido && !supports_legacy_fido_config; + + let led_card = self + .render_led_card(cx, is_fido, hardware_config_disabled) + .into_any_element(); + let options_card = self + .render_options_card(cx, is_fido, hardware_config_disabled) + .into_any_element(); + + let identity_card = self + .render_identity_card(cx.theme(), is_fido, hardware_config_disabled) + .into_any_element(); + let touch_card = self + .render_touch_card(cx.theme(), is_fido) + .into_any_element(); + + let is_wide = window.bounds().size.width > px(1100.0); + let columns = if is_wide { 2 } else { 1 }; + + let is_rskey = status.as_ref().map(|s| &s.firmware_type) + == Some(&crate::hal::types::FirmwareType::RSKey); + + let mut grid_children = vec![identity_card, led_card, touch_card, options_card]; + + if is_rskey { + let rskey_led = self.render_rskey_led_card(cx, is_fido).into_any_element(); + let rskey_apps = self.render_rskey_apps_card(cx, is_fido).into_any_element(); + let rskey_usb_itf = self + .render_rskey_usb_itf_card(cx, is_fido) + .into_any_element(); + grid_children.push(rskey_led); + grid_children.push(rskey_apps); + grid_children.push(rskey_usb_itf); + } + + let theme = cx.theme(); + + PageView::build( + "Configuration", + "Customize device settings and behavior.", + v_flex() + .gap_6() + .child( + div() + .grid() + .grid_cols(columns) + .gap_6() + .children(grid_children), + ) + .child( + gpui_component::h_flex().justify_end().pt_4().child( + Button::new("apply-changes") + .icon(Icon::default().path("icons/save.svg")) + .child("Apply Changes") + .disabled(self.loading || hardware_config_disabled) + .custom( + ButtonCustomVariant::new(cx) + .color(rgb(0xe3e3e6).into()) + .hover(rgb(0xcfcfd1).into()) + .active(rgb(0xe3e3e6).into()) + .foreground(rgb(0x4b4b4e).into()), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.apply_changes(window, cx); + })), + ), + ), + theme, + ) + .into_any_element() + } +} diff --git a/src/ui/screens/config/view_model.rs b/src/ui/screens/config/view_model.rs new file mode 100644 index 0000000..305a2b7 --- /dev/null +++ b/src/ui/screens/config/view_model.rs @@ -0,0 +1,849 @@ +use crate::hal::types::{AppConfigInput, DeviceMethod}; +use crate::hal::{fido, io}; +use crate::ui::app::AppModels; +use crate::ui::components::dialog::PinPromptContent; +use crate::ui::components::{dialog, dialog::StatusContent}; +use crate::ui::models::device::{DeviceEvent, DeviceRepo}; +use gpui::*; +use gpui_component::input::InputState; +use gpui_component::select::{SelectItem, SelectState}; +use gpui_component::slider::SliderState; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsbIdentityPreset { + Custom, + Generic, + LibreKeys, + PicoHsm, + PicoFido, + PicoOpenPgp, + Pico, + SoloKeys, + NitroHsm, + NitroFido2, + NitroStart, + NitroPro, + NitroKey3, + YubiKey5, + YubiKeyNeo, + YubiHsm2, + Gnuk, + GnuPg, +} + +impl UsbIdentityPreset { + pub fn details(&self) -> (SharedString, Option<&'static str>, Option<&'static str>) { + match self { + Self::Custom => ("Custom (Manual Entry)".into(), None, None), + Self::Generic => ("Generic (FEFF:FCFD)".into(), Some("FEFF"), Some("FCFD")), + Self::LibreKeys => ( + "LibreKeys One (1D50:619B)".into(), + Some("1D50"), + Some("619B"), + ), + Self::PicoHsm => ( + "Pico Keys HSM (2E8A:10FD)".into(), + Some("2E8A"), + Some("10FD"), + ), + Self::PicoFido => ( + "Pico Keys Fido (2E8A:10FE)".into(), + Some("2E8A"), + Some("10FE"), + ), + Self::PicoOpenPgp => ( + "Pico Keys OpenPGP (2E8A:10FF)".into(), + Some("2E8A"), + Some("10FF"), + ), + Self::Pico => ("Pico (2E8A:0003)".into(), Some("2E8A"), Some("0003")), + Self::SoloKeys => ("SoloKeys (0483:A2CA)".into(), Some("0483"), Some("A2CA")), + Self::NitroHsm => ("NitroHSM (20A0:4230)".into(), Some("20A0"), Some("4230")), + Self::NitroFido2 => ("NitroFIDO2 (20A0:42D4)".into(), Some("20A0"), Some("42D4")), + Self::NitroStart => ("NitroStart (20A0:4211)".into(), Some("20A0"), Some("4211")), + Self::NitroPro => ("NitroPro (20A0:4108)".into(), Some("20A0"), Some("4108")), + Self::NitroKey3 => ("Nitrokey 3 (20A0:42B2)".into(), Some("20A0"), Some("42B2")), + Self::YubiKey5 => ("YubiKey 5 (1050:0407)".into(), Some("1050"), Some("0407")), + Self::YubiKeyNeo => ("YubiKey Neo (1050:0116)".into(), Some("1050"), Some("0116")), + Self::YubiHsm2 => ("YubiHSM 2 (1050:0030)".into(), Some("1050"), Some("0030")), + Self::Gnuk => ("Gnuk Token (234B:0000)".into(), Some("234B"), Some("0000")), + Self::GnuPg => ("GnuPG (234B:0000)".into(), Some("234B"), Some("0000")), + } + } + + pub fn from_vid_pid(vid: &str, pid: &str) -> Self { + let vid = vid.to_uppercase(); + let pid = pid.to_uppercase(); + + match (vid.as_str(), pid.as_str()) { + ("FEFF", "FCFD") => Self::Generic, + ("1D50", "619B") => Self::LibreKeys, + ("2E8A", "10FD") => Self::PicoHsm, + ("2E8A", "10FE") => Self::PicoFido, + ("2E8A", "10FF") => Self::PicoOpenPgp, + ("2E8A", "0003") => Self::Pico, + ("0483", "A2CA") => Self::SoloKeys, + ("20A0", "4230") => Self::NitroHsm, + ("20A0", "42D4") => Self::NitroFido2, + ("20A0", "4211") => Self::NitroStart, + ("20A0", "4108") => Self::NitroPro, + ("20A0", "42B2") => Self::NitroKey3, + ("1050", "0407") => Self::YubiKey5, + ("1050", "0116") => Self::YubiKeyNeo, + ("1050", "0030") => Self::YubiHsm2, + ("234B", "0000") => Self::Gnuk, + _ => Self::Custom, + } + } + + pub fn all() -> &'static [Self] { + &[ + Self::Custom, + Self::Generic, + Self::LibreKeys, + Self::PicoHsm, + Self::PicoFido, + Self::PicoOpenPgp, + Self::Pico, + Self::SoloKeys, + Self::NitroHsm, + Self::NitroFido2, + Self::NitroStart, + Self::NitroPro, + Self::NitroKey3, + Self::YubiKey5, + Self::YubiKeyNeo, + Self::YubiHsm2, + Self::Gnuk, + Self::GnuPg, + ] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LedDriverType { + PicoGpio = 1, + PimoroniRgb = 2, + Ws2812Neopixel = 3, + Esp32Neopixel = 5, +} + +impl LedDriverType { + pub fn label(&self) -> SharedString { + match self { + Self::PicoGpio => "Pico (Standard GPIO)".into(), + Self::PimoroniRgb => "Pimoroni (RGB)".into(), + Self::Ws2812Neopixel => "WS2812 (Neopixel)".into(), + Self::Esp32Neopixel => "ESP32 Neopixel".into(), + } + } + + pub fn value(&self) -> u8 { + *self as u8 + } + + pub fn all() -> &'static [Self] { + &[ + Self::PicoGpio, + Self::PimoroniRgb, + Self::Ws2812Neopixel, + Self::Esp32Neopixel, + ] + } +} + +#[derive(Clone, PartialEq)] +pub(super) struct VendorSelectOption { + preset: UsbIdentityPreset, + label: SharedString, +} + +impl SelectItem for VendorSelectOption { + type Value = UsbIdentityPreset; + + fn title(&self) -> SharedString { + self.label.clone() + } + + fn value(&self) -> &Self::Value { + &self.preset + } +} + +#[derive(Clone, PartialEq)] +pub(super) struct DriverSelectOption { + driver_type: LedDriverType, + label: SharedString, +} + +impl SelectItem for DriverSelectOption { + type Value = LedDriverType; + + fn title(&self) -> SharedString { + self.label.clone() + } + + fn value(&self) -> &Self::Value { + &self.driver_type + } +} + +pub(super) enum StatusDialogHandle { + Pin(WeakEntity), + Status(WeakEntity), +} + +pub struct ConfigView { + pub(super) device: Entity, + pub(super) vendor_select: Entity>>, + pub(super) vid_input: Entity, + pub(super) pid_input: Entity, + pub(super) product_name_input: Entity, + pub(super) led_gpio_input: Entity, + pub(super) led_driver_select: Entity>>, + pub(super) led_brightness_slider: Entity, + pub(super) led_dimmable: bool, + pub(super) led_steady: bool, + pub(super) touch_timeout_input: Entity, + pub(super) power_cycle: bool, + pub(super) enable_secp256k1: bool, + pub(super) loading: bool, + pub(super) is_custom_vendor: bool, + + // RS-Key specific state + pub(super) led_status_steady: bool, + pub(super) led_status_colors: [u8; 4], + pub(super) led_status_brightness: [u8; 4], + pub(super) usb_apps_supported: u16, + pub(super) usb_apps_enabled: u16, + pub(super) enabled_usb_itf: Option, + + pub(super) _task: Option>, +} + +impl ConfigView { + pub fn new(window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |_, _, _: &DeviceEvent, cx| cx.notify()) + .detach(); + + let device_read = device.read(cx); + let config = device_read.status.as_ref().map(|s| &s.config); + + let current_vid: SharedString = config + .map(|c| c.vid.clone().into()) + .unwrap_or_else(|| "CAFE".into()); + let current_pid: SharedString = config + .map(|c| c.pid.clone().into()) + .unwrap_or_else(|| "4242".into()); + let current_product_name: SharedString = config + .map(|c| c.product_name.clone().into()) + .unwrap_or_else(|| "My Key".into()); + let current_led_gpio: SharedString = config + .map(|c| c.led_gpio.to_string().into()) + .unwrap_or_else(|| "25".into()); + let current_touch_timeout: SharedString = config + .map(|c| c.touch_timeout.to_string().into()) + .unwrap_or_else(|| "10".into()); + let current_brightness = config.map(|c| c.led_brightness as f32).unwrap_or(8.0); + + let led_dimmable = config.map(|c| c.led_dimmable).unwrap_or(true); + let led_steady = config.map(|c| c.led_steady).unwrap_or(false); + let power_cycle = config.map(|c| c.power_cycle_on_reset).unwrap_or(false); + let enable_secp256k1 = config.map(|c| c.enable_secp256k1).unwrap_or(true); + let enabled_usb_itf = config.and_then(|c| c.enabled_usb_itf); + let current_driver_val = config.and_then(|c| c.led_driver).unwrap_or(0); + + let mut led_status_steady = false; + let mut led_status_colors = [0; 4]; + let mut led_status_brightness = [0; 4]; + if let Some(led) = &device_read.led_status { + led_status_steady = led.steady; + for i in 0..4 { + led_status_colors[i] = led.statuses[i].0; + led_status_brightness[i] = led.statuses[i].1; + } + } + + let mut usb_apps_supported = 0; + let mut usb_apps_enabled = 0; + if let Some(apps) = &device_read.management_apps { + usb_apps_supported = apps.usb_supported; + usb_apps_enabled = apps.usb_enabled; + } + + let vendors: Vec = UsbIdentityPreset::all() + .iter() + .map(|preset| { + let (label, _, _) = preset.details(); + VendorSelectOption { + preset: *preset, + label, + } + }) + .collect(); + + let drivers: Vec = LedDriverType::all() + .iter() + .map(|driver| DriverSelectOption { + driver_type: *driver, + label: driver.label(), + }) + .collect(); + + let initial_preset = UsbIdentityPreset::from_vid_pid(¤t_vid, ¤t_pid); + let is_custom_vendor = initial_preset == UsbIdentityPreset::Custom; + + let initial_vendor_idx = UsbIdentityPreset::all() + .iter() + .position(|p| *p == initial_preset) + .unwrap_or(0); + + let vendor_select = cx.new(|cx| { + SelectState::new( + vendors, + Some(gpui_component::IndexPath::default().row(initial_vendor_idx)), + window, + cx, + ) + }); + + let vid_input = cx.new(|cx| InputState::new(window, cx).default_value(current_vid.clone())); + let pid_input = cx.new(|cx| InputState::new(window, cx).default_value(current_pid.clone())); + let product_name_input = + cx.new(|cx| InputState::new(window, cx).default_value(current_product_name.clone())); + + let led_gpio_input = + cx.new(|cx| InputState::new(window, cx).default_value(current_led_gpio.clone())); + + let initial_driver_idx = LedDriverType::all() + .iter() + .position(|d| d.value() == current_driver_val) + .unwrap_or(0); + + let led_driver_select = cx.new(|cx| { + SelectState::new( + drivers, + Some(gpui_component::IndexPath::default().row(initial_driver_idx)), + window, + cx, + ) + }); + + cx.subscribe_in( + &vendor_select, + window, + |this: &mut Self, _, event, window, cx| { + if let gpui_component::select::SelectEvent::Confirm(Some(preset)) = event { + let (_, vid_opt, pid_opt) = preset.details(); + + if let (Some(vid), Some(pid)) = (vid_opt, pid_opt) { + this.is_custom_vendor = false; + this.vid_input + .update(cx, |input, cx| input.set_value(vid, window, cx)); + this.pid_input + .update(cx, |input, cx| input.set_value(pid, window, cx)); + } else { + this.is_custom_vendor = true; + } + cx.notify(); + } + }, + ) + .detach(); + + let led_brightness_slider = cx.new(|_| { + SliderState::new() + .min(0.0) + .max(15.0) + .step(1.0) + .default_value(current_brightness) + }); + + let touch_timeout_input = + cx.new(|cx| InputState::new(window, cx).default_value(current_touch_timeout.clone())); + + Self { + device, + vendor_select, + vid_input, + pid_input, + product_name_input, + led_gpio_input, + led_driver_select, + led_brightness_slider, + led_dimmable, + led_steady, + touch_timeout_input, + power_cycle, + enable_secp256k1, + loading: false, + is_custom_vendor, + led_status_steady, + led_status_colors, + led_status_brightness, + usb_apps_supported, + usb_apps_enabled, + enabled_usb_itf, + _task: None, + } + } + + pub(super) fn write_config_to_device( + &mut self, + changes: AppConfigInput, + method: crate::hal::types::DeviceMethod, + pin: Option, + dialog_handle: StatusDialogHandle, + cx: &mut Context, + ) { + let expected_serial = self + .device + .read(cx) + .status + .as_ref() + .map(|s| s.info.serial.clone()); + + self.loading = true; + cx.notify(); + + let entity = cx.entity().downgrade(); + let method_clone = method.clone(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { io::write_config(changes, method_clone, pin) }) + .await; + + let new_status_result = if result.is_ok() { + Some( + cx.background_executor() + .spawn(async move { io::read_device_details() }) + .await, + ) + } else { + None + }; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + + match result { + Ok(msg) => { + log::info!("Success: {}", msg); + + if let Some(Ok(new_status)) = new_status_result { + let serial_matches = expected_serial.as_deref() + == Some(new_status.info.serial.as_str()); + + if serial_matches { + log::info!( + "Refreshed device status. LED Steady: {}", + new_status.config.led_steady + ); + + let config = &new_status.config; + this.led_dimmable = config.led_dimmable; + this.led_steady = config.led_steady; + this.power_cycle = config.power_cycle_on_reset; + this.enable_secp256k1 = config.enable_secp256k1; + + this.device.update(cx, |repo, _| { + repo.status = Some(new_status); + }); + } else { + log::warn!("Device changed during config write, discarding stale status"); + } + } + + match &dialog_handle { + StatusDialogHandle::Pin(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_success( + "Configuration applied successfully.".to_string(), + cx, + ); + }); + } + StatusDialogHandle::Status(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_success( + "Configuration applied successfully.".to_string(), + cx, + ); + }); + } + } + } + Err(e) => { + log::error!("Error saving config: {}", e); + + let mut err_msg = format!("Failed to apply configuration: {}", e); + + if method == DeviceMethod::Fido && err_msg.contains("0x3E") { + err_msg = "The device firmware does not support being configured in fido only communication mode. \nHave a look at the troubleshooting guide to fix this".to_string(); + } + + match &dialog_handle { + StatusDialogHandle::Pin(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_error(err_msg, cx); + }); + } + StatusDialogHandle::Status(dh) => { + let _ = dh.update(cx, |d, cx| { + d.set_error(err_msg, cx); + }); + } + } + } + } + + cx.notify(); + }); + })); + } + + fn open_pin_dialog( + &mut self, + changes: AppConfigInput, + window: &mut Window, + cx: &mut Context, + ) { + let view_handle = cx.entity().downgrade(); + + dialog::open_pin_prompt( + "Authentication Required", + "Enter your device PIN to apply changes.", + None, + "Confirm", + window, + cx, + move |pin, dialog_handle, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.write_config_to_device( + changes.clone(), + DeviceMethod::Fido, + Some(pin), + StatusDialogHandle::Pin(dialog_handle), + cx, + ); + }); + }, + ); + } + + pub(super) fn apply_changes(&mut self, window: &mut Window, cx: &mut Context) { + let device = self.device.read(cx); + let Some(status) = &device.status else { return }; + + let current_vid = status.config.vid.clone(); + let current_pid = status.config.pid.clone(); + let current_product_name = status.config.product_name.clone(); + let current_led_gpio = status.config.led_gpio; + let current_led_driver = status.config.led_driver; + let current_led_brightness = status.config.led_brightness; + let current_touch_timeout = status.config.touch_timeout; + let current_led_dimmable = status.config.led_dimmable; + let current_led_steady = status.config.led_steady; + let current_power_cycle = status.config.power_cycle_on_reset; + let current_enable_secp256k1 = status.config.enable_secp256k1; + let current_enabled_usb_itf = status.config.enabled_usb_itf; + let raw_curves_mask = status.config.raw_curves_mask; + let led_order = status.config.led_order; + let method = status.method.clone(); + + let mut has_changes = false; + + let vid = self.vid_input.read(cx).text().to_string(); + if vid != current_vid { + has_changes = true; + } + + let pid = self.pid_input.read(cx).text().to_string(); + if pid != current_pid { + has_changes = true; + } + + let product_name = self.product_name_input.read(cx).text().to_string(); + if product_name != current_product_name { + has_changes = true; + } + + let mut final_led_gpio = current_led_gpio; + let led_gpio_str = self.led_gpio_input.read(cx).text().to_string(); + if let Ok(val) = led_gpio_str.parse::() { + if val != current_led_gpio { + has_changes = true; + } + final_led_gpio = val; + } + + let mut final_led_driver = current_led_driver; + let driver_idx = self.led_driver_select.read(cx).selected_index(cx); + if let Some(idx) = driver_idx + && let Some(driver) = LedDriverType::all().get(idx.row) + { + let val = driver.value(); + let current_val = current_led_driver.unwrap_or(1); + if val != current_val { + has_changes = true; + } + final_led_driver = Some(val); + } + + let brightness = self.led_brightness_slider.read(cx).value().start() as u8; + if brightness != current_led_brightness { + has_changes = true; + } + + let mut final_touch_timeout = current_touch_timeout; + let touch_timeout_str = self.touch_timeout_input.read(cx).text().to_string(); + if let Ok(val) = touch_timeout_str.parse::() { + if val != current_touch_timeout { + has_changes = true; + } + final_touch_timeout = val; + } + + if (self.led_dimmable != current_led_dimmable) + || (self.led_steady != current_led_steady) + || (self.power_cycle != current_power_cycle) + { + has_changes = true; + } + + if self.enable_secp256k1 != current_enable_secp256k1 { + has_changes = true; + } + + let mut final_enabled_usb_itf = current_enabled_usb_itf; + if self.enabled_usb_itf != current_enabled_usb_itf { + has_changes = true; + final_enabled_usb_itf = self.enabled_usb_itf; + } + + if !has_changes { + log::info!("No changes detected"); + return; + } + + let changes = AppConfigInput { + vid: Some(vid), + pid: Some(pid), + product_name: Some(product_name), + led_gpio: Some(final_led_gpio), + led_brightness: Some(brightness), + touch_timeout: Some(final_touch_timeout), + led_driver: final_led_driver, + led_dimmable: Some(self.led_dimmable), + power_cycle_on_reset: Some(self.power_cycle), + led_steady: Some(self.led_steady), + enable_secp256k1: Some(self.enable_secp256k1), + raw_curves_mask, + led_order, + enabled_usb_itf: final_enabled_usb_itf, + }; + + if method == DeviceMethod::Fido { + if Self::status_supports_legacy_fido_config(status) { + self.open_pin_dialog(changes, window, cx); + } else { + let handle = + dialog::open_status_dialog("Configuration Requires Rescue Mode", window, cx); + self.write_config_to_device( + changes, + method, + None, + StatusDialogHandle::Status(handle), + cx, + ); + } + } else { + let handle = dialog::open_status_dialog("Applying Configuration", window, cx); + self.write_config_to_device( + changes, + method, + None, + StatusDialogHandle::Status(handle), + cx, + ); + } + } + + pub(super) fn status_supports_legacy_fido_config( + status: &crate::hal::types::FullDeviceStatus, + ) -> bool { + status.method == DeviceMethod::Fido + && fido::firmware_supports_legacy_fido_hardware_config(&status.info.firmware_version) + } + + pub fn sync_from_device(&mut self, window: &mut Window, cx: &mut Context) { + let device = self.device.read(cx); + let config = device.status.as_ref().map(|s| &s.config); + + let vid = config + .map(|c| c.vid.clone()) + .unwrap_or_else(|| "CAFE".into()); + let pid = config + .map(|c| c.pid.clone()) + .unwrap_or_else(|| "4242".into()); + let product = config + .map(|c| c.product_name.clone()) + .unwrap_or_else(|| "My Key".into()); + let gpio = config + .map(|c| c.led_gpio.to_string()) + .unwrap_or_else(|| "25".into()); + let timeout = config + .map(|c| c.touch_timeout.to_string()) + .unwrap_or_else(|| "10".into()); + + self.led_dimmable = config.map(|c| c.led_dimmable).unwrap_or(true); + self.led_steady = config.map(|c| c.led_steady).unwrap_or(false); + self.power_cycle = config.map(|c| c.power_cycle_on_reset).unwrap_or(false); + self.enable_secp256k1 = config.map(|c| c.enable_secp256k1).unwrap_or(true); + + let brightness = config.map(|c| c.led_brightness as f32).unwrap_or(8.0); + + let new_driver_val = config.and_then(|c| c.led_driver).unwrap_or(1); + + if let Some(led) = &device.led_status { + self.led_status_steady = led.steady; + for i in 0..4 { + self.led_status_colors[i] = led.statuses[i].0; + self.led_status_brightness[i] = led.statuses[i].1; + } + } + + if let Some(apps) = &device.management_apps { + self.usb_apps_supported = apps.usb_supported; + self.usb_apps_enabled = apps.usb_enabled; + } + + self.enabled_usb_itf = config.and_then(|c| c.enabled_usb_itf); + + let preset = UsbIdentityPreset::from_vid_pid(&vid, &pid); + self.is_custom_vendor = preset == UsbIdentityPreset::Custom; + let preset_idx = UsbIdentityPreset::all() + .iter() + .position(|p| *p == preset) + .unwrap_or(0); + self.vendor_select.update(cx, |select, cx| { + select.set_selected_index( + Some(gpui_component::IndexPath::default().row(preset_idx)), + window, + cx, + ); + }); + + self.vid_input + .update(cx, |input, cx| input.set_value(vid, window, cx)); + self.pid_input + .update(cx, |input, cx| input.set_value(pid, window, cx)); + self.product_name_input + .update(cx, |input, cx| input.set_value(product, window, cx)); + self.led_gpio_input + .update(cx, |input, cx| input.set_value(gpio, window, cx)); + self.touch_timeout_input + .update(cx, |input, cx| input.set_value(timeout, window, cx)); + self.led_brightness_slider + .update(cx, |slider, cx| slider.set_value(brightness, window, cx)); + + let new_driver_idx = LedDriverType::all() + .iter() + .position(|d| d.value() == new_driver_val) + .unwrap_or(0); + self.led_driver_select.update(cx, |select, cx| { + select.set_selected_index( + Some(gpui_component::IndexPath::default().row(new_driver_idx)), + window, + cx, + ); + }); + + cx.notify(); + } + + pub(super) fn apply_rskey_led_settings(&mut self, window: &mut Window, cx: &mut Context) { + let steady = self.led_status_steady; + let colors = self.led_status_colors; + let brightnesses = self.led_status_brightness; + + self.loading = true; + let handle = dialog::open_status_dialog("Applying LED Configuration...", window, cx); + let entity = cx.entity().downgrade(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { + for i in 0..4 { + io::write_led_status(i as u8, colors[i], brightnesses[i], steady)?; + } + Ok::<_, crate::error::PFError>(()) + }) + .await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(_) => { + let _ = handle.update(cx, |d, cx| { + d.set_success( + "LED configuration applied successfully.".to_string(), + cx, + ); + }); + } + Err(e) => { + let _ = handle.update(cx, |d, cx| { + d.set_error(format!("Failed to apply LED config: {}", e), cx); + }); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn apply_rskey_apps_settings( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let mask = self.usb_apps_enabled; + + self.loading = true; + let handle = dialog::open_status_dialog("Applying USB Applications...", window, cx); + let entity = cx.entity().downgrade(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { io::write_management_config(mask) }) + .await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(_) => { + let _ = handle.update(cx, |d, cx| { + d.set_success( + "USB applications updated successfully. Please re-plug the device." + .to_string(), + cx, + ); + }); + } + Err(e) => { + let _ = handle.update(cx, |d, cx| { + d.set_error(format!("Failed to apply USB applications: {}", e), cx); + }); + } + } + cx.notify(); + }); + })); + } +} diff --git a/src/ui/screens/home/mod.rs b/src/ui/screens/home/mod.rs new file mode 100644 index 0000000..ba441e2 --- /dev/null +++ b/src/ui/screens/home/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::HomeViewModel; diff --git a/src/ui/screens/home.rs b/src/ui/screens/home/view.rs similarity index 91% rename from src/ui/screens/home.rs rename to src/ui/screens/home/view.rs index 17b022b..6333a51 100644 --- a/src/ui/screens/home.rs +++ b/src/ui/screens/home/view.rs @@ -1,59 +1,12 @@ -use crate::hal::types::DeviceMethod; +use crate::hal::types::{DeviceMethod, FidoDeviceInfo, FullDeviceStatus}; use crate::ui::components::{card::Card, page_view::PageView, tag::Tag}; -use crate::ui::types::DeviceConnectionState; +use crate::ui::screens::home::view_model::HomeViewModel; use gpui::prelude::FluentBuilder; use gpui::*; -use gpui_component::StyledExt; +use gpui_component::{ActiveTheme, StyledExt}; use gpui_component::{Icon, IconName, Theme, h_flex, progress::Progress, v_flex}; -pub struct HomeView; - -impl HomeView { - pub fn build( - state: &DeviceConnectionState, - theme: &Theme, - window_width: Pixels, - ) -> impl IntoElement { - let connected = state.status.is_some(); - let is_wide = window_width > px(1100.0); - let columns = if is_wide { 2 } else { 1 }; - - PageView::build( - "Device Overview", - "Quick view of your device status and specifications.", - if !connected { - // No Device Status Placeholder - div() - .flex() - .items_center() - .justify_center() - .h_64() - .border_1() - .border_color(theme.border) - .rounded_xl() - .child( - div() - .text_color(theme.muted_foreground) - .child("No Device Connected"), - ) - .into_any_element() - } else { - // Card Grid - div() - .grid() - .grid_cols(columns) - .gap_6() - .child(Self::render_device_info(state, theme)) - .child(Self::render_fido_info(state, theme)) - .child(Self::render_led_config(state, theme)) - .child(Self::render_security_status(state, theme)) - .into_any_element() - }, - theme, - ) - } - - // Helper for Key-Value pairs +impl HomeViewModel { fn render_kv( label: &str, value: impl IntoElement, @@ -82,8 +35,7 @@ impl HomeView { ) } - fn render_device_info(state: &DeviceConnectionState, theme: &Theme) -> impl IntoElement { - let status = state.status.as_ref().unwrap(); + fn render_device_info(status: &FullDeviceStatus, theme: &Theme) -> impl IntoElement { let info = &status.info; let config = &status.config; @@ -165,15 +117,14 @@ impl HomeView { ) } - fn render_fido_info(state: &DeviceConnectionState, theme: &Theme) -> impl IntoElement { + fn render_fido_info(fido: Option<&FidoDeviceInfo>, theme: &Theme) -> impl IntoElement { Card::new() .title("FIDO2 Information") .icon(Icon::default().path("icons/shield.svg")) - .child(if let Some(fido) = &state.fido_info { + .child(if let Some(fido) = fido { v_flex() .gap_3() .text_sm() - // AAGUID .child( h_flex() .justify_between() @@ -188,7 +139,6 @@ impl HomeView { .child(fido.aaguid.clone()), ), ) - // FIDO Versions .child( h_flex() .justify_between() @@ -209,7 +159,6 @@ impl HomeView { )), ) .child(div().h_px().bg(theme.border)) - // PIN Set .child( h_flex() .justify_between() @@ -221,7 +170,6 @@ impl HomeView { Tag::new(if pin_set { "Set" } else { "Not Set" }).active(pin_set) }), ) - // Resident Keys .child( h_flex() .justify_between() @@ -236,7 +184,6 @@ impl HomeView { Tag::new(if rk { "Supported" } else { "Not Supported" }).active(rk) }), ) - // Min PIN Length .child( h_flex() .justify_between() @@ -253,7 +200,6 @@ impl HomeView { .child(fido.min_pin_length.to_string()), ), ) - // Enterprise attestation .child( h_flex() .justify_between() @@ -268,7 +214,6 @@ impl HomeView { Tag::new(if ep_set { "Set" } else { "Not Set" }).active(ep_set) })), ) - // Remaining Credentials .when(fido.remaining_discoverable_credentials.is_some(), |this| { this.child( h_flex() @@ -298,8 +243,7 @@ impl HomeView { }) } - fn render_led_config(state: &DeviceConnectionState, theme: &Theme) -> impl IntoElement { - let status = state.status.as_ref().unwrap(); + fn render_led_config(status: &FullDeviceStatus, theme: &Theme) -> impl IntoElement { let config = &status.config; Card::new() .title("LED Configuration") @@ -386,8 +330,7 @@ impl HomeView { }) } - fn render_security_status(state: &DeviceConnectionState, theme: &Theme) -> impl IntoElement { - let status = state.status.as_ref().unwrap(); + fn render_security_status(status: &FullDeviceStatus, theme: &Theme) -> impl IntoElement { Card::new() .title("Security Status") .icon(Icon::default().path("icons/shield-check.svg")) @@ -463,3 +406,48 @@ impl HomeView { ) } } + +impl Render for HomeViewModel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let device = self.device.read(cx); + let connected = device.status.is_some(); + let is_wide = window.bounds().size.width > px(1100.0); + let columns = if is_wide { 2 } else { 1 }; + + PageView::build( + "Device Overview", + "Quick view of your device status and specifications.", + if !connected { + div() + .flex() + .items_center() + .justify_center() + .h_64() + .border_1() + .border_color(cx.theme().border) + .rounded_xl() + .child( + div() + .text_color(cx.theme().muted_foreground) + .child("No Device Connected"), + ) + .into_any_element() + } else { + let status = device.status.as_ref().unwrap(); + div() + .grid() + .grid_cols(columns) + .gap_6() + .child(Self::render_device_info(status, cx.theme())) + .child(Self::render_fido_info( + device.fido_info.as_ref(), + cx.theme(), + )) + .child(Self::render_led_config(status, cx.theme())) + .child(Self::render_security_status(status, cx.theme())) + .into_any_element() + }, + cx.theme(), + ) + } +} diff --git a/src/ui/screens/home/view_model.rs b/src/ui/screens/home/view_model.rs new file mode 100644 index 0000000..2cb18f5 --- /dev/null +++ b/src/ui/screens/home/view_model.rs @@ -0,0 +1,16 @@ +use crate::ui::app::AppModels; +use crate::ui::models::device::{DeviceEvent, DeviceRepo}; +use gpui::*; + +pub struct HomeViewModel { + pub device: Entity, +} + +impl HomeViewModel { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |_, _, _: &DeviceEvent, cx| cx.notify()) + .detach(); + Self { device } + } +} diff --git a/src/ui/screens/passkeys.rs b/src/ui/screens/passkeys.rs deleted file mode 100644 index 73b0a25..0000000 --- a/src/ui/screens/passkeys.rs +++ /dev/null @@ -1,1788 +0,0 @@ -use crate::hal::io; -use crate::hal::types::StoredCredential; -use crate::ui::components::{ - button::{PFButton, PFIconButton}, - card::Card, - dialog, - dialog::{ChangePinContent, ConfirmContent, PinPromptContent, SetPinContent, StatusContent}, - page_view::PageView, -}; -use crate::ui::rootview::ApplicationRoot; -use crate::ui::types::DeviceConnectionState; -use directories::UserDirs; -use gpui::prelude::FluentBuilder; -use gpui::*; -use gpui_component::Disableable; -use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants}; -use gpui_component::{ - ActiveTheme, Icon, Placement, Sizable, StyledExt, Theme, WindowExt, - badge::Badge, - h_flex, - input::{Input, InputState}, - slider::{Slider, SliderState}, - switch::Switch, - v_flex, -}; - -struct SliderLabel { - slider: Entity, -} - -impl Render for SliderLabel { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let val = self.slider.read(cx).value().start() as u8; - format!("Minimum PIN Length ({})", val) - } -} - -pub struct PasskeysView { - root: WeakEntity, - credentials: Vec, - unlocked: bool, - cached_pin: Option, - loading: bool, - csr_loading: bool, - csr_pem: Option, - show_csr: bool, - _task: Option>, -} - -pub enum PasskeysEvent { - Notification(String), -} - -impl EventEmitter for PasskeysView {} - -impl PasskeysView { - pub fn new( - _window: &mut Window, - _cx: &mut Context, - root: WeakEntity, - ) -> Self { - Self { - root, - credentials: Vec::new(), - unlocked: false, - cached_pin: None, - loading: false, - csr_loading: false, - csr_pem: None, - show_csr: false, - _task: None, - } - } - - fn unlock_storage( - &mut self, - pin: String, - dialog_handle: WeakEntity, - cx: &mut Context, - ) { - if self.loading { - return; - } - self.loading = true; - cx.notify(); - - log::info!("Unlocking FIDO storage..."); - 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_credentials(pin_for_bg) }) - .await; - - let _ = entity.update(cx, |this, cx| { - this.loading = false; - match result { - Ok(creds) => { - log::info!("Storage unlocked. {} credentials found.", creds.len()); - this.unlocked = true; - this.cached_pin = Some(pin); - this.credentials = creds; - let _ = dialog_handle.update(cx, |d, cx| { - d.set_success("Storage unlocked successfully.".to_string(), cx); - }); - } - Err(e) => { - log::error!("Failed to unlock storage: {}", e); - let _ = dialog_handle.update(cx, |d, cx| { - d.set_error(format!("Failed to unlock: {}", e), cx); - }); - } - } - cx.notify(); - }); - })); - } - - fn lock_storage(&mut self, cx: &mut Context) { - self.unlocked = false; - self.cached_pin = None; - self.credentials.clear(); - cx.notify(); - } - - fn execute_delete( - &mut self, - credential_id: String, - pin: String, - dialog_handle: WeakEntity, - cx: &mut Context, - ) { - if self.loading { - return; - } - self.loading = true; - cx.notify(); - - log::info!("Deleting credential..."); - let entity = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { io::delete_credential(pin, credential_id) }) - .await; - - let _ = entity.update(cx, |this, cx| match result { - Ok(_) => { - log::info!("Credential deleted successfully."); - let _ = dialog_handle.update(cx, |d, cx| { - d.set_success("Credential deleted successfully.".to_string(), cx); - }); - this.sync_fido_state(None, cx); - } - Err(e) => { - log::error!("Error deleting credential: {}", e); - this.loading = false; - let _ = dialog_handle.update(cx, |d, cx| { - d.set_error(format!("Error deleting: {}", e), cx); - }); - cx.notify(); - } - }); - })); - } - - fn refresh_credentials(&mut self, pin: String, cx: &mut Context) { - let entity = cx.entity().downgrade(); - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { io::get_credentials(pin) }) - .await; - - let _ = entity.update(cx, |this, cx| { - this.loading = false; - if let Ok(creds) = result { - this.credentials = creds; - } - cx.notify(); - }); - })); - } - - /// Re-fetch credentials using the cached PIN (used by sidebar refresh). - pub fn refresh_if_unlocked(&mut self, cx: &mut Context) { - if !self.unlocked || self.loading { - return; - } - let Some(pin) = self.cached_pin.clone() else { - return; - }; - self.loading = true; - cx.notify(); - self.refresh_credentials(pin, cx); - } - - /// Re-sync all cached FIDO state after a mutation. - /// - /// Refreshes `fido_info`, optionally replaces `cached_pin`, and re-pulls - /// credentials when unlocked. `loading` is cleared on completion. - fn sync_fido_state(&mut self, new_pin: Option, cx: &mut Context) { - if let Ok(info) = io::get_fido_info() { - let _ = self.root.update(cx, |root, cx| { - root.device.fido_info = Some(info); - cx.notify(); - }); - } - - // Only update cached_pin when the caller changed it. - if let Some(pin) = new_pin { - self.cached_pin = Some(pin); - } - - if self.unlocked - && let Some(pin) = self.cached_pin.clone() - { - self.refresh_credentials(pin, cx); - return; - } - self.loading = false; - cx.notify(); - } - - fn open_unlock_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let view_handle = cx.entity().downgrade(); - - dialog::open_pin_prompt( - "Unlock Storage", - "Enter your device PIN to view saved passkeys", - None, - "Unlock", - window, - cx, - move |pin, dialog_handle, cx| { - let _ = view_handle.update(cx, |this, cx| { - this.unlock_storage(pin, dialog_handle, cx); - }); - }, - ); - } - - fn open_delete_dialog( - &mut self, - cred: &StoredCredential, - pin: String, - window: &mut Window, - cx: &mut Context, - ) { - let cred_id = cred.credential_id.clone(); - let pin_str = pin.clone(); - let name = cred.rp_id.clone(); - let view_handle = cx.entity().downgrade(); - - dialog::open_confirm( - "Delete Passkey", - format!("Are you sure you want to delete the passkey for {}?", name), - "Delete", - ButtonVariant::Danger, - window, - cx, - move |dialog_handle, _, cx| { - let _ = view_handle.update(cx, |this, cx| { - this.execute_delete(cred_id.clone(), pin_str.clone(), dialog_handle, cx); - }); - }, - ); - } - - fn open_change_pin_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let view_handle = cx.entity().downgrade(); - - dialog::open_change_pin(window, cx, move |current, new, dialog_handle, cx| { - let _ = view_handle.update(cx, |this, cx| { - this.change_pin(current, new, dialog_handle, cx); - }); - }); - } - - fn open_setup_pin_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let view_handle = cx.entity().downgrade(); - - dialog::open_setup_pin(window, cx, move |new_pin, dialog_handle, cx| { - let _ = view_handle.update(cx, |this, cx| { - this.setup_pin(new_pin, dialog_handle, cx); - }); - }); - } - - fn setup_pin( - &mut self, - new: String, - dialog_handle: WeakEntity, - cx: &mut Context, - ) { - if self.loading { - return; - } - self.loading = true; - cx.notify(); - - log::info!("Setting up FIDO PIN..."); - let entity = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { io::change_fido_pin(None, new) }) - .await; - - let _ = entity.update(cx, |this, cx| match result { - Ok(msg) => { - log::info!("PIN configured: {}", msg); - let _ = dialog_handle.update(cx, |d, cx| { - d.set_success("PIN configured successfully.".to_string(), cx); - }); - this.sync_fido_state(None, cx); - } - Err(e) => { - log::error!("PIN setup failed: {}", e); - this.loading = false; - let _ = dialog_handle.update(cx, |d, cx| { - d.set_error(format!("Error: {}", e), cx); - }); - cx.notify(); - } - }); - })); - } - - fn open_min_pin_length_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let current_min = self - .root - .upgrade() - .and_then(|r| { - r.read(cx) - .device - .fido_info - .as_ref() - .map(|f| f.min_pin_length) - }) - .unwrap_or(4); - - let slider = cx.new(|_| { - SliderState::new() - .min(4.0) - .max(63.0) - .step(1.0) - .default_value(current_min as f32) - }); - - let current_pin = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Enter current PIN") - .masked(true) - }); - let new_pin = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Enter new PIN") - .masked(true) - }); - let confirm_pin = cx.new(|cx| { - InputState::new(window, cx) - .placeholder("Confirm new PIN") - .masked(true) - }); - - let label_view = cx.new(|_cx| SliderLabel { - slider: slider.clone(), - }); - - let view_handle = cx.entity().downgrade(); - - // Shared submit closure used by both the Enter key (on_ok) and the Update button. - let submit = { - let current_pin2 = current_pin.clone(); - let new_pin2 = new_pin.clone(); - let confirm_pin2 = confirm_pin.clone(); - let slider2 = slider.clone(); - let view2 = view_handle.clone(); - std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { - let current_val = current_pin2.read(cx).text().to_string(); - let new_val = new_pin2.read(cx).text().to_string(); - let confirm_val = confirm_pin2.read(cx).text().to_string(); - let min_len = slider2.read(cx).value().start() as u8; - - if current_val.is_empty() { - return; - } - - if !new_val.is_empty() { - if new_val != confirm_val { - let _ = view2.update(cx, |_, cx| { - cx.emit(PasskeysEvent::Notification("PINs do not match".to_string())); - }); - return; - } - if new_val.len() < min_len as usize { - let _ = view2.update(cx, |_, cx| { - cx.emit(PasskeysEvent::Notification(format!( - "PIN must be at least {} characters", - min_len - ))); - }); - return; - } - } - // Close the input dialog and open a status dialog for loading feedback. - window.close_dialog(cx); - let status_handle = - dialog::open_status_dialog("Update Minimum PIN Length", window, cx); - let _ = view2.update(cx, |this, cx| { - this.update_min_length(current_val, min_len, new_val, status_handle, cx); - }); - }) - }; - - window.open_dialog(cx, move |dialog, window, _| { - let current = current_pin.clone(); - let new = new_pin.clone(); - let confirm = confirm_pin.clone(); - let slider_handle = slider.clone(); - let submit_for_ok = submit.clone(); - let submit_for_btn = submit.clone(); - let _ = window; - - dialog - .title("Update Minimum PIN Length") - .child( - "Set the minimum allowed PIN length (4-63 characters) and enter a new PIN that meets this requirement.", - ) - .child( - v_flex() - .gap_4() - .pb_4() - .child( - v_flex() - .gap_2() - .child(label_view.clone()) - .child(Slider::new(&slider_handle)) - ) - .child("Current PIN") - .child(Input::new(¤t)) - .child( - v_flex() - .gap_2() - .child(format!("New PIN (min {} chars)", current_min)) - .child(Input::new(&new)) - ) - .child("Confirm New PIN") - .child(Input::new(&confirm)), - ) - .on_ok(move |_, window, cx| { - submit_for_ok(window, cx); - false - }) - .footer(move |_, _window, _cx, _| { - let s = submit_for_btn.clone(); - vec![ - Button::new("cancel") - .label("Cancel") - .on_click(|_, window, cx| window.close_dialog(cx)), - Button::new("update") - .primary() - .label("Update") - .on_click(move |_, window, cx| { - s(window, cx); - }), - ] - }) - }); - } - - fn change_pin( - &mut self, - current: String, - new: String, - dialog_handle: WeakEntity, - cx: &mut Context, - ) { - if self.loading { - return; - } - self.loading = true; - cx.notify(); - - log::info!("Changing FIDO PIN..."); - let entity = cx.entity().downgrade(); - let new_for_sync = new.clone(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { io::change_fido_pin(Some(current), new) }) - .await; - - let _ = entity.update(cx, |this, cx| match result { - Ok(msg) => { - log::info!("PIN changed: {}", msg); - let _ = dialog_handle.update(cx, |d, cx| { - d.set_success("PIN changed successfully.".to_string(), cx); - }); - this.sync_fido_state(Some(new_for_sync), cx); - } - Err(e) => { - log::error!("PIN change failed: {}", e); - this.loading = false; - let _ = dialog_handle.update(cx, |d, cx| { - d.set_error(format!("Error: {}", e), cx); - }); - cx.notify(); - } - }); - })); - } - - fn update_min_length( - &mut self, - current: String, - min_len: u8, - new_pin: String, - status_handle: WeakEntity, - cx: &mut Context, - ) { - if self.loading { - return; - } - self.loading = true; - cx.notify(); - log::info!("Updating minimum PIN length to {}...", min_len); - let entity = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - // 1. Set Min Length - let current_for_bg = current.clone(); - let res_len = cx - .background_executor() - .spawn(async move { io::set_min_pin_length(current_for_bg, min_len) }) - .await; - - if let Err(e) = res_len { - log::error!("Failed to set minimum PIN length: {}", e); - let _ = entity.update(cx, |this, cx| { - this.loading = false; - let _ = status_handle.update(cx, |s, cx| { - s.set_error(format!("Failed to set length: {}", e), cx); - }); - cx.notify(); - }); - return; - } - - if !new_pin.is_empty() { - let new_pin_for_sync = new_pin.clone(); - let res_pin = cx - .background_executor() - .spawn(async move { io::change_fido_pin(Some(current), new_pin) }) - .await; - let _ = entity.update(cx, |this, cx| match res_pin { - Ok(_) => { - log::info!("Minimum length and PIN updated successfully."); - let _ = status_handle.update(cx, |s, cx| { - s.set_success("Minimum length and PIN updated.".to_string(), cx); - }); - this.sync_fido_state(Some(new_pin_for_sync), cx); - } - Err(e) => { - log::error!("Length set, but PIN change failed: {}", e); - this.loading = false; - let _ = status_handle.update(cx, |s, cx| { - s.set_error(format!("Length set, but PIN change failed: {}", e), cx); - }); - cx.notify(); - } - }); - } else { - let _ = entity.update(cx, |this, cx| { - log::info!("Minimum PIN length updated to {}.", min_len); - let _ = status_handle.update(cx, |s, cx| { - s.set_success(format!("Minimum length updated to {}.", min_len), cx); - }); - this.sync_fido_state(None, cx); - }); - } - })); - } - - fn request_csr(&mut self, status_handle: WeakEntity, cx: &mut Context) { - if self.loading { - return; - } - self.loading = true; - self.csr_loading = true; - cx.notify(); - - log::info!("Request Attestation CSR..."); - let entity = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { io::get_enterprise_attestation_csr() }) - .await; - - let _ = entity.update(cx, |this, cx| { - this.loading = false; - this.csr_loading = false; - match result { - Ok(pem) => { - log::info!("CSR retrieved successfully ({} bytes).", pem.len()); - this.csr_pem = Some(pem); - let _ = status_handle.update(cx, |s, cx| { - s.set_success( - "CSR retrieved from device. Click \"View CSR\" to inspect or save it.".to_string(), - cx, - ); - }); - } - Err(e) => { - log::error!("Failed to retrieve CSR: {}", e); - let _ = status_handle.update(cx, |s, cx| { - s.set_error(format!("Failed to retrieve CSR: {}", e), cx); - }); - } - } - cx.notify(); - }); - })); - } - - fn execute_upload_cert( - &mut self, - pin: String, - cert_path: String, - dialog_handle: WeakEntity, - cx: &mut Context, - ) { - if self.loading { - return; - } - self.loading = true; - cx.notify(); - - log::info!( - "Uploading enterprise attestation certificate from: {}", - cert_path - ); - let entity = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { io::upload_enterprise_attestation_cert(pin, cert_path) }) - .await; - - let _ = entity.update(cx, |this, cx| { - this.loading = false; - match result { - Ok(msg) => { - log::info!("{}", msg); - let _ = dialog_handle.update(cx, |d, cx| { - d.set_success(msg, cx); - }); - } - Err(e) => { - log::error!("Certificate upload failed: {}", e); - let _ = dialog_handle.update(cx, |d, cx| { - d.set_error(format!("Upload failed: {}", e), cx); - }); - } - } - cx.notify(); - }); - })); - } - - fn open_enable_ea_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let view_handle = cx.entity().downgrade(); - - dialog::open_pin_prompt( - "Enable Enterprise Attestation", - "Enter your device PIN to enable enterprise attestation", - Some("This operation is irreversible"), - "Enable", - window, - cx, - move |pin, dialog_handle, cx| { - let _ = view_handle.update(cx, |this, cx| { - this.enable_ea(pin, dialog_handle, cx); - }); - }, - ); - } - - fn enable_ea( - &mut self, - pin: String, - dialog_handle: WeakEntity, - cx: &mut Context, - ) { - if self.loading { - return; - } - self.loading = true; - cx.notify(); - - log::info!("Enabling enterprise attestation..."); - let entity = cx.entity().downgrade(); - - self._task = Some(cx.spawn(async move |_, cx| { - let result = cx - .background_executor() - .spawn(async move { io::enable_enterprise_attestation(pin) }) - .await; - - let _ = entity.update(cx, |this, cx| match result { - Ok(msg) => { - log::info!("{}", msg); - let _ = dialog_handle.update(cx, |d, cx| { - d.set_success(msg, cx); - }); - this.sync_fido_state(None, cx); - } - Err(e) => { - log::error!("Failed to enable EA: {}", e); - this.loading = false; - let _ = dialog_handle.update(cx, |d, cx| { - d.set_error(format!("Error: {}", e), cx); - }); - cx.notify(); - } - }); - })); - } - - fn open_upload_cert_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let window_handle = window.window_handle(); - let entity = cx.entity().downgrade(); - - let receiver = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: Some("Select Certificate File (PEM or DER)".into()), - }); - - self._task = Some(cx.spawn(async move |_, cx| { - let Ok(Ok(Some(paths))) = receiver.await else { - return; - }; - let Some(first) = paths.into_iter().next() else { - return; - }; - let cert_path = first.to_string_lossy().to_string(); - - let _ = cx.update_window(window_handle, |_, window, cx| { - dialog::open_pin_prompt( - "Upload Certificate", - "Enter your device PIN to upload the certificate to the device", - None, - "Upload", - window, - cx, - move |pin, dialog_handle, cx| { - let _ = entity.update(cx, |this, cx| { - this.execute_upload_cert(pin, cert_path.clone(), dialog_handle, cx); - }); - }, - ); - }); - })); - } - - fn render_enterprise_attestation(&self, cx: &mut Context) -> impl IntoElement { - let csr_ready = self.csr_pem.is_some(); - let show_csr = self.show_csr && csr_ready; - let is_loading = self.csr_loading; - let pem = self.csr_pem.clone().unwrap_or_default(); - let pem_for_copy = pem.clone(); - - let request_listener = cx.listener(|this, _, window, cx| { - let status_handle = dialog::open_status_dialog("Certificate Request", window, cx); - this.request_csr(status_handle, cx); - }); - - let view_listener = cx.listener(|this, _, _, cx| { - this.show_csr = !this.show_csr; - cx.notify(); - }); - - let save_listener = cx.listener(|this, _, _, cx| { - let Some(pem) = this.csr_pem.clone() else { - return; - }; - let default_dir = UserDirs::new() - .and_then(|d| { - d.document_dir() - .or_else(|| d.download_dir()) - .map(|p| p.to_path_buf()) - }) - .unwrap_or_else(|| { - std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into())) - }); - let receiver = cx.prompt_for_new_path(&default_dir, Some("device_attestation.csr")); - let entity = cx.entity().downgrade(); - this._task = Some(cx.spawn(async move |_, cx| match receiver.await { - Ok(Ok(Some(path))) => match std::fs::write(&path, pem.as_bytes()) { - Ok(_) => { - let _ = entity.update(cx, |_, cx| { - cx.emit(PasskeysEvent::Notification(format!( - "CSR saved to {}", - path.display() - ))); - }); - } - Err(e) => { - let _ = entity.update(cx, |_, cx| { - cx.emit(PasskeysEvent::Notification(format!( - "Failed to save CSR: {}", - e - ))); - }); - } - }, - Ok(Err(e)) => { - let _ = entity.update(cx, |_, cx| { - cx.emit(PasskeysEvent::Notification(format!( - "Save dialog error: {}", - e - ))); - }); - } - _ => {} - })); - }); - - let upload_listener = cx.listener(|this, _, window, cx| { - this.open_upload_cert_dialog(window, cx); - }); - - let theme = cx.theme(); - - let fido_info = self - .root - .upgrade() - .and_then(|r| r.read(cx).device.fido_info.clone()); - let ep_set = fido_info - .as_ref() - .and_then(|f| f.options.get("ep").copied()) - .unwrap_or(false); - - let enable_ea_listener = cx.listener(|this, _checked: &bool, window, cx| { - this.open_enable_ea_dialog(window, cx); - }); - - let enable_row = div() - .border_1() - .border_color(theme.border) - .rounded_lg() - .child( - div() - .flex() - .items_center() - .justify_between() - .p_4() - .child( - v_flex().child(div().font_medium().child("Enable enterprise attestation")), - ) - .child( - h_flex().gap_2().child( - Switch::new("enable-ea-switch") - .checked(ep_set) - .disabled(ep_set) - .on_click(enable_ea_listener), - ), - ), - ); - - let csr_row = div() - .border_1() - .border_color(theme.border) - .rounded_lg() - .child( - div() - .flex() - .items_center() - .justify_between() - .p_4() - .child( - v_flex() - .child(div().font_medium().child("Certificate Signing Request")) - .child(div().text_sm().text_color(theme.muted_foreground).child( - if csr_ready { - "CSR retrieved" - } else { - "Get a CSR for enterprise attestation enrollment" - }, - )), - ) - .child( - h_flex() - .gap_2() - .when(csr_ready, |el| { - el.child( - PFButton::new(if show_csr { "Hide CSR" } else { "View CSR" }) - .id("view-csr-btn") - .with_colors(rgb(0x222225), rgb(0x2a2a2d), rgb(0x333336)) - .on_click(view_listener), - ) - }) - .child( - PFButton::new(if csr_ready { "Refresh" } else { "Request CSR" }) - .id("request-csr-btn") - .with_colors(rgb(0x222225), rgb(0x2a2a2d), rgb(0x333336)) - .loading(is_loading) - .on_click(request_listener), - ), - ), - ) - .when(show_csr, |el| { - el.child( - div().border_t_1().border_color(theme.border).p_4().child( - v_flex() - .gap_3() - .child(div().text_sm().text_color(theme.muted_foreground).child( - "Certificate Signing Request from the device's attestation key.", - )) - .child( - div() - .font_family("monospace") - .text_xs() - .bg(theme.muted) - .p_3() - .rounded_lg() - .overflow_hidden() - .child(pem.clone()), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("copy-csr") - .label("Copy to Clipboard") - .on_click(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new_string( - pem_for_copy.clone(), - )); - }), - ) - .child( - Button::new("save-csr") - .primary() - .label("Save to File") - .on_click(save_listener), - ), - ), - ), - ) - }); - - let upload_row = div() - .flex() - .items_center() - .justify_between() - .p_4() - .border_1() - .border_color(theme.border) - .rounded_lg() - .child( - v_flex() - .child(div().font_medium().child("Upload Certificate")) - .child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Upload the signed certificate to the device"), - ), - ) - .child( - PFButton::new("Upload Certificate") - .id("upload-cert-btn") - .with_colors(rgb(0x222225), rgb(0x2a2a2d), rgb(0x333336)) - .on_click(upload_listener), - ); - - Card::new() - .title("Enterprise Attestation") - .description("Configure enterprise-specific features") - .icon(Icon::default().path("icons/shield-check.svg")) - .child( - v_flex() - .gap_3() - .child(enable_row) - .child(csr_row) - .child(upload_row), - ) - } - - fn render_reset_device_row(&self, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - - let header = gpui_component::h_flex() - .items_center() - .justify_between() - .w_full() - .gap_4() - .child( - v_flex() - .gap_1() - .child( - div() - .text_base() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(theme.foreground) - .child("Factory Reset"), - ) - .child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Erase all passkeys, credentials, and PIN. Cannot be undone."), - ), - ) - .child( - Button::new("reset-device") - .icon(Icon::default().path("icons/circle-alert.svg")) - .child("Reset Device") - .custom( - ButtonCustomVariant::new(cx) - .color(theme.danger) - .hover(theme.danger_hover) - .active(theme.danger_active) - .foreground(theme.danger_foreground), - ) - .disabled(self.loading) - .on_click(cx.listener(|this, _, window, cx| { - this.open_reset_dialog(window, cx); - })), - ); - - Card::new() - .title("Reset") - .description("Perform a destructive factory reset") - .icon(Icon::default().path("icons/trash.svg")) - .child(header) - } - - 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( - div() - .text_color(theme.muted_foreground) - .child("Connect your pico-key to manage passkeys."), - ) - .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("FIDO Passkeys are not supported on this device."), - ) - .into_any_element() - } - - /// Triggers the confirmation flow for a hardware factory reset. - /// - /// Warns the user of the destructive nature of this action (all credentials, passkeys, - /// and PINs will be irrecoverably erased) via a GPUI modal dialog. If confirmed, - /// it transitions to `execute_reset` to begin the 10-second touch confirmation window. - fn open_reset_dialog(&mut self, window: &mut Window, cx: &mut Context) { - let view_handle = cx.entity().downgrade(); - - dialog::open_confirm( - "Factory Reset Device", - "Are you sure you want to completely erase your device? This will permanently delete ALL passkeys, credentials, and your PIN. This action cannot be undone.".to_string(), - "Reset Device", - ButtonVariant::Danger, - window, - cx, - move |_dialog_handle, window, cx| { - // Close the confirm dialog before opening the status dialog - window.close_dialog(cx); - // When they click confirm, we swap to a status dialog for the reconnect wizard - let _ = view_handle.update(cx, |this, cx| { - this.execute_reset(window, cx); - }); - }, - ); - } - - /// Orchestrates the underlying FIDO factory reset protocol asynchronously. - /// - /// Changes the UI to a loading/status phase instructing the user to unplug, replug, - /// and touch the key within 10 seconds. Monitors the reset task and propagates any - /// success or error state back to the UI thread upon completion. - fn execute_reset(&mut self, window: &mut Window, cx: &mut Context) { - if self.loading { - return; - } - self.loading = true; - - let status_handle = dialog::open_status_dialog("Resetting Device...", window, cx); - let entity = cx.entity().downgrade(); - - let _ = status_handle.update(cx, |d, cx| { - d.set_loading( - "Unplug your security key, then plug it back in within 10 seconds.", - cx, - ); - }); - - self._task = Some(cx.spawn(async move |_, cx| { - // Wait for unplug/replug - let reconnected = cx - .background_executor() - .spawn(async move { - let start = std::time::Instant::now(); - // 1. Wait for unplug - while start.elapsed().as_secs() < 15 { - std::thread::sleep(std::time::Duration::from_millis(200)); - if crate::hal::fido::hid::HidTransport::open().is_err() { - break; - } - } - - // 2. Wait for replug - while start.elapsed().as_secs() < 15 { - std::thread::sleep(std::time::Duration::from_millis(500)); - if crate::hal::fido::hid::HidTransport::open().is_ok() { - return true; - } - } - false - }) - .await; - - if !reconnected { - let _ = entity.update(cx, |this, cx| { - this.loading = false; - let _ = status_handle.update(cx, |d, cx| { - d.set_error( - "Timeout waiting for device reconnection. Reset canceled.".to_string(), - cx, - ); - }); - cx.notify(); - }); - return; - } - - // Tell user to touch - let _ = status_handle.update(cx, |d, cx| { - d.set_loading("Touch your security key now to confirm the reset...", cx); - }); - - // Execute reset - let result = cx - .background_executor() - .spawn(async move { io::reset_device() }) - .await; - - let _ = entity.update(cx, |this, cx| { - match result { - Ok(msg) => { - log::info!("Device Reset: {}", msg); - this.lock_storage(cx); // clear cached pin/creds - let _ = status_handle.update(cx, |d, cx| { - d.set_success(msg, cx); - }); - cx.emit(PasskeysEvent::Notification( - "Device reset successfully".into(), - )); - this.lock_storage(cx); - this.sync_fido_state(None, cx); - } - Err(e) => { - log::error!("Error resetting device: {}", e); - this.loading = false; - let _ = status_handle.update(cx, |d, cx| { - d.set_error(format!("Reset failed: {}", e), cx); - }); - cx.notify(); - } - } - }); - })); - } - - fn render_pin_management(&self, cx: &mut Context) -> impl IntoElement { - let status_row = self.render_pin_status_row(cx).into_any_element(); - let min_len_row = self.render_min_pin_length_row(cx).into_any_element(); - - Card::new() - .title("PIN Management") - .icon(Icon::default().path("icons/key.svg")) - .description("Configure FIDO2 PIN security") - .child(v_flex().gap_4().child(status_row).child(min_len_row)) - } - - fn render_pin_status_row(&self, cx: &mut Context) -> impl IntoElement { - let fido_info = self - .root - .upgrade() - .and_then(|r| r.read(cx).device.fido_info.clone()); - let pin_set = fido_info - .as_ref() - .and_then(|f| f.options.get("clientPin").copied()) - .unwrap_or(false); - - let listener = cx.listener(move |this, _, window, cx| { - if pin_set { - this.open_change_pin_dialog(window, cx); - } else { - this.open_setup_pin_dialog(window, cx); - } - }); - - let theme = cx.theme(); - - div() - .flex() - .items_center() - .justify_between() - .p_4() - .border_1() - .border_color(theme.border) - .rounded_lg() - .child( - v_flex() - .child(div().font_medium().child("Current PIN Status")) - .child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child(if pin_set { - "PIN is set" - } else { - "No PIN configured" - }), - ), - ) - .child( - PFButton::new(if pin_set { "Change PIN" } else { "Set up PIN" }) - .id("change-pin-btn") - .with_colors(rgb(0x222225), rgb(0x2a2a2d), rgb(0x333336)) - .on_click(listener), - ) - } - - fn render_min_pin_length_row(&self, cx: &mut Context) -> impl IntoElement { - let fido_info = self - .root - .upgrade() - .and_then(|r| r.read(cx).device.fido_info.clone()); - let min_len = fido_info.as_ref().map(|f| f.min_pin_length).unwrap_or(4); - let pin_set = fido_info - .as_ref() - .and_then(|f| f.options.get("clientPin").copied()) - .unwrap_or(false); - - let theme = cx.theme(); - - div() - .flex() - .items_center() - .justify_between() - .p_4() - .border_1() - .border_color(theme.border) - .rounded_lg() - .child( - v_flex() - .child(div().font_medium().child("Minimum PIN Length")) - .child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child(format!("Current: {} characters", min_len)), - ), - ) - .child( - PFButton::new("Update Minimum Length") - .id("update-min-len-btn") - .with_colors(rgb(0x222225), rgb(0x2a2a2d), rgb(0x333336)) - .disabled(!pin_set) - .on_click(cx.listener(|this, _, window, cx| { - this.open_min_pin_length_dialog(window, cx); - })), - ) - } - - fn render_stored_passkeys(&self, cx: &mut Context) -> impl IntoElement { - if !self.unlocked { - self.render_locked_state(cx).into_any_element() - } else { - self.render_unlocked_state(cx).into_any_element() - } - } - - fn render_locked_state(&self, cx: &mut Context) -> impl IntoElement { - let listener = cx.listener(|this, _, window, cx| { - this.open_unlock_dialog(window, cx); - }); - let theme = cx.theme(); - - Card::new() - .title("Stored Passkeys") - .icon(Icon::default().path("icons/key-round.svg")) - .description("View and manage your resident credentials") - .child( - v_flex() - .items_center() - .justify_center() - .gap_3() - .py_3() - .child( - div().rounded_full().bg(theme.muted).p_4().child( - Icon::default() - .path("icons/shield.svg") - .size_12() - .text_color(theme.muted_foreground), - ), - ) - .child( - div() - .text_lg() - .font_semibold() - .child("Authentication Required"), - ) - .child( - div() - .text_color(theme.muted_foreground) - .text_sm() - .child("Unlock your device to view and manage passkeys."), - ) - .child( - PFIconButton::new( - Icon::default().path("icons/lock-open.svg"), - "Unlock Storage", - ) - .on_click(listener) - .with_colors(rgb(0xe4e4e7), rgb(0xd0d0d3), rgb(0xe4e4e7)) - .with_text_color(rgb(0x18181b)), - ), - ) - } - - fn render_unlocked_state(&self, cx: &mut Context) -> impl IntoElement { - let creds_len = self.credentials.len(); - let lock_listener = cx.listener(|this, _, _, cx| { - this.lock_storage(cx); - }); - - let mut cards = Vec::new(); - for cred in &self.credentials { - cards.push(self.render_credential_card(cred, cx).into_any_element()); - } - - let theme = cx.theme(); - - Card::new() - .title("Stored Passkeys") - .icon(Icon::default().path("icons/key-round.svg")) - .description("View and manage your resident credentials") - .child( - v_flex() - .gap_6() - .child( - h_flex() - .justify_between() - .items_center() - .child( - h_flex() - .gap_4() - .items_center() - .child( - Badge::new() - .child( - h_flex() - .gap_1() - .items_center() - .child( - Icon::default() - .path("icons/lock-open.svg") - .size_3p5(), - ) - .child("Unlocked"), - ) - .color(gpui::green()), - ) - .child(div().w_px().h_4().bg(theme.border)) - .child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child(format!("{} credentials stored", creds_len)), - ), - ) - .child( - PFIconButton::new( - Icon::default().path("icons/lock.svg").size_3p5(), - "Lock Storage", - ) - .small() - .on_click(lock_listener), - ), - ) - .child(if self.credentials.is_empty() { - self.render_empty_credentials_with_theme(theme) - .into_any_element() - } else { - div() - .grid() - .grid_cols(3) - .gap_4() - .children(cards) - .into_any_element() - }), - ) - } - - fn render_empty_credentials_with_theme(&self, theme: &Theme) -> impl IntoElement { - v_flex() - .items_center() - .justify_center() - .py_12() - .border_1() - .border_color(theme.border) - .rounded_xl() - .gap_4() - .child( - div() - .rounded_full() - .bg(theme.muted) - .p_4() - .child( - Icon::default() - .path("icons/key-round.svg") - .size_8() - .text_color(theme.muted_foreground), - ), - ) - .child(div().text_lg().font_semibold().child("No Passkeys Found")) - .child( - div() - .text_color(theme.muted_foreground) - .text_sm() - .text_center() - .max_w(px(384.0)) - .child("This device doesn't have any resident credentials stored yet. Create passkeys on websites to see them here."), - ) - } - - fn render_credential_card( - &self, - cred: &StoredCredential, - cx: &mut Context, - ) -> impl IntoElement { - let cred_clone = cred.clone(); - let cred_for_click = cred.clone(); - - let delete_listener = cx.listener(move |this, _, window, cx| { - this.open_ask_delete_pin(cred_clone.clone(), window, cx); - }); - - let click_listener = cx.listener(move |this, _, window, cx| { - this.open_credential_details(&cred_for_click, window, cx); - }); - - let theme = cx.theme(); - - div() - .id(SharedString::from(format!( - "cred-card-{}", - cred.credential_id - ))) - .cursor_pointer() - .on_click(click_listener) - .border_1() - .border_color(theme.border) - .rounded_xl() - .p_4() - .hover(|s| s.bg(theme.accent).border_color(theme.primary)) - .child( - h_flex() - .justify_between() - .items_center() - .child( - h_flex() - .gap_3() - .items_center() - .flex_1() - .min_w_0() - .child( - div() - .size_10() - .rounded_md() - .bg(rgb(0x3b3b3e)) - .flex() - .items_center() - .justify_center() - .child( - Icon::default() - .path("icons/key-round.svg") - .text_color(theme.primary) - .size_5(), - ), - ) - .child( - v_flex() - .min_w_0() - .overflow_hidden() - .child( - div() - .font_semibold() - .whitespace_nowrap() - .overflow_hidden() - .text_ellipsis() - .child(if !cred.rp_name.is_empty() { - cred.rp_name.clone() - } else if !cred.rp_id.is_empty() { - cred.rp_id.clone() - } else { - "Unknown Service".to_string() - }), - ) - .child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .whitespace_nowrap() - .overflow_hidden() - .text_ellipsis() - .child(cred.user_name.clone()), - ), - ), - ) - .child( - div() - .on_mouse_down(MouseButton::Left, |_, _, cx| { - cx.stop_propagation(); - }) - .child( - Button::new("delete-cred-btn") - .ghost() - .small() - .child( - Icon::default() - .path("icons/trash-2.svg") - .size_4() - .text_color(theme.muted_foreground), - ) - .on_click(delete_listener), - ), - ), - ) - } - - fn open_credential_details( - &mut self, - cred: &StoredCredential, - window: &mut Window, - cx: &mut Context, - ) { - let title = if !cred.rp_name.is_empty() { - cred.rp_name.clone() - } else if !cred.rp_id.is_empty() { - cred.rp_id.clone() - } else { - "Passkey Details".to_string() - }; - let rp_id = cred.rp_id.clone(); - let user_name = cred.user_name.clone(); - let display_name = if cred.user_display_name.is_empty() { - "N/A".to_string() - } else { - cred.user_display_name.clone() - }; - let user_id = cred.user_id.clone(); - let credential_id = cred.credential_id.clone(); - - window.open_sheet_at(Placement::Bottom, cx, move |sheet, _, cx| { - let theme = cx.theme(); - - let header_row = h_flex() - .gap_3() - .p_4() - .bg(theme.muted.opacity(0.3)) - .border_1() - .border_color(theme.border) - .rounded_lg() - .child( - div() - .size_12() - .rounded_full() - .bg(theme.primary.opacity(0.1)) - .flex() - .items_center() - .justify_center() - .child( - Icon::default() - .path("icons/key-round.svg") - .text_color(theme.primary) - .size_6(), - ), - ) - .child( - v_flex() - .child(div().font_semibold().child(rp_id.clone())) - .child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .font_family("monospace") - .child(user_name.clone()), - ), - ); - - let separator = div().w_full().h(px(1.)).bg(theme.border); - - let detail_field = |label: &str, value: String, mono: bool| { - let mut value_el = div().text_sm().font_medium().child(value.clone()); - if mono { - value_el = div() - .text_xs() - .font_family("monospace") - .bg(theme.muted) - .p_2() - .rounded_md() - .overflow_hidden() - .child(value); - } - v_flex() - .gap_1() - .child( - div() - .text_sm() - .font_medium() - .text_color(theme.muted_foreground) - .child(label.to_string()), - ) - .child(value_el) - }; - - let description = h_flex() - .gap_1() - .child( - div() - .text_sm() - .text_color(theme.muted_foreground) - .child("Credential details for user"), - ) - .child( - div() - .text_sm() - .font_semibold() - .text_color(theme.foreground) - .child(user_name.clone()), - ); - - sheet - .title( - div().w_full().child( - v_flex() - .mx_auto() - .max_w(px(512.)) - .px_4() - .gap_0p5() - .child(div().text_2xl().font_bold().child(title.clone())) - .child(description), - ), - ) - .size(px(500.)) - .resizable(false) - .margin_top(px(0.)) - .child( - div().mx_auto().max_w(px(512.)).w_full().px_4().child( - v_flex() - .gap_4() - .child(header_row) - .child(separator) - .child(detail_field("Display Name", display_name.clone(), false)) - .child(detail_field("User ID (Hex)", user_id.clone(), true)) - .child(detail_field( - "Credential ID (Hex)", - credential_id.clone(), - true, - )), - ), - ) - }); - } - - fn open_ask_delete_pin( - &mut self, - cred: StoredCredential, - window: &mut Window, - cx: &mut Context, - ) { - if let Some(pin) = &self.cached_pin { - self.open_delete_dialog(&cred, pin.clone(), window, cx); - } else { - window.push_notification("Session expired, please unlock again.", cx); - self.lock_storage(cx); - } - } -} - -impl Render for PasskeysView { - 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); - - let device_connected = device.status.is_some(); - - if !device_connected { - let theme = cx.theme(); - return PageView::build( - "Passkeys", - "Manage your security PIN and the FIDO credentials (passkeys) stored on your device.", - self.render_no_device(theme).into_any_element(), - theme, - ) - .into_any_element(); - } - - let has_fido = device - .status - .as_ref() - .map(|s| s.method == crate::hal::types::DeviceMethod::Fido) - .unwrap_or(false) - || device.fido_info.is_some(); - - if !has_fido { - let theme = cx.theme(); - return PageView::build( - "Passkeys", - "Manage your security PIN and the FIDO credentials (passkeys) stored on your device.", - self.render_not_supported(theme).into_any_element(), - theme, - ) - .into_any_element(); - } - - let content = v_flex() - .gap_6() - .child(self.render_pin_management(cx)) - .child(self.render_stored_passkeys(cx)) - .child(self.render_enterprise_attestation(cx)) - .child(self.render_reset_device_row(cx)); - - let theme = cx.theme(); - - div() - .size_full() - .relative() - .child(PageView::build( - "Passkeys", - "Manage your security PIN and the FIDO credentials (passkeys) stored on your device.", - content.into_any_element(), - theme, - )) - .into_any_element() - } -} diff --git a/src/ui/screens/passkeys/mod.rs b/src/ui/screens/passkeys/mod.rs new file mode 100644 index 0000000..ee71166 --- /dev/null +++ b/src/ui/screens/passkeys/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::{PasskeysEvent, PasskeysView}; diff --git a/src/ui/screens/passkeys/view.rs b/src/ui/screens/passkeys/view.rs new file mode 100644 index 0000000..5c0580d --- /dev/null +++ b/src/ui/screens/passkeys/view.rs @@ -0,0 +1,743 @@ +use crate::hal::types::StoredCredential; +use crate::ui::components::{ + button::{PFButton, PFIconButton}, + card::Card, + dialog, + page_view::PageView, +}; +use crate::ui::screens::passkeys::view_model::{PasskeysEvent, PasskeysView}; +use directories::UserDirs; +use gpui::prelude::FluentBuilder; +use gpui::*; +use gpui_component::Disableable; +use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants}; +use gpui_component::{ + ActiveTheme, Icon, Sizable, StyledExt, Theme, badge::Badge, h_flex, switch::Switch, v_flex, +}; + +impl PasskeysView { + fn render_enterprise_attestation(&self, cx: &mut Context) -> impl IntoElement { + let csr_ready = self.csr_pem.is_some(); + let show_csr = self.show_csr && csr_ready; + let is_loading = self.csr_loading; + let pem = self.csr_pem.clone().unwrap_or_default(); + let pem_for_copy = pem.clone(); + + let request_listener = cx.listener(|this, _, window, cx| { + let status_handle = dialog::open_status_dialog("Certificate Request", window, cx); + this.request_csr(status_handle, cx); + }); + + let view_listener = cx.listener(|this, _, _, cx| { + this.show_csr = !this.show_csr; + cx.notify(); + }); + + let save_listener = cx.listener(|this, _, _, cx| { + let Some(pem) = this.csr_pem.clone() else { + return; + }; + let default_dir = UserDirs::new() + .and_then(|d| { + d.document_dir() + .or_else(|| d.download_dir()) + .map(|p| p.to_path_buf()) + }) + .unwrap_or_else(|| { + std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into())) + }); + let receiver = cx.prompt_for_new_path(&default_dir, Some("device_attestation.csr")); + let entity = cx.entity().downgrade(); + this._task = Some(cx.spawn(async move |_, cx| match receiver.await { + Ok(Ok(Some(path))) => match std::fs::write(&path, pem.as_bytes()) { + Ok(_) => { + let _ = entity.update(cx, |_, cx| { + cx.emit(PasskeysEvent::Notification(format!( + "CSR saved to {}", + path.display() + ))); + }); + } + Err(e) => { + let _ = entity.update(cx, |_, cx| { + cx.emit(PasskeysEvent::Notification(format!( + "Failed to save CSR: {}", + e + ))); + }); + } + }, + Ok(Err(e)) => { + let _ = entity.update(cx, |_, cx| { + cx.emit(PasskeysEvent::Notification(format!( + "Save dialog error: {}", + e + ))); + }); + } + _ => {} + })); + }); + + let upload_listener = cx.listener(|this, _, window, cx| { + this.open_upload_cert_dialog(window, cx); + }); + + let theme = cx.theme(); + + let fido_info = self.device.read(cx).fido_info.clone(); + let ep_set = fido_info + .as_ref() + .and_then(|f| f.options.get("ep").copied()) + .unwrap_or(false); + + let enable_ea_listener = cx.listener(|this, _checked: &bool, window, cx| { + this.open_enable_ea_dialog(window, cx); + }); + + let enable_row = div() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + div() + .flex() + .items_center() + .justify_between() + .p_4() + .child( + v_flex().child(div().font_medium().child("Enable enterprise attestation")), + ) + .child( + h_flex().gap_2().child( + Switch::new("enable-ea-switch") + .checked(ep_set) + .disabled(ep_set) + .on_click(enable_ea_listener), + ), + ), + ); + + let csr_row = div() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + div() + .flex() + .items_center() + .justify_between() + .p_4() + .child( + v_flex() + .child(div().font_medium().child("Certificate Signing Request")) + .child(div().text_sm().text_color(theme.muted_foreground).child( + if csr_ready { + "CSR retrieved" + } else { + "Get a CSR for enterprise attestation enrollment" + }, + )), + ) + .child( + h_flex() + .gap_2() + .when(csr_ready, |el| { + el.child( + PFButton::new(if show_csr { "Hide CSR" } else { "View CSR" }) + .id("view-csr-btn") + .with_colors(rgb(0x222225), rgb(0x2a2a2d), rgb(0x333336)) + .on_click(view_listener), + ) + }) + .child( + PFButton::new(if csr_ready { "Refresh" } else { "Request CSR" }) + .id("request-csr-btn") + .with_colors(rgb(0x222225), rgb(0x2a2a2d), rgb(0x333336)) + .loading(is_loading) + .on_click(request_listener), + ), + ), + ) + .when(show_csr, |el| { + el.child( + div().border_t_1().border_color(theme.border).p_4().child( + v_flex() + .gap_3() + .child(div().text_sm().text_color(theme.muted_foreground).child( + "Certificate Signing Request from the device's attestation key.", + )) + .child( + div() + .font_family("monospace") + .text_xs() + .bg(theme.muted) + .p_3() + .rounded_lg() + .overflow_hidden() + .child(pem.clone()), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("copy-csr") + .label("Copy to Clipboard") + .on_click(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + pem_for_copy.clone(), + )); + }), + ) + .child( + Button::new("save-csr") + .primary() + .label("Save to File") + .on_click(save_listener), + ), + ), + ), + ) + }); + + let upload_row = div() + .flex() + .items_center() + .justify_between() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .child(div().font_medium().child("Upload Certificate")) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Upload the signed certificate to the device"), + ), + ) + .child( + PFButton::new("Upload Certificate") + .id("upload-cert-btn") + .with_colors(rgb(0x222225), rgb(0x2a2a2d), rgb(0x333336)) + .on_click(upload_listener), + ); + + Card::new() + .title("Enterprise Attestation") + .description("Configure enterprise-specific features") + .icon(Icon::default().path("icons/shield-check.svg")) + .child( + v_flex() + .gap_3() + .child(enable_row) + .child(csr_row) + .child(upload_row), + ) + } + + fn render_reset_device_row(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + + let header = gpui_component::h_flex() + .items_center() + .justify_between() + .w_full() + .gap_4() + .child( + v_flex() + .gap_1() + .child( + div() + .text_base() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(theme.foreground) + .child("Factory Reset"), + ) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Erase all passkeys, credentials, and PIN. Cannot be undone."), + ), + ) + .child( + Button::new("reset-device") + .icon(Icon::default().path("icons/circle-alert.svg")) + .child("Reset Device") + .custom( + ButtonCustomVariant::new(cx) + .color(theme.danger) + .hover(theme.danger_hover) + .active(theme.danger_active) + .foreground(theme.danger_foreground), + ) + .disabled(self.loading) + .on_click(cx.listener(|this, _, window, cx| { + this.open_reset_dialog(window, cx); + })), + ); + + Card::new() + .title("Reset") + .description("Perform a destructive factory reset") + .icon(Icon::default().path("icons/trash.svg")) + .child(header) + } + + 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( + div() + .text_color(theme.muted_foreground) + .child("Connect your pico-key to manage passkeys."), + ) + .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("FIDO Passkeys are not supported on this device."), + ) + .into_any_element() + } + + fn render_pin_management(&self, cx: &mut Context) -> impl IntoElement { + let status_row = self.render_pin_status_row(cx).into_any_element(); + let min_len_row = self.render_min_pin_length_row(cx).into_any_element(); + + Card::new() + .title("PIN Management") + .icon(Icon::default().path("icons/key.svg")) + .description("Configure FIDO2 PIN security") + .child(v_flex().gap_4().child(status_row).child(min_len_row)) + } + + fn render_pin_status_row(&self, cx: &mut Context) -> impl IntoElement { + let fido_info = self.device.read(cx).fido_info.clone(); + let pin_set = fido_info + .as_ref() + .and_then(|f| f.options.get("clientPin").copied()) + .unwrap_or(false); + + let listener = cx.listener(move |this, _, window, cx| { + if pin_set { + this.open_change_pin_dialog(window, cx); + } else { + this.open_setup_pin_dialog(window, cx); + } + }); + + let theme = cx.theme(); + + div() + .flex() + .items_center() + .justify_between() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .child(div().font_medium().child("Current PIN Status")) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(if pin_set { + "PIN is set" + } else { + "No PIN configured" + }), + ), + ) + .child( + PFButton::new(if pin_set { "Change PIN" } else { "Set up PIN" }) + .id("change-pin-btn") + .with_colors(rgb(0x222225), rgb(0x2a2a2d), rgb(0x333336)) + .on_click(listener), + ) + } + + fn render_min_pin_length_row(&self, cx: &mut Context) -> impl IntoElement { + let fido_info = self.device.read(cx).fido_info.clone(); + let min_len = fido_info.as_ref().map(|f| f.min_pin_length).unwrap_or(4); + let pin_set = fido_info + .as_ref() + .and_then(|f| f.options.get("clientPin").copied()) + .unwrap_or(false); + + let theme = cx.theme(); + + div() + .flex() + .items_center() + .justify_between() + .p_4() + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + v_flex() + .child(div().font_medium().child("Minimum PIN Length")) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(format!("Current: {} characters", min_len)), + ), + ) + .child( + PFButton::new("Update Minimum Length") + .id("update-min-len-btn") + .with_colors(rgb(0x222225), rgb(0x2a2a2d), rgb(0x333336)) + .disabled(!pin_set) + .on_click(cx.listener(|this, _, window, cx| { + this.open_min_pin_length_dialog(window, cx); + })), + ) + } + + fn render_stored_passkeys(&self, cx: &mut Context) -> impl IntoElement { + if !self.unlocked { + self.render_locked_state(cx).into_any_element() + } else { + self.render_unlocked_state(cx).into_any_element() + } + } + + fn render_locked_state(&self, cx: &mut Context) -> impl IntoElement { + let listener = cx.listener(|this, _, window, cx| { + this.open_unlock_dialog(window, cx); + }); + let theme = cx.theme(); + + Card::new() + .title("Stored Passkeys") + .icon(Icon::default().path("icons/key-round.svg")) + .description("View and manage your resident credentials") + .child( + v_flex() + .items_center() + .justify_center() + .gap_3() + .py_3() + .child( + div().rounded_full().bg(theme.muted).p_4().child( + Icon::default() + .path("icons/shield.svg") + .size_12() + .text_color(theme.muted_foreground), + ), + ) + .child( + div() + .text_lg() + .font_semibold() + .child("Authentication Required"), + ) + .child( + div() + .text_color(theme.muted_foreground) + .text_sm() + .child("Unlock your device to view and manage passkeys."), + ) + .child( + PFIconButton::new( + Icon::default().path("icons/lock-open.svg"), + "Unlock Storage", + ) + .on_click(listener) + .with_colors(rgb(0xe4e4e7), rgb(0xd0d0d3), rgb(0xe4e4e7)) + .with_text_color(rgb(0x18181b)), + ), + ) + } + + fn render_unlocked_state(&self, cx: &mut Context) -> impl IntoElement { + let creds_len = self.credentials.len(); + let lock_listener = cx.listener(|this, _, _, cx| { + this.lock_storage(cx); + }); + + let mut cards = Vec::new(); + for cred in &self.credentials { + cards.push(self.render_credential_card(cred, cx).into_any_element()); + } + + let theme = cx.theme(); + + Card::new() + .title("Stored Passkeys") + .icon(Icon::default().path("icons/key-round.svg")) + .description("View and manage your resident credentials") + .child( + v_flex() + .gap_6() + .child( + h_flex() + .justify_between() + .items_center() + .child( + h_flex() + .gap_4() + .items_center() + .child( + Badge::new() + .child( + h_flex() + .gap_1() + .items_center() + .child( + Icon::default() + .path("icons/lock-open.svg") + .size_3p5(), + ) + .child("Unlocked"), + ) + .color(gpui::green()), + ) + .child(div().w_px().h_4().bg(theme.border)) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(format!("{} credentials stored", creds_len)), + ), + ) + .child( + PFIconButton::new( + Icon::default().path("icons/lock.svg").size_3p5(), + "Lock Storage", + ) + .small() + .on_click(lock_listener), + ), + ) + .child(if self.credentials.is_empty() { + self.render_empty_credentials_with_theme(theme) + .into_any_element() + } else { + div() + .grid() + .grid_cols(3) + .gap_4() + .children(cards) + .into_any_element() + }), + ) + } + + fn render_empty_credentials_with_theme(&self, theme: &Theme) -> impl IntoElement { + v_flex() + .items_center() + .justify_center() + .py_12() + .border_1() + .border_color(theme.border) + .rounded_xl() + .gap_4() + .child( + div() + .rounded_full() + .bg(theme.muted) + .p_4() + .child( + Icon::default() + .path("icons/key-round.svg") + .size_8() + .text_color(theme.muted_foreground), + ), + ) + .child(div().text_lg().font_semibold().child("No Passkeys Found")) + .child( + div() + .text_color(theme.muted_foreground) + .text_sm() + .text_center() + .max_w(px(384.0)) + .child("This device doesn't have any resident credentials stored yet. Create passkeys on websites to see them here."), + ) + } + + fn render_credential_card( + &self, + cred: &StoredCredential, + cx: &mut Context, + ) -> impl IntoElement { + let cred_clone = cred.clone(); + let cred_for_click = cred.clone(); + + let delete_listener = cx.listener(move |this, _, window, cx| { + this.open_ask_delete_pin(cred_clone.clone(), window, cx); + }); + + let click_listener = cx.listener(move |this, _, window, cx| { + this.open_credential_details(&cred_for_click, window, cx); + }); + + let theme = cx.theme(); + + div() + .id(SharedString::from(format!( + "cred-card-{}", + cred.credential_id + ))) + .cursor_pointer() + .on_click(click_listener) + .border_1() + .border_color(theme.border) + .rounded_xl() + .p_4() + .hover(|s| s.bg(theme.accent).border_color(theme.primary)) + .child( + h_flex() + .justify_between() + .items_center() + .child( + h_flex() + .gap_3() + .items_center() + .flex_1() + .min_w_0() + .child( + div() + .size_10() + .rounded_md() + .bg(rgb(0x3b3b3e)) + .flex() + .items_center() + .justify_center() + .child( + Icon::default() + .path("icons/key-round.svg") + .text_color(theme.primary) + .size_5(), + ), + ) + .child( + v_flex() + .min_w_0() + .overflow_hidden() + .child( + div() + .font_semibold() + .whitespace_nowrap() + .overflow_hidden() + .text_ellipsis() + .child(if !cred.rp_name.is_empty() { + cred.rp_name.clone() + } else if !cred.rp_id.is_empty() { + cred.rp_id.clone() + } else { + "Unknown Service".to_string() + }), + ) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .whitespace_nowrap() + .overflow_hidden() + .text_ellipsis() + .child(cred.user_name.clone()), + ), + ), + ) + .child( + div() + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .child( + Button::new("delete-cred-btn") + .ghost() + .small() + .child( + Icon::default() + .path("icons/trash-2.svg") + .size_4() + .text_color(theme.muted_foreground), + ) + .on_click(delete_listener), + ), + ), + ) + } +} + +impl Render for PasskeysView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let device = self.device.read(cx); + let device_connected = device.status.is_some(); + + if !device_connected { + let theme = cx.theme(); + return PageView::build( + "Passkeys", + "Manage your security PIN and the FIDO credentials (passkeys) stored on your device.", + self.render_no_device(theme).into_any_element(), + theme, + ) + .into_any_element(); + } + + let has_fido = device + .status + .as_ref() + .map(|s| s.method == crate::hal::types::DeviceMethod::Fido) + .unwrap_or(false) + || device.fido_info.is_some(); + + if !has_fido { + let theme = cx.theme(); + return PageView::build( + "Passkeys", + "Manage your security PIN and the FIDO credentials (passkeys) stored on your device.", + self.render_not_supported(theme).into_any_element(), + theme, + ) + .into_any_element(); + } + + let content = v_flex() + .gap_6() + .child(self.render_pin_management(cx)) + .child(self.render_stored_passkeys(cx)) + .child(self.render_enterprise_attestation(cx)) + .child(self.render_reset_device_row(cx)); + + let theme = cx.theme(); + + div() + .size_full() + .relative() + .child(PageView::build( + "Passkeys", + "Manage your security PIN and the FIDO credentials (passkeys) stored on your device.", + content.into_any_element(), + theme, + )) + .into_any_element() + } +} diff --git a/src/ui/screens/passkeys/view_model.rs b/src/ui/screens/passkeys/view_model.rs new file mode 100644 index 0000000..214071f --- /dev/null +++ b/src/ui/screens/passkeys/view_model.rs @@ -0,0 +1,1019 @@ +use crate::hal::io; +use crate::hal::types::StoredCredential; +use crate::ui::app::AppModels; +use crate::ui::components::dialog; +use crate::ui::components::dialog::{ + ChangePinContent, ConfirmContent, PinPromptContent, SetPinContent, StatusContent, +}; +use crate::ui::models::device::{DeviceEvent, DeviceRepo}; +use gpui::*; +use gpui_component::button::ButtonVariants; +use gpui_component::{ActiveTheme, StyledExt, WindowExt}; + +pub struct PasskeysView { + pub(super) device: Entity, + pub(super) credentials: Vec, + pub(super) unlocked: bool, + cached_pin: Option, + pub(super) loading: bool, + pub(super) csr_loading: bool, + pub(super) csr_pem: Option, + pub(super) show_csr: bool, + pub(super) _task: Option>, +} + +pub enum PasskeysEvent { + Notification(String), +} + +impl EventEmitter for PasskeysView {} + +impl PasskeysView { + pub fn new(_window: &mut Window, cx: &mut Context, models: &AppModels) -> Self { + let device = models.device.clone(); + cx.subscribe(&device, |_, _, _: &DeviceEvent, cx| cx.notify()) + .detach(); + Self { + device, + credentials: Vec::new(), + unlocked: false, + cached_pin: None, + loading: false, + csr_loading: false, + csr_pem: None, + show_csr: false, + _task: None, + } + } + + pub(super) fn unlock_storage( + &mut self, + pin: String, + dialog_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + + log::info!("Unlocking FIDO storage..."); + 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_credentials(pin_for_bg) }) + .await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(creds) => { + log::info!("Storage unlocked. {} credentials found.", creds.len()); + this.unlocked = true; + this.cached_pin = Some(pin); + this.credentials = creds; + let _ = dialog_handle.update(cx, |d, cx| { + d.set_success("Storage unlocked successfully.".to_string(), cx); + }); + } + Err(e) => { + log::error!("Failed to unlock storage: {}", e); + let _ = dialog_handle.update(cx, |d, cx| { + d.set_error(format!("Failed to unlock: {}", e), cx); + }); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn lock_storage(&mut self, cx: &mut Context) { + self.unlocked = false; + self.cached_pin = None; + self.credentials.clear(); + cx.notify(); + } + + pub(super) fn execute_delete( + &mut self, + credential_id: String, + pin: String, + dialog_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + + log::info!("Deleting credential..."); + let entity = cx.entity().downgrade(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { io::delete_credential(pin, credential_id) }) + .await; + + let _ = entity.update(cx, |this, cx| match result { + Ok(_) => { + log::info!("Credential deleted successfully."); + let _ = dialog_handle.update(cx, |d, cx| { + d.set_success("Credential deleted successfully.".to_string(), cx); + }); + this.sync_fido_state(None, cx); + } + Err(e) => { + log::error!("Error deleting credential: {}", e); + this.loading = false; + let _ = dialog_handle.update(cx, |d, cx| { + d.set_error(format!("Error deleting: {}", e), cx); + }); + cx.notify(); + } + }); + })); + } + + fn refresh_credentials(&mut self, pin: String, cx: &mut Context) { + let entity = cx.entity().downgrade(); + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { io::get_credentials(pin) }) + .await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + if let Ok(creds) = result { + this.credentials = creds; + } + cx.notify(); + }); + })); + } + + pub fn refresh_if_unlocked(&mut self, cx: &mut Context) { + if !self.unlocked || self.loading { + return; + } + let Some(pin) = self.cached_pin.clone() else { + return; + }; + self.loading = true; + cx.notify(); + self.refresh_credentials(pin, cx); + } + + fn sync_fido_state(&mut self, new_pin: Option, cx: &mut Context) { + if let Ok(info) = io::get_fido_info() { + self.device.update(cx, |repo, _| { + repo.fido_info = Some(info); + }); + } + + if let Some(pin) = new_pin { + self.cached_pin = Some(pin); + } + + if self.unlocked + && let Some(pin) = self.cached_pin.clone() + { + self.refresh_credentials(pin, cx); + return; + } + self.loading = false; + cx.notify(); + } + + pub(super) fn open_unlock_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view_handle = cx.entity().downgrade(); + + dialog::open_pin_prompt( + "Unlock Storage", + "Enter your device PIN to view saved passkeys", + None, + "Unlock", + window, + cx, + move |pin, dialog_handle, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.unlock_storage(pin, dialog_handle, cx); + }); + }, + ); + } + + fn open_delete_dialog( + &mut self, + cred: &StoredCredential, + pin: String, + window: &mut Window, + cx: &mut Context, + ) { + let cred_id = cred.credential_id.clone(); + let pin_str = pin.clone(); + let name = cred.rp_id.clone(); + let view_handle = cx.entity().downgrade(); + + dialog::open_confirm( + "Delete Passkey", + format!("Are you sure you want to delete the passkey for {}?", name), + "Delete", + gpui_component::button::ButtonVariant::Danger, + window, + cx, + move |dialog_handle, _, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.execute_delete(cred_id.clone(), pin_str.clone(), dialog_handle, cx); + }); + }, + ); + } + + pub(super) fn open_change_pin_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view_handle = cx.entity().downgrade(); + + dialog::open_change_pin(window, cx, move |current, new, dialog_handle, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.change_pin(current, new, dialog_handle, cx); + }); + }); + } + + pub(super) fn open_setup_pin_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view_handle = cx.entity().downgrade(); + + dialog::open_setup_pin(window, cx, move |new_pin, dialog_handle, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.setup_pin(new_pin, dialog_handle, cx); + }); + }); + } + + fn setup_pin( + &mut self, + new: String, + dialog_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + + log::info!("Setting up FIDO PIN..."); + let entity = cx.entity().downgrade(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { io::change_fido_pin(None, new) }) + .await; + + let _ = entity.update(cx, |this, cx| match result { + Ok(msg) => { + log::info!("PIN configured: {}", msg); + let _ = dialog_handle.update(cx, |d, cx| { + d.set_success("PIN configured successfully.".to_string(), cx); + }); + this.sync_fido_state(None, cx); + } + Err(e) => { + log::error!("PIN setup failed: {}", e); + this.loading = false; + let _ = dialog_handle.update(cx, |d, cx| { + d.set_error(format!("Error: {}", e), cx); + }); + cx.notify(); + } + }); + })); + } + + pub(super) fn open_min_pin_length_dialog( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + let current_min = self + .device + .read(cx) + .fido_info + .as_ref() + .map(|f| f.min_pin_length) + .unwrap_or(4); + + let slider = cx.new(|_| { + gpui_component::slider::SliderState::new() + .min(4.0) + .max(63.0) + .step(1.0) + .default_value(current_min as f32) + }); + + let current_pin = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder("Enter current PIN") + .masked(true) + }); + let new_pin = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder("Enter new PIN") + .masked(true) + }); + let confirm_pin = cx.new(|cx| { + gpui_component::input::InputState::new(window, cx) + .placeholder("Confirm new PIN") + .masked(true) + }); + + let label_view = cx.new(|_cx| SliderLabel { + slider: slider.clone(), + }); + + let view_handle = cx.entity().downgrade(); + + let submit = { + let current_pin2 = current_pin.clone(); + let new_pin2 = new_pin.clone(); + let confirm_pin2 = confirm_pin.clone(); + let slider2 = slider.clone(); + let view2 = view_handle.clone(); + std::rc::Rc::new(move |window: &mut Window, cx: &mut App| { + let current_val = current_pin2.read(cx).text().to_string(); + let new_val = new_pin2.read(cx).text().to_string(); + let confirm_val = confirm_pin2.read(cx).text().to_string(); + let min_len = slider2.read(cx).value().start() as u8; + + if current_val.is_empty() { + return; + } + + if !new_val.is_empty() { + if new_val != confirm_val { + let _ = view2.update(cx, |_, cx| { + cx.emit(PasskeysEvent::Notification("PINs do not match".to_string())); + }); + return; + } + if new_val.len() < min_len as usize { + let _ = view2.update(cx, |_, cx| { + cx.emit(PasskeysEvent::Notification(format!( + "PIN must be at least {} characters", + min_len + ))); + }); + return; + } + } + window.close_dialog(cx); + let status_handle = + dialog::open_status_dialog("Update Minimum PIN Length", window, cx); + let _ = view2.update(cx, |this, cx| { + this.update_min_length(current_val, min_len, new_val, status_handle, cx); + }); + }) + }; + + window.open_dialog(cx, move |dialog, window, _| { + let current = current_pin.clone(); + let new = new_pin.clone(); + let confirm = confirm_pin.clone(); + let slider_handle = slider.clone(); + let submit_for_ok = submit.clone(); + let submit_for_btn = submit.clone(); + let _ = window; + + dialog + .title("Update Minimum PIN Length") + .child( + "Set the minimum allowed PIN length (4-63 characters) and enter a new PIN that meets this requirement.", + ) + .child( + gpui_component::v_flex() + .gap_4() + .pb_4() + .child( + gpui_component::v_flex() + .gap_2() + .child(label_view.clone()) + .child(gpui_component::slider::Slider::new(&slider_handle)) + ) + .child("Current PIN") + .child(gpui_component::input::Input::new(¤t)) + .child( + gpui_component::v_flex() + .gap_2() + .child(format!("New PIN (min {} chars)", current_min)) + .child(gpui_component::input::Input::new(&new)) + ) + .child("Confirm New PIN") + .child(gpui_component::input::Input::new(&confirm)), + ) + .on_ok(move |_, window, cx| { + submit_for_ok(window, cx); + false + }) + .footer(move |_, _window, _cx, _| { + let s = submit_for_btn.clone(); + vec![ + gpui_component::button::Button::new("cancel") + .label("Cancel") + .on_click(|_, window, cx| window.close_dialog(cx)), + gpui_component::button::Button::new("update") + .primary() + .label("Update") + .on_click(move |_, window, cx| { + s(window, cx); + }), + ] + }) + }); + } + + fn change_pin( + &mut self, + current: String, + new: String, + dialog_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + + log::info!("Changing FIDO PIN..."); + let entity = cx.entity().downgrade(); + let new_for_sync = new.clone(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { io::change_fido_pin(Some(current), new) }) + .await; + + let _ = entity.update(cx, |this, cx| match result { + Ok(msg) => { + log::info!("PIN changed: {}", msg); + let _ = dialog_handle.update(cx, |d, cx| { + d.set_success("PIN changed successfully.".to_string(), cx); + }); + this.sync_fido_state(Some(new_for_sync), cx); + } + Err(e) => { + log::error!("PIN change failed: {}", e); + this.loading = false; + let _ = dialog_handle.update(cx, |d, cx| { + d.set_error(format!("Error: {}", e), cx); + }); + cx.notify(); + } + }); + })); + } + + fn update_min_length( + &mut self, + current: String, + min_len: u8, + new_pin: String, + status_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + log::info!("Updating minimum PIN length to {}...", min_len); + let entity = cx.entity().downgrade(); + + self._task = Some(cx.spawn(async move |_, cx| { + let current_for_bg = current.clone(); + let res_len = cx + .background_executor() + .spawn(async move { io::set_min_pin_length(current_for_bg, min_len) }) + .await; + + if let Err(e) = res_len { + log::error!("Failed to set minimum PIN length: {}", e); + let _ = entity.update(cx, |this, cx| { + this.loading = false; + let _ = status_handle.update(cx, |s, cx| { + s.set_error(format!("Failed to set length: {}", e), cx); + }); + cx.notify(); + }); + return; + } + + if !new_pin.is_empty() { + let new_pin_for_sync = new_pin.clone(); + let res_pin = cx + .background_executor() + .spawn(async move { io::change_fido_pin(Some(current), new_pin) }) + .await; + let _ = entity.update(cx, |this, cx| match res_pin { + Ok(_) => { + log::info!("Minimum length and PIN updated successfully."); + let _ = status_handle.update(cx, |s, cx| { + s.set_success("Minimum length and PIN updated.".to_string(), cx); + }); + this.sync_fido_state(Some(new_pin_for_sync), cx); + } + Err(e) => { + log::error!("Length set, but PIN change failed: {}", e); + this.loading = false; + let _ = status_handle.update(cx, |s, cx| { + s.set_error(format!("Length set, but PIN change failed: {}", e), cx); + }); + cx.notify(); + } + }); + } else { + let _ = entity.update(cx, |this, cx| { + log::info!("Minimum PIN length updated to {}.", min_len); + let _ = status_handle.update(cx, |s, cx| { + s.set_success(format!("Minimum length updated to {}.", min_len), cx); + }); + this.sync_fido_state(None, cx); + }); + } + })); + } + + pub(super) fn request_csr( + &mut self, + status_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + self.csr_loading = true; + cx.notify(); + + log::info!("Request Attestation CSR..."); + let entity = cx.entity().downgrade(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { io::get_enterprise_attestation_csr() }) + .await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + this.csr_loading = false; + match result { + Ok(pem) => { + log::info!("CSR retrieved successfully ({} bytes).", pem.len()); + this.csr_pem = Some(pem); + let _ = status_handle.update(cx, |s, cx| { + s.set_success( + "CSR retrieved from device. Click \"View CSR\" to inspect or save it.".to_string(), + cx, + ); + }); + } + Err(e) => { + log::error!("Failed to retrieve CSR: {}", e); + let _ = status_handle.update(cx, |s, cx| { + s.set_error(format!("Failed to retrieve CSR: {}", e), cx); + }); + } + } + cx.notify(); + }); + })); + } + + fn execute_upload_cert( + &mut self, + pin: String, + cert_path: String, + dialog_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + + log::info!( + "Uploading enterprise attestation certificate from: {}", + cert_path + ); + let entity = cx.entity().downgrade(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { io::upload_enterprise_attestation_cert(pin, cert_path) }) + .await; + + let _ = entity.update(cx, |this, cx| { + this.loading = false; + match result { + Ok(msg) => { + log::info!("{}", msg); + let _ = dialog_handle.update(cx, |d, cx| { + d.set_success(msg, cx); + }); + } + Err(e) => { + log::error!("Certificate upload failed: {}", e); + let _ = dialog_handle.update(cx, |d, cx| { + d.set_error(format!("Upload failed: {}", e), cx); + }); + } + } + cx.notify(); + }); + })); + } + + pub(super) fn open_enable_ea_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view_handle = cx.entity().downgrade(); + + dialog::open_pin_prompt( + "Enable Enterprise Attestation", + "Enter your device PIN to enable enterprise attestation", + Some("This operation is irreversible"), + "Enable", + window, + cx, + move |pin, dialog_handle, cx| { + let _ = view_handle.update(cx, |this, cx| { + this.enable_ea(pin, dialog_handle, cx); + }); + }, + ); + } + + fn enable_ea( + &mut self, + pin: String, + dialog_handle: WeakEntity, + cx: &mut Context, + ) { + if self.loading { + return; + } + self.loading = true; + cx.notify(); + + log::info!("Enabling enterprise attestation..."); + let entity = cx.entity().downgrade(); + + self._task = Some(cx.spawn(async move |_, cx| { + let result = cx + .background_executor() + .spawn(async move { io::enable_enterprise_attestation(pin) }) + .await; + + let _ = entity.update(cx, |this, cx| match result { + Ok(msg) => { + log::info!("{}", msg); + let _ = dialog_handle.update(cx, |d, cx| { + d.set_success(msg, cx); + }); + this.sync_fido_state(None, cx); + } + Err(e) => { + log::error!("Failed to enable EA: {}", e); + this.loading = false; + let _ = dialog_handle.update(cx, |d, cx| { + d.set_error(format!("Error: {}", e), cx); + }); + cx.notify(); + } + }); + })); + } + + pub(super) fn open_upload_cert_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let window_handle = window.window_handle(); + let entity = cx.entity().downgrade(); + + let receiver = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("Select Certificate File (PEM or DER)".into()), + }); + + self._task = Some(cx.spawn(async move |_, cx| { + let Ok(Ok(Some(paths))) = receiver.await else { + return; + }; + let Some(first) = paths.into_iter().next() else { + return; + }; + let cert_path = first.to_string_lossy().to_string(); + + let _ = cx.update_window(window_handle, |_, window, cx| { + dialog::open_pin_prompt( + "Upload Certificate", + "Enter your device PIN to upload the certificate to the device", + None, + "Upload", + window, + cx, + move |pin, dialog_handle, cx| { + let _ = entity.update(cx, |this, cx| { + this.execute_upload_cert(pin, cert_path.clone(), dialog_handle, cx); + }); + }, + ); + }); + })); + } + + pub(super) fn open_reset_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view_handle = cx.entity().downgrade(); + + dialog::open_confirm( + "Factory Reset Device", + "Are you sure you want to completely erase your device? This will permanently delete ALL passkeys, credentials, and your PIN. This action cannot be undone.".to_string(), + "Reset Device", + gpui_component::button::ButtonVariant::Danger, + window, + cx, + move |_dialog_handle, window, cx| { + window.close_dialog(cx); + let _ = view_handle.update(cx, |this, cx| { + this.execute_reset(window, cx); + }); + }, + ); + } + + fn execute_reset(&mut self, window: &mut Window, cx: &mut Context) { + if self.loading { + return; + } + self.loading = true; + + let status_handle = dialog::open_status_dialog("Resetting Device...", window, cx); + let entity = cx.entity().downgrade(); + + let _ = status_handle.update(cx, |d, cx| { + d.set_loading( + "Unplug your security key, then plug it back in within 10 seconds.", + cx, + ); + }); + + self._task = Some(cx.spawn(async move |_, cx| { + let reconnected = cx + .background_executor() + .spawn(async move { + let start = std::time::Instant::now(); + while start.elapsed().as_secs() < 15 { + std::thread::sleep(std::time::Duration::from_millis(200)); + if crate::hal::fido::hid::HidTransport::open().is_err() { + break; + } + } + + while start.elapsed().as_secs() < 15 { + std::thread::sleep(std::time::Duration::from_millis(500)); + if crate::hal::fido::hid::HidTransport::open().is_ok() { + return true; + } + } + false + }) + .await; + + if !reconnected { + let _ = entity.update(cx, |this, cx| { + this.loading = false; + let _ = status_handle.update(cx, |d, cx| { + d.set_error( + "Timeout waiting for device reconnection. Reset canceled.".to_string(), + cx, + ); + }); + cx.notify(); + }); + return; + } + + let _ = status_handle.update(cx, |d, cx| { + d.set_loading("Touch your security key now to confirm the reset...", cx); + }); + + let result = cx + .background_executor() + .spawn(async move { io::reset_device() }) + .await; + + let _ = entity.update(cx, |this, cx| match result { + Ok(msg) => { + log::info!("Device Reset: {}", msg); + this.lock_storage(cx); + let _ = status_handle.update(cx, |d, cx| { + d.set_success(msg, cx); + }); + cx.emit(PasskeysEvent::Notification( + "Device reset successfully".into(), + )); + this.lock_storage(cx); + this.sync_fido_state(None, cx); + } + Err(e) => { + log::error!("Error resetting device: {}", e); + this.loading = false; + let _ = status_handle.update(cx, |d, cx| { + d.set_error(format!("Reset failed: {}", e), cx); + }); + cx.notify(); + } + }); + })); + } + + pub(super) fn open_ask_delete_pin( + &mut self, + cred: StoredCredential, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(pin) = &self.cached_pin { + self.open_delete_dialog(&cred, pin.clone(), window, cx); + } else { + window.push_notification("Session expired, please unlock again.", cx); + self.lock_storage(cx); + } + } + + pub(super) fn open_credential_details( + &mut self, + cred: &StoredCredential, + window: &mut Window, + cx: &mut Context, + ) { + let title = if !cred.rp_name.is_empty() { + cred.rp_name.clone() + } else if !cred.rp_id.is_empty() { + cred.rp_id.clone() + } else { + "Passkey Details".to_string() + }; + let rp_id = cred.rp_id.clone(); + let user_name = cred.user_name.clone(); + let display_name = if cred.user_display_name.is_empty() { + "N/A".to_string() + } else { + cred.user_display_name.clone() + }; + let user_id = cred.user_id.clone(); + let credential_id = cred.credential_id.clone(); + + window.open_sheet_at( + gpui_component::Placement::Bottom, + cx, + move |sheet, _, cx| { + let theme = cx.theme(); + + let header_row = gpui_component::h_flex() + .gap_3() + .p_4() + .bg(theme.muted.opacity(0.3)) + .border_1() + .border_color(theme.border) + .rounded_lg() + .child( + div() + .size_12() + .rounded_full() + .bg(theme.primary.opacity(0.1)) + .flex() + .items_center() + .justify_center() + .child( + gpui_component::Icon::default() + .path("icons/key-round.svg") + .text_color(theme.primary) + .size_6(), + ), + ) + .child( + gpui_component::v_flex() + .child(div().font_semibold().child(rp_id.clone())) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .font_family("monospace") + .child(user_name.clone()), + ), + ); + + let separator = div().w_full().h(px(1.)).bg(theme.border); + + let detail_field = |label: &str, value: String, mono: bool| { + let value_el = if mono { + div() + .text_xs() + .font_family("monospace") + .bg(theme.muted) + .p_2() + .rounded_md() + .overflow_hidden() + .child(value) + .into_any_element() + } else { + div() + .text_sm() + .font_medium() + .child(value) + .into_any_element() + }; + gpui_component::v_flex() + .gap_1() + .child( + div() + .text_sm() + .font_medium() + .text_color(theme.muted_foreground) + .child(label.to_string()), + ) + .child(value_el) + }; + + let description = gpui_component::h_flex() + .gap_1() + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("Credential details for user"), + ) + .child( + div() + .text_sm() + .font_semibold() + .text_color(theme.foreground) + .child(user_name.clone()), + ); + + sheet + .title( + div().w_full().child( + gpui_component::v_flex() + .mx_auto() + .max_w(px(512.)) + .px_4() + .gap_0p5() + .child(div().text_2xl().font_bold().child(title.clone())) + .child(description), + ), + ) + .size(px(500.)) + .resizable(false) + .margin_top(px(0.)) + .child( + div().mx_auto().max_w(px(512.)).w_full().px_4().child( + gpui_component::v_flex() + .gap_4() + .child(header_row) + .child(separator) + .child(detail_field("Display Name", display_name.clone(), false)) + .child(detail_field("User ID (Hex)", user_id.clone(), true)) + .child(detail_field( + "Credential ID (Hex)", + credential_id.clone(), + true, + )), + ), + ) + }, + ); + } +} + +struct SliderLabel { + slider: Entity, +} + +impl Render for SliderLabel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let val = self.slider.read(cx).value().start() as u8; + format!("Minimum PIN Length ({})", val) + } +} diff --git a/src/ui/screens/security/mod.rs b/src/ui/screens/security/mod.rs new file mode 100644 index 0000000..7adbba6 --- /dev/null +++ b/src/ui/screens/security/mod.rs @@ -0,0 +1,3 @@ +pub mod view; +pub mod view_model; +pub use view_model::SecurityViewModel; diff --git a/src/ui/screens/security.rs b/src/ui/screens/security/view.rs similarity index 93% rename from src/ui/screens/security.rs rename to src/ui/screens/security/view.rs index 00eee09..1e97fd1 100644 --- a/src/ui/screens/security.rs +++ b/src/ui/screens/security/view.rs @@ -1,4 +1,5 @@ use crate::ui::components::page_view::PageView; +use crate::ui::screens::security::view_model::SecurityViewModel; use gpui::*; use gpui_component::{ ActiveTheme, Disableable, Icon, StyledExt, @@ -8,19 +9,13 @@ use gpui_component::{ v_flex, }; -pub struct SecurityView; - -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, - ) - }; +impl Render for SecurityViewModel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted_fg = theme.muted_foreground; + let border = theme.border; + let card_bg = theme.secondary; let destructive_red = rgb(0xef4444); let destructive_red_hover = rgb(0xdc2626); @@ -80,7 +75,6 @@ impl SecurityView { .child("Lock Settings"), ), ) - // Card Content .child( v_flex() .px_6() @@ -112,7 +106,6 @@ impl SecurityView { ), ) .child( - // Secure Lock Row (Disabled) h_flex() .justify_between() .items_center() @@ -153,7 +146,6 @@ impl SecurityView { ), ), ) - // Card Footer .child( div() .border_t_1() @@ -187,7 +179,7 @@ impl SecurityView { "Secure Boot", "Permanently lock this device to the current firmware vendor.", content, - cx.theme(), + theme, ) } } diff --git a/src/ui/screens/security/view_model.rs b/src/ui/screens/security/view_model.rs new file mode 100644 index 0000000..6c243c6 --- /dev/null +++ b/src/ui/screens/security/view_model.rs @@ -0,0 +1,10 @@ +use crate::ui::app::AppModels; +use gpui::*; + +pub struct SecurityViewModel; + +impl SecurityViewModel { + pub fn new(_window: &mut Window, _cx: &mut Context, _models: &AppModels) -> Self { + Self + } +} diff --git a/src/ui/types.rs b/src/ui/types.rs deleted file mode 100644 index e1f481b..0000000 --- a/src/ui/types.rs +++ /dev/null @@ -1,217 +0,0 @@ -use crate::{ - hal::types::{FidoDeviceInfo, FullDeviceStatus, LedStatusConfig, ManagementAppConfig}, - ui::screens::{config::ConfigView, passkeys::PasskeysView}, -}; -use gpui::{Entity, Pixels, SharedString, px}; - -#[derive(Clone, Copy, PartialEq, Debug)] -pub enum ActiveView { - Home, - Passkeys, - Configuration, - Security, - About, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct DeviceConnectionState { - pub status: Option, - pub fido_info: Option, - pub led_status: Option, - pub management_apps: Option, - pub error: Option, - pub loading: bool, -} - -impl DeviceConnectionState { - pub fn new() -> Self { - Self { - status: None, - fido_info: None, - led_status: None, - management_apps: None, - error: None, - loading: false, - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct LayoutState { - pub active_view: ActiveView, - pub is_sidebar_collapsed: bool, - pub sidebar_toggle_hovered: bool, - pub sidebar_width: Pixels, -} - -impl LayoutState { - pub fn new() -> Self { - Self { - active_view: ActiveView::Home, - is_sidebar_collapsed: false, - sidebar_toggle_hovered: false, - sidebar_width: px(255.), - } - } -} - -pub struct ViewCache { - pub passkeys: Option>, - pub config: Option>, -} - -impl ViewCache { - pub fn new() -> Self { - Self { - passkeys: None, - config: None, - } - } -} - -// config view: - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UsbIdentityPreset { - Custom, - Generic, - LibreKeys, - PicoHsm, - PicoFido, - PicoOpenPgp, - Pico, - SoloKeys, - NitroHsm, - NitroFido2, - NitroStart, - NitroPro, - NitroKey3, - YubiKey5, - YubiKeyNeo, - YubiHsm2, - Gnuk, - GnuPg, -} - -impl UsbIdentityPreset { - pub fn details(&self) -> (SharedString, Option<&'static str>, Option<&'static str>) { - match self { - Self::Custom => ("Custom (Manual Entry)".into(), None, None), - Self::Generic => ("Generic (FEFF:FCFD)".into(), Some("FEFF"), Some("FCFD")), - Self::LibreKeys => ( - "LibreKeys One (1D50:619B)".into(), - Some("1D50"), - Some("619B"), - ), - Self::PicoHsm => ( - "Pico Keys HSM (2E8A:10FD)".into(), - Some("2E8A"), - Some("10FD"), - ), - Self::PicoFido => ( - "Pico Keys Fido (2E8A:10FE)".into(), - Some("2E8A"), - Some("10FE"), - ), - Self::PicoOpenPgp => ( - "Pico Keys OpenPGP (2E8A:10FF)".into(), - Some("2E8A"), - Some("10FF"), - ), - Self::Pico => ("Pico (2E8A:0003)".into(), Some("2E8A"), Some("0003")), - Self::SoloKeys => ("SoloKeys (0483:A2CA)".into(), Some("0483"), Some("A2CA")), - Self::NitroHsm => ("NitroHSM (20A0:4230)".into(), Some("20A0"), Some("4230")), - Self::NitroFido2 => ("NitroFIDO2 (20A0:42D4)".into(), Some("20A0"), Some("42D4")), - Self::NitroStart => ("NitroStart (20A0:4211)".into(), Some("20A0"), Some("4211")), - Self::NitroPro => ("NitroPro (20A0:4108)".into(), Some("20A0"), Some("4108")), - Self::NitroKey3 => ("Nitrokey 3 (20A0:42B2)".into(), Some("20A0"), Some("42B2")), - Self::YubiKey5 => ("YubiKey 5 (1050:0407)".into(), Some("1050"), Some("0407")), - Self::YubiKeyNeo => ("YubiKey Neo (1050:0116)".into(), Some("1050"), Some("0116")), - Self::YubiHsm2 => ("YubiHSM 2 (1050:0030)".into(), Some("1050"), Some("0030")), - Self::Gnuk => ("Gnuk Token (234B:0000)".into(), Some("234B"), Some("0000")), - Self::GnuPg => ("GnuPG (234B:0000)".into(), Some("234B"), Some("0000")), - } - } - - /// Helper to find a preset by VID/PID string matching - pub fn from_vid_pid(vid: &str, pid: &str) -> Self { - let vid = vid.to_uppercase(); - let pid = pid.to_uppercase(); - - match (vid.as_str(), pid.as_str()) { - ("FEFF", "FCFD") => Self::Generic, - ("1D50", "619B") => Self::LibreKeys, - ("2E8A", "10FD") => Self::PicoHsm, - ("2E8A", "10FE") => Self::PicoFido, - ("2E8A", "10FF") => Self::PicoOpenPgp, - ("2E8A", "0003") => Self::Pico, - ("0483", "A2CA") => Self::SoloKeys, - ("20A0", "4230") => Self::NitroHsm, - ("20A0", "42D4") => Self::NitroFido2, - ("20A0", "4211") => Self::NitroStart, - ("20A0", "4108") => Self::NitroPro, - ("20A0", "42B2") => Self::NitroKey3, - ("1050", "0407") => Self::YubiKey5, - ("1050", "0116") => Self::YubiKeyNeo, - ("1050", "0030") => Self::YubiHsm2, - ("234B", "0000") => Self::Gnuk, - _ => Self::Custom, - } - } - - pub fn all() -> &'static [Self] { - &[ - Self::Custom, - Self::Generic, - Self::LibreKeys, - Self::PicoHsm, - Self::PicoFido, - Self::PicoOpenPgp, - Self::Pico, - Self::SoloKeys, - Self::NitroHsm, - Self::NitroFido2, - Self::NitroStart, - Self::NitroPro, - Self::NitroKey3, - Self::YubiKey5, - Self::YubiKeyNeo, - Self::YubiHsm2, - Self::Gnuk, - Self::GnuPg, - ] - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LedDriverType { - PicoGpio = 1, - PimoroniRgb = 2, - Ws2812Neopixel = 3, - Esp32Neopixel = 5, -} - -impl LedDriverType { - pub fn label(&self) -> SharedString { - match self { - Self::PicoGpio => "Pico (Standard GPIO)".into(), - Self::PimoroniRgb => "Pimoroni (RGB)".into(), - Self::Ws2812Neopixel => "WS2812 (Neopixel)".into(), - Self::Esp32Neopixel => "ESP32 Neopixel".into(), - } - } - - /// Returns the u8 value expected by the firmware configuration - pub fn value(&self) -> u8 { - *self as u8 - } - - pub fn all() -> &'static [Self] { - &[ - Self::PicoGpio, - Self::PimoroniRgb, - Self::Ws2812Neopixel, - Self::Esp32Neopixel, - ] - } -}