commit 8c44bd9ee9e4f0a2a2cf679e548b6d385801120a
parent 2e4a2948abec948b662200583023fcf3c2f0008d
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 21:07:07 +0000
core: add remote signer add account flow
- add typed remote signer preview and pending state models to the core surface
- add setup and home add-account ui flows for review, waiting, and pending cancellation
- extend the backend trait with remote signer preview, connect, pending, and cancel hooks
- cover the new core state machine with focused remote signer tests
Diffstat:
2 files changed, 516 insertions(+), 2 deletions(-)
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
@@ -8,6 +8,7 @@ mod account_roster;
mod home_location_tools;
mod location_resolver;
mod offline_geocoder;
+mod remote_signer;
mod secret_keys;
pub const APP_NAME: &str = "Rad Roots";
@@ -22,6 +23,7 @@ pub use offline_geocoder::{
RadrootsOfflineGeocoderDiagnostic, RadrootsOfflineGeocoderPlatform,
RadrootsOfflineGeocoderState, RadrootsOfflineGeocoderUnavailableKind,
};
+pub use remote_signer::{RadrootsPendingRemoteSignerConnection, RadrootsRemoteSignerPreview};
pub use secret_keys::{RadrootsSecretImportMode, RadrootsSecretImportRequest};
use home_location_tools::HomeLocationTools;
@@ -113,6 +115,29 @@ pub trait RadrootsAppBackend {
fn request_import_paste_action(&self) -> Result<Option<String>, String> {
Ok(None)
}
+ fn remote_signer_action_state(&self) -> Option<SetupActionState> {
+ None
+ }
+ fn preview_remote_signer_connection(
+ &self,
+ _input: &str,
+ ) -> Result<RadrootsRemoteSignerPreview, String> {
+ Err("remote signer onboarding is not available in this build".to_owned())
+ }
+ fn request_remote_signer_connection(
+ &self,
+ _input: &str,
+ ) -> Result<Option<IdentityGateState>, String> {
+ Ok(None)
+ }
+ fn pending_remote_signer_connection(
+ &self,
+ ) -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> {
+ Ok(None)
+ }
+ fn request_cancel_pending_remote_signer_connection(&self) -> Result<(), String> {
+ Ok(())
+ }
fn home_action_states(&self) -> Vec<HomeActionState> {
Vec::new()
}
@@ -205,6 +230,14 @@ enum RevealedSecretMaterial {
},
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum RemoteSignerEntryState {
+ Closed,
+ Editing,
+ Review(RadrootsRemoteSignerPreview),
+ WaitingApproval(RadrootsPendingRemoteSignerConnection),
+}
+
impl RevealedSecretMaterial {
fn label(&self) -> &'static str {
match self {
@@ -250,6 +283,8 @@ pub struct RadrootsApp {
home_location_tools: HomeLocationTools,
pending_home_confirmation: Option<HomeActionKind>,
pending_import_mode: Option<RadrootsSecretImportMode>,
+ remote_signer_entry_state: RemoteSignerEntryState,
+ remote_signer_input: Zeroizing<String>,
secret_key_input: Zeroizing<String>,
import_password_input: Zeroizing<String>,
pending_secret_key_backup_entry: bool,
@@ -275,7 +310,13 @@ impl RadrootsApp {
self.revealed_secret_material = None;
}
+ fn clear_remote_signer_entry(&mut self) {
+ self.remote_signer_entry_state = RemoteSignerEntryState::Closed;
+ self.remote_signer_input.clear();
+ }
+
fn clear_secret_key_ui_state(&mut self) {
+ self.clear_remote_signer_entry();
self.clear_secret_import_entry();
self.clear_secret_key_backup_entry();
self.clear_revealed_secret_material();
@@ -389,6 +430,8 @@ impl RadrootsApp {
home_location_tools: HomeLocationTools::new(),
pending_home_confirmation: None,
pending_import_mode: None,
+ remote_signer_entry_state: RemoteSignerEntryState::Closed,
+ remote_signer_input: Zeroizing::new(String::new()),
secret_key_input: Zeroizing::new(String::new()),
import_password_input: Zeroizing::new(String::new()),
pending_secret_key_backup_entry: false,
@@ -404,6 +447,7 @@ impl RadrootsApp {
app.status_message = Some(err);
}
}
+ app.sync_remote_signer_entry_from_backend();
app
}
@@ -464,6 +508,7 @@ impl RadrootsApp {
self.status_message = None;
self.clear_revealed_secret_material();
self.pending_home_confirmation = None;
+ self.clear_remote_signer_entry();
self.clear_secret_import_entry();
match self.backend.request_home_setup_action() {
Ok(Some(state)) => self.apply_identity_state(state),
@@ -507,6 +552,93 @@ impl RadrootsApp {
}
}
+ fn open_remote_signer_entry(&mut self) {
+ self.remote_signer_entry_state = RemoteSignerEntryState::Editing;
+ self.remote_signer_input.clear();
+ self.status_message = None;
+ }
+
+ fn sync_remote_signer_entry_from_backend(&mut self) {
+ match self.backend.pending_remote_signer_connection() {
+ Ok(Some(pending)) => {
+ if !matches!(
+ self.remote_signer_entry_state,
+ RemoteSignerEntryState::Editing | RemoteSignerEntryState::Review(_)
+ ) {
+ self.remote_signer_entry_state =
+ RemoteSignerEntryState::WaitingApproval(pending);
+ }
+ }
+ Ok(None) => {
+ if matches!(
+ self.remote_signer_entry_state,
+ RemoteSignerEntryState::WaitingApproval(_)
+ ) {
+ self.clear_remote_signer_entry();
+ }
+ }
+ Err(err) => {
+ self.status_message = Some(err);
+ }
+ }
+ }
+
+ fn request_remote_signer_preview(&mut self) {
+ self.status_message = None;
+ self.clear_revealed_secret_material();
+ match self
+ .backend
+ .preview_remote_signer_connection(self.remote_signer_input.as_str())
+ {
+ Ok(preview) => {
+ self.remote_signer_entry_state = RemoteSignerEntryState::Review(preview);
+ }
+ Err(err) => {
+ self.status_message = Some(err);
+ }
+ }
+ }
+
+ fn request_remote_signer_connect(&mut self) {
+ self.status_message = None;
+ self.clear_revealed_secret_material();
+ let pending_summary = match &self.remote_signer_entry_state {
+ RemoteSignerEntryState::Review(preview) => preview.pending_summary(),
+ _ => {
+ self.status_message =
+ Some("review the remote signer details before connecting".to_owned());
+ return;
+ }
+ };
+ match self
+ .backend
+ .request_remote_signer_connection(self.remote_signer_input.as_str())
+ {
+ Ok(Some(state)) => self.apply_identity_state(state),
+ Ok(None) => {
+ self.remote_signer_entry_state =
+ RemoteSignerEntryState::WaitingApproval(pending_summary);
+ self.sync_remote_signer_entry_from_backend();
+ }
+ Err(err) => {
+ self.status_message = Some(err);
+ }
+ }
+ }
+
+ fn request_cancel_pending_remote_signer(&mut self) {
+ self.status_message = None;
+ match self
+ .backend
+ .request_cancel_pending_remote_signer_connection()
+ {
+ Ok(()) => self.clear_remote_signer_entry(),
+ Err(err) => {
+ self.status_message = Some(err);
+ }
+ }
+ }
+
fn request_select_account(&mut self, account_id: &str) {
self.status_message = None;
self.clear_revealed_secret_material();
@@ -571,7 +703,7 @@ impl RadrootsApp {
"This removes all app-managed local identity state from this device and returns the app to setup."
}
HomeActionKind::DisconnectSigner => {
- "This disconnects the current browser signer from the app. It does not delete the signer key."
+ "This disconnects the current external signer from the app. It does not delete the signer key."
}
}
}
@@ -623,6 +755,7 @@ impl RadrootsApp {
self.status_message = Some(err);
}
}
+ self.sync_remote_signer_entry_from_backend();
}
fn render_import_entry(
@@ -721,6 +854,92 @@ impl RadrootsApp {
});
}
+ fn render_remote_signer_entry(&mut self, ui: &mut egui::Ui, action: &SetupActionState) {
+ ui.vertical_centered(|ui| {
+ ui.set_max_width(ui.available_width().min(560.0));
+ match &self.remote_signer_entry_state {
+ RemoteSignerEntryState::Closed => {}
+ RemoteSignerEntryState::Editing => {
+ ui.label(
+ "Connect an approved remote signer using its bunker uri or discovery url.",
+ );
+ ui.add_space(8.0);
+ ui.add(
+ egui::TextEdit::singleline(&mut *self.remote_signer_input)
+ .hint_text("bunker://... or http://localhost/connect?uri=...")
+ .desired_width(ui.available_width()),
+ );
+ ui.add_space(8.0);
+ ui.horizontal_centered(|ui| {
+ if ui
+ .add_enabled(action.enabled, egui::Button::new("Review Remote Signer"))
+ .clicked()
+ {
+ self.request_remote_signer_preview();
+ }
+ if ui.button("Cancel").clicked() {
+ self.clear_remote_signer_entry();
+ self.status_message = None;
+ }
+ });
+ }
+ RemoteSignerEntryState::Review(preview) => {
+ ui.label("Review the remote signer before connecting.");
+ ui.add_space(8.0);
+ ui.monospace(format!("source: {}", preview.source_label));
+ ui.monospace(format!("signer: {}", preview.signer_npub));
+ if preview.relays.is_empty() {
+ ui.label("No relays were provided by this signer.");
+ } else {
+ ui.label("Relays");
+ for relay in &preview.relays {
+ ui.monospace(relay);
+ }
+ }
+ ui.add_space(8.0);
+ if preview.requested_permissions.is_empty() {
+ ui.label("No additional permissions are requested in this slice.");
+ } else {
+ ui.label("Requested permissions");
+ for permission in &preview.requested_permissions {
+ ui.monospace(permission);
+ }
+ }
+ ui.add_space(8.0);
+ ui.horizontal_centered(|ui| {
+ if ui
+ .add_enabled(action.enabled, egui::Button::new(action.label.clone()))
+ .clicked()
+ {
+ self.request_remote_signer_connect();
+ }
+ if ui.button("Cancel").clicked() {
+ self.clear_remote_signer_entry();
+ self.status_message = None;
+ }
+ });
+ }
+ RemoteSignerEntryState::WaitingApproval(pending) => {
+ ui.label("Remote signer connection is waiting for signer approval.");
+ ui.add_space(8.0);
+ ui.monospace(format!("signer: {}", pending.signer_npub));
+ if pending.relays.is_empty() {
+ ui.label("No relays were provided by this signer.");
+ } else {
+ ui.label("Relays");
+ for relay in &pending.relays {
+ ui.monospace(relay);
+ }
+ }
+ ui.add_space(8.0);
+ if ui.button("Cancel Pending Remote Signer").clicked() {
+ self.request_cancel_pending_remote_signer();
+ }
+ }
+ }
+ });
+ }
+
fn render_home_account_section(&mut self, ui: &mut egui::Ui) {
let AppScreen::Home { account_id } = &self.screen else {
return;
@@ -773,7 +992,9 @@ impl RadrootsApp {
let home_setup_action = self.backend.home_setup_action_state();
let import_action = self.backend.import_action_state();
let import_paste_action = self.backend.import_paste_action_state();
- if home_setup_action.is_some() || import_action.is_some() {
+ let remote_signer_action = self.backend.remote_signer_action_state();
+ if home_setup_action.is_some() || import_action.is_some() || remote_signer_action.is_some()
+ {
ui.add_space(16.0);
ui.label("Add account");
}
@@ -810,6 +1031,29 @@ impl RadrootsApp {
self.open_import_entry();
}
}
+
+ if let Some(remote_signer_action) = remote_signer_action {
+ if remote_signer_action.pending {
+ ui.ctx().request_repaint();
+ }
+ ui.add_space(8.0);
+ if matches!(
+ self.remote_signer_entry_state,
+ RemoteSignerEntryState::Closed
+ ) {
+ if ui
+ .add_enabled(
+ remote_signer_action.enabled,
+ egui::Button::new(remote_signer_action.label),
+ )
+ .clicked()
+ {
+ self.open_remote_signer_entry();
+ }
+ } else {
+ self.render_remote_signer_entry(ui, &remote_signer_action);
+ }
+ }
}
fn render_offline_geocoder_status(&self, ui: &mut egui::Ui) {
@@ -895,6 +1139,12 @@ impl eframe::App for RadrootsApp {
ctx.request_repaint();
}
}
+ let remote_signer_action = self.backend.remote_signer_action_state();
+ if let Some(remote_signer_action) = &remote_signer_action {
+ if remote_signer_action.pending {
+ ctx.request_repaint();
+ }
+ }
ui.label("setup");
ui.add_space(8.0);
@@ -919,6 +1169,24 @@ impl eframe::App for RadrootsApp {
self.open_import_entry();
}
}
+
+ if let Some(remote_signer_action) = remote_signer_action {
+ ui.add_space(12.0);
+ if matches!(self.remote_signer_entry_state, RemoteSignerEntryState::Closed)
+ {
+ if ui
+ .add_enabled(
+ remote_signer_action.enabled,
+ egui::Button::new(remote_signer_action.label),
+ )
+ .clicked()
+ {
+ self.open_remote_signer_entry();
+ }
+ } else {
+ self.render_remote_signer_entry(ui, &remote_signer_action);
+ }
+ }
}
AppScreen::Home { .. } => {
self.render_home_account_section(ui);
@@ -1052,6 +1320,11 @@ mod tests {
home_setup_action_state: Rc<RefCell<Option<SetupActionState>>>,
import_action_state: Rc<RefCell<Option<ImportActionState>>>,
import_paste_action_state: Rc<RefCell<Option<PasteActionState>>>,
+ remote_signer_action_state: Rc<RefCell<Option<SetupActionState>>>,
+ remote_signer_preview: Rc<RefCell<VecDeque<Result<RadrootsRemoteSignerPreview, String>>>>,
+ remote_signer_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
+ pending_remote_signer: Rc<RefCell<Option<RadrootsPendingRemoteSignerConnection>>>,
+ cancel_pending_remote_signer: Rc<RefCell<VecDeque<Result<(), String>>>>,
home_action_states: Rc<RefCell<Vec<HomeActionState>>>,
request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
home_setup_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
@@ -1084,6 +1357,11 @@ mod tests {
home_setup_action_state: Rc::new(RefCell::new(None)),
import_action_state: Rc::new(RefCell::new(None)),
import_paste_action_state: Rc::new(RefCell::new(None)),
+ remote_signer_action_state: Rc::new(RefCell::new(None)),
+ remote_signer_preview: Rc::new(RefCell::new(VecDeque::new())),
+ remote_signer_request: Rc::new(RefCell::new(VecDeque::new())),
+ pending_remote_signer: Rc::new(RefCell::new(None)),
+ cancel_pending_remote_signer: Rc::new(RefCell::new(VecDeque::new())),
home_action_states: Rc::new(RefCell::new(Vec::new())),
request: Rc::new(RefCell::new(request.into())),
home_setup_request: Rc::new(RefCell::new(VecDeque::new())),
@@ -1144,6 +1422,42 @@ mod tests {
self
}
+ fn with_remote_signer_action(self, action_state: SetupActionState) -> Self {
+ *self.remote_signer_action_state.borrow_mut() = Some(action_state);
+ self
+ }
+
+ fn with_remote_signer_preview(
+ self,
+ preview: Vec<Result<RadrootsRemoteSignerPreview, String>>,
+ ) -> Self {
+ self.remote_signer_preview.borrow_mut().extend(preview);
+ self
+ }
+
+ fn with_remote_signer_request(
+ self,
+ request: Vec<Result<Option<IdentityGateState>, String>>,
+ ) -> Self {
+ self.remote_signer_request.borrow_mut().extend(request);
+ self
+ }
+
+ fn with_pending_remote_signer(
+ self,
+ pending: Option<RadrootsPendingRemoteSignerConnection>,
+ ) -> Self {
+ *self.pending_remote_signer.borrow_mut() = pending;
+ self
+ }
+
+ fn with_cancel_pending_remote_signer(self, request: Vec<Result<(), String>>) -> Self {
+ self.cancel_pending_remote_signer
+ .borrow_mut()
+ .extend(request);
+ self
+ }
+
fn with_home_action(
self,
action_state: HomeActionState,
@@ -1279,6 +1593,48 @@ mod tests {
.unwrap_or(Ok(None))
}
+ fn remote_signer_action_state(&self) -> Option<SetupActionState> {
+ self.remote_signer_action_state.borrow().clone()
+ }
+
+ fn preview_remote_signer_connection(
+ &self,
+ _input: &str,
+ ) -> Result<RadrootsRemoteSignerPreview, String> {
+ self.remote_signer_preview
+ .borrow_mut()
+ .pop_front()
+ .unwrap_or_else(|| Err("missing remote signer preview".into()))
+ }
+
+ fn request_remote_signer_connection(
+ &self,
+ _input: &str,
+ ) -> Result<Option<IdentityGateState>, String> {
+ self.remote_signer_request
+ .borrow_mut()
+ .pop_front()
+ .unwrap_or(Ok(None))
+ }
+
+ fn pending_remote_signer_connection(
+ &self,
+ ) -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> {
+ Ok(self.pending_remote_signer.borrow().clone())
+ }
+
+ fn request_cancel_pending_remote_signer_connection(&self) -> Result<(), String> {
+ let result = self
+ .cancel_pending_remote_signer
+ .borrow_mut()
+ .pop_front()
+ .unwrap_or(Ok(()));
+ if result.is_ok() {
+ *self.pending_remote_signer.borrow_mut() = None;
+ }
+ result
+ }
+
fn home_action_states(&self) -> Vec<HomeActionState> {
self.home_action_states.borrow().clone()
}
@@ -1373,6 +1729,19 @@ mod tests {
}
}
+ fn fixture_remote_signer_preview() -> RadrootsRemoteSignerPreview {
+ RadrootsRemoteSignerPreview {
+ source_label: "discovery url".into(),
+ signer_npub: FIXTURE_BOB.npub.into(),
+ relays: vec!["ws://localhost:8080".into()],
+ requested_permissions: Vec::new(),
+ }
+ }
+
+ fn fixture_pending_remote_signer() -> RadrootsPendingRemoteSignerConnection {
+ fixture_remote_signer_preview().pending_summary()
+ }
+
#[test]
fn startup_missing_key_enters_setup() {
let app = RadrootsApp::new(Box::new(MockBackend::new(
@@ -1425,6 +1794,33 @@ mod tests {
}
#[test]
+ fn startup_restores_pending_remote_signer_connection() {
+ let app = RadrootsApp::new(Box::new(
+ MockBackend::new(
+ Ok(IdentityGateState::Missing),
+ vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
+ )
+ .with_remote_signer_action(SetupActionState {
+ label: "Connect Remote Signer".into(),
+ enabled: true,
+ pending: false,
+ })
+ .with_pending_remote_signer(Some(fixture_pending_remote_signer())),
+ ));
+
+ assert_eq!(
+ app.remote_signer_entry_state,
+ RemoteSignerEntryState::WaitingApproval(fixture_pending_remote_signer())
+ );
+ }
+
+ #[test]
fn startup_unsupported_shows_reason() {
let app = RadrootsApp::new(Box::new(MockBackend::new(
Ok(IdentityGateState::Unsupported {
@@ -1770,6 +2166,102 @@ mod tests {
}
#[test]
+ fn remote_signer_preview_moves_entry_into_review_state() {
+ let mut app = RadrootsApp::new(Box::new(
+ MockBackend::new(
+ Ok(IdentityGateState::Missing),
+ vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
+ )
+ .with_remote_signer_action(SetupActionState {
+ label: "Connect Remote Signer".into(),
+ enabled: true,
+ pending: false,
+ })
+ .with_remote_signer_preview(vec![Ok(fixture_remote_signer_preview())]),
+ ));
+
+ app.open_remote_signer_entry();
+ app.remote_signer_input =
+ Zeroizing::new("http://localhost/connect?uri=bunker%3A%2F%2Fexample".into());
+ app.request_remote_signer_preview();
+
+ assert_eq!(
+ app.remote_signer_entry_state,
+ RemoteSignerEntryState::Review(fixture_remote_signer_preview())
+ );
+ }
+
+ #[test]
+ fn remote_signer_connect_enters_waiting_state() {
+ let mut app = RadrootsApp::new(Box::new(
+ MockBackend::new(
+ Ok(IdentityGateState::Missing),
+ vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
+ )
+ .with_remote_signer_action(SetupActionState {
+ label: "Connect Remote Signer".into(),
+ enabled: true,
+ pending: false,
+ })
+ .with_remote_signer_request(vec![Ok(None)])
+ .with_pending_remote_signer(Some(fixture_pending_remote_signer())),
+ ));
+
+ app.remote_signer_entry_state =
+ RemoteSignerEntryState::Review(fixture_remote_signer_preview());
+ app.remote_signer_input =
+ Zeroizing::new("http://localhost/connect?uri=bunker%3A%2F%2Fexample".into());
+ app.request_remote_signer_connect();
+
+ assert_eq!(
+ app.remote_signer_entry_state,
+ RemoteSignerEntryState::WaitingApproval(fixture_pending_remote_signer())
+ );
+ }
+
+ #[test]
+ fn cancel_pending_remote_signer_clears_waiting_state() {
+ let mut app = RadrootsApp::new(Box::new(
+ MockBackend::new(
+ Ok(IdentityGateState::Missing),
+ vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
+ )
+ .with_remote_signer_action(SetupActionState {
+ label: "Connect Remote Signer".into(),
+ enabled: true,
+ pending: false,
+ })
+ .with_pending_remote_signer(Some(fixture_pending_remote_signer()))
+ .with_cancel_pending_remote_signer(vec![Ok(())]),
+ ));
+
+ app.request_cancel_pending_remote_signer();
+
+ assert_eq!(
+ app.remote_signer_entry_state,
+ RemoteSignerEntryState::Closed
+ );
+ }
+
+ #[test]
fn encrypted_backup_home_action_reveals_secret_key_without_leaving_home() {
let encrypted_secret_key =
fixture_identity_ncryptsec(&FIXTURE_ALICE, FIXTURE_BACKUP_PASSWORD)
diff --git a/crates/core/src/remote_signer.rs b/crates/core/src/remote_signer.rs
@@ -0,0 +1,22 @@
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsRemoteSignerPreview {
+ pub source_label: String,
+ pub signer_npub: String,
+ pub relays: Vec<String>,
+ pub requested_permissions: Vec<String>,
+}
+
+impl RadrootsRemoteSignerPreview {
+ pub fn pending_summary(&self) -> RadrootsPendingRemoteSignerConnection {
+ RadrootsPendingRemoteSignerConnection {
+ signer_npub: self.signer_npub.clone(),
+ relays: self.relays.clone(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsPendingRemoteSignerConnection {
+ pub signer_npub: String,
+ pub relays: Vec<String>,
+}