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:
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();