mirror of
https://github.com/librekeys/picoforge.git
synced 2026-07-28 08:01:19 -07:00
feat(ui): RS-Key applet + management screens
The GUI for everything the new HAL exposes, plus a reorganised configuration surface: - Applet screens: Accounts (OATH, live TOTP), Slots (OTP), PIV and OpenPGP — ykman / Yubico-Authenticator-parity management, each gated by an `AppletGate` (CCID off / applet disabled / not supported). - Management screens: Audit (journal, checkpoint verify, on/off toggle), Backup, Lock, Attestation and Offboard. - Sidebar grouped into Device / Credentials / Protection / System sections, with Offboard moved down to just above About. - Configuration: an editable Manufacturer field; the effective LED pin / driver and touch timeout shown as placeholders instead of a bare "firmware default"; a single Apply that writes every changed domain in one ceremony; Hardware Endpoints trimmed to the interfaces the firmware actually builds (CCID/HID/KB); the LED-driver list without ESP32 on RS-Key. - Home surfaces the real firmware version, manufacturer, storage and the effective LED / timeout values.
This commit is contained in:
+152
-2
@@ -9,8 +9,13 @@
|
||||
use crate::ui::components::sidebar::{AppSidebar, SidebarEvent};
|
||||
use crate::ui::models::device::{DeviceEvent, DeviceRepo};
|
||||
use crate::ui::screens::{
|
||||
about::AboutViewModel, config::ConfigViewModel, home::HomeViewModel, passkeys::PasskeysEvent,
|
||||
passkeys::PasskeysViewModel, security::SecurityViewModel,
|
||||
about::AboutViewModel, accounts::AccountsEvent, accounts::AccountsViewModel,
|
||||
attestation::AttestationEvent, attestation::AttestationViewModel, audit::AuditViewModel,
|
||||
backup::BackupViewModel, config::ConfigViewModel, home::HomeViewModel, lock::LockViewModel,
|
||||
offboard::OffboardEvent, offboard::OffboardViewModel, openpgp::OpenPgpEvent,
|
||||
openpgp::OpenPgpViewModel, passkeys::PasskeysEvent,
|
||||
passkeys::PasskeysViewModel, piv::PivEvent, piv::PivViewModel, security::SecurityViewModel,
|
||||
slots::SlotsEvent, slots::SlotsViewModel,
|
||||
};
|
||||
use gpui::prelude::*;
|
||||
use gpui::*;
|
||||
@@ -33,6 +38,15 @@ pub struct ViewModelStore {
|
||||
pub about: Option<Entity<AboutViewModel>>,
|
||||
pub security: Option<Entity<SecurityViewModel>>,
|
||||
pub passkeys: Option<Entity<PasskeysViewModel>>,
|
||||
pub accounts: Option<Entity<AccountsViewModel>>,
|
||||
pub slots: Option<Entity<SlotsViewModel>>,
|
||||
pub piv: Option<Entity<PivViewModel>>,
|
||||
pub openpgp: Option<Entity<OpenPgpViewModel>>,
|
||||
pub audit: Option<Entity<AuditViewModel>>,
|
||||
pub backup: Option<Entity<BackupViewModel>>,
|
||||
pub lock: Option<Entity<LockViewModel>>,
|
||||
pub attestation: Option<Entity<AttestationViewModel>>,
|
||||
pub offboard: Option<Entity<OffboardViewModel>>,
|
||||
pub config: Option<Entity<ConfigViewModel>>,
|
||||
}
|
||||
|
||||
@@ -44,6 +58,15 @@ impl ViewModelStore {
|
||||
about: None,
|
||||
security: None,
|
||||
passkeys: None,
|
||||
accounts: None,
|
||||
slots: None,
|
||||
piv: None,
|
||||
openpgp: None,
|
||||
audit: None,
|
||||
backup: None,
|
||||
lock: None,
|
||||
attestation: None,
|
||||
offboard: None,
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
@@ -54,6 +77,15 @@ impl ViewModelStore {
|
||||
pub enum Destination {
|
||||
Home,
|
||||
Passkeys,
|
||||
Accounts,
|
||||
Slots,
|
||||
Piv,
|
||||
OpenPgp,
|
||||
Audit,
|
||||
Backup,
|
||||
Lock,
|
||||
Attestation,
|
||||
Offboard,
|
||||
Configuration,
|
||||
Security,
|
||||
About,
|
||||
@@ -187,6 +219,124 @@ impl Render for ApplicationRoot {
|
||||
});
|
||||
view.clone().into_any_element()
|
||||
}
|
||||
Destination::Accounts => {
|
||||
let view = self.views_store.accounts.get_or_insert_with(|| {
|
||||
let view = cx.new(|cx| AccountsViewModel::new(window, cx, &self.models));
|
||||
cx.subscribe_in(
|
||||
&view,
|
||||
window,
|
||||
|_, _, event: &AccountsEvent, window, cx| match event {
|
||||
AccountsEvent::Notification(msg) => {
|
||||
window.push_notification(msg.to_string(), cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
view
|
||||
});
|
||||
view.clone().into_any_element()
|
||||
}
|
||||
Destination::Slots => {
|
||||
let view = self.views_store.slots.get_or_insert_with(|| {
|
||||
let view = cx.new(|cx| SlotsViewModel::new(window, cx, &self.models));
|
||||
cx.subscribe_in(
|
||||
&view,
|
||||
window,
|
||||
|_, _, event: &SlotsEvent, window, cx| match event {
|
||||
SlotsEvent::Notification(msg) => {
|
||||
window.push_notification(msg.to_string(), cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
view
|
||||
});
|
||||
view.clone().into_any_element()
|
||||
}
|
||||
Destination::Piv => {
|
||||
let view = self.views_store.piv.get_or_insert_with(|| {
|
||||
let view = cx.new(|cx| PivViewModel::new(window, cx, &self.models));
|
||||
cx.subscribe_in(&view, window, |_, _, event: &PivEvent, window, cx| {
|
||||
match event {
|
||||
PivEvent::Notification(msg) => {
|
||||
window.push_notification(msg.to_string(), cx);
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
view
|
||||
});
|
||||
view.clone().into_any_element()
|
||||
}
|
||||
Destination::OpenPgp => {
|
||||
let view = self.views_store.openpgp.get_or_insert_with(|| {
|
||||
let view = cx.new(|cx| OpenPgpViewModel::new(window, cx, &self.models));
|
||||
cx.subscribe_in(
|
||||
&view,
|
||||
window,
|
||||
|_, _, event: &OpenPgpEvent, window, cx| match event {
|
||||
OpenPgpEvent::Notification(msg) => {
|
||||
window.push_notification(msg.to_string(), cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
view
|
||||
});
|
||||
view.clone().into_any_element()
|
||||
}
|
||||
Destination::Audit => {
|
||||
let view = self.views_store.audit.get_or_insert_with(|| {
|
||||
cx.new(|cx| AuditViewModel::new(window, cx, &self.models))
|
||||
});
|
||||
view.clone().into_any_element()
|
||||
}
|
||||
Destination::Backup => {
|
||||
let view = self.views_store.backup.get_or_insert_with(|| {
|
||||
cx.new(|cx| BackupViewModel::new(window, cx, &self.models))
|
||||
});
|
||||
view.clone().into_any_element()
|
||||
}
|
||||
Destination::Lock => {
|
||||
let view = self.views_store.lock.get_or_insert_with(|| {
|
||||
cx.new(|cx| LockViewModel::new(window, cx, &self.models))
|
||||
});
|
||||
view.clone().into_any_element()
|
||||
}
|
||||
Destination::Attestation => {
|
||||
let view = self.views_store.attestation.get_or_insert_with(|| {
|
||||
let view = cx.new(|cx| AttestationViewModel::new(window, cx, &self.models));
|
||||
cx.subscribe_in(
|
||||
&view,
|
||||
window,
|
||||
|_, _, event: &AttestationEvent, window, cx| match event {
|
||||
AttestationEvent::Notification(msg) => {
|
||||
window.push_notification(msg.to_string(), cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
view
|
||||
});
|
||||
view.clone().into_any_element()
|
||||
}
|
||||
Destination::Offboard => {
|
||||
let view = self.views_store.offboard.get_or_insert_with(|| {
|
||||
let view = cx.new(|cx| OffboardViewModel::new(window, cx, &self.models));
|
||||
cx.subscribe_in(
|
||||
&view,
|
||||
window,
|
||||
|_, _, event: &OffboardEvent, window, cx| match event {
|
||||
OffboardEvent::Notification(msg) => {
|
||||
window.push_notification(msg.to_string(), cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
.detach();
|
||||
view
|
||||
});
|
||||
view.clone().into_any_element()
|
||||
}
|
||||
Destination::Configuration => {
|
||||
let view = self.views_store.config.get_or_insert_with(|| {
|
||||
cx.new(|cx| ConfigViewModel::new(window, cx, &self.models))
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Shared empty-state gating for the CCID applet screens (Accounts, Slots,
|
||||
//! PIV, OpenPGP).
|
||||
//!
|
||||
//! Three orthogonal questions decide whether a screen can show its content, in
|
||||
//! priority order: is the CCID interface on, is the applet enabled on the
|
||||
//! device, does this firmware expose it. Each screen computes an [`AppletGate`]
|
||||
//! and, unless [`AppletGate::Ready`], renders the message below.
|
||||
|
||||
/// Why an applet screen cannot show its content — or `Ready` to proceed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AppletGate {
|
||||
/// The applet is reachable; render the real UI.
|
||||
Ready,
|
||||
/// The CCID / smart-card USB interface is turned off.
|
||||
CcidOff,
|
||||
/// The applet is disabled in USB Applications (carries its display name).
|
||||
Disabled(&'static str),
|
||||
/// This firmware does not expose the applet.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl AppletGate {
|
||||
/// Heading + body copy for the empty state, or `None` when [`Self::Ready`].
|
||||
///
|
||||
/// Copy is firmware-neutral by design — it never names "pico-fido".
|
||||
pub fn message(&self) -> Option<(&'static str, String)> {
|
||||
match self {
|
||||
Self::Ready => None,
|
||||
Self::CcidOff => Some((
|
||||
"Smart-card interface off",
|
||||
"Enable the CCID interface in Configuration → Hardware Endpoints, then reconnect the device."
|
||||
.into(),
|
||||
)),
|
||||
Self::Disabled(name) => Some((
|
||||
"Applet disabled",
|
||||
format!("{name} is turned off. Enable it in Configuration → USB Applications."),
|
||||
)),
|
||||
Self::Unsupported => Some((
|
||||
"Not available",
|
||||
"This firmware does not expose this applet.".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Small form helpers shared by the applet screens' dialogs.
|
||||
|
||||
use gpui::*;
|
||||
use gpui_component::select::{SelectItem, SelectState};
|
||||
|
||||
/// A labelled dropdown option carrying a small integer key (a wire value where
|
||||
/// one exists — algorithm byte, period, digits — or a 0/1 flag otherwise).
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct LabeledU8 {
|
||||
label: SharedString,
|
||||
key: u8,
|
||||
}
|
||||
|
||||
impl SelectItem for LabeledU8 {
|
||||
type Value = u8;
|
||||
fn title(&self) -> SharedString {
|
||||
self.label.clone()
|
||||
}
|
||||
fn value(&self) -> &Self::Value {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `Select` state from `(label, key)` options with a default row.
|
||||
pub fn select_state(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
options: &[(&str, u8)],
|
||||
default_row: usize,
|
||||
) -> Entity<SelectState<Vec<LabeledU8>>> {
|
||||
let opts: Vec<LabeledU8> = options
|
||||
.iter()
|
||||
.map(|(label, key)| LabeledU8 {
|
||||
label: (*label).to_string().into(),
|
||||
key: *key,
|
||||
})
|
||||
.collect();
|
||||
cx.new(|cx| {
|
||||
SelectState::new(
|
||||
opts,
|
||||
Some(gpui_component::IndexPath::default().row(default_row)),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a select's chosen key by mapping its row back through `options`.
|
||||
pub fn selected_key(
|
||||
sel: &Entity<SelectState<Vec<LabeledU8>>>,
|
||||
options: &[(&str, u8)],
|
||||
cx: &App,
|
||||
) -> u8 {
|
||||
let row = sel.read(cx).selected_index(cx).map(|p| p.row).unwrap_or(0);
|
||||
options.get(row).map(|(_, k)| *k).unwrap_or(options[0].1)
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Reusable UI components built on top of gpui-component primitives.
|
||||
|
||||
pub mod applet_gate;
|
||||
pub mod button;
|
||||
pub mod card;
|
||||
pub mod dialog;
|
||||
pub mod form;
|
||||
pub mod page_view;
|
||||
pub mod sidebar;
|
||||
pub mod tag;
|
||||
|
||||
@@ -174,16 +174,77 @@ impl Render for AppSidebar {
|
||||
.flex_grow()
|
||||
.bg(sidebar_bg)
|
||||
.border_color(gpui::transparent_white())
|
||||
// Grouped so the panel reads as sections, not one long list: the
|
||||
// device overview, the credential applets, RS-Key's protection
|
||||
// features, then device-wide system actions (Offboard sits just
|
||||
// above About as a bottom-of-list decommission action).
|
||||
.child(
|
||||
SidebarGroup::new("Menu").child(
|
||||
SidebarGroup::new("Device").child(
|
||||
SidebarMenu::new().child(self.menu_item(
|
||||
cx,
|
||||
"Home",
|
||||
"icons/house.svg",
|
||||
Destination::Home,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
SidebarGroup::new("Credentials").child(
|
||||
SidebarMenu::new()
|
||||
.child(self.menu_item(cx, "Home", "icons/house.svg", Destination::Home))
|
||||
.child(self.menu_item(
|
||||
cx,
|
||||
"Passkeys",
|
||||
"icons/key-round.svg",
|
||||
Destination::Passkeys,
|
||||
))
|
||||
.child(self.menu_item(
|
||||
cx,
|
||||
"Accounts",
|
||||
"icons/key.svg",
|
||||
Destination::Accounts,
|
||||
))
|
||||
.child(self.menu_item(
|
||||
cx,
|
||||
"Slots",
|
||||
"icons/asterisk.svg",
|
||||
Destination::Slots,
|
||||
))
|
||||
.child(self.menu_item(cx, "PIV", "icons/shield.svg", Destination::Piv))
|
||||
.child(self.menu_item(
|
||||
cx,
|
||||
"OpenPGP",
|
||||
"icons/scroll-text.svg",
|
||||
Destination::OpenPgp,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
SidebarGroup::new("Protection").child(
|
||||
SidebarMenu::new()
|
||||
.child(self.menu_item(
|
||||
cx,
|
||||
"Audit",
|
||||
"icons/book-open.svg",
|
||||
Destination::Audit,
|
||||
))
|
||||
.child(self.menu_item(
|
||||
cx,
|
||||
"Backup",
|
||||
"icons/save.svg",
|
||||
Destination::Backup,
|
||||
))
|
||||
.child(self.menu_item(cx, "Lock", "icons/lock.svg", Destination::Lock))
|
||||
.child(self.menu_item(
|
||||
cx,
|
||||
"Attestation",
|
||||
"icons/building-2.svg",
|
||||
Destination::Attestation,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
SidebarGroup::new("System").child(
|
||||
SidebarMenu::new()
|
||||
.child(self.menu_item(
|
||||
cx,
|
||||
"Configuration",
|
||||
@@ -196,6 +257,12 @@ impl Render for AppSidebar {
|
||||
"icons/shield-check.svg",
|
||||
Destination::Security,
|
||||
))
|
||||
.child(self.menu_item(
|
||||
cx,
|
||||
"Offboard",
|
||||
"icons/trash-2.svg",
|
||||
Destination::Offboard,
|
||||
))
|
||||
.child(self.menu_item_icon_name(
|
||||
cx,
|
||||
"About",
|
||||
|
||||
+427
-19
@@ -24,6 +24,16 @@ use std::time::Duration;
|
||||
/// triggers a refresh, so this is a detection-latency knob, not a poll cost.
|
||||
const HOTPLUG_POLL_MS: u64 = 1000;
|
||||
|
||||
pub use crate::hal::applets::oath;
|
||||
pub use crate::hal::applets::openpgp;
|
||||
pub use crate::hal::fido::audit;
|
||||
pub use crate::hal::fido::backup;
|
||||
pub use crate::hal::fido::AttStatus;
|
||||
pub use crate::hal::offboard::OffboardReport;
|
||||
pub use crate::hal::applets::otp;
|
||||
pub use crate::hal::io::MgmAuth;
|
||||
pub use crate::hal::applets::piv;
|
||||
pub use crate::hal::applets::{OathFeatures, OpenPgpFeatures, OtpFeatures, PivFeatures};
|
||||
pub 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,
|
||||
@@ -33,6 +43,9 @@ pub use types::{
|
||||
StoredCredential,
|
||||
};
|
||||
|
||||
/// CCID (smart-card) USB interface bit in `AppConfig.enabled_usb_itf`.
|
||||
const USB_ITF_CCID: u8 = 0x01;
|
||||
|
||||
// ── Events ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Events emitted by [`DeviceRepo`] to notify subscribers of state changes.
|
||||
@@ -91,6 +104,338 @@ impl DeviceRepo {
|
||||
AnyFirmware::new(fw_type.clone(), version).supports_legacy_fido_hardware_config()
|
||||
}
|
||||
|
||||
// ── OATH (Accounts) blocking wrappers ─────────────────────────────────
|
||||
|
||||
pub fn oath_password_required_blocking() -> Result<bool, crate::error::PFError> {
|
||||
io::oath_password_required()
|
||||
}
|
||||
|
||||
pub fn oath_list_accounts_blocking(
|
||||
password: Option<String>,
|
||||
) -> Result<Vec<oath::Account>, crate::error::PFError> {
|
||||
io::oath_list_accounts(password)
|
||||
}
|
||||
|
||||
pub fn oath_add_blocking(
|
||||
password: Option<String>,
|
||||
cred: oath::NewCredential,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::oath_add(password, cred)
|
||||
}
|
||||
|
||||
pub fn oath_delete_blocking(
|
||||
password: Option<String>,
|
||||
id: String,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::oath_delete(password, id)
|
||||
}
|
||||
|
||||
pub fn oath_rename_blocking(
|
||||
password: Option<String>,
|
||||
old_id: String,
|
||||
new_id: String,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::oath_rename(password, old_id, new_id)
|
||||
}
|
||||
|
||||
pub fn oath_calculate_blocking(
|
||||
password: Option<String>,
|
||||
id: String,
|
||||
period: u32,
|
||||
) -> Result<String, crate::error::PFError> {
|
||||
io::oath_calculate(password, id, period)
|
||||
}
|
||||
|
||||
pub fn oath_set_password_blocking(
|
||||
current: Option<String>,
|
||||
new_password: Option<String>,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::oath_set_password(current, new_password)
|
||||
}
|
||||
|
||||
pub fn oath_reset_blocking() -> Result<(), crate::error::PFError> {
|
||||
io::oath_reset()
|
||||
}
|
||||
|
||||
// ── OTP (Slots) blocking wrappers ─────────────────────────────────────
|
||||
|
||||
pub fn otp_read_info_blocking() -> Result<[otp::SlotInfo; 4], crate::error::PFError> {
|
||||
io::otp_read_info()
|
||||
}
|
||||
|
||||
pub fn otp_program_chalresp_blocking(
|
||||
slot: u8,
|
||||
secret: Vec<u8>,
|
||||
touch: bool,
|
||||
new_acc: [u8; 6],
|
||||
current_acc: [u8; 6],
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::otp_program_chalresp(slot, secret, touch, new_acc, current_acc)
|
||||
}
|
||||
|
||||
pub fn otp_program_hotp_blocking(
|
||||
slot: u8,
|
||||
secret: Vec<u8>,
|
||||
digits8: bool,
|
||||
append_cr: bool,
|
||||
new_acc: [u8; 6],
|
||||
current_acc: [u8; 6],
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::otp_program_hotp(slot, secret, digits8, append_cr, new_acc, current_acc)
|
||||
}
|
||||
|
||||
pub fn otp_program_static_blocking(
|
||||
slot: u8,
|
||||
scancodes: Vec<u8>,
|
||||
append_cr: bool,
|
||||
new_acc: [u8; 6],
|
||||
current_acc: [u8; 6],
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::otp_program_static(slot, scancodes, append_cr, new_acc, current_acc)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn otp_program_yubico_blocking(
|
||||
slot: u8,
|
||||
public_id: Vec<u8>,
|
||||
private_id: [u8; 6],
|
||||
key: [u8; 16],
|
||||
append_cr: bool,
|
||||
new_acc: [u8; 6],
|
||||
current_acc: [u8; 6],
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::otp_program_yubico(slot, public_id, private_id, key, append_cr, new_acc, current_acc)
|
||||
}
|
||||
|
||||
pub fn otp_delete_blocking(slot: u8, current_acc: [u8; 6]) -> Result<(), crate::error::PFError> {
|
||||
io::otp_delete(slot, current_acc)
|
||||
}
|
||||
|
||||
pub fn otp_swap_blocking(current_acc: [u8; 6]) -> Result<(), crate::error::PFError> {
|
||||
io::otp_swap(current_acc)
|
||||
}
|
||||
|
||||
pub fn otp_calculate_blocking(
|
||||
slot: u8,
|
||||
challenge: Vec<u8>,
|
||||
) -> Result<Vec<u8>, crate::error::PFError> {
|
||||
io::otp_calculate(slot, challenge)
|
||||
}
|
||||
|
||||
// ── PIV blocking wrappers ─────────────────────────────────────────────
|
||||
|
||||
pub fn piv_read_info_blocking() -> Result<piv::PivInfo, crate::error::PFError> {
|
||||
io::piv_read_info()
|
||||
}
|
||||
|
||||
pub fn piv_change_pin_blocking(old: String, new: String) -> Result<(), crate::error::PFError> {
|
||||
io::piv_change_pin(old, new)
|
||||
}
|
||||
|
||||
pub fn piv_change_puk_blocking(old: String, new: String) -> Result<(), crate::error::PFError> {
|
||||
io::piv_change_puk(old, new)
|
||||
}
|
||||
|
||||
pub fn piv_unblock_pin_blocking(
|
||||
puk: String,
|
||||
new_pin: String,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::piv_unblock_pin(puk, new_pin)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn piv_generate_blocking(
|
||||
slot: u8,
|
||||
algo: u8,
|
||||
pin_policy: u8,
|
||||
touch_policy: u8,
|
||||
auth: MgmAuth,
|
||||
) -> Result<Vec<u8>, crate::error::PFError> {
|
||||
io::piv_generate(slot, algo, pin_policy, touch_policy, auth)
|
||||
}
|
||||
|
||||
pub fn piv_export_cert_blocking(slot: u8) -> Result<Vec<u8>, crate::error::PFError> {
|
||||
io::piv_export_cert(slot)
|
||||
}
|
||||
|
||||
pub fn piv_import_cert_blocking(
|
||||
slot: u8,
|
||||
der: Vec<u8>,
|
||||
auth: MgmAuth,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::piv_import_cert(slot, der, auth)
|
||||
}
|
||||
|
||||
pub fn piv_attest_blocking(slot: u8) -> Result<Vec<u8>, crate::error::PFError> {
|
||||
io::piv_attest(slot)
|
||||
}
|
||||
|
||||
pub fn piv_move_key_blocking(
|
||||
src: u8,
|
||||
dst: u8,
|
||||
auth: MgmAuth,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::piv_move_key(src, dst, auth)
|
||||
}
|
||||
|
||||
pub fn piv_delete_key_blocking(slot: u8, auth: MgmAuth) -> Result<(), crate::error::PFError> {
|
||||
io::piv_delete_key(slot, auth)
|
||||
}
|
||||
|
||||
pub fn piv_import_key_blocking(
|
||||
slot: u8,
|
||||
key_file: Vec<u8>,
|
||||
auth: MgmAuth,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::piv_import_key(slot, key_file, auth)
|
||||
}
|
||||
|
||||
pub fn piv_delete_cert_blocking(
|
||||
slot: u8,
|
||||
auth: MgmAuth,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::piv_delete_cert(slot, auth)
|
||||
}
|
||||
|
||||
pub fn piv_set_mgm_blocking(
|
||||
current: MgmAuth,
|
||||
new_algo: u8,
|
||||
new_key: Vec<u8>,
|
||||
touch: bool,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::piv_set_mgm(current, new_algo, new_key, touch)
|
||||
}
|
||||
|
||||
pub fn piv_set_retries_blocking(
|
||||
auth: MgmAuth,
|
||||
pin: String,
|
||||
pin_tries: u8,
|
||||
puk_tries: u8,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::piv_set_retries(auth, pin, pin_tries, puk_tries)
|
||||
}
|
||||
|
||||
pub fn piv_reset_blocking() -> Result<(), crate::error::PFError> {
|
||||
io::piv_reset()
|
||||
}
|
||||
|
||||
// ── OpenPGP blocking wrappers ─────────────────────────────────────────
|
||||
|
||||
pub fn openpgp_read_info_blocking() -> Result<openpgp::PgpInfo, crate::error::PFError> {
|
||||
io::openpgp_read_info()
|
||||
}
|
||||
|
||||
pub fn openpgp_change_user_pin_blocking(
|
||||
old: String,
|
||||
new: String,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::openpgp_change_user_pin(old, new)
|
||||
}
|
||||
|
||||
pub fn openpgp_change_admin_pin_blocking(
|
||||
old: String,
|
||||
new: String,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::openpgp_change_admin_pin(old, new)
|
||||
}
|
||||
|
||||
pub fn openpgp_unblock_with_code_blocking(
|
||||
rc: String,
|
||||
new_pin: String,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::openpgp_unblock_with_code(rc, new_pin)
|
||||
}
|
||||
|
||||
pub fn openpgp_unblock_with_admin_blocking(
|
||||
admin: String,
|
||||
new_pin: String,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::openpgp_unblock_with_admin(admin, new_pin)
|
||||
}
|
||||
|
||||
pub fn openpgp_set_reset_code_blocking(
|
||||
admin: String,
|
||||
new_rc: String,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::openpgp_set_reset_code(admin, new_rc)
|
||||
}
|
||||
|
||||
pub fn openpgp_set_cardholder_blocking(
|
||||
admin: String,
|
||||
name: String,
|
||||
login: String,
|
||||
url: String,
|
||||
lang: String,
|
||||
sex: u8,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::openpgp_set_cardholder(admin, name, login, url, lang, sex)
|
||||
}
|
||||
|
||||
pub fn openpgp_set_touch_blocking(
|
||||
admin: String,
|
||||
slot: openpgp::PgpSlot,
|
||||
on: bool,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::openpgp_set_touch(admin, slot, on)
|
||||
}
|
||||
|
||||
pub fn openpgp_generate_blocking(
|
||||
admin: String,
|
||||
slot: openpgp::PgpSlot,
|
||||
choice: u8,
|
||||
) -> Result<(), crate::error::PFError> {
|
||||
io::openpgp_generate(admin, slot, choice)
|
||||
}
|
||||
|
||||
pub fn openpgp_reset_blocking() -> Result<(), crate::error::PFError> {
|
||||
io::openpgp_reset()
|
||||
}
|
||||
|
||||
// ── Applet gating (read already-held device state) ────────────────────
|
||||
|
||||
/// OATH feature profile of the connected firmware, if it exposes the applet.
|
||||
pub fn oath_features(&self) -> Option<OathFeatures> {
|
||||
let status = self.status.as_ref()?;
|
||||
AnyFirmware::new(status.firmware_type.clone(), &status.info.firmware_version).oath_features()
|
||||
}
|
||||
|
||||
/// OTP feature profile of the connected firmware, if it exposes the applet.
|
||||
pub fn otp_features(&self) -> Option<OtpFeatures> {
|
||||
let status = self.status.as_ref()?;
|
||||
AnyFirmware::new(status.firmware_type.clone(), &status.info.firmware_version).otp_features()
|
||||
}
|
||||
|
||||
/// PIV feature profile of the connected firmware, if it exposes the applet.
|
||||
pub fn piv_features(&self) -> Option<PivFeatures> {
|
||||
let status = self.status.as_ref()?;
|
||||
AnyFirmware::new(status.firmware_type.clone(), &status.info.firmware_version).piv_features()
|
||||
}
|
||||
|
||||
/// OpenPGP feature profile of the connected firmware, if it exposes the applet.
|
||||
pub fn openpgp_features(&self) -> Option<OpenPgpFeatures> {
|
||||
let status = self.status.as_ref()?;
|
||||
AnyFirmware::new(status.firmware_type.clone(), &status.info.firmware_version)
|
||||
.openpgp_features()
|
||||
}
|
||||
|
||||
/// Whether an applet capability bit is enabled in USB Applications. Lenient
|
||||
/// when the mask is unknown — the SELECT then gives the authoritative answer.
|
||||
pub fn applet_enabled(&self, cap: u16) -> bool {
|
||||
self.management_apps
|
||||
.as_ref()
|
||||
.map(|m| m.usb_enabled & cap != 0)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether the CCID/smart-card USB interface is on (lenient when unknown).
|
||||
pub fn ccid_on(&self) -> bool {
|
||||
self.status
|
||||
.as_ref()
|
||||
.and_then(|s| s.config.enabled_usb_itf)
|
||||
.map(|m| m & USB_ITF_CCID != 0)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn read_device_state_blocking() -> Result<FreshDeviceState, crate::error::PFError> {
|
||||
let status = io::read_device_details()?;
|
||||
let (led_status, management_apps) = if status.firmware_type == types::FirmwareType::RSKey {
|
||||
@@ -108,28 +453,14 @@ impl DeviceRepo {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_config_blocking(
|
||||
config: types::AppConfigInput,
|
||||
method: types::DeviceMethod,
|
||||
pin: Option<String>,
|
||||
) -> Result<String, crate::error::PFError> {
|
||||
io::write_config(config, method, pin)
|
||||
}
|
||||
|
||||
pub fn write_led_config_blocking(
|
||||
pub fn write_all_config_blocking(
|
||||
method: DeviceMethod,
|
||||
config: LedStatusConfig,
|
||||
phy: Option<types::AppConfigInput>,
|
||||
led: Option<LedStatusConfig>,
|
||||
apps: Option<u16>,
|
||||
pin: Option<String>,
|
||||
) -> Result<String, crate::error::PFError> {
|
||||
io::write_led_config(method, config, pin)
|
||||
}
|
||||
|
||||
pub fn write_management_config_blocking(
|
||||
method: DeviceMethod,
|
||||
enabled_mask: u16,
|
||||
pin: Option<String>,
|
||||
) -> Result<String, crate::error::PFError> {
|
||||
io::write_management_config(method, enabled_mask, pin)
|
||||
io::write_all_config(method, phy, led, apps, pin)
|
||||
}
|
||||
|
||||
pub fn get_fido_info_blocking() -> Result<types::FidoDeviceInfo, String> {
|
||||
@@ -177,6 +508,83 @@ impl DeviceRepo {
|
||||
io::reset_device()
|
||||
}
|
||||
|
||||
// ── Audit journal blocking wrappers ───────────────────────────────────
|
||||
|
||||
pub fn audit_log_blocking(pin: Option<String>) -> Result<audit::AuditJournal, String> {
|
||||
io::audit_log(pin)
|
||||
}
|
||||
|
||||
pub fn audit_verify_blocking(
|
||||
pin: Option<String>,
|
||||
expect_key: Option<String>,
|
||||
) -> Result<audit::AuditVerification, String> {
|
||||
io::audit_verify(pin, expect_key)
|
||||
}
|
||||
|
||||
pub fn audit_status_blocking() -> Result<bool, String> {
|
||||
io::audit_status()
|
||||
}
|
||||
|
||||
pub fn audit_set_enabled_blocking(on: bool, pin: Option<String>) -> Result<bool, String> {
|
||||
io::audit_set_enabled(on, pin)
|
||||
}
|
||||
|
||||
// ── Seed backup blocking wrappers ─────────────────────────────────────
|
||||
|
||||
pub fn backup_status_blocking() -> Result<backup::BackupStatus, String> {
|
||||
io::backup_status()
|
||||
}
|
||||
|
||||
pub fn backup_finalize_blocking() -> Result<(), String> {
|
||||
io::backup_finalize()
|
||||
}
|
||||
|
||||
pub fn backup_export_blocking(pin: Option<String>) -> Result<String, String> {
|
||||
io::backup_export(pin)
|
||||
}
|
||||
|
||||
pub fn backup_restore_blocking(pin: Option<String>, mnemonic: String) -> Result<(), String> {
|
||||
io::backup_restore(pin, mnemonic)
|
||||
}
|
||||
|
||||
// ── At-rest soft lock blocking wrappers ───────────────────────────────
|
||||
|
||||
pub fn lock_enable_blocking(pin: String) -> Result<String, String> {
|
||||
io::lock_enable(pin)
|
||||
}
|
||||
|
||||
pub fn lock_unlock_blocking(mnemonic: String) -> Result<(), String> {
|
||||
io::lock_unlock(mnemonic)
|
||||
}
|
||||
|
||||
pub fn lock_disable_blocking(pin: String, mnemonic: String) -> Result<(), String> {
|
||||
io::lock_disable(pin, mnemonic)
|
||||
}
|
||||
|
||||
// ── Org attestation blocking wrappers ─────────────────────────────────
|
||||
|
||||
pub fn att_status_blocking() -> Result<AttStatus, String> {
|
||||
io::att_status()
|
||||
}
|
||||
|
||||
pub fn att_clear_blocking(pin: Option<String>) -> Result<(), String> {
|
||||
io::att_clear(pin)
|
||||
}
|
||||
|
||||
pub fn att_import_blocking(
|
||||
pin: Option<String>,
|
||||
key_file: Vec<u8>,
|
||||
chain_file: Vec<u8>,
|
||||
) -> Result<(), String> {
|
||||
io::att_import(pin, key_file, chain_file)
|
||||
}
|
||||
|
||||
// ── Offboard blocking wrapper ─────────────────────────────────────────
|
||||
|
||||
pub fn offboard_blocking(serial: String) -> Result<OffboardReport, String> {
|
||||
io::offboard(serial)
|
||||
}
|
||||
|
||||
pub fn read_device_serial_blocking() -> Option<String> {
|
||||
io::read_device_details().ok().map(|s| s.info.serial)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod view;
|
||||
pub mod view_model;
|
||||
pub use view_model::{AccountsEvent, AccountsViewModel};
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Accounts (OATH) screen rendering.
|
||||
|
||||
use crate::ui::components::card::Card;
|
||||
use crate::ui::components::page_view::PageView;
|
||||
use crate::ui::models::device::oath;
|
||||
use crate::ui::screens::accounts::view_model::AccountsViewModel;
|
||||
use gpui::*;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme};
|
||||
|
||||
/// Split a numeric code into two halves for readability ("123 456").
|
||||
fn format_code(code: &str) -> String {
|
||||
if code.len() >= 6 {
|
||||
let mid = code.len() / 2;
|
||||
format!("{} {}", &code[..mid], &code[mid..])
|
||||
} else {
|
||||
code.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn seconds_left(now: u64, period: u32) -> u32 {
|
||||
let p = period.max(1) as u64;
|
||||
(p - (now % p)) as u32
|
||||
}
|
||||
|
||||
fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement {
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_64()
|
||||
.gap_2()
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded_xl()
|
||||
.child(div().font_semibold().child(heading.to_string()))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.max_w(px(380.))
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(body),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl AccountsViewModel {
|
||||
fn render_account_row(
|
||||
&self,
|
||||
acc: &oath::Account,
|
||||
now: u64,
|
||||
can_rename: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let issuer = acc.issuer.clone().unwrap_or_default();
|
||||
let account = acc.account.clone();
|
||||
let id = acc.id.clone();
|
||||
let period = acc.period;
|
||||
|
||||
let (primary, secondary) = if issuer.is_empty() {
|
||||
(account.clone(), String::new())
|
||||
} else {
|
||||
(issuer, account)
|
||||
};
|
||||
|
||||
let identity = v_flex()
|
||||
.gap_0p5()
|
||||
.child(div().font_medium().child(primary))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(secondary),
|
||||
);
|
||||
|
||||
let acc_for_rename = acc.clone();
|
||||
let rename_btn = can_rename.then(|| {
|
||||
Button::new(SharedString::from(format!("ren-{id}")))
|
||||
.icon(Icon::default().path("icons/tag.svg"))
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open_rename_dialog(&acc_for_rename, window, cx);
|
||||
}))
|
||||
});
|
||||
let acc_for_delete = acc.clone();
|
||||
let delete_btn = Button::new(SharedString::from(format!("del-{id}")))
|
||||
.icon(Icon::default().path("icons/trash-2.svg"))
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.open_delete_dialog(&acc_for_delete, window, cx);
|
||||
}));
|
||||
|
||||
let right = match &acc.state {
|
||||
oath::CodeState::Code { value, period: p } => {
|
||||
let code = value.clone();
|
||||
let rem = seconds_left(now, *p);
|
||||
let copy_code = code.clone();
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.font_family("Mono")
|
||||
.text_xl()
|
||||
.text_color(theme.foreground)
|
||||
.child(format_code(&code)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w_8()
|
||||
.text_xs()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(format!("{rem}s")),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("copy-{id}")))
|
||||
.icon(Icon::default().path("icons/copy.svg"))
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.copy_code(copy_code.clone(), cx);
|
||||
})),
|
||||
)
|
||||
.children(rename_btn)
|
||||
.child(delete_btn)
|
||||
}
|
||||
oath::CodeState::Hotp => h_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("calc-{id}")))
|
||||
.label("Generate")
|
||||
.outline()
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.calculate(id.clone(), period, cx);
|
||||
})),
|
||||
)
|
||||
.children(rename_btn)
|
||||
.child(delete_btn),
|
||||
oath::CodeState::Touch => h_flex()
|
||||
.gap_3()
|
||||
.items_center()
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("touch-{id}")))
|
||||
.label("Touch to reveal")
|
||||
.outline()
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.calculate(id.clone(), period, cx);
|
||||
})),
|
||||
)
|
||||
.children(rename_btn)
|
||||
.child(delete_btn),
|
||||
};
|
||||
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.items_center()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded_lg()
|
||||
.child(identity)
|
||||
.child(right)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AccountsViewModel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const TITLE: &str = "Accounts";
|
||||
const SUBTITLE: &str = "One-time password accounts (OATH).";
|
||||
|
||||
if let Some((heading, body)) = self.gate(cx).message() {
|
||||
let theme = cx.theme();
|
||||
return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
if self.needs_password && !self.loaded {
|
||||
let unlock = Button::new("unlock-oath")
|
||||
.icon(Icon::default().path("icons/lock-open.svg"))
|
||||
.label("Unlock")
|
||||
.primary()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_unlock_dialog(window, cx)));
|
||||
let theme = cx.theme();
|
||||
let card = Card::new()
|
||||
.title("Accounts")
|
||||
.description("Password-protected")
|
||||
.icon(Icon::default().path("icons/key.svg"))
|
||||
.child(
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_3()
|
||||
.py_6()
|
||||
.child(div().font_semibold().child("Accounts are password-protected"))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child("Enter the OATH password to view your codes."),
|
||||
)
|
||||
.child(unlock),
|
||||
);
|
||||
return PageView::build(TITLE, SUBTITLE, card, theme).into_any_element();
|
||||
}
|
||||
|
||||
// Build rows first (mutable cx), then the chrome.
|
||||
let accounts = self.accounts.clone();
|
||||
let now = self.now;
|
||||
let can_rename = self
|
||||
.device
|
||||
.read(cx)
|
||||
.oath_features()
|
||||
.map(|f| f.rename)
|
||||
.unwrap_or(false);
|
||||
let mut rows = Vec::with_capacity(accounts.len());
|
||||
for acc in &accounts {
|
||||
rows.push(self.render_account_row(acc, now, can_rename, cx));
|
||||
}
|
||||
|
||||
let password_label = if self.password_is_set() {
|
||||
"Change password"
|
||||
} else {
|
||||
"Set password"
|
||||
};
|
||||
let password_btn = Button::new("password-oath")
|
||||
.icon(Icon::default().path("icons/lock.svg"))
|
||||
.label(password_label)
|
||||
.ghost()
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_password_dialog(window, cx)));
|
||||
let refresh_btn = Button::new("refresh-oath")
|
||||
.icon(Icon::default().path("icons/refresh-cw.svg"))
|
||||
.ghost()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, _, cx| this.refresh(cx)));
|
||||
let add_btn = Button::new("add-account")
|
||||
.icon(Icon::default().path("icons/plus.svg"))
|
||||
.label("Add account")
|
||||
.primary()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_add_dialog(window, cx)));
|
||||
let reset_btn = Button::new("reset-oath")
|
||||
.label("Reset OATH applet")
|
||||
.danger()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_reset_dialog(window, cx)));
|
||||
|
||||
let theme = cx.theme();
|
||||
let toolbar = h_flex()
|
||||
.gap_2()
|
||||
.child(password_btn)
|
||||
.child(refresh_btn)
|
||||
.child(add_btn);
|
||||
let list = if rows.is_empty() {
|
||||
empty_state(
|
||||
"No accounts yet",
|
||||
"Add an account from an otpauth:// URI or a base32 secret.".into(),
|
||||
theme,
|
||||
)
|
||||
} else {
|
||||
v_flex().gap_2().children(rows).into_any_element()
|
||||
};
|
||||
|
||||
let accounts_card = Card::new()
|
||||
.title("Accounts")
|
||||
.description(format!("{} stored", accounts.len()))
|
||||
.icon(Icon::default().path("icons/key.svg"))
|
||||
.header_right(toolbar)
|
||||
.child(list);
|
||||
let reset_card = Card::new()
|
||||
.title("Reset")
|
||||
.description("Erase all accounts and the OATH password")
|
||||
.icon(Icon::default().path("icons/trash.svg"))
|
||||
.child(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(div().font_medium().child("Reset OATH applet"))
|
||||
.child(div().text_sm().text_color(theme.muted_foreground).child(
|
||||
"Deletes every account and the password. Cannot be undone.",
|
||||
)),
|
||||
)
|
||||
.child(reset_btn),
|
||||
);
|
||||
|
||||
let content = v_flex().gap_6().child(accounts_card).child(reset_card);
|
||||
PageView::build(TITLE, SUBTITLE, content, theme).into_any_element()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
pub mod view;
|
||||
pub mod view_model;
|
||||
pub use view_model::{AttestationEvent, AttestationViewModel};
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Attestation screen rendering.
|
||||
|
||||
use crate::ui::components::card::Card;
|
||||
use crate::ui::components::page_view::PageView;
|
||||
use crate::ui::screens::attestation::view_model::AttestationViewModel;
|
||||
use gpui::*;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme};
|
||||
|
||||
fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement {
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_64()
|
||||
.gap_2()
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded_xl()
|
||||
.child(div().font_semibold().child(heading.to_string()))
|
||||
.child(div().text_sm().max_w(px(380.)).text_color(theme.muted_foreground).child(body))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl AttestationViewModel {
|
||||
fn action_row(
|
||||
&self,
|
||||
title: &'static str,
|
||||
subtitle: &'static str,
|
||||
btn: Button,
|
||||
theme: &Theme,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.p_4()
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded_lg()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(div().font_medium().child(title))
|
||||
.child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)),
|
||||
)
|
||||
.child(btn)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AttestationViewModel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const TITLE: &str = "Attestation";
|
||||
const SUBTITLE: &str = "Organisation (enterprise) attestation key and chain.";
|
||||
|
||||
if let Some((heading, body)) = self.gate(cx).message() {
|
||||
let theme = cx.theme();
|
||||
return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let status = self.status.clone();
|
||||
let installed = status.as_ref().map(|s| s.installed).unwrap_or(false);
|
||||
|
||||
let refresh_btn = Button::new("att-refresh")
|
||||
.icon(Icon::default().path("icons/refresh-cw.svg"))
|
||||
.ghost()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, _, cx| this.refresh(cx)));
|
||||
let import_btn = Button::new("att-import")
|
||||
.label(if installed { "Replace" } else { "Import" })
|
||||
.outline()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_import(window, cx)));
|
||||
let clear_btn = Button::new("att-clear")
|
||||
.label("Remove")
|
||||
.danger()
|
||||
.disabled(self.loading || !installed)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_clear(window, cx)));
|
||||
|
||||
let theme = cx.theme();
|
||||
|
||||
let status_card = {
|
||||
let body = match &status {
|
||||
Some(s) => {
|
||||
let mut col = v_flex().gap_2().child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(div().text_color(if s.installed { theme.green } else { theme.muted_foreground }).child("●"))
|
||||
.child(div().text_sm().child(if s.installed {
|
||||
"Org attestation installed"
|
||||
} else {
|
||||
"Not installed — self-signed device certificate in use"
|
||||
})),
|
||||
);
|
||||
if let Some(h) = &s.chain_hash {
|
||||
col = col.child(
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(div().text_xs().text_color(theme.muted_foreground).child("Chain hash"))
|
||||
.child(div().font_family("monospace").text_xs().child(h.clone())),
|
||||
);
|
||||
}
|
||||
col.into_any_element()
|
||||
}
|
||||
None => div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child("Reading attestation state…")
|
||||
.into_any_element(),
|
||||
};
|
||||
Card::new()
|
||||
.title("Attestation status")
|
||||
.description("Whether an org attestation key + chain is installed")
|
||||
.icon(Icon::default().path("icons/building-2.svg"))
|
||||
.header_right(refresh_btn)
|
||||
.child(body)
|
||||
};
|
||||
|
||||
let actions_card = Card::new()
|
||||
.title("Manage")
|
||||
.description("Provision or remove the org attestation")
|
||||
.icon(Icon::default().path("icons/shield-check.svg"))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(self.action_row(
|
||||
"Import key + chain",
|
||||
"P-256 key (PEM/DER) and certificate chain (PIN or touch)",
|
||||
import_btn,
|
||||
theme,
|
||||
))
|
||||
.child(self.action_row(
|
||||
"Remove attestation",
|
||||
"Revert to the self-signed device certificate",
|
||||
clear_btn,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
|
||||
let content = v_flex().gap_6().child(status_card).child(actions_card);
|
||||
PageView::build(TITLE, SUBTITLE, content, theme).into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! View model for the Attestation screen — org (enterprise) attestation key +
|
||||
//! certificate chain provisioning.
|
||||
|
||||
use crate::ui::app::AppModels;
|
||||
use crate::ui::components::applet_gate::AppletGate;
|
||||
use crate::ui::components::dialog;
|
||||
use crate::ui::components::dialog::StatusContent;
|
||||
use crate::ui::models::device::{AttStatus, DeviceEvent, DeviceRepo, FirmwareType};
|
||||
use gpui::*;
|
||||
use gpui_component::button::ButtonVariants;
|
||||
use gpui_component::input::InputState;
|
||||
use gpui_component::WindowExt;
|
||||
|
||||
pub struct AttestationViewModel {
|
||||
pub(super) device: Entity<DeviceRepo>,
|
||||
pub(super) status: Option<AttStatus>,
|
||||
pub(super) loading: bool,
|
||||
_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
pub enum AttestationEvent {
|
||||
Notification(String),
|
||||
}
|
||||
|
||||
impl EventEmitter<AttestationEvent> for AttestationViewModel {}
|
||||
|
||||
impl AttestationViewModel {
|
||||
pub fn new(_window: &mut Window, cx: &mut Context<Self>, models: &AppModels) -> Self {
|
||||
let device = models.device.clone();
|
||||
cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| {
|
||||
if this.device.read(cx).device_changed {
|
||||
this.status = None;
|
||||
}
|
||||
this.load(cx);
|
||||
cx.notify();
|
||||
})
|
||||
.detach();
|
||||
let mut this = Self {
|
||||
device,
|
||||
status: None,
|
||||
loading: false,
|
||||
_task: None,
|
||||
};
|
||||
this.load(cx);
|
||||
this
|
||||
}
|
||||
|
||||
pub(super) fn gate(&self, cx: &App) -> AppletGate {
|
||||
let repo = self.device.read(cx);
|
||||
match &repo.status {
|
||||
None => AppletGate::Unsupported,
|
||||
Some(s) if s.firmware_type != FirmwareType::RSKey => AppletGate::Unsupported,
|
||||
Some(_) => AppletGate::Ready,
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
if self.loading || self.gate(cx) != AppletGate::Ready {
|
||||
return;
|
||||
}
|
||||
self.loading = true;
|
||||
cx.notify();
|
||||
let weak = cx.entity().downgrade();
|
||||
self._task = Some(cx.spawn(async move |_, cx| {
|
||||
let res = cx
|
||||
.background_executor()
|
||||
.spawn(async { DeviceRepo::att_status_blocking() })
|
||||
.await;
|
||||
let _ = weak.update(cx, |this, cx| {
|
||||
this.loading = false;
|
||||
if let Ok(s) = res {
|
||||
this.status = Some(s);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
pub(super) fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.load(cx);
|
||||
}
|
||||
|
||||
fn pin_input(window: &mut Window, cx: &mut Context<Self>) -> Entity<InputState> {
|
||||
cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.masked(true)
|
||||
.placeholder("FIDO PIN — leave blank to touch instead")
|
||||
})
|
||||
}
|
||||
|
||||
// ── Import (key file → chain file → PIN → run) ───────────────────────────
|
||||
|
||||
pub(super) fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let handle = window.window_handle();
|
||||
let key_recv = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some("Select attestation P-256 private key (PEM/DER)".into()),
|
||||
});
|
||||
let view = cx.entity().downgrade();
|
||||
self._task = Some(cx.spawn(async move |_, cx| {
|
||||
let Ok(Ok(Some(kp))) = key_recv.await else {
|
||||
return;
|
||||
};
|
||||
let Some(key_path) = kp.into_iter().next() else {
|
||||
return;
|
||||
};
|
||||
let Ok(key_bytes) = std::fs::read(&key_path) else {
|
||||
let _ = view.update(cx, |_, cx| {
|
||||
cx.emit(AttestationEvent::Notification("Could not read the key file".into()))
|
||||
});
|
||||
return;
|
||||
};
|
||||
// Now the chain file.
|
||||
let chain_recv = cx.update_window(handle, |_, _window, cx| {
|
||||
cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some("Select certificate chain, leaf first (PEM/DER)".into()),
|
||||
})
|
||||
});
|
||||
let Ok(chain_recv) = chain_recv else { return };
|
||||
let Ok(Ok(Some(cp))) = chain_recv.await else {
|
||||
return;
|
||||
};
|
||||
let Some(chain_path) = cp.into_iter().next() else {
|
||||
return;
|
||||
};
|
||||
let Ok(chain_bytes) = std::fs::read(&chain_path) else {
|
||||
let _ = view.update(cx, |_, cx| {
|
||||
cx.emit(AttestationEvent::Notification("Could not read the chain file".into()))
|
||||
});
|
||||
return;
|
||||
};
|
||||
let _ = cx.update_window(handle, |_, window, cx| {
|
||||
let _ = view.update(cx, |this, cx| {
|
||||
this.open_pin_dialog(
|
||||
"Import Org Attestation",
|
||||
"Installs the org attestation key and chain (P-256). Requires the FIDO PIN, or a touch if none is set.",
|
||||
move |pin, this, window, cx| {
|
||||
let status = dialog::open_status_dialog("Importing Attestation", window, cx);
|
||||
let (kb, cb) = (key_bytes.clone(), chain_bytes.clone());
|
||||
this.run_unit(
|
||||
move || DeviceRepo::att_import_blocking(pin, kb, cb),
|
||||
"Org attestation installed.",
|
||||
status,
|
||||
cx,
|
||||
);
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Clear ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn open_clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.open_pin_dialog(
|
||||
"Remove Org Attestation",
|
||||
"Removes the org attestation and reverts to the self-signed device certificate. Requires the FIDO PIN, or a touch if none is set.",
|
||||
|pin, this, window, cx| {
|
||||
let status = dialog::open_status_dialog("Removing Attestation", window, cx);
|
||||
this.run_unit(
|
||||
move || DeviceRepo::att_clear_blocking(pin),
|
||||
"Org attestation removed.",
|
||||
status,
|
||||
cx,
|
||||
);
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// An optional-PIN dialog that invokes `on_submit(pin, this, window, cx)`.
|
||||
fn open_pin_dialog(
|
||||
&mut self,
|
||||
title: &'static str,
|
||||
body: &'static str,
|
||||
on_submit: impl Fn(Option<String>, &mut Self, &mut Window, &mut Context<Self>) + 'static,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let pin = Self::pin_input(window, cx);
|
||||
let view = cx.entity().downgrade();
|
||||
let on_submit = std::rc::Rc::new(on_submit);
|
||||
let submit = {
|
||||
let pin = pin.clone();
|
||||
std::rc::Rc::new(move |window: &mut Window, cx: &mut App| {
|
||||
let p = pin.read(cx).text().to_string();
|
||||
let p = (!p.is_empty()).then_some(p);
|
||||
window.close_dialog(cx);
|
||||
let on_submit = on_submit.clone();
|
||||
let _ = view.update(cx, |this, cx| on_submit(p, this, window, cx));
|
||||
})
|
||||
};
|
||||
window.open_dialog(cx, move |dialog, _w, _| {
|
||||
let pin = pin.clone();
|
||||
let ok = submit.clone();
|
||||
let btn = submit.clone();
|
||||
dialog
|
||||
.title(title)
|
||||
.child(body)
|
||||
.child(
|
||||
gpui_component::v_flex()
|
||||
.gap_2()
|
||||
.pb_2()
|
||||
.child("FIDO PIN")
|
||||
.child(gpui_component::input::Input::new(&pin)),
|
||||
)
|
||||
.on_ok(move |_, window, cx| {
|
||||
ok(window, cx);
|
||||
false
|
||||
})
|
||||
.footer(move |_, _w, _c, _| {
|
||||
let s = btn.clone();
|
||||
vec![
|
||||
gpui_component::button::Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
gpui_component::button::Button::new("go")
|
||||
.primary()
|
||||
.label("Run")
|
||||
.on_click(move |_, window, cx| s(window, cx)),
|
||||
]
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn run_unit(
|
||||
&mut self,
|
||||
op: impl FnOnce() -> Result<(), String> + Send + 'static,
|
||||
ok_msg: &'static str,
|
||||
status: WeakEntity<StatusContent>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.loading {
|
||||
return;
|
||||
}
|
||||
self.loading = true;
|
||||
let _ = status.update(cx, |d, cx| {
|
||||
d.set_loading("Working… touch the device (BOOTSEL).", cx)
|
||||
});
|
||||
cx.notify();
|
||||
let weak = cx.entity().downgrade();
|
||||
self._task = Some(cx.spawn(async move |_, cx| {
|
||||
let res = cx.background_executor().spawn(async move { op() }).await;
|
||||
let _ = weak.update(cx, |this, cx| {
|
||||
this.loading = false;
|
||||
match res {
|
||||
Ok(_) => {
|
||||
let _ = status.update(cx, |d, cx| d.set_success(ok_msg.into(), cx));
|
||||
this.load(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = status.update(cx, |d, cx| d.set_error(e, cx));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod view;
|
||||
pub mod view_model;
|
||||
pub use view_model::AuditViewModel;
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Audit screen rendering.
|
||||
|
||||
use crate::ui::components::card::Card;
|
||||
use crate::ui::components::page_view::PageView;
|
||||
use crate::ui::models::device::audit;
|
||||
use crate::ui::screens::audit::view_model::AuditViewModel;
|
||||
use gpui::*;
|
||||
use gpui_component::button::Button;
|
||||
use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme};
|
||||
|
||||
fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement {
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_64()
|
||||
.gap_2()
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded_xl()
|
||||
.child(div().font_semibold().child(heading.to_string()))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.max_w(px(380.))
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(body),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn mono(theme: &Theme, s: String) -> AnyElement {
|
||||
div()
|
||||
.font_family("monospace")
|
||||
.text_xs()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(s)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn short_hex(bytes: &[u8; 32]) -> String {
|
||||
let h = hex::encode(bytes);
|
||||
format!("{}…{}", &h[..8], &h[h.len() - 8..])
|
||||
}
|
||||
|
||||
impl AuditViewModel {
|
||||
fn entry_row(entry: &audit::AuditEntry, theme: &Theme) -> AnyElement {
|
||||
h_flex()
|
||||
.gap_3()
|
||||
.py_1()
|
||||
.text_sm()
|
||||
.child(div().w(px(56.)).text_color(theme.muted_foreground).child(entry.seq.to_string()))
|
||||
.child(div().w(px(72.)).text_color(theme.muted_foreground).child(format!("{:.1}s", entry.uptime_s())))
|
||||
.child(div().w(px(160.)).font_medium().child(entry.event_label()))
|
||||
.child(div().w(px(40.)).text_color(theme.muted_foreground).child(entry.aux.to_string()))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.font_family("monospace")
|
||||
.text_xs()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(entry.detail_hex()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn journal_body(&self, theme: &Theme) -> AnyElement {
|
||||
let Some(j) = &self.journal else {
|
||||
return div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child("Read the journal to view the security-event log.")
|
||||
.into_any_element();
|
||||
};
|
||||
|
||||
let header = h_flex()
|
||||
.gap_3()
|
||||
.pb_1()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(div().w(px(56.)).child("seq"))
|
||||
.child(div().w(px(72.)).child("uptime"))
|
||||
.child(div().w(px(160.)).child("event"))
|
||||
.child(div().w(px(40.)).child("aux"))
|
||||
.child(div().flex_1().child("detail"));
|
||||
|
||||
let mut rows = vec![header.into_any_element()];
|
||||
for e in &j.entries {
|
||||
rows.push(Self::entry_row(e, theme));
|
||||
}
|
||||
if j.entries.is_empty() {
|
||||
rows.push(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child("No live entries in the window.")
|
||||
.into_any_element(),
|
||||
);
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(div().text_sm().child(format!(
|
||||
"Window [{}, {}) — {} entries, {} folded into the epoch",
|
||||
j.start,
|
||||
j.seq_next,
|
||||
j.entries.len(),
|
||||
j.start,
|
||||
)))
|
||||
.child(mono(theme, format!("epoch {}", short_hex(&j.epoch))))
|
||||
.child(mono(theme, format!("head {} (chain OK)", short_hex(&j.head)))),
|
||||
)
|
||||
.child(div().h(px(1.)).bg(theme.border))
|
||||
.child(v_flex().gap_0p5().children(rows))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn verify_body(&self, theme: &Theme) -> AnyElement {
|
||||
let Some(v) = &self.verification else {
|
||||
return div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child("Verify a signed checkpoint to prove the journal is authentic and the device genuine.")
|
||||
.into_any_element();
|
||||
};
|
||||
|
||||
let (label, color) = if v.authentic() {
|
||||
("Authentic ✓", theme.green)
|
||||
} else {
|
||||
("Not trusted ✗", theme.danger)
|
||||
};
|
||||
|
||||
let kv = |k: &str, val: String| {
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(div().text_xs().text_color(theme.muted_foreground).child(k.to_string()))
|
||||
.child(div().font_family("monospace").text_xs().child(val))
|
||||
};
|
||||
|
||||
let expected_line = match v.expected_match {
|
||||
Some(true) => Some(("Pinned key", "matches ✓".to_string())),
|
||||
Some(false) => Some(("Pinned key", "MISMATCH ✗".to_string())),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mut col = v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(div().w(px(10.)).h(px(10.)).rounded_full().bg(color))
|
||||
.child(div().font_semibold().text_color(color).child(label)),
|
||||
)
|
||||
.child(div().text_sm().child(format!(
|
||||
"Signature {} · chain head {} · checkpoint over seq {}",
|
||||
if v.signature_ok { "OK" } else { "INVALID" },
|
||||
if v.head_matches { "bound" } else { "MISMATCH" },
|
||||
v.seq_signed,
|
||||
)))
|
||||
.child(kv("Attestation key", v.pubkey_hex.clone()))
|
||||
.child(kv(
|
||||
"Fingerprint (pin later with Expected key)",
|
||||
v.fingerprint.clone(),
|
||||
));
|
||||
if let Some((k, val)) = expected_line {
|
||||
col = col.child(kv(k, val));
|
||||
}
|
||||
col.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AuditViewModel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const TITLE: &str = "Audit";
|
||||
const SUBTITLE: &str = "Tamper-evident security journal.";
|
||||
|
||||
if let Some((heading, body)) = self.gate(cx).message() {
|
||||
let theme = cx.theme();
|
||||
return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let read_btn = Button::new("audit-read")
|
||||
.icon(Icon::default().path("icons/refresh-cw.svg"))
|
||||
.label("Read journal")
|
||||
.outline()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_read(window, cx)));
|
||||
let verify_btn = Button::new("audit-verify")
|
||||
.label("Verify")
|
||||
.outline()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_verify(window, cx)));
|
||||
let toggle_btn = match self.enabled {
|
||||
Some(true) => Some(
|
||||
Button::new("audit-disable")
|
||||
.label("Disable")
|
||||
.outline()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_toggle(false, window, cx))),
|
||||
),
|
||||
Some(false) => Some(
|
||||
Button::new("audit-enable")
|
||||
.label("Enable")
|
||||
.outline()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_toggle(true, window, cx))),
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let theme = cx.theme();
|
||||
let journal_body = self.journal_body(theme);
|
||||
let verify_body = self.verify_body(theme);
|
||||
|
||||
let (dot, status_text) = match self.enabled {
|
||||
Some(true) => (theme.green, "On — recording security events to the key's flash."),
|
||||
Some(false) => (
|
||||
theme.muted_foreground,
|
||||
"Off — journalling is opt-in; nothing is being recorded.",
|
||||
),
|
||||
None => (theme.muted_foreground, "Reading status…"),
|
||||
};
|
||||
let status_body = h_flex()
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(div().w(px(10.)).h(px(10.)).rounded_full().bg(dot))
|
||||
.child(div().text_sm().child(status_text.to_string()));
|
||||
let status_card = {
|
||||
let mut c = Card::new()
|
||||
.title("Journalling")
|
||||
.description("Turn the tamper-evident journal on or off (PIN + touch)")
|
||||
.icon(Icon::default().path("icons/book-open.svg"));
|
||||
if let Some(btn) = toggle_btn {
|
||||
c = c.header_right(btn);
|
||||
}
|
||||
c.child(status_body)
|
||||
};
|
||||
|
||||
let journal_card = Card::new()
|
||||
.title("Audit journal")
|
||||
.description("Hash-chained security events (boots, FIDO ops, PIN, config)")
|
||||
.icon(Icon::default().path("icons/scroll-text.svg"))
|
||||
.header_right(read_btn)
|
||||
.child(journal_body);
|
||||
|
||||
let verify_card = Card::new()
|
||||
.title("Checkpoint verification")
|
||||
.description("DEVK-signed proof of authenticity and device identity")
|
||||
.icon(Icon::default().path("icons/shield-check.svg"))
|
||||
.header_right(verify_btn)
|
||||
.child(verify_body);
|
||||
|
||||
let content = v_flex()
|
||||
.gap_6()
|
||||
.child(status_card)
|
||||
.child(journal_card)
|
||||
.child(verify_card);
|
||||
PageView::build(TITLE, SUBTITLE, content, theme).into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
//! View model for the Audit screen — export and verify the device's
|
||||
//! tamper-evident security journal.
|
||||
|
||||
use crate::ui::app::AppModels;
|
||||
use crate::ui::components::applet_gate::AppletGate;
|
||||
use crate::ui::components::dialog;
|
||||
use crate::ui::components::dialog::StatusContent;
|
||||
use crate::ui::models::device::{audit, DeviceEvent, DeviceRepo, FirmwareType};
|
||||
use gpui::*;
|
||||
use gpui_component::button::ButtonVariants;
|
||||
use gpui_component::input::InputState;
|
||||
use gpui_component::WindowExt;
|
||||
|
||||
pub struct AuditViewModel {
|
||||
pub(super) device: Entity<DeviceRepo>,
|
||||
pub(super) journal: Option<audit::AuditJournal>,
|
||||
pub(super) verification: Option<audit::AuditVerification>,
|
||||
/// Whether journalling is currently on. `None` until the status is read (the
|
||||
/// query is ungated, so it loads automatically). Journalling is opt-in.
|
||||
pub(super) enabled: Option<bool>,
|
||||
pub(super) loading: bool,
|
||||
_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl AuditViewModel {
|
||||
pub fn new(_window: &mut Window, cx: &mut Context<Self>, models: &AppModels) -> Self {
|
||||
let device = models.device.clone();
|
||||
cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| {
|
||||
if this.device.read(cx).device_changed {
|
||||
this.journal = None;
|
||||
this.verification = None;
|
||||
this.enabled = None;
|
||||
}
|
||||
this.refresh_status(cx);
|
||||
cx.notify();
|
||||
})
|
||||
.detach();
|
||||
let mut this = Self {
|
||||
device,
|
||||
journal: None,
|
||||
verification: None,
|
||||
enabled: None,
|
||||
loading: false,
|
||||
_task: None,
|
||||
};
|
||||
this.refresh_status(cx);
|
||||
this
|
||||
}
|
||||
|
||||
/// Load whether journalling is on (ungated — no PIN, no touch).
|
||||
pub(super) fn refresh_status(&mut self, cx: &mut Context<Self>) {
|
||||
if self.loading || self.gate(cx) != AppletGate::Ready {
|
||||
return;
|
||||
}
|
||||
let weak = cx.entity().downgrade();
|
||||
self._task = Some(cx.spawn(async move |_, cx| {
|
||||
let res = cx
|
||||
.background_executor()
|
||||
.spawn(async { DeviceRepo::audit_status_blocking() })
|
||||
.await;
|
||||
let _ = weak.update(cx, |this, cx| {
|
||||
this.enabled = res.ok();
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Enable / disable journalling (PIN + touch) ──────────────────────────
|
||||
|
||||
pub(super) fn open_toggle(&mut self, enable: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pin = Self::pin_input(window, cx);
|
||||
let view = cx.entity().downgrade();
|
||||
let submit = {
|
||||
let pin = pin.clone();
|
||||
std::rc::Rc::new(move |window: &mut Window, cx: &mut App| {
|
||||
let p = pin.read(cx).text().to_string();
|
||||
let p = (!p.is_empty()).then_some(p);
|
||||
window.close_dialog(cx);
|
||||
let status = dialog::open_status_dialog(
|
||||
if enable { "Enabling Journalling" } else { "Disabling Journalling" },
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let _ = view.update(cx, |this, cx| this.run_toggle(enable, p, status, cx));
|
||||
})
|
||||
};
|
||||
let (title, body) = if enable {
|
||||
(
|
||||
"Enable Audit Journalling",
|
||||
"Turns the tamper-evident journal ON — security events are then recorded to the key's flash. Requires the FIDO PIN (or a touch if none is set) plus a touch to confirm.",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"Disable Audit Journalling",
|
||||
"Turns the journal OFF — no further events are recorded. Requires the FIDO PIN (or a touch if none is set) plus a touch to confirm.",
|
||||
)
|
||||
};
|
||||
Self::open_gate_dialog(title, body, pin, None, submit, window, cx);
|
||||
}
|
||||
|
||||
fn run_toggle(
|
||||
&mut self,
|
||||
enable: bool,
|
||||
pin: Option<String>,
|
||||
status: WeakEntity<StatusContent>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.loading {
|
||||
return;
|
||||
}
|
||||
self.loading = true;
|
||||
let _ = status.update(cx, |d, cx| {
|
||||
d.set_loading("Touch the device (BOOTSEL) to confirm.", cx)
|
||||
});
|
||||
cx.notify();
|
||||
let weak = cx.entity().downgrade();
|
||||
self._task = Some(cx.spawn(async move |_, cx| {
|
||||
let res = cx
|
||||
.background_executor()
|
||||
.spawn(async move { DeviceRepo::audit_set_enabled_blocking(enable, pin) })
|
||||
.await;
|
||||
let _ = weak.update(cx, |this, cx| {
|
||||
this.loading = false;
|
||||
match res {
|
||||
Ok(on) => {
|
||||
this.enabled = Some(on);
|
||||
let _ = status.update(cx, |d, cx| {
|
||||
d.set_success(
|
||||
format!("Journalling {}.", if on { "enabled" } else { "disabled" }),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = status.update(cx, |d, cx| d.set_error(e, cx));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
pub(super) fn gate(&self, cx: &App) -> AppletGate {
|
||||
let repo = self.device.read(cx);
|
||||
match &repo.status {
|
||||
None => AppletGate::Unsupported,
|
||||
Some(s) if s.firmware_type != FirmwareType::RSKey => AppletGate::Unsupported,
|
||||
Some(_) => AppletGate::Ready,
|
||||
}
|
||||
}
|
||||
|
||||
/// Masked, optional PIN input (blank = authorise by touch).
|
||||
fn pin_input(window: &mut Window, cx: &mut Context<Self>) -> Entity<InputState> {
|
||||
cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.masked(true)
|
||||
.placeholder("FIDO PIN — leave blank to touch instead")
|
||||
})
|
||||
}
|
||||
|
||||
// ── Read journal ────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn open_read(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pin = Self::pin_input(window, cx);
|
||||
let view = cx.entity().downgrade();
|
||||
let submit = {
|
||||
let pin = pin.clone();
|
||||
std::rc::Rc::new(move |window: &mut Window, cx: &mut App| {
|
||||
let p = pin.read(cx).text().to_string();
|
||||
let p = (!p.is_empty()).then_some(p);
|
||||
window.close_dialog(cx);
|
||||
let status = dialog::open_status_dialog("Reading Journal", window, cx);
|
||||
let _ = view.update(cx, |this, cx| this.run_read(p, status, cx));
|
||||
})
|
||||
};
|
||||
Self::open_gate_dialog(
|
||||
"Read Audit Journal",
|
||||
"Exports the security journal. Requires the FIDO PIN, or a touch if no PIN is set.",
|
||||
pin,
|
||||
None,
|
||||
submit,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn run_read(
|
||||
&mut self,
|
||||
pin: Option<String>,
|
||||
status: WeakEntity<StatusContent>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.loading {
|
||||
return;
|
||||
}
|
||||
self.loading = true;
|
||||
let _ = status.update(cx, |d, cx| {
|
||||
d.set_loading("Reading… touch the device (BOOTSEL) if it blinks.", cx)
|
||||
});
|
||||
cx.notify();
|
||||
let weak = cx.entity().downgrade();
|
||||
self._task = Some(cx.spawn(async move |_, cx| {
|
||||
let res = cx
|
||||
.background_executor()
|
||||
.spawn(async move { DeviceRepo::audit_log_blocking(pin) })
|
||||
.await;
|
||||
let _ = weak.update(cx, |this, cx| {
|
||||
this.loading = false;
|
||||
match res {
|
||||
Ok(journal) => {
|
||||
let n = journal.entries.len();
|
||||
this.journal = Some(journal);
|
||||
this.verification = None;
|
||||
let _ = status.update(cx, |d, cx| {
|
||||
d.set_success(format!("Journal read — {n} entries."), cx)
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = status.update(cx, |d, cx| d.set_error(e, cx));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Verify checkpoint ───────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn open_verify(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pin = Self::pin_input(window, cx);
|
||||
let expect = cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Expected key: 16-hex fingerprint or full pubkey (optional)")
|
||||
});
|
||||
let view = cx.entity().downgrade();
|
||||
let submit = {
|
||||
let pin = pin.clone();
|
||||
let expect = expect.clone();
|
||||
std::rc::Rc::new(move |window: &mut Window, cx: &mut App| {
|
||||
let p = pin.read(cx).text().to_string();
|
||||
let p = (!p.is_empty()).then_some(p);
|
||||
let e = expect.read(cx).text().to_string();
|
||||
let e = (!e.trim().is_empty()).then_some(e);
|
||||
window.close_dialog(cx);
|
||||
let status = dialog::open_status_dialog("Verifying Checkpoint", window, cx);
|
||||
let _ = view.update(cx, |this, cx| this.run_verify(p, e, status, cx));
|
||||
})
|
||||
};
|
||||
Self::open_gate_dialog(
|
||||
"Verify Audit Checkpoint",
|
||||
"Exports the journal and checks a fresh DEVK-signed checkpoint over it — proving the log is authentic and the device genuine.",
|
||||
pin,
|
||||
Some(("Expected key (optional)", expect)),
|
||||
submit,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn run_verify(
|
||||
&mut self,
|
||||
pin: Option<String>,
|
||||
expect: Option<String>,
|
||||
status: WeakEntity<StatusContent>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.loading {
|
||||
return;
|
||||
}
|
||||
self.loading = true;
|
||||
let _ = status.update(cx, |d, cx| {
|
||||
d.set_loading("Signing checkpoint… touch the device (BOOTSEL).", cx)
|
||||
});
|
||||
cx.notify();
|
||||
let weak = cx.entity().downgrade();
|
||||
self._task = Some(cx.spawn(async move |_, cx| {
|
||||
let res = cx
|
||||
.background_executor()
|
||||
.spawn(async move { DeviceRepo::audit_verify_blocking(pin, expect) })
|
||||
.await;
|
||||
let _ = weak.update(cx, |this, cx| {
|
||||
this.loading = false;
|
||||
match res {
|
||||
Ok(v) => {
|
||||
let msg = if v.authentic() {
|
||||
"Journal authentic — signature and chain verified.".to_string()
|
||||
} else if !v.signature_ok {
|
||||
"SIGNATURE INVALID — do not trust this journal.".to_string()
|
||||
} else if !v.head_matches {
|
||||
"Head mismatch — the journal changed mid-read (possible tamper)."
|
||||
.to_string()
|
||||
} else {
|
||||
"Attestation key MISMATCH — not the enrolled device.".to_string()
|
||||
};
|
||||
this.journal = Some(v.journal.clone());
|
||||
this.verification = Some(v);
|
||||
let _ = status.update(cx, |d, cx| d.set_success(msg, cx));
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = status.update(cx, |d, cx| d.set_error(e, cx));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/// A dialog with an optional-PIN field and an optional second field.
|
||||
fn open_gate_dialog(
|
||||
title: &'static str,
|
||||
body: &'static str,
|
||||
pin: Entity<InputState>,
|
||||
extra: Option<(&'static str, Entity<InputState>)>,
|
||||
submit: std::rc::Rc<dyn Fn(&mut Window, &mut App)>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.open_dialog(cx, move |dialog, _w, _| {
|
||||
let pin = pin.clone();
|
||||
let extra = extra.clone();
|
||||
let ok = submit.clone();
|
||||
let btn = submit.clone();
|
||||
let mut fields = gpui_component::v_flex()
|
||||
.gap_3()
|
||||
.pb_2()
|
||||
.child("FIDO PIN")
|
||||
.child(gpui_component::input::Input::new(&pin));
|
||||
if let Some((label, input)) = &extra {
|
||||
fields = fields
|
||||
.child(label.to_string())
|
||||
.child(gpui_component::input::Input::new(input));
|
||||
}
|
||||
dialog
|
||||
.title(title)
|
||||
.child(body)
|
||||
.child(fields)
|
||||
.on_ok(move |_, window, cx| {
|
||||
ok(window, cx);
|
||||
false
|
||||
})
|
||||
.footer(move |_, _w, _c, _| {
|
||||
let s = btn.clone();
|
||||
vec![
|
||||
gpui_component::button::Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
gpui_component::button::Button::new("run")
|
||||
.primary()
|
||||
.label("Run")
|
||||
.on_click(move |_, window, cx| s(window, cx)),
|
||||
]
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod view;
|
||||
pub mod view_model;
|
||||
pub use view_model::BackupViewModel;
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Backup screen rendering.
|
||||
|
||||
use crate::ui::components::card::Card;
|
||||
use crate::ui::components::page_view::PageView;
|
||||
use crate::ui::screens::backup::view_model::BackupViewModel;
|
||||
use gpui::*;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::{h_flex, v_flex, ActiveTheme, Disableable, Icon, StyledExt, Theme};
|
||||
|
||||
fn empty_state(heading: &str, body: String, theme: &Theme) -> AnyElement {
|
||||
v_flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_64()
|
||||
.gap_2()
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded_xl()
|
||||
.child(div().font_semibold().child(heading.to_string()))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.max_w(px(380.))
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(body),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl BackupViewModel {
|
||||
fn action_row(
|
||||
&self,
|
||||
title: &'static str,
|
||||
subtitle: &'static str,
|
||||
btn: Button,
|
||||
theme: &Theme,
|
||||
) -> impl IntoElement {
|
||||
h_flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.p_4()
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.rounded_lg()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
.child(div().font_medium().child(title))
|
||||
.child(div().text_sm().text_color(theme.muted_foreground).child(subtitle)),
|
||||
)
|
||||
.child(btn)
|
||||
}
|
||||
|
||||
fn exported_card(&self, phrase: &str, cx: &mut Context<Self>) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let copy = {
|
||||
let p = phrase.to_string();
|
||||
Button::new("bk-copy")
|
||||
.label("Copy")
|
||||
.ghost()
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(p.clone()));
|
||||
}))
|
||||
};
|
||||
let clear = Button::new("bk-clear")
|
||||
.label("Clear from screen")
|
||||
.ghost()
|
||||
.on_click(cx.listener(|this, _, _, cx| this.clear_exported(cx)));
|
||||
|
||||
Card::new()
|
||||
.title("Recovery phrase")
|
||||
.description("Shown once — write it down now, then seal the window")
|
||||
.icon(Icon::default().path("icons/key-round.svg"))
|
||||
.header_right(h_flex().gap_2().child(copy).child(clear))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.child(
|
||||
div()
|
||||
.p_3()
|
||||
.rounded_md()
|
||||
.bg(rgb(0x18181b))
|
||||
.text_color(rgb(0xf59e0b))
|
||||
.text_sm()
|
||||
.child("Anyone with this phrase can clone your FIDO identity. Store it offline; never paste it into a website."),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.p_4()
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(theme.border)
|
||||
.font_family("monospace")
|
||||
.text_sm()
|
||||
.child(phrase.to_string()),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BackupViewModel {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
const TITLE: &str = "Backup";
|
||||
const SUBTITLE: &str = "Wallet-style FIDO seed backup and restore.";
|
||||
|
||||
if let Some((heading, body)) = self.gate(cx).message() {
|
||||
let theme = cx.theme();
|
||||
return PageView::build(TITLE, SUBTITLE, empty_state(heading, body, theme), theme)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let status = self.status;
|
||||
let exported = self.exported.clone();
|
||||
|
||||
let exported_card = exported.map(|p| self.exported_card(&p, cx));
|
||||
|
||||
let refresh_btn = Button::new("bk-refresh")
|
||||
.icon(Icon::default().path("icons/refresh-cw.svg"))
|
||||
.ghost()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, _, cx| this.refresh(cx)));
|
||||
let export_btn = Button::new("bk-export")
|
||||
.label("Export seed")
|
||||
.danger()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_export(window, cx)));
|
||||
let seal_btn = Button::new("bk-seal")
|
||||
.label("Seal window")
|
||||
.outline()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_finalize(window, cx)));
|
||||
let restore_btn = Button::new("bk-restore")
|
||||
.label("Restore seed")
|
||||
.danger()
|
||||
.disabled(self.loading)
|
||||
.on_click(cx.listener(|this, _, window, cx| this.open_restore(window, cx)));
|
||||
|
||||
let theme = cx.theme();
|
||||
|
||||
let status_card = {
|
||||
let body = match status {
|
||||
Some(s) => {
|
||||
let yn = |b: bool| if b { "yes" } else { "no" };
|
||||
let export_state = if s.sealed {
|
||||
"sealed (export refused until a factory reset)"
|
||||
} else if s.has_seed {
|
||||
"open — seed can be exported once"
|
||||
} else {
|
||||
"no seed present"
|
||||
};
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(div().text_sm().child(format!("Seed present: {}", yn(s.has_seed))))
|
||||
.child(div().text_sm().child(format!("Export window: {export_state}")))
|
||||
.into_any_element()
|
||||
}
|
||||
None => div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child("Reading backup state…")
|
||||
.into_any_element(),
|
||||
};
|
||||
Card::new()
|
||||
.title("Backup status")
|
||||
.description("Whether a seed is present and the export window is open")
|
||||
.icon(Icon::default().path("icons/cpu.svg"))
|
||||
.header_right(refresh_btn)
|
||||
.child(body)
|
||||
};
|
||||
|
||||
let export_card = Card::new()
|
||||
.title("Export")
|
||||
.description("Reveal the seed as a 24-word phrase, then seal the window")
|
||||
.icon(Icon::default().path("icons/lock-open.svg"))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(self.action_row(
|
||||
"Export seed",
|
||||
"Show the recovery phrase (offline; PIN or touch)",
|
||||
export_btn,
|
||||
theme,
|
||||
))
|
||||
.child(self.action_row(
|
||||
"Seal export window",
|
||||
"Refuse further exports until a factory reset",
|
||||
seal_btn,
|
||||
theme,
|
||||
)),
|
||||
);
|
||||
|
||||
let restore_card = Card::new()
|
||||
.title("Restore")
|
||||
.description("Install a seed from a 24-word phrase")
|
||||
.icon(Icon::default().path("icons/lock.svg"))
|
||||
.child(self.action_row(
|
||||
"Restore seed",
|
||||
"Replace the FIDO identity from a recovery phrase",
|
||||
restore_btn,
|
||||
theme,
|
||||
));
|
||||
|
||||
let content = v_flex()
|
||||
.gap_6()
|
||||
.child(status_card)
|
||||
.children(exported_card)
|
||||
.child(export_card)
|
||||
.child(restore_card);
|
||||
|
||||
PageView::build(TITLE, SUBTITLE, content, theme).into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! View model for the Backup screen — wallet-style FIDO seed export/restore.
|
||||
|
||||
use crate::ui::app::AppModels;
|
||||
use crate::ui::components::applet_gate::AppletGate;
|
||||
use crate::ui::components::dialog;
|
||||
use crate::ui::components::dialog::StatusContent;
|
||||
use crate::ui::models::device::{backup, DeviceEvent, DeviceRepo, FirmwareType};
|
||||
use gpui::*;
|
||||
use gpui_component::button::{ButtonVariant, ButtonVariants};
|
||||
use gpui_component::input::InputState;
|
||||
use gpui_component::WindowExt;
|
||||
|
||||
pub struct BackupViewModel {
|
||||
pub(super) device: Entity<DeviceRepo>,
|
||||
pub(super) status: Option<backup::BackupStatus>,
|
||||
/// The last exported mnemonic, held for on-screen display until cleared.
|
||||
pub(super) exported: Option<String>,
|
||||
pub(super) loading: bool,
|
||||
_task: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl BackupViewModel {
|
||||
pub fn new(_window: &mut Window, cx: &mut Context<Self>, models: &AppModels) -> Self {
|
||||
let device = models.device.clone();
|
||||
cx.subscribe(&device, |this: &mut Self, _, _: &DeviceEvent, cx| {
|
||||
if this.device.read(cx).device_changed {
|
||||
this.status = None;
|
||||
this.exported = None;
|
||||
}
|
||||
this.load(cx);
|
||||
cx.notify();
|
||||
})
|
||||
.detach();
|
||||
let mut this = Self {
|
||||
device,
|
||||
status: None,
|
||||
exported: None,
|
||||
loading: false,
|
||||
_task: None,
|
||||
};
|
||||
this.load(cx);
|
||||
this
|
||||
}
|
||||
|
||||
pub(super) fn gate(&self, cx: &App) -> AppletGate {
|
||||
let repo = self.device.read(cx);
|
||||
match &repo.status {
|
||||
None => AppletGate::Unsupported,
|
||||
Some(s) if s.firmware_type != FirmwareType::RSKey => AppletGate::Unsupported,
|
||||
Some(_) => AppletGate::Ready,
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&mut self, cx: &mut Context<Self>) {
|
||||
if self.loading || self.gate(cx) != AppletGate::Ready {
|
||||
return;
|
||||
}
|
||||
self.loading = true;
|
||||
cx.notify();
|
||||
let weak = cx.entity().downgrade();
|
||||
self._task = Some(cx.spawn(async move |_, cx| {
|
||||
let res = cx
|
||||
.background_executor()
|
||||
.spawn(async { DeviceRepo::backup_status_blocking() })
|
||||
.await;
|
||||
let _ = weak.update(cx, |this, cx| {
|
||||
this.loading = false;
|
||||
if let Ok(s) = res {
|
||||
this.status = Some(s);
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
pub(super) fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.load(cx);
|
||||
}
|
||||
|
||||
pub(super) fn clear_exported(&mut self, cx: &mut Context<Self>) {
|
||||
self.exported = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn pin_input(window: &mut Window, cx: &mut Context<Self>) -> Entity<InputState> {
|
||||
cx.new(|cx| {
|
||||
InputState::new(window, cx)
|
||||
.masked(true)
|
||||
.placeholder("FIDO PIN — leave blank to touch instead")
|
||||
})
|
||||
}
|
||||
|
||||
// ── Export ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn open_export(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pin = Self::pin_input(window, cx);
|
||||
let view = cx.entity().downgrade();
|
||||
let submit = {
|
||||
let pin = pin.clone();
|
||||
std::rc::Rc::new(move |window: &mut Window, cx: &mut App| {
|
||||
let p = pin.read(cx).text().to_string();
|
||||
let p = (!p.is_empty()).then_some(p);
|
||||
window.close_dialog(cx);
|
||||
let status = dialog::open_status_dialog("Exporting Seed", window, cx);
|
||||
let _ = view.update(cx, |this, cx| this.run_export(p, status, cx));
|
||||
})
|
||||
};
|
||||
Self::gated_dialog(
|
||||
"Export FIDO Seed",
|
||||
"Reveals the 32-byte master seed as a 24-word phrase — anyone with it can clone this FIDO identity. Do it offline, write it down, then seal the window. Requires the FIDO PIN, or a touch if none is set.",
|
||||
pin,
|
||||
None,
|
||||
("Export", ButtonVariant::Danger),
|
||||
submit,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
fn run_export(
|
||||
&mut self,
|
||||
pin: Option<String>,
|
||||
status: WeakEntity<StatusContent>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.loading {
|
||||
return;
|
||||
}
|
||||
self.loading = true;
|
||||
let _ = status.update(cx, |d, cx| {
|
||||
d.set_loading("Exporting… touch the device (BOOTSEL).", cx)
|
||||
});
|
||||
cx.notify();
|
||||
let weak = cx.entity().downgrade();
|
||||
self._task = Some(cx.spawn(async move |_, cx| {
|
||||
let res = cx
|
||||
.background_executor()
|
||||
.spawn(async move { DeviceRepo::backup_export_blocking(pin) })
|
||||
.await;
|
||||
let _ = weak.update(cx, |this, cx| {
|
||||
this.loading = false;
|
||||
match res {
|
||||
Ok(mnemonic) => {
|
||||
this.exported = Some(mnemonic);
|
||||
this.load(cx);
|
||||
let _ = status.update(cx, |d, cx| {
|
||||
d.set_success(
|
||||
"Seed exported — write down the phrase shown below, then seal the window.".into(),
|
||||
cx,
|
||||
)
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = status.update(cx, |d, cx| d.set_error(e, cx));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Finalize (seal) ─────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn open_finalize(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let view = cx.entity().downgrade();
|
||||
dialog::open_confirm(
|
||||
"Seal Export Window",
|
||||
"Permanently refuses further seed exports until a FIDO factory reset. Only do this after you have safely recorded the phrase. Touch the device to confirm.".to_string(),
|
||||
"Seal",
|
||||
ButtonVariant::Primary,
|
||||
window,
|
||||
cx,
|
||||
move |_dh, window, cx| {
|
||||
window.close_dialog(cx);
|
||||
let status = dialog::open_status_dialog("Sealing Window", window, cx);
|
||||
let _ = view.update(cx, |this, cx| {
|
||||
this.run_unit(DeviceRepo::backup_finalize_blocking, "Export window sealed.", status, cx);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Restore ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) fn open_restore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pin = Self::pin_input(window, cx);
|
||||
let phrase = cx.new(|cx| {
|
||||
InputState::new(window, cx).placeholder("24 words separated by spaces")
|
||||
});
|
||||
let view = cx.entity().downgrade();
|
||||
let submit = {
|
||||
let pin = pin.clone();
|
||||
let phrase = phrase.clone();
|
||||
std::rc::Rc::new(move |window: &mut Window, cx: &mut App| {
|
||||
let m = phrase.read(cx).text().to_string();
|
||||
if m.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = pin.read(cx).text().to_string();
|
||||
let p = (!p.is_empty()).then_some(p);
|
||||
window.close_dialog(cx);
|
||||
let status = dialog::open_status_dialog("Restoring Seed", window, cx);
|
||||
let _ = view.update(cx, |this, cx| {
|
||||
this.run_unit(
|
||||
move || DeviceRepo::backup_restore_blocking(p, m),
|
||||
"Seed restored — the FIDO identity now matches the backup.",
|
||||
status,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
})
|
||||
};
|
||||
Self::gated_dialog(
|
||||
"Restore FIDO Seed",
|
||||
"Installs a seed from a 24-word phrase, replacing the device's FIDO identity. Requires the FIDO PIN, or a touch if none is set.",
|
||||
pin,
|
||||
Some(("Recovery phrase", phrase)),
|
||||
("Restore", ButtonVariant::Danger),
|
||||
submit,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Run a blocking op returning `()`, reporting on `status` and reloading.
|
||||
fn run_unit(
|
||||
&mut self,
|
||||
op: impl FnOnce() -> Result<(), String> + Send + 'static,
|
||||
ok_msg: &'static str,
|
||||
status: WeakEntity<StatusContent>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.loading {
|
||||
return;
|
||||
}
|
||||
self.loading = true;
|
||||
let _ = status.update(cx, |d, cx| {
|
||||
d.set_loading("Working… touch the device (BOOTSEL).", cx)
|
||||
});
|
||||
cx.notify();
|
||||
let weak = cx.entity().downgrade();
|
||||
self._task = Some(cx.spawn(async move |_, cx| {
|
||||
let res = cx.background_executor().spawn(async move { op() }).await;
|
||||
let _ = weak.update(cx, |this, cx| {
|
||||
this.loading = false;
|
||||
match res {
|
||||
Ok(_) => {
|
||||
let _ = status.update(cx, |d, cx| d.set_success(ok_msg.into(), cx));
|
||||
this.load(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = status.update(cx, |d, cx| d.set_error(e, cx));
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/// A dialog with a warning body, an optional-PIN field, an optional second
|
||||
/// text field, and a coloured submit button.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn gated_dialog(
|
||||
title: &'static str,
|
||||
body: &'static str,
|
||||
pin: Entity<InputState>,
|
||||
extra: Option<(&'static str, Entity<InputState>)>,
|
||||
action: (&'static str, ButtonVariant),
|
||||
submit: std::rc::Rc<dyn Fn(&mut Window, &mut App)>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
window.open_dialog(cx, move |dialog, _w, _| {
|
||||
let pin = pin.clone();
|
||||
let extra = extra.clone();
|
||||
let ok = submit.clone();
|
||||
let btn = submit.clone();
|
||||
let (action_label, action_variant) = action;
|
||||
let mut fields = gpui_component::v_flex().gap_3().pb_2();
|
||||
if let Some((label, input)) = &extra {
|
||||
fields = fields
|
||||
.child(label.to_string())
|
||||
.child(gpui_component::input::Input::new(input));
|
||||
}
|
||||
fields = fields
|
||||
.child("FIDO PIN")
|
||||
.child(gpui_component::input::Input::new(&pin));
|
||||
dialog
|
||||
.title(title)
|
||||
.child(body)
|
||||
.child(fields)
|
||||
.on_ok(move |_, window, cx| {
|
||||
ok(window, cx);
|
||||
false
|
||||
})
|
||||
.footer(move |_, _w, _c, _| {
|
||||
let s = btn.clone();
|
||||
vec![
|
||||
gpui_component::button::Button::new("cancel")
|
||||
.label("Cancel")
|
||||
.on_click(|_, window, cx| window.close_dialog(cx)),
|
||||
gpui_component::button::Button::new("go")
|
||||
.with_variant(action_variant)
|
||||
.label(action_label)
|
||||
.on_click(move |_, window, cx| s(window, cx)),
|
||||
]
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
+173
-159
@@ -7,6 +7,12 @@ use crate::ui::screens::config::view_model::ConfigViewModel;
|
||||
use gpui::*;
|
||||
use gpui_component::{button::*, input::*, select::*, slider::*, switch::*, *};
|
||||
|
||||
/// Per-status LED brightness is a full u8 on the device (0-255, 0 = off). The
|
||||
/// +/- steppers move in coarse steps (15 divides 255 evenly) so the whole range
|
||||
/// stays reachable without hundreds of clicks.
|
||||
const LED_BRIGHTNESS_MAX: u8 = 255;
|
||||
const LED_BRIGHTNESS_STEP: u8 = 15;
|
||||
|
||||
impl ConfigViewModel {
|
||||
fn render_identity_card(
|
||||
&self,
|
||||
@@ -48,11 +54,24 @@ impl ConfigViewModel {
|
||||
)
|
||||
.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),
|
||||
),
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(2)
|
||||
.gap_4()
|
||||
.child(
|
||||
v_flex().gap_2().child("Product Name").child(
|
||||
Input::new(&self.product_name_input)
|
||||
.bg(rgb(0x222225))
|
||||
.disabled(is_fido),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex().gap_2().child("Manufacturer").child(
|
||||
Input::new(&self.manufacturer_input)
|
||||
.bg(rgb(0x222225))
|
||||
.disabled(is_fido),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Card::new()
|
||||
@@ -66,101 +85,118 @@ impl ConfigViewModel {
|
||||
&mut self,
|
||||
cx: &mut Context<Self>,
|
||||
is_fido: bool,
|
||||
is_rskey: 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(
|
||||
h_flex()
|
||||
.gap_4()
|
||||
.flex_wrap()
|
||||
.child(
|
||||
v_flex().gap_2().flex_1().child("LED GPIO Pin").child(
|
||||
Input::new(&self.led_gpio_input)
|
||||
.bg(rgb(0x222225))
|
||||
.disabled(hardware_config_disabled),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex().gap_2().flex_1().child("LED Driver").child(
|
||||
Select::new(&self.led_driver_select)
|
||||
.w_full()
|
||||
.bg(rgb(0x222225))
|
||||
.disabled(is_fido),
|
||||
),
|
||||
// GPIO pin + driver are the LED hardware topology — always shown.
|
||||
let mut content = v_flex().gap_4().child(
|
||||
h_flex()
|
||||
.gap_4()
|
||||
.flex_wrap()
|
||||
.child(
|
||||
v_flex().gap_2().flex_1().child("LED GPIO Pin").child(
|
||||
Input::new(&self.led_gpio_input)
|
||||
.bg(rgb(0x222225))
|
||||
.disabled(hardware_config_disabled),
|
||||
),
|
||||
)
|
||||
.child(div().h_px().bg(theme.border))
|
||||
.child(
|
||||
v_flex().gap_2().child("Brightness (0-15)").child(
|
||||
)
|
||||
.child(
|
||||
v_flex().gap_2().flex_1().child("LED Driver").child(
|
||||
Select::new(&self.led_driver_select)
|
||||
.w_full()
|
||||
.bg(rgb(0x222225))
|
||||
.disabled(is_fido),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Colour order is an RS-Key extension (phy tag 0x0D); pico-fido ignores
|
||||
// it, so only surface it for RS-Key. Fixes red/green swap on GRB panels.
|
||||
if is_rskey {
|
||||
content = content.child(
|
||||
v_flex().gap_2().child("LED Colour Order").child(
|
||||
Select::new(&self.led_order_select)
|
||||
.w_full()
|
||||
.bg(rgb(0x222225))
|
||||
.disabled(hardware_config_disabled),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Global brightness / dimmable / steady live in the phy record. On RS-Key
|
||||
// the per-status EF_LED_CONF (Status LED Colors card) overrides them at
|
||||
// boot, so showing them here too would be duplicate, dead controls.
|
||||
if !is_rskey {
|
||||
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;
|
||||
|
||||
content = content
|
||||
.child(div().h_px().bg(theme.border))
|
||||
.child(
|
||||
v_flex().gap_2().child("Brightness (0-15)").child(
|
||||
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(
|
||||
h_flex()
|
||||
.items_center()
|
||||
.gap_4()
|
||||
.justify_between()
|
||||
.child(
|
||||
Slider::new(&self.led_brightness_slider)
|
||||
.flex_1()
|
||||
.disabled(hardware_config_disabled),
|
||||
v_flex().gap_0p5().child("LED Dimmable").child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child("Allow brightness adjustment"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(format!("Level {}", brightness)),
|
||||
Switch::new("led-dimmable")
|
||||
.checked(self.led_dimmable)
|
||||
.disabled(hardware_config_disabled)
|
||||
.on_click(dim_listener),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
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(
|
||||
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),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Switch::new("led-dimmable")
|
||||
.checked(self.led_dimmable)
|
||||
.disabled(hardware_config_disabled)
|
||||
.on_click(dim_listener),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
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")
|
||||
@@ -267,18 +303,15 @@ impl ConfigViewModel {
|
||||
});
|
||||
|
||||
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;
|
||||
let b = this.led_status_brightness[i];
|
||||
this.led_status_brightness[i] = b.saturating_sub(LED_BRIGHTNESS_STEP);
|
||||
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;
|
||||
let b = this.led_status_brightness[i];
|
||||
this.led_status_brightness[i] =
|
||||
b.saturating_add(LED_BRIGHTNESS_STEP).min(LED_BRIGHTNESS_MAX);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
@@ -339,32 +372,13 @@ impl ConfigViewModel {
|
||||
.active(rgb(0x3f3f46).into())
|
||||
.border(theme.border),
|
||||
)
|
||||
.disabled(is_fido || brightness_val >= 15)
|
||||
.disabled(is_fido || brightness_val >= LED_BRIGHTNESS_MAX)
|
||||
.on_click(inc_bright_listener),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
rows = rows.child(div().h_px().bg(theme.border));
|
||||
rows = rows.child(
|
||||
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")
|
||||
@@ -425,25 +439,6 @@ impl ConfigViewModel {
|
||||
);
|
||||
}
|
||||
|
||||
rows = rows.child(div().h_px().bg(theme.border));
|
||||
rows = rows.child(
|
||||
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")
|
||||
@@ -457,19 +452,37 @@ impl ConfigViewModel {
|
||||
is_fido: bool,
|
||||
) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let mut rows = v_flex().gap_4();
|
||||
// Only the interfaces the firmware actually instantiates (USB_ITF_SUPPORTED
|
||||
// = CCID | HID | KB). WCID (WebUSB) and LWIP are pico-fido concepts RS-Key
|
||||
// never builds, so toggling them would be a no-op — don't offer them.
|
||||
let mut rows = v_flex().gap_4().child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(0xf59e0b))
|
||||
.child("Advanced. HID off disables all FIDO2/U2F; CCID off disables every smart-card app (and the rescue applet). The firmware always keeps one of them, so you can't lock yourself out here."),
|
||||
);
|
||||
|
||||
let interfaces = [
|
||||
("CCID (Smart Card)", 0x01u8),
|
||||
("WCID (WebUSB)", 0x02u8),
|
||||
("HID (FIDO)", 0x04u8),
|
||||
("KB (Keyboard)", 0x08u8),
|
||||
("LWIP", 0x10u8),
|
||||
(
|
||||
"CCID (Smart Card)",
|
||||
0x01u8,
|
||||
"Required for the rescue applet and all smart-card apps",
|
||||
),
|
||||
(
|
||||
"HID (FIDO)",
|
||||
0x04u8,
|
||||
"FIDO/CTAP transport — off disables all FIDO2 and U2F",
|
||||
),
|
||||
(
|
||||
"KB (Keyboard)",
|
||||
0x08u8,
|
||||
"OTP keyboard — Yubico OTP and static-password typing",
|
||||
),
|
||||
];
|
||||
|
||||
let current_mask = self.enabled_usb_itf.unwrap_or(0x1F);
|
||||
|
||||
for (name, bit) in interfaces {
|
||||
for (name, bit, desc) in interfaces {
|
||||
let is_enabled = (current_mask & bit) != 0;
|
||||
let is_ccid = bit == 0x01;
|
||||
|
||||
@@ -498,11 +511,7 @@ impl ConfigViewModel {
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(theme.muted_foreground)
|
||||
.child(if is_ccid {
|
||||
"Required for Rescue Applet"
|
||||
} else {
|
||||
"USB Endpoint"
|
||||
}),
|
||||
.child(desc),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -566,7 +575,7 @@ impl Render for ConfigViewModel {
|
||||
let is_fido_no_rskey = is_fido && !is_rskey;
|
||||
|
||||
let led_card = self
|
||||
.render_led_card(cx, is_fido_no_rskey, hardware_config_disabled)
|
||||
.render_led_card(cx, is_fido_no_rskey, is_rskey, hardware_config_disabled)
|
||||
.into_any_element();
|
||||
let options_card = self
|
||||
.render_options_card(cx, hardware_config_disabled)
|
||||
@@ -579,22 +588,27 @@ impl Render for ConfigViewModel {
|
||||
.render_touch_card(cx.theme(), is_fido_no_rskey)
|
||||
.into_any_element();
|
||||
|
||||
let mut inner = v_flex()
|
||||
.gap_6()
|
||||
.child(identity_card)
|
||||
.child(led_card)
|
||||
.child(touch_card)
|
||||
.child(options_card);
|
||||
let mut inner = v_flex().gap_6().child(identity_card);
|
||||
|
||||
// RS-Key: put the functional config (which apps + transports are on)
|
||||
// right after Identity, before appearance/misc, so the panel reads
|
||||
// top-down by importance rather than burying it under the LED cards.
|
||||
// No curves card: the firmware ignores the phy ENABLED_CURVES tag
|
||||
// (curve support is compile-time), so exposing it would only mislead.
|
||||
if is_rskey {
|
||||
// No curves card: RS-Key's firmware ignores the phy ENABLED_CURVES
|
||||
// tag (curve support is compile-time), so exposing it would only mislead.
|
||||
inner = inner
|
||||
.child(self.render_rskey_led_card(cx, false))
|
||||
.child(self.render_rskey_apps_card(cx, false))
|
||||
.child(self.render_rskey_usb_itf_card(cx, false));
|
||||
}
|
||||
|
||||
inner = inner.child(led_card);
|
||||
|
||||
if is_rskey {
|
||||
inner = inner.child(self.render_rskey_led_card(cx, false));
|
||||
}
|
||||
|
||||
inner = inner.child(touch_card).child(options_card);
|
||||
|
||||
inner = inner.child(
|
||||
h_flex().justify_end().pt_4().child(
|
||||
Button::new("apply-changes")
|
||||
|
||||
+284
-287
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user