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:
Maxim Muravev
2026-07-25 14:17:36 +03:00
parent de154183c9
commit d321a57b6b
38 changed files with 8184 additions and 471 deletions
+152 -2
View File
@@ -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))
+44
View File
@@ -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(),
)),
}
}
}
+56
View File
@@ -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)
}
+2
View File
@@ -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;
+69 -2
View File
@@ -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
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
pub mod view;
pub mod view_model;
pub use view_model::{AccountsEvent, AccountsViewModel};
+292
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
pub mod view;
pub mod view_model;
pub use view_model::{AttestationEvent, AttestationViewModel};
+143
View File
@@ -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()
}
}
+268
View File
@@ -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();
});
}));
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod view;
pub mod view_model;
pub use view_model::AuditViewModel;
+265
View File
@@ -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()
}
}
+355
View File
@@ -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)),
]
})
});
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod view;
pub mod view_model;
pub use view_model::BackupViewModel;
+213
View File
@@ -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()
}
}
+310
View File
@@ -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
View File
@@ -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")
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