app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit d4e5b040af038be940c6c99d64146c4b85db14c7
parent 39c0313aed49b558d8fed339c2b256390d94f2e7
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 20:29:50 +0000

app: wire signer preview and pending onboarding

- connect the startup signer submit path through the shared remote signer client
- render signer review pending approval auth challenge and approved status cards
- localize the signer status copy and extend the desktop source guards
- verify the launcher and shared remote signer crates with cargo test

Diffstat:
Mcrates/launchers/desktop/Cargo.toml | 1+
Mcrates/launchers/desktop/src/source_guards.rs | 23+++++++++++++++++++++++
Mcrates/launchers/desktop/src/window.rs | 576++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/shared/i18n/src/keys.rs | 9+++++++++
Mcrates/shared/i18n/src/lib.rs | 36++++++++++++++++++++++++++++++++++++
Mi18n/locales/en/messages.json | 9+++++++++
6 files changed, 624 insertions(+), 30 deletions(-)

diff --git a/crates/launchers/desktop/Cargo.toml b/crates/launchers/desktop/Cargo.toml @@ -17,6 +17,7 @@ radroots_secret_vault.workspace = true radroots_app_core.workspace = true radroots_app_i18n.workspace = true radroots_app_models.workspace = true +radroots_app_remote_signer = { path = "../../shared/remote_signer" } radroots_app_sqlite.workspace = true radroots_app_state.workspace = true radroots_app_sync.workspace = true diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -8,8 +8,11 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "${dollars}.{cents:02} / {}", ", ", "0", + "1111111111111111111111111111111111111111111111111111111111111111", "14", "14.5", + "2222222222222222222222222222222222222222222222222222222222222222", + "3333333333333333333333333333333333333333333333333333333333333333", "6", "6.", "6.5", @@ -24,6 +27,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "account-open-workspace", "account-log-out", "account-more", + "bunker uri", + "bunker://466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27?relay=wss%3A%2F%2Frelay.radroots.example", "failed to add relay `{relay_url}`: {error}", "failed to open existing product editor", "failed to open new product editor", @@ -51,6 +56,11 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "home-today-open-products-low-stock", "home-products-scroll", "home-today-scroll", + "https://auth.example/challenge", + "identity", + "none", + "npub1", + "preview", "products", "products-filter-all", "products-filter-archived", @@ -83,6 +93,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "products.search_query_update_failed", "products.stock_update_failed", "products.sort_update_failed", + "remote signer connection failed: relay refused the request", + "remote signer did not respond yet", "runtime unavailable", "settings-allow-relay-connections", "settings-launch-at-login", @@ -93,8 +105,10 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "settings-panel-scroll", "settings-use-media-servers", "settings-use-nip05", + "sign_event:kind:1, switch_relays", "startup-title-radroots", "startup-title-starting", + "wss://relay.radroots.example", "{quantity} {unit_label}", ]; @@ -105,6 +119,15 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::HomeSetupGenerateKeyAction", "AppTextKey::HomeSetupSignerConnectAction", "AppTextKey::HomeSetupSignerSourcePlaceholder", + "AppTextKey::HomeSetupSignerReviewTitle", + "AppTextKey::HomeSetupSignerSourceLabel", + "AppTextKey::HomeSetupSignerSignerLabel", + "AppTextKey::HomeSetupSignerRelaysLabel", + "AppTextKey::HomeSetupSignerPermissionsLabel", + "AppTextKey::HomeSetupSignerConnectingTitle", + "AppTextKey::HomeSetupSignerPendingTitle", + "AppTextKey::HomeSetupSignerAuthChallengeTitle", + "AppTextKey::HomeSetupSignerApprovedTitle", "AppTextKey::HomeFarmSetupOnboardingTitle", "AppTextKey::HomeFarmSetupOnboardingBody", "AppTextKey::HomeFarmSetupOnboardingAction", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -19,6 +19,12 @@ use radroots_app_models::{ ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection, TodayAgendaProjection, TodaySetupTaskKind, }; +use radroots_app_remote_signer::{ + RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, + RadrootsAppRemoteSignerPendingSession, radroots_app_remote_signer_connect_pending, + radroots_app_remote_signer_poll_pending_session_with_progress, + radroots_app_remote_signer_preview, radroots_app_remote_signer_requested_permissions, +}; use radroots_app_state::{FarmSetupFlowStage, HomeRoute}; use radroots_app_ui::{ APP_UI_THEME, AppCheckboxFieldSpec, IconSegmentButtonSpec, LabelValueRow, action_button, @@ -156,6 +162,8 @@ pub struct HomeView { runtime: DesktopAppRuntime, startup_view: StartupHomeView, startup_signer_entry: Option<StartupSignerEntryState>, + startup_signer_connect_state: StartupSignerConnectState, + startup_signer_task_token: u64, logged_in_view: LoggedInHomeView, farm_setup_form: Option<FarmSetupFormState>, products_search: Option<ProductsSearchState>, @@ -164,12 +172,43 @@ pub struct HomeView { relay_client: Option<RadrootsNostrClient>, } +#[derive(Clone, Debug)] +enum StartupSignerConnectState { + Idle, + Connecting, + PendingApproval { + pending_session: RadrootsAppRemoteSignerPendingSession, + auth_challenge_url: Option<String>, + }, + Approved { + pending_session: RadrootsAppRemoteSignerPendingSession, + approved_session: RadrootsAppRemoteSignerApprovedSession, + auth_challenge_url: Option<String>, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct StartupSignerPreviewSummary { + source_label: String, + signer_npub: String, + relays_label: String, + permissions_label: String, +} + +#[derive(Clone, Debug)] +struct StartupSignerPollCycleResult { + auth_challenge_url: Option<String>, + outcome: Result<RadrootsAppRemoteSignerPendingPollOutcome, String>, +} + impl HomeView { pub fn new(runtime: DesktopAppRuntime) -> Self { Self { runtime, startup_view: StartupHomeView::new(), startup_signer_entry: None, + startup_signer_connect_state: StartupSignerConnectState::Idle, + startup_signer_task_token: 0, logged_in_view: LoggedInHomeView::new(), farm_setup_form: None, products_search: None, @@ -189,15 +228,31 @@ impl HomeView { false } + fn reset_startup_signer_flow(&mut self) { + self.startup_signer_task_token = self.startup_signer_task_token.wrapping_add(1); + self.startup_signer_connect_state = StartupSignerConnectState::Idle; + } + + fn next_startup_signer_task_token(&mut self) -> u64 { + self.startup_signer_task_token = self.startup_signer_task_token.wrapping_add(1); + self.startup_signer_task_token + } + + fn startup_signer_task_is_current(&self, task_token: u64) -> bool { + self.startup_signer_task_token == task_token + } + fn show_startup_identity_choice(&mut self, cx: &mut Context<Self>) { - self.startup_view.clear_error(); + self.startup_view.clear_notice(); + self.reset_startup_signer_flow(); if self.runtime.show_startup_identity_choice() { cx.notify(); } } fn show_startup_signer_entry(&mut self, cx: &mut Context<Self>) { - self.startup_view.clear_error(); + self.startup_view.clear_notice(); + self.reset_startup_signer_flow(); if self.runtime.show_startup_signer_entry() { cx.notify(); } @@ -208,7 +263,8 @@ impl HomeView { return; } - self.startup_view.clear_error(); + self.startup_view.clear_notice(); + self.reset_startup_signer_flow(); let relay_url = self.runtime.default_nostr_relay_url(); cx.notify(); cx.spawn_in(window, async move |this, cx| { @@ -232,14 +288,14 @@ impl HomeView { match startup_result { Ok(result) => { self.relay_client = Some(result.relay_client); - self.startup_view.clear_error(); + self.startup_view.clear_notice(); if !self.generate_local_account(cx) { self.show_startup_identity_choice(cx); } } Err(error) => { self.runtime.show_startup_identity_choice(); - self.startup_view.fail_starting(error); + self.startup_view.set_notice(error); cx.notify(); } } @@ -254,6 +310,14 @@ impl HomeView { if runtime_summary.startup_gate != AppStartupGate::SetupRequired || runtime_summary.logged_out_startup.phase != LoggedOutStartupPhase::SignerEntry { + if self.startup_signer_entry.is_some() + || !matches!( + self.startup_signer_connect_state, + StartupSignerConnectState::Idle + ) + { + self.reset_startup_signer_flow(); + } self.startup_signer_entry = None; return; } @@ -273,6 +337,155 @@ impl HomeView { } } + fn submit_startup_signer(&mut self, window: &mut Window, cx: &mut Context<Self>) { + let Some(entry) = self.startup_signer_entry.as_ref() else { + return; + }; + + let source_input = entry.input.read(cx).value().to_string(); + match startup_signer_preview_summary(source_input.as_str()) { + Ok(_) => {} + Err(error) => { + self.startup_view.set_notice(error); + cx.notify(); + return; + } + } + + self.startup_view.clear_notice(); + let task_token = self.next_startup_signer_task_token(); + self.startup_signer_connect_state = StartupSignerConnectState::Connecting; + cx.notify(); + + cx.spawn_in(window, async move |this, cx| { + let connect_result = cx + .background_executor() + .spawn(run_startup_signer_connect(source_input)) + .await; + let Some(pending_session) = this + .update(cx, |this, cx| { + this.finish_startup_signer_connect(task_token, connect_result, cx) + }) + .ok() + .flatten() + else { + return; + }; + + loop { + let poll_result = cx + .background_executor() + .spawn(run_startup_signer_pending_poll( + pending_session.record.clone(), + pending_session.client_secret_key_hex.clone(), + )) + .await; + let should_continue = this + .update(cx, |this, cx| { + this.apply_startup_signer_poll_result( + task_token, + pending_session.clone(), + poll_result, + cx, + ) + }) + .ok() + .unwrap_or(false); + if !should_continue { + return; + } + + Timer::after(Duration::from_secs(1)).await; + } + }) + .detach(); + } + + fn finish_startup_signer_connect( + &mut self, + task_token: u64, + connect_result: Result<RadrootsAppRemoteSignerPendingSession, String>, + cx: &mut Context<Self>, + ) -> Option<RadrootsAppRemoteSignerPendingSession> { + if !self.startup_signer_task_is_current(task_token) { + return None; + } + + match connect_result { + Ok(pending_session) => { + self.startup_view.clear_notice(); + self.startup_signer_connect_state = StartupSignerConnectState::PendingApproval { + pending_session: pending_session.clone(), + auth_challenge_url: None, + }; + cx.notify(); + Some(pending_session) + } + Err(error) => { + self.startup_signer_connect_state = StartupSignerConnectState::Idle; + self.startup_view.set_notice(error); + cx.notify(); + None + } + } + } + + fn apply_startup_signer_poll_result( + &mut self, + task_token: u64, + pending_session: RadrootsAppRemoteSignerPendingSession, + poll_result: StartupSignerPollCycleResult, + cx: &mut Context<Self>, + ) -> bool { + if !self.startup_signer_task_is_current(task_token) { + return false; + } + + let auth_challenge_url = poll_result.auth_challenge_url; + match poll_result.outcome { + Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) => { + self.startup_view.clear_notice(); + self.startup_signer_connect_state = StartupSignerConnectState::PendingApproval { + pending_session, + auth_challenge_url, + }; + cx.notify(); + true + } + Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { message }) => { + if startup_signer_transport_failure_requires_notice(message.as_str()) { + self.startup_view.set_notice(message); + } else { + self.startup_view.clear_notice(); + } + self.startup_signer_connect_state = StartupSignerConnectState::PendingApproval { + pending_session, + auth_challenge_url, + }; + cx.notify(); + true + } + Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(approved_session)) => { + self.startup_view.clear_notice(); + self.startup_signer_connect_state = StartupSignerConnectState::Approved { + pending_session, + approved_session, + auth_challenge_url, + }; + cx.notify(); + false + } + Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) + | Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) + | Err(message) => { + self.startup_signer_connect_state = StartupSignerConnectState::Idle; + self.startup_view.set_notice(message); + cx.notify(); + false + } + } + } + fn open_farm_setup(&mut self, window: &mut Window, cx: &mut Context<Self>) { let runtime_summary = self.runtime.summary(); @@ -494,6 +707,8 @@ impl HomeView { let value = state.read(cx).value().to_string(); if self.runtime.set_startup_signer_source_input(value.as_str()) { + self.startup_view.clear_notice(); + self.reset_startup_signer_flow(); cx.notify(); } } @@ -1174,10 +1389,11 @@ impl Render for HomeView { .render( &runtime_summary, self.startup_signer_entry.as_ref(), + &self.startup_signer_connect_state, cx.listener(|this, _, _, cx| this.show_startup_identity_choice(cx)), cx.listener(|this, _, window, cx| this.start_generate_key(window, cx)), cx.listener(|this, _, _, cx| this.show_startup_signer_entry(cx)), - cx.listener(|_, _, _, _| {}), + cx.listener(|this, _, window, cx| this.submit_startup_signer(window, cx)), cx.listener(|this, _, _, cx| this.show_startup_identity_choice(cx)), cx, ) @@ -1491,26 +1707,29 @@ impl ProductEditorFormState { } struct StartupHomeView { - relay_error: Option<String>, + startup_notice: Option<String>, } impl StartupHomeView { fn new() -> Self { - Self { relay_error: None } + Self { + startup_notice: None, + } } - fn fail_starting(&mut self, error: String) { - self.relay_error = Some(error); + fn set_notice(&mut self, notice: String) { + self.startup_notice = Some(notice); } - fn clear_error(&mut self) { - self.relay_error = None; + fn clear_notice(&mut self) { + self.startup_notice = None; } fn render( &self, runtime: &DesktopAppRuntimeSummary, signer_entry: Option<&StartupSignerEntryState>, + connect_state: &StartupSignerConnectState, on_continue: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_generate_key: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_connect_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -1520,8 +1739,9 @@ impl StartupHomeView { ) -> impl IntoElement { startup_home_shell( runtime, - self.relay_error.as_deref(), + self.startup_notice.as_deref(), signer_entry, + connect_state, on_continue, on_generate_key, on_connect_signer, @@ -2189,8 +2409,9 @@ fn startup_home_surface(runtime: &DesktopAppRuntimeSummary) -> StartupHomeSurfac fn startup_home_shell( runtime: &DesktopAppRuntimeSummary, - relay_error: Option<&str>, + startup_notice: Option<&str>, signer_entry: Option<&StartupSignerEntryState>, + connect_state: &StartupSignerConnectState, on_continue: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_generate_key: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_connect_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -2240,7 +2461,7 @@ fn startup_home_shell( on_continue, cx, )) - .when_some(relay_error, |this, error| { + .when_some(startup_notice, |this, error| { this.child(startup_home_support_text( error.to_owned(), )) @@ -2267,7 +2488,7 @@ fn startup_home_shell( on_connect_signer, cx, )) - .when_some(relay_error, |this, error| { + .when_some(startup_notice, |this, error| { this.child(startup_home_support_text( error.to_owned(), )) @@ -2289,7 +2510,8 @@ fn startup_home_shell( StartupHomeSurface::SignerEntry => { startup_signer_entry_surface( signer_entry, - relay_error, + connect_state, + startup_notice, on_submit_signer, on_back, cx, @@ -2348,11 +2570,27 @@ fn startup_home_support_text(body: impl Into<SharedString>) -> impl IntoElement fn startup_signer_entry_surface( signer_entry: Option<&StartupSignerEntryState>, - relay_error: Option<&str>, + connect_state: &StartupSignerConnectState, + startup_notice: Option<&str>, on_submit_signer: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, on_back: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, cx: &App, ) -> impl IntoElement { + let source_input = signer_entry + .map(|signer_entry| signer_entry.input.read(cx).value().to_string()) + .unwrap_or_default(); + let preview = + startup_signer_preview_summary_for_connect_state(source_input.as_str(), connect_state); + let parse_error = if source_input.trim().is_empty() + || !matches!(connect_state, StartupSignerConnectState::Idle) + { + None + } else { + preview.as_ref().err().cloned() + }; + let submit_enabled = + preview.is_ok() && matches!(connect_state, StartupSignerConnectState::Idle); + div() .w_full() .flex() @@ -2372,23 +2610,177 @@ fn startup_signer_entry_surface( ), ) }) - .child(action_button_primary( - "home-connect-signer-submit", - app_shared_text(AppTextKey::HomeSetupSignerConnectAction), - on_submit_signer, - cx, - )) + .when_some(preview.as_ref().ok(), |this, preview| { + this.child(startup_home_card( + app_shared_text(AppTextKey::HomeSetupSignerReviewTitle), + label_value_list([ + LabelValueRow::new( + app_shared_text(AppTextKey::HomeSetupSignerSourceLabel), + preview.source_label.clone(), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::HomeSetupSignerSignerLabel), + preview.signer_npub.clone(), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::HomeSetupSignerRelaysLabel), + preview.relays_label.clone(), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::HomeSetupSignerPermissionsLabel), + preview.permissions_label.clone(), + ), + ]), + )) + }) + .when_some(startup_signer_status_spec(connect_state), |this, status| { + this.child(startup_home_card( + app_shared_text(status.0), + status + .1 + .map(|body| startup_home_support_text(body).into_any_element()) + .unwrap_or_else(|| div().into_any_element()), + )) + }) + .when_some(parse_error, |this, error| { + this.child(startup_home_support_text(error)) + }) + .child(if submit_enabled { + action_button_primary( + "home-connect-signer-submit", + app_shared_text(AppTextKey::HomeSetupSignerConnectAction), + on_submit_signer, + cx, + ) + .into_any_element() + } else { + action_button_primary_disabled( + "home-connect-signer-submit", + app_shared_text(AppTextKey::HomeSetupSignerConnectAction), + cx, + ) + .into_any_element() + }) .child(startup_text_button( "home-signer-back", AppTextKey::HomeSetupBackAction, on_back, cx, )) - .when_some(relay_error, |this, error| { - this.child(startup_home_support_text(error.to_owned())) + .when_some(startup_notice, |this, notice| { + this.child(startup_home_support_text(notice.to_owned())) }) } +fn startup_signer_preview_summary(input: &str) -> Result<StartupSignerPreviewSummary, String> { + let target = radroots_app_remote_signer_preview(input).map_err(|error| error.to_string())?; + let requested_permissions = target.requested_permission_labels(); + + Ok(StartupSignerPreviewSummary { + source_label: target.source_label().to_owned(), + signer_npub: target.signer_identity.public_key_npub.clone(), + relays_label: startup_signer_csv_or_none(target.relays.as_slice()), + permissions_label: startup_signer_csv_or_none(requested_permissions.as_slice()), + }) +} + +fn startup_signer_preview_summary_for_connect_state( + input: &str, + connect_state: &StartupSignerConnectState, +) -> Result<StartupSignerPreviewSummary, String> { + let mut preview = startup_signer_preview_summary(input)?; + + match connect_state { + StartupSignerConnectState::Idle | StartupSignerConnectState::Connecting => {} + StartupSignerConnectState::PendingApproval { + pending_session, .. + } => { + preview.signer_npub = pending_session + .record + .signer_identity + .public_key_npub + .clone(); + preview.relays_label = + startup_signer_csv_or_none(pending_session.record.relays.as_slice()); + preview.permissions_label = startup_signer_requested_permissions_label(); + } + StartupSignerConnectState::Approved { + pending_session, + approved_session, + .. + } => { + preview.signer_npub = pending_session + .record + .signer_identity + .public_key_npub + .clone(); + preview.relays_label = startup_signer_csv_or_none(approved_session.relays.as_slice()); + preview.permissions_label = startup_signer_permissions_label( + approved_session + .approved_permissions + .as_slice() + .iter() + .map(ToString::to_string) + .collect(), + ); + } + } + + Ok(preview) +} + +fn startup_signer_csv_or_none(values: &[String]) -> String { + if values.is_empty() { + return "none".to_owned(); + } + + values.join(", ") +} + +fn startup_signer_requested_permissions_label() -> String { + startup_signer_permissions_label( + radroots_app_remote_signer_requested_permissions() + .as_slice() + .iter() + .map(ToString::to_string) + .collect(), + ) +} + +fn startup_signer_permissions_label(permissions: Vec<String>) -> String { + startup_signer_csv_or_none(permissions.as_slice()) +} + +fn startup_signer_status_spec( + connect_state: &StartupSignerConnectState, +) -> Option<(AppTextKey, Option<String>)> { + match connect_state { + StartupSignerConnectState::Idle => None, + StartupSignerConnectState::Connecting => { + Some((AppTextKey::HomeSetupSignerConnectingTitle, None)) + } + StartupSignerConnectState::PendingApproval { + auth_challenge_url, .. + } => Some(match auth_challenge_url { + Some(url) => ( + AppTextKey::HomeSetupSignerAuthChallengeTitle, + Some(url.clone()), + ), + None => (AppTextKey::HomeSetupSignerPendingTitle, None), + }), + StartupSignerConnectState::Approved { + auth_challenge_url, .. + } => Some(( + AppTextKey::HomeSetupSignerApprovedTitle, + auth_challenge_url.clone(), + )), + } +} + +fn startup_signer_transport_failure_requires_notice(message: &str) -> bool { + message != "remote signer did not respond yet" +} + fn startup_text_button( id: &'static str, key: AppTextKey, @@ -2477,6 +2869,35 @@ async fn run_startup_app_init(relay_url: String) -> Result<StartupAppInitResult, Ok(StartupAppInitResult { relay_client }) } +async fn run_startup_signer_connect( + source_input: String, +) -> Result<RadrootsAppRemoteSignerPendingSession, String> { + radroots_app_remote_signer_connect_pending(source_input.as_str()) + .map_err(|error| error.to_string()) +} + +async fn run_startup_signer_pending_poll( + record: radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord, + client_secret_key_hex: String, +) -> StartupSignerPollCycleResult { + let mut auth_challenge_url = None; + let outcome = radroots_app_remote_signer_poll_pending_session_with_progress( + &record, + client_secret_key_hex.as_str(), + |progress| match progress { + radroots_app_remote_signer::RadrootsAppRemoteSignerProgressUpdate::AuthChallenge { + url, + } => auth_challenge_url = Some(url), + }, + ) + .map_err(|error| error.to_string()); + + StartupSignerPollCycleResult { + auth_challenge_url, + outcome, + } +} + fn home_shell_frame(sidebar: AnyElement, main_content: AnyElement) -> impl IntoElement { app_window_shell( APP_UI_THEME.surfaces.window_background, @@ -4458,10 +4879,12 @@ fn home_farm_order_method_label_key(method: FarmOrderMethod) -> AppTextKey { #[cfg(test)] mod tests { use super::{ - AppTextKey, FarmerHomeFarmState, StartupHomeSurface, farm_setup_onboarding_card_spec, - farmer_home_farm_state, home_saved_farm, home_window_launch_size_px, - home_window_minimum_size_px, parse_optional_product_editor_stock_input, - parse_product_editor_price_input, product_display_title, startup_home_surface, + AppTextKey, FarmerHomeFarmState, StartupHomeSurface, StartupSignerConnectState, + farm_setup_onboarding_card_spec, farmer_home_farm_state, home_saved_farm, + home_window_launch_size_px, home_window_minimum_size_px, + parse_optional_product_editor_stock_input, parse_product_editor_price_input, + product_display_title, startup_home_surface, startup_signer_preview_summary, + startup_signer_status_spec, startup_signer_transport_failure_requires_notice, }; use crate::runtime::DesktopAppRuntimeSummary; use radroots_app_models::SettingsAccountProjection; @@ -4470,8 +4893,13 @@ mod tests { FarmSetupProjection, FarmSummary, LoggedOutStartupPhase, LoggedOutStartupProjection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; + use radroots_app_remote_signer::{ + RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, + RadrootsAppRemoteSignerSessionRecord, + }; use radroots_app_state::AppShellProjection; use radroots_app_state::HomeRoute; + use radroots_identity::RadrootsIdentity; #[test] fn farm_setup_onboarding_uses_frozen_copy_and_primary_action() { @@ -4661,6 +5089,74 @@ mod tests { assert_eq!(product_display_title("Salad mix"), "Salad mix"); } + #[test] + fn startup_signer_preview_summary_surfaces_parsed_signer_details() { + let preview = startup_signer_preview_summary( + "bunker://466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27?relay=wss%3A%2F%2Frelay.radroots.example", + ) + .expect("preview"); + + assert_eq!(preview.source_label, "bunker uri"); + assert!(preview.signer_npub.starts_with("npub1")); + assert_eq!(preview.relays_label, "wss://relay.radroots.example"); + assert_eq!( + preview.permissions_label, + "sign_event:kind:1, switch_relays" + ); + } + + #[test] + fn startup_signer_status_prefers_auth_challenge_until_approval_is_complete() { + let pending_session = fixture_pending_session(); + + assert_eq!( + startup_signer_status_spec(&StartupSignerConnectState::Connecting), + Some((AppTextKey::HomeSetupSignerConnectingTitle, None)) + ); + assert_eq!( + startup_signer_status_spec(&StartupSignerConnectState::PendingApproval { + pending_session: pending_session.clone(), + auth_challenge_url: None, + }), + Some((AppTextKey::HomeSetupSignerPendingTitle, None)) + ); + assert_eq!( + startup_signer_status_spec(&StartupSignerConnectState::PendingApproval { + pending_session: pending_session.clone(), + auth_challenge_url: Some("https://auth.example/challenge".to_owned()), + }), + Some(( + AppTextKey::HomeSetupSignerAuthChallengeTitle, + Some("https://auth.example/challenge".to_owned()), + )) + ); + assert_eq!( + startup_signer_status_spec(&StartupSignerConnectState::Approved { + pending_session, + approved_session: RadrootsAppRemoteSignerApprovedSession { + user_identity: fixture_identity( + "2222222222222222222222222222222222222222222222222222222222222222", + ) + .to_public(), + relays: vec!["wss://relay.radroots.example".to_owned()], + approved_permissions: Default::default(), + }, + auth_challenge_url: None, + }), + Some((AppTextKey::HomeSetupSignerApprovedTitle, None)) + ); + } + + #[test] + fn startup_signer_transport_failure_notice_ignores_the_waiting_timeout_copy() { + assert!(!startup_signer_transport_failure_requires_notice( + "remote signer did not respond yet" + )); + assert!(startup_signer_transport_failure_requires_notice( + "remote signer connection failed: relay refused the request" + )); + } + fn summary( home_route: HomeRoute, today_projection: TodayAgendaProjection, @@ -4694,4 +5190,24 @@ mod tests { ) } } + + fn fixture_identity(secret_key_hex: &str) -> RadrootsIdentity { + RadrootsIdentity::from_secret_key_str(secret_key_hex).expect("identity") + } + + fn fixture_pending_session() -> RadrootsAppRemoteSignerPendingSession { + let signer_identity = + fixture_identity("1111111111111111111111111111111111111111111111111111111111111111"); + let client_identity = + fixture_identity("3333333333333333333333333333333333333333333333333333333333333333"); + + RadrootsAppRemoteSignerPendingSession { + record: RadrootsAppRemoteSignerSessionRecord::pending( + client_identity.to_public(), + signer_identity.to_public(), + vec!["wss://relay.radroots.example".to_owned()], + ), + client_secret_key_hex: client_identity.secret_key_hex(), + } + } } diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -51,6 +51,15 @@ define_app_text_keys! { HomeSetupSignerSourcePlaceholder => "home.setup.signer_source.placeholder", HomeSetupSignerConnectAction => "home.setup.signer_connect_action", HomeSetupBackAction => "home.setup.back_action", + HomeSetupSignerReviewTitle => "home.setup.signer.review_title", + HomeSetupSignerSourceLabel => "home.setup.signer.source_label", + HomeSetupSignerSignerLabel => "home.setup.signer.signer_label", + HomeSetupSignerRelaysLabel => "home.setup.signer.relays_label", + HomeSetupSignerPermissionsLabel => "home.setup.signer.permissions_label", + HomeSetupSignerConnectingTitle => "home.setup.signer.connecting_title", + HomeSetupSignerPendingTitle => "home.setup.signer.pending_title", + HomeSetupSignerAuthChallengeTitle => "home.setup.signer.auth_challenge_title", + HomeSetupSignerApprovedTitle => "home.setup.signer.approved_title", HomeFarmSetupOnboardingTitle => "home.farm_setup.onboarding.title", HomeFarmSetupOnboardingBody => "home.farm_setup.onboarding.body", HomeFarmSetupOnboardingAction => "home.farm_setup.onboarding.action", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -178,6 +178,15 @@ mod tests { "HomeSetupSignerSourcePlaceholder => \"home.setup.signer_source.placeholder\"", "HomeSetupSignerConnectAction => \"home.setup.signer_connect_action\"", "HomeSetupBackAction => \"home.setup.back_action\"", + "HomeSetupSignerReviewTitle => \"home.setup.signer.review_title\"", + "HomeSetupSignerSourceLabel => \"home.setup.signer.source_label\"", + "HomeSetupSignerSignerLabel => \"home.setup.signer.signer_label\"", + "HomeSetupSignerRelaysLabel => \"home.setup.signer.relays_label\"", + "HomeSetupSignerPermissionsLabel => \"home.setup.signer.permissions_label\"", + "HomeSetupSignerConnectingTitle => \"home.setup.signer.connecting_title\"", + "HomeSetupSignerPendingTitle => \"home.setup.signer.pending_title\"", + "HomeSetupSignerAuthChallengeTitle => \"home.setup.signer.auth_challenge_title\"", + "HomeSetupSignerApprovedTitle => \"home.setup.signer.approved_title\"", ] { assert!( source.contains(entry), @@ -206,6 +215,33 @@ mod tests { "Connect signer" ); assert_eq!(app_text(AppTextKey::HomeSetupBackAction), "Back"); + assert_eq!( + app_text(AppTextKey::HomeSetupSignerReviewTitle), + "Review signer details" + ); + assert_eq!(app_text(AppTextKey::HomeSetupSignerSourceLabel), "Source"); + assert_eq!(app_text(AppTextKey::HomeSetupSignerSignerLabel), "Signer"); + assert_eq!(app_text(AppTextKey::HomeSetupSignerRelaysLabel), "Relays"); + assert_eq!( + app_text(AppTextKey::HomeSetupSignerPermissionsLabel), + "Permissions" + ); + assert_eq!( + app_text(AppTextKey::HomeSetupSignerConnectingTitle), + "Connecting to signer" + ); + assert_eq!( + app_text(AppTextKey::HomeSetupSignerPendingTitle), + "Waiting for signer approval" + ); + assert_eq!( + app_text(AppTextKey::HomeSetupSignerAuthChallengeTitle), + "Continue in your signer" + ); + assert_eq!( + app_text(AppTextKey::HomeSetupSignerApprovedTitle), + "Signer approved" + ); } #[test] diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -30,6 +30,15 @@ "home.setup.signer_source.placeholder": "Paste bunker URI or discovery URL", "home.setup.signer_connect_action": "Connect signer", "home.setup.back_action": "Back", + "home.setup.signer.review_title": "Review signer details", + "home.setup.signer.source_label": "Source", + "home.setup.signer.signer_label": "Signer", + "home.setup.signer.relays_label": "Relays", + "home.setup.signer.permissions_label": "Permissions", + "home.setup.signer.connecting_title": "Connecting to signer", + "home.setup.signer.pending_title": "Waiting for signer approval", + "home.setup.signer.auth_challenge_title": "Continue in your signer", + "home.setup.signer.approved_title": "Signer approved", "home.farm_setup.onboarding.title": "Set up your farm", "home.farm_setup.onboarding.body": "Add the basics now. You can change them later.", "home.farm_setup.onboarding.action": "Set up your farm",