app

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

commit 102573418eda67421bb12fd756fa18d15bfd8896
parent d4e5b040af038be940c6c99d64146c4b85db14c7
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 21:07:43 +0000

app: activate restart-safe remote signer sessions

Diffstat:
Mcrates/launchers/desktop/Cargo.toml | 2++
Mcrates/launchers/desktop/src/accounts.rs | 2+-
Mcrates/launchers/desktop/src/lib.rs | 1+
Acrates/launchers/desktop/src/remote_signer.rs | 558+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/launchers/desktop/src/runtime.rs | 174++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/launchers/desktop/src/window.rs | 170++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
6 files changed, 864 insertions(+), 43 deletions(-)

diff --git a/crates/launchers/desktop/Cargo.toml b/crates/launchers/desktop/Cargo.toml @@ -12,7 +12,9 @@ gpui.workspace = true gpui-component.workspace = true gpui-component-assets.workspace = true radroots_identity.workspace = true +radroots_nostr.workspace = true radroots_nostr_accounts.workspace = true +radroots_protected_store = { path = "../../../../lib/crates/protected_store" } radroots_secret_vault.workspace = true radroots_app_core.workspace = true radroots_app_i18n.workspace = true diff --git a/crates/launchers/desktop/src/accounts.rs b/crates/launchers/desktop/src/accounts.rs @@ -319,7 +319,7 @@ fn blocked_identity_projection_from_store_state( )) } -fn identity_projection_from_manager( +pub(crate) fn identity_projection_from_manager( manager: &RadrootsNostrAccountsManager, sqlite_store: &AppSqliteStore, ) -> Result<AppIdentityProjection, DesktopAccountsProjectionError> { diff --git a/crates/launchers/desktop/src/lib.rs b/crates/launchers/desktop/src/lib.rs @@ -3,6 +3,7 @@ mod accounts; mod app; mod menus; +mod remote_signer; mod runtime; #[cfg(test)] mod source_guards; diff --git a/crates/launchers/desktop/src/remote_signer.rs b/crates/launchers/desktop/src/remote_signer.rs @@ -0,0 +1,558 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use radroots_app_core::AppDesktopRuntimePaths; +use radroots_app_models::{AccountCustody, AppIdentityProjection}; +use radroots_app_remote_signer::{ + RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerError, + RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, + RadrootsAppRemoteSignerSessionStatus, RadrootsAppRemoteSignerSessionStoreLoadResult, + RadrootsAppRemoteSignerSessionStoreState, +}; +use radroots_identity::{IdentityError, RadrootsIdentityId}; +use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountRecord, RadrootsNostrAccountsError, RadrootsNostrAccountsManager, + account_secret_slot, +}; +use radroots_protected_store::RadrootsProtectedFileSecretVault; +use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultAccessError}; +use thiserror::Error; + +const REMOTE_SIGNER_LABEL: &str = "remote signer"; +const REMOTE_SIGNER_SESSIONS_FILE_NAME: &str = "remote-signer-sessions.json"; +const REMOTE_SIGNER_SESSIONS_DIR_NAME: &str = "nostr"; +const REMOTE_SIGNER_CLIENT_SECRET_DIR_NAME: &str = "remote_signer"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DesktopRemoteSignerPaths { + pub(crate) sessions_path: PathBuf, + pub(crate) client_secret_root: PathBuf, +} + +impl DesktopRemoteSignerPaths { + pub(crate) fn from_runtime_paths(paths: &AppDesktopRuntimePaths) -> Self { + Self { + sessions_path: paths + .app + .data + .join(REMOTE_SIGNER_SESSIONS_DIR_NAME) + .join(REMOTE_SIGNER_SESSIONS_FILE_NAME), + client_secret_root: paths + .shared_accounts + .secrets_root + .join(REMOTE_SIGNER_CLIENT_SECRET_DIR_NAME), + } + } +} + +#[derive(Debug, Error)] +pub(crate) enum DesktopRemoteSignerError { + #[error(transparent)] + Accounts(#[from] RadrootsNostrAccountsError), + #[error(transparent)] + Identity(#[from] IdentityError), + #[error(transparent)] + SessionStore(#[from] RadrootsAppRemoteSignerError), + #[error(transparent)] + SecretVault(#[from] RadrootsSecretVaultAccessError), + #[error("{0}")] + State(String), +} + +pub(crate) fn reconcile_startup( + manager: &RadrootsNostrAccountsManager, + paths: &DesktopRemoteSignerPaths, +) -> Result<(), DesktopRemoteSignerError> { + let load = load_sessions_with_recovery(paths)?; + let mut state = load.state; + let mut dirty = false; + let accounts = manager.list_accounts()?; + let account_ids = accounts + .iter() + .map(|record| record.account_id.to_string()) + .collect::<HashSet<_>>(); + let active_session_account_ids = state + .sessions + .iter() + .filter(|record| record.status == RadrootsAppRemoteSignerSessionStatus::Active) + .filter_map(|record| record.account_id().map(ToOwned::to_owned)) + .collect::<HashSet<_>>(); + + if load.recovered_from_corruption || state.sessions.is_empty() { + purge_client_secret_namespace(paths)?; + } + + for account in remote_signer_public_only_accounts(manager, &accounts)? + .into_iter() + .filter(|account| !active_session_account_ids.contains(account.account_id.as_str())) + { + manager.remove_account(&account.account_id)?; + } + + if let Some(record) = state.pending_session().cloned() + && load_client_secret(paths, record.client_account_id()).is_err() + { + state.remove_pending_session(); + dirty = true; + } + + let stale_active_sessions = state + .sessions + .iter() + .filter(|record| record.status == RadrootsAppRemoteSignerSessionStatus::Active) + .filter_map(|record| { + let account_id = record.account_id()?; + (!account_ids.contains(account_id)).then_some(record.clone()) + }) + .collect::<Vec<_>>(); + + for session in stale_active_sessions { + remove_client_secret(paths, session.client_account_id())?; + let Some(account_id) = session.account_id() else { + continue; + }; + state.remove_active_session_for_account_id(account_id); + dirty = true; + } + + if dirty || load.recovered_from_corruption { + save_sessions(paths, &state)?; + } + + Ok(()) +} + +pub(crate) fn store_pending_session( + paths: &DesktopRemoteSignerPaths, + pending: &RadrootsAppRemoteSignerPendingSession, +) -> Result<(), DesktopRemoteSignerError> { + let client_account_id = pending.record.client_account_id().to_owned(); + store_client_secret( + paths, + client_account_id.as_str(), + pending.client_secret_key_hex.as_str(), + )?; + + let mut state = load_sessions(paths)?; + if let Err(error) = state.upsert_pending(pending.record.clone()) { + let _ = remove_client_secret(paths, client_account_id.as_str()); + return Err(error.into()); + } + if let Err(error) = save_sessions(paths, &state) { + let _ = remove_client_secret(paths, client_account_id.as_str()); + return Err(error); + } + + Ok(()) +} + +pub(crate) fn load_pending_session( + paths: &DesktopRemoteSignerPaths, +) -> Result<Option<RadrootsAppRemoteSignerPendingSession>, DesktopRemoteSignerError> { + let state = load_sessions(paths)?; + let Some(record) = state.pending_session().cloned() else { + return Ok(None); + }; + let client_secret_key_hex = load_client_secret(paths, record.client_account_id())?; + Ok(Some(RadrootsAppRemoteSignerPendingSession { + record, + client_secret_key_hex, + })) +} + +pub(crate) fn clear_pending_session( + paths: &DesktopRemoteSignerPaths, +) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, DesktopRemoteSignerError> { + let state = load_sessions(paths)?; + let Some(record) = state.pending_session().cloned() else { + return Ok(None); + }; + let mut next_state = state.clone(); + let removed = next_state.remove_pending_session(); + if removed.is_none() { + return Err(DesktopRemoteSignerError::State( + "remote signer pending session record cleanup could not complete".to_owned(), + )); + } + save_sessions(paths, &next_state)?; + + if let Err(error) = remove_client_secret(paths, record.client_account_id()) { + return Err(DesktopRemoteSignerError::State(format!( + "remote signer pending session record was removed but session secret cleanup needs retry: {error}" + ))); + } + + Ok(removed) +} + +pub(crate) fn activate_pending_session( + manager: &RadrootsNostrAccountsManager, + paths: &DesktopRemoteSignerPaths, + client_account_id: &str, + approved: &RadrootsAppRemoteSignerApprovedSession, +) -> Result<(), DesktopRemoteSignerError> { + manager.upsert_public_identity( + approved.user_identity.clone(), + Some(REMOTE_SIGNER_LABEL.to_owned()), + true, + )?; + + let activation_result = (|| -> Result<(), DesktopRemoteSignerError> { + let mut state = load_sessions(paths)?; + state + .activate_session( + client_account_id, + approved.user_identity.clone(), + approved.relays.clone(), + approved.approved_permissions.clone(), + ) + .ok_or_else(|| { + DesktopRemoteSignerError::State( + "pending remote signer session disappeared before activation".to_owned(), + ) + })?; + save_sessions(paths, &state) + })(); + + if let Err(error) = activation_result { + if let Err(rollback_error) = manager.remove_account(&approved.user_identity.id) { + return Err(DesktopRemoteSignerError::State(format!( + "{error}. remote signer account rollback needs retry: {rollback_error}" + ))); + } + return Err(error); + } + + Ok(()) +} + +pub(crate) fn purge_all_state( + paths: &DesktopRemoteSignerPaths, +) -> Result<(), DesktopRemoteSignerError> { + let load = load_sessions_with_recovery(paths)?; + for record in &load.state.sessions { + remove_client_secret(paths, record.client_account_id())?; + } + purge_client_secret_namespace(paths)?; + remove_sessions_file_if_present(paths.sessions_path.as_path())?; + Ok(()) +} + +pub(crate) fn apply_remote_signer_custody( + projection: AppIdentityProjection, + paths: &DesktopRemoteSignerPaths, +) -> Result<AppIdentityProjection, DesktopRemoteSignerError> { + let active_account_ids = active_remote_signer_account_ids(paths)?; + if active_account_ids.is_empty() { + return Ok(projection); + } + + let mut projection = projection; + for account in &mut projection.roster { + if active_account_ids.contains(account.account_id.as_str()) { + account.custody = AccountCustody::RemoteSigner; + } + } + if let Some(selected_account) = projection.selected_account.as_mut() + && active_account_ids.contains(selected_account.account.account_id.as_str()) + { + selected_account.account.custody = AccountCustody::RemoteSigner; + } + + Ok(projection) +} + +fn active_remote_signer_account_ids( + paths: &DesktopRemoteSignerPaths, +) -> Result<HashSet<String>, DesktopRemoteSignerError> { + Ok(load_sessions(paths)? + .sessions + .into_iter() + .filter(|record| record.status == RadrootsAppRemoteSignerSessionStatus::Active) + .filter_map(|record| record.account_id().map(ToOwned::to_owned)) + .collect()) +} + +fn remote_signer_public_only_accounts( + manager: &RadrootsNostrAccountsManager, + accounts: &[RadrootsNostrAccountRecord], +) -> Result<Vec<RadrootsNostrAccountRecord>, DesktopRemoteSignerError> { + let mut stale = Vec::new(); + for account in accounts { + if account.label.as_deref() != Some(REMOTE_SIGNER_LABEL) { + continue; + } + if manager.get_signing_identity(&account.account_id)?.is_none() { + stale.push(account.clone()); + } + } + Ok(stale) +} + +fn client_secret_vault(paths: &DesktopRemoteSignerPaths) -> RadrootsProtectedFileSecretVault { + RadrootsProtectedFileSecretVault::new(paths.client_secret_root.as_path()) +} + +fn client_secret_slot(client_account_id: &str) -> Result<String, DesktopRemoteSignerError> { + let account_id = RadrootsIdentityId::parse(client_account_id)?; + Ok(account_secret_slot(&account_id)) +} + +fn store_client_secret( + paths: &DesktopRemoteSignerPaths, + client_account_id: &str, + secret_key_hex: &str, +) -> Result<(), DesktopRemoteSignerError> { + let slot = client_secret_slot(client_account_id)?; + client_secret_vault(paths).store_secret(slot.as_str(), secret_key_hex)?; + Ok(()) +} + +fn load_client_secret( + paths: &DesktopRemoteSignerPaths, + client_account_id: &str, +) -> Result<String, DesktopRemoteSignerError> { + let slot = client_secret_slot(client_account_id)?; + client_secret_vault(paths) + .load_secret(slot.as_str())? + .ok_or_else(|| { + DesktopRemoteSignerError::State("remote signer session secret is missing".to_owned()) + }) +} + +fn remove_client_secret( + paths: &DesktopRemoteSignerPaths, + client_account_id: &str, +) -> Result<(), DesktopRemoteSignerError> { + let slot = client_secret_slot(client_account_id)?; + client_secret_vault(paths).remove_secret(slot.as_str())?; + Ok(()) +} + +fn purge_client_secret_namespace( + paths: &DesktopRemoteSignerPaths, +) -> Result<(), DesktopRemoteSignerError> { + match fs::remove_dir_all(paths.client_secret_root.as_path()) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(DesktopRemoteSignerError::State(format!( + "failed to purge remote signer client secret namespace: {error}" + ))), + } +} + +fn load_sessions( + paths: &DesktopRemoteSignerPaths, +) -> Result<RadrootsAppRemoteSignerSessionStoreState, DesktopRemoteSignerError> { + Ok(RadrootsAppRemoteSignerSessionStoreState::load( + paths.sessions_path.as_path(), + )?) +} + +fn load_sessions_with_recovery( + paths: &DesktopRemoteSignerPaths, +) -> Result<RadrootsAppRemoteSignerSessionStoreLoadResult, DesktopRemoteSignerError> { + Ok( + RadrootsAppRemoteSignerSessionStoreState::load_with_recovery( + paths.sessions_path.as_path(), + )?, + ) +} + +fn save_sessions( + paths: &DesktopRemoteSignerPaths, + state: &RadrootsAppRemoteSignerSessionStoreState, +) -> Result<(), DesktopRemoteSignerError> { + Ok(state.save(paths.sessions_path.as_path())?) +} + +fn remove_sessions_file_if_present(path: &Path) -> Result<(), DesktopRemoteSignerError> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(DesktopRemoteSignerError::State(format!( + "failed to remove remote signer session store: {error}" + ))), + } +} + +#[cfg(test)] +mod tests { + use std::env; + use std::time::{SystemTime, UNIX_EPOCH}; + + use radroots_app_remote_signer::{ + RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, + RadrootsAppRemoteSignerSessionRecord, radroots_app_remote_signer_requested_permissions, + }; + use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; + use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; + + use super::{ + DesktopRemoteSignerPaths, activate_pending_session, apply_remote_signer_custody, + clear_pending_session, load_pending_session, purge_all_state, reconcile_startup, + store_pending_session, + }; + + const CLIENT_SECRET_KEY_HEX: &str = + "1111111111111111111111111111111111111111111111111111111111111111"; + const SIGNER_SECRET_KEY_HEX: &str = + "2222222222222222222222222222222222222222222222222222222222222222"; + const USER_SECRET_KEY_HEX: &str = + "3333333333333333333333333333333333333333333333333333333333333333"; + + fn public_identity(secret_key_hex: &str) -> RadrootsIdentityPublic { + RadrootsIdentity::from_secret_key_str(secret_key_hex) + .expect("identity") + .to_public() + } + + fn temp_paths(label: &str) -> DesktopRemoteSignerPaths { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let root = env::temp_dir() + .join("radroots-app-desktop-remote-signer") + .join(format!("{label}-{unique}")); + DesktopRemoteSignerPaths { + sessions_path: root.join("data").join("remote-signer-sessions.json"), + client_secret_root: root.join("secrets").join("remote_signer"), + } + } + + fn pending_session() -> RadrootsAppRemoteSignerPendingSession { + RadrootsAppRemoteSignerPendingSession { + record: RadrootsAppRemoteSignerSessionRecord::pending( + public_identity(CLIENT_SECRET_KEY_HEX), + public_identity(SIGNER_SECRET_KEY_HEX), + vec!["ws://127.0.0.1:8080".to_owned()], + ), + client_secret_key_hex: CLIENT_SECRET_KEY_HEX.to_owned(), + } + } + + #[test] + fn pending_session_round_trips_with_client_secret() { + let paths = temp_paths("pending"); + let pending = pending_session(); + + store_pending_session(&paths, &pending).expect("store pending"); + let restored = load_pending_session(&paths) + .expect("load pending") + .expect("pending session"); + + assert_eq!( + restored.record.client_account_id(), + pending.record.client_account_id() + ); + assert_eq!( + restored.record.signer_identity.id, + pending.record.signer_identity.id + ); + assert_eq!(restored.record.relays, pending.record.relays); + assert_eq!(restored.record.status, pending.record.status); + assert_eq!( + restored.client_secret_key_hex, + pending.client_secret_key_hex + ); + + clear_pending_session(&paths).expect("clear pending"); + assert!( + load_pending_session(&paths) + .expect("load after clear") + .is_none() + ); + } + + #[test] + fn activating_pending_session_upserts_selected_remote_signer_account() { + let paths = temp_paths("activate"); + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let pending = pending_session(); + let approved = RadrootsAppRemoteSignerApprovedSession { + user_identity: public_identity(USER_SECRET_KEY_HEX), + relays: vec!["ws://127.0.0.1:8080".to_owned()], + approved_permissions: radroots_app_remote_signer_requested_permissions(), + }; + + store_pending_session(&paths, &pending).expect("store pending"); + activate_pending_session( + &manager, + &paths, + pending.record.client_account_id(), + &approved, + ) + .expect("activate pending"); + + let selected = manager + .selected_account() + .expect("selected account") + .expect("configured account"); + assert_eq!( + selected.account_id.as_str(), + approved.user_identity.id.as_str() + ); + assert_eq!(selected.label.as_deref(), Some("remote signer")); + + let projection = apply_remote_signer_custody( + radroots_app_models::AppIdentityProjection::ready( + vec![radroots_app_models::AccountSummary { + account_id: approved.user_identity.id.to_string(), + npub: approved.user_identity.public_key_npub.clone(), + label: Some("remote signer".to_owned()), + custody: radroots_app_models::AccountCustody::LocalManaged, + }], + radroots_app_models::SelectedAccountProjection::new( + radroots_app_models::AccountSummary { + account_id: approved.user_identity.id.to_string(), + npub: approved.user_identity.public_key_npub.clone(), + label: Some("remote signer".to_owned()), + custody: radroots_app_models::AccountCustody::LocalManaged, + }, + radroots_app_models::SelectedSurfaceProjection::default(), + radroots_app_models::FarmerActivationProjection::inactive(), + ), + ), + &paths, + ) + .expect("decorate projection"); + assert_eq!( + projection + .selected_account + .as_ref() + .expect("selected") + .account + .custody, + radroots_app_models::AccountCustody::RemoteSigner + ); + } + + #[test] + fn reconcile_startup_removes_orphan_remote_signer_account_and_pending_without_secret() { + let paths = temp_paths("reconcile"); + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let pending = pending_session(); + store_pending_session(&paths, &pending).expect("store pending"); + clear_pending_session(&paths).expect("clear pending"); + store_pending_session(&paths, &pending).expect("store pending again"); + manager + .upsert_public_identity( + public_identity(USER_SECRET_KEY_HEX), + Some("remote signer".to_owned()), + true, + ) + .expect("upsert remote signer account"); + + purge_all_state(&paths).expect("purge all"); + reconcile_startup(&manager, &paths).expect("reconcile startup"); + + assert!(manager.list_accounts().expect("accounts").is_empty()); + assert!( + load_pending_session(&paths) + .expect("load pending") + .is_none() + ); + } +} diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -9,6 +9,9 @@ use radroots_app_models::{ ProductsListProjection, ProductsSort, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, }; +use radroots_app_remote_signer::{ + RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, +}; use radroots_app_sqlite::{ APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget, }; @@ -21,10 +24,15 @@ use thiserror::Error; use tracing::error; use crate::accounts::{ - DesktopAccountsBootstrapError, DesktopAccountsCommandError, DesktopLocalIdentityImportRequest, - bootstrap_desktop_accounts, generate_local_account, import_local_account, - remove_selected_local_key, reset_local_device_state, select_active_surface, - select_local_account, + DesktopAccountsBootstrapError, DesktopAccountsCommandError, DesktopAccountsProjectionError, + DesktopLocalIdentityImportRequest, bootstrap_desktop_accounts, generate_local_account, + identity_projection_from_manager, import_local_account, remove_selected_local_key, + reset_local_device_state, select_active_surface, select_local_account, +}; +use crate::remote_signer::{ + DesktopRemoteSignerError, DesktopRemoteSignerPaths, activate_pending_session, + apply_remote_signer_custody, clear_pending_session, load_pending_session, purge_all_state, + reconcile_startup, store_pending_session, }; const APP_DATABASE_FILE_NAME: &str = "app.sqlite3"; @@ -102,6 +110,37 @@ impl DesktopAppRuntime { ) } + pub fn load_startup_pending_remote_signer_session( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerPendingSession>, DesktopAppRuntimeCommandError> { + self.lock_state() + .load_startup_pending_remote_signer_session() + } + + pub fn store_startup_pending_remote_signer_session( + &self, + pending: &RadrootsAppRemoteSignerPendingSession, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + self.lock_state_mut() + .store_startup_pending_remote_signer_session(pending) + } + + pub fn clear_startup_pending_remote_signer_session( + &self, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + self.lock_state_mut() + .clear_startup_pending_remote_signer_session() + } + + pub fn activate_startup_approved_remote_signer_session( + &self, + pending: &RadrootsAppRemoteSignerPendingSession, + approved: &RadrootsAppRemoteSignerApprovedSession, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + self.lock_state_mut() + .activate_startup_approved_remote_signer_session(pending, approved) + } + pub fn select_settings_section(&self, section: SettingsSection) -> bool { let changed = self.sync_settings_section(section); @@ -348,6 +387,7 @@ struct DesktopAppRuntimeState { state_store: AppStateStore<InMemoryAppStateRepository>, default_nostr_relay_url: String, shared_accounts_paths: Option<AppSharedAccountsPaths>, + remote_signer_paths: Option<DesktopRemoteSignerPaths>, accounts_manager: Option<RadrootsNostrAccountsManager>, sqlite_store: Option<AppSqliteStore>, startup_issue: Option<String>, @@ -363,6 +403,10 @@ impl fmt::Debug for DesktopAppRuntimeState { &self.shared_accounts_paths.as_ref().map(|_| "available"), ) .field( + "remote_signer_paths", + &self.remote_signer_paths.as_ref().map(|_| "available"), + ) + .field( "accounts_manager", &self.accounts_manager.as_ref().map(|_| "available"), ) @@ -380,18 +424,44 @@ impl DesktopAppRuntimeState { default_nostr_relay_url: String, ) -> Result<Self, DesktopAppRuntimeBootstrapError> { let paths = AppDesktopRuntimePaths::current_desktop()?; + Self::bootstrap_from_paths(paths, default_nostr_relay_url) + } + + fn bootstrap_from_paths( + paths: AppDesktopRuntimePaths, + default_nostr_relay_url: String, + ) -> Result<Self, DesktopAppRuntimeBootstrapError> { let database_path = paths.app.data.join(APP_DATABASE_FILE_NAME); let sqlite_store = AppSqliteStore::open(DatabaseTarget::Path(database_path.clone()))?; let mut state_store = AppStateStore::load(InMemoryAppStateRepository::default())?; + let remote_signer_paths = DesktopRemoteSignerPaths::from_runtime_paths(&paths); let accounts_bootstrap = bootstrap_desktop_accounts(&paths.shared_accounts, &sqlite_store)?; + if let Some(accounts_manager) = accounts_bootstrap.accounts_manager.as_ref() { + reconcile_startup(accounts_manager, &remote_signer_paths)?; + } + let identity_projection = apply_remote_signer_custody( + identity_projection_from_manager( + accounts_bootstrap + .accounts_manager + .as_ref() + .expect("desktop bootstrap always returns an accounts manager"), + &sqlite_store, + )?, + &remote_signer_paths, + )?; let selected_account_context = load_selected_account_context( &sqlite_store, - &accounts_bootstrap.identity_projection, + &identity_projection, state_store.products_projection().query.clone(), )?; let _ = state_store.apply_in_memory(AppStateCommand::replace_identity_projection( - accounts_bootstrap.identity_projection, + identity_projection.clone(), )); + if identity_projection.startup_gate() == AppStartupGate::SetupRequired + && load_pending_session(&remote_signer_paths)?.is_some() + { + let _ = state_store.apply_in_memory(AppStateCommand::show_startup_signer_entry()); + } let _ = state_store.apply_in_memory(AppStateCommand::replace_farm_setup_projection( selected_account_context.farm_setup_projection, )); @@ -406,6 +476,7 @@ impl DesktopAppRuntimeState { state_store, default_nostr_relay_url, shared_accounts_paths: Some(paths.shared_accounts.clone()), + remote_signer_paths: Some(remote_signer_paths), accounts_manager: accounts_bootstrap.accounts_manager, sqlite_store: Some(sqlite_store), startup_issue: None, @@ -417,6 +488,7 @@ impl DesktopAppRuntimeState { state_store: AppStateStore::in_memory(AppShellProjection::default()), default_nostr_relay_url: String::new(), shared_accounts_paths: None, + remote_signer_paths: None, accounts_manager: None, sqlite_store: None, startup_issue: Some(error.to_string()), @@ -486,6 +558,9 @@ impl DesktopAppRuntimeState { } fn reset_local_device_state(&mut self) -> Result<bool, DesktopAppRuntimeCommandError> { + if let Some(paths) = self.remote_signer_paths.as_ref() { + purge_all_state(paths)?; + } let projection = { let accounts_manager = self.accounts_manager()?; let sqlite_store = self.sqlite_store()?; @@ -760,6 +835,7 @@ impl DesktopAppRuntimeState { &mut self, projection: AppIdentityProjection, ) -> Result<bool, DesktopAppRuntimeCommandError> { + let projection = self.decorate_identity_projection(projection)?; let selected_account_context = load_selected_account_context( self.sqlite_store()?, &projection, @@ -935,6 +1011,74 @@ impl DesktopAppRuntimeState { .ok_or(DesktopAppRuntimeCommandError::RuntimeUnavailable) } + fn remote_signer_paths(&self) -> Option<&DesktopRemoteSignerPaths> { + self.remote_signer_paths.as_ref() + } + + fn load_startup_pending_remote_signer_session( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerPendingSession>, DesktopAppRuntimeCommandError> { + let Some(paths) = self.remote_signer_paths() else { + return Ok(None); + }; + Ok(load_pending_session(paths)?) + } + + fn store_startup_pending_remote_signer_session( + &mut self, + pending: &RadrootsAppRemoteSignerPendingSession, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + let Some(paths) = self.remote_signer_paths() else { + return Err(DesktopAppRuntimeCommandError::RuntimeUnavailable); + }; + store_pending_session(paths, pending)?; + Ok(true) + } + + fn clear_startup_pending_remote_signer_session( + &mut self, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + let Some(paths) = self.remote_signer_paths() else { + return Ok(false); + }; + Ok(clear_pending_session(paths)?.is_some()) + } + + fn activate_startup_approved_remote_signer_session( + &mut self, + pending: &RadrootsAppRemoteSignerPendingSession, + approved: &RadrootsAppRemoteSignerApprovedSession, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + let Some(paths) = self.remote_signer_paths() else { + return Err(DesktopAppRuntimeCommandError::RuntimeUnavailable); + }; + { + let accounts_manager = self.accounts_manager()?; + activate_pending_session( + accounts_manager, + paths, + pending.record.client_account_id(), + approved, + )?; + } + let projection = { + let accounts_manager = self.accounts_manager()?; + let sqlite_store = self.sqlite_store()?; + identity_projection_from_manager(accounts_manager, sqlite_store)? + }; + self.replace_identity_projection(projection) + } + + fn decorate_identity_projection( + &self, + projection: AppIdentityProjection, + ) -> Result<AppIdentityProjection, DesktopAppRuntimeCommandError> { + let Some(paths) = self.remote_signer_paths() else { + return Ok(projection); + }; + Ok(apply_remote_signer_custody(projection, paths)?) + } + fn command_unavailable_error(&self) -> DesktopAppRuntimeCommandError { let _ = self; DesktopAppRuntimeCommandError::RuntimeUnavailable @@ -948,6 +1092,10 @@ pub enum DesktopAppRuntimeCommandError { #[error(transparent)] Accounts(#[from] DesktopAccountsCommandError), #[error(transparent)] + Projection(#[from] DesktopAccountsProjectionError), + #[error(transparent)] + RemoteSigner(#[from] DesktopRemoteSignerError), + #[error(transparent)] Sqlite(#[from] AppSqliteError), } @@ -970,6 +1118,10 @@ enum DesktopAppRuntimeBootstrapError { #[error(transparent)] Accounts(#[from] DesktopAccountsBootstrapError), #[error(transparent)] + Projection(#[from] DesktopAccountsProjectionError), + #[error(transparent)] + RemoteSigner(#[from] DesktopRemoteSignerError), + #[error(transparent)] Sqlite(#[from] AppSqliteError), #[error(transparent)] State(#[from] AppStateStoreError), @@ -1102,6 +1254,7 @@ mod tests { .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, + remote_signer_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -1151,6 +1304,7 @@ mod tests { .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, + remote_signer_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -1186,6 +1340,7 @@ mod tests { .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, + remote_signer_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -1278,6 +1433,7 @@ mod tests { .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, + remote_signer_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -1326,6 +1482,7 @@ mod tests { .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, + remote_signer_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -1358,6 +1515,7 @@ mod tests { .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, + remote_signer_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -1388,6 +1546,7 @@ mod tests { .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, + remote_signer_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -2335,6 +2494,7 @@ mod tests { .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: Some(paths), + remote_signer_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -2359,6 +2519,7 @@ mod tests { .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: None, + remote_signer_paths: None, accounts_manager: Some( RadrootsNostrAccountsManager::new( Arc::new(RadrootsNostrMemoryAccountStore::new()), @@ -2385,6 +2546,7 @@ mod tests { .expect("in-memory state store should load"), default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), shared_accounts_paths: Some(paths.clone()), + remote_signer_paths: None, accounts_manager: Some( RadrootsNostrAccountsManager::new( Arc::new(RadrootsNostrFileAccountStore::new( diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -164,6 +164,7 @@ pub struct HomeView { startup_signer_entry: Option<StartupSignerEntryState>, startup_signer_connect_state: StartupSignerConnectState, startup_signer_task_token: u64, + startup_signer_recovery_attempted: bool, logged_in_view: LoggedInHomeView, farm_setup_form: Option<FarmSetupFormState>, products_search: Option<ProductsSearchState>, @@ -209,6 +210,7 @@ impl HomeView { startup_signer_entry: None, startup_signer_connect_state: StartupSignerConnectState::Idle, startup_signer_task_token: 0, + startup_signer_recovery_attempted: false, logged_in_view: LoggedInHomeView::new(), farm_setup_form: None, products_search: None, @@ -243,8 +245,16 @@ impl HomeView { } fn show_startup_identity_choice(&mut self, cx: &mut Context<Self>) { + if !matches!( + self.startup_signer_connect_state, + StartupSignerConnectState::Idle + ) && !self.clear_startup_pending_remote_signer_session(cx) + { + return; + } self.startup_view.clear_notice(); self.reset_startup_signer_flow(); + self.startup_signer_recovery_attempted = false; if self.runtime.show_startup_identity_choice() { cx.notify(); } @@ -253,18 +263,23 @@ impl HomeView { fn show_startup_signer_entry(&mut self, cx: &mut Context<Self>) { self.startup_view.clear_notice(); self.reset_startup_signer_flow(); + self.startup_signer_recovery_attempted = false; if self.runtime.show_startup_signer_entry() { cx.notify(); } } fn start_generate_key(&mut self, window: &mut Window, cx: &mut Context<Self>) { + if !self.clear_startup_pending_remote_signer_session(cx) { + return; + } if !self.runtime.begin_generate_key_startup() { return; } self.startup_view.clear_notice(); self.reset_startup_signer_flow(); + self.startup_signer_recovery_attempted = false; let relay_url = self.runtime.default_nostr_relay_url(); cx.notify(); cx.spawn_in(window, async move |this, cx| { @@ -318,6 +333,7 @@ impl HomeView { { self.reset_startup_signer_flow(); } + self.startup_signer_recovery_attempted = false; self.startup_signer_entry = None; return; } @@ -335,6 +351,11 @@ impl HomeView { Some(StartupSignerEntryState::new(source_input, window, cx)); } } + + if !self.startup_signer_recovery_attempted { + self.startup_signer_recovery_attempted = true; + self.restore_startup_pending_remote_signer_session(window, cx); + } } fn submit_startup_signer(&mut self, window: &mut Window, cx: &mut Context<Self>) { @@ -371,32 +392,9 @@ impl HomeView { 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; - } + let _ = this.update_in(cx, |this, window, cx| { + this.spawn_startup_signer_pending_poll(window, task_token, pending_session, cx); + }); }) .detach(); } @@ -413,6 +411,15 @@ impl HomeView { match connect_result { Ok(pending_session) => { + if let Err(error) = self + .runtime + .store_startup_pending_remote_signer_session(&pending_session) + { + self.startup_signer_connect_state = StartupSignerConnectState::Idle; + self.startup_view.set_notice(error.to_string()); + cx.notify(); + return None; + } self.startup_view.clear_notice(); self.startup_signer_connect_state = StartupSignerConnectState::PendingApproval { pending_session: pending_session.clone(), @@ -465,19 +472,37 @@ impl HomeView { 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::Approved(approved_session)) => match self + .runtime + .activate_startup_approved_remote_signer_session( + &pending_session, + &approved_session, + ) { + Ok(_) => { + self.startup_view.clear_notice(); + self.startup_signer_connect_state = StartupSignerConnectState::Approved { + pending_session, + approved_session, + auth_challenge_url, + }; + cx.notify(); + false + } + Err(error) => { + self.startup_view.set_notice(error.to_string()); + self.startup_signer_connect_state = + StartupSignerConnectState::PendingApproval { + pending_session, + auth_challenge_url, + }; + cx.notify(); + false + } + }, Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) | Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) | Err(message) => { + let _ = self.runtime.clear_startup_pending_remote_signer_session(); self.startup_signer_connect_state = StartupSignerConnectState::Idle; self.startup_view.set_notice(message); cx.notify(); @@ -486,6 +511,79 @@ impl HomeView { } } + fn spawn_startup_signer_pending_poll( + &mut self, + window: &mut Window, + task_token: u64, + pending_session: RadrootsAppRemoteSignerPendingSession, + cx: &mut Context<Self>, + ) { + cx.spawn_in(window, async move |this, cx| { + 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 restore_startup_pending_remote_signer_session( + &mut self, + window: &mut Window, + cx: &mut Context<Self>, + ) { + let pending_session = match self.runtime.load_startup_pending_remote_signer_session() { + Ok(Some(pending_session)) => pending_session, + Ok(None) => return, + Err(error) => { + self.startup_view.set_notice(error.to_string()); + cx.notify(); + return; + } + }; + + let task_token = self.next_startup_signer_task_token(); + self.startup_view.clear_notice(); + self.startup_signer_connect_state = StartupSignerConnectState::PendingApproval { + pending_session: pending_session.clone(), + auth_challenge_url: None, + }; + cx.notify(); + self.spawn_startup_signer_pending_poll(window, task_token, pending_session, cx); + } + + fn clear_startup_pending_remote_signer_session(&mut self, cx: &mut Context<Self>) -> bool { + match self.runtime.clear_startup_pending_remote_signer_session() { + Ok(_) => true, + Err(error) => { + self.startup_view.set_notice(error.to_string()); + cx.notify(); + false + } + } + } + fn open_farm_setup(&mut self, window: &mut Window, cx: &mut Context<Self>) { let runtime_summary = self.runtime.summary();