app

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

commit 68132c54581d5fab068714f91d206a40a4635fe8
parent bd0ced242d453eacb44a352f09e9fc0bb5b5e121
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 03:22:57 +0000

app: add local identity runtime commands

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mcrates/launchers/desktop/Cargo.toml | 1+
Mcrates/launchers/desktop/src/accounts.rs | 453++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/launchers/desktop/src/runtime.rs | 499+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
5 files changed, 934 insertions(+), 21 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5052,6 +5052,7 @@ dependencies = [ "radroots_app_state", "radroots_app_sync", "radroots_app_ui", + "radroots_identity", "radroots_nostr_accounts", "radroots_secret_vault", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml @@ -30,6 +30,7 @@ hex = "0.4" mf2-i18n-build = { git = "https://github.com/triesap/mf2-i18n.git", rev = "0c3ba2729b309f27aed3e27ae4e753b0147a75ec" } mf2-i18n-core = { git = "https://github.com/triesap/mf2-i18n.git", rev = "0c3ba2729b309f27aed3e27ae4e753b0147a75ec" } mf2-i18n-native = { git = "https://github.com/triesap/mf2-i18n.git", rev = "0c3ba2729b309f27aed3e27ae4e753b0147a75ec" } +radroots_identity = { path = "../lib/crates/identity" } radroots_nostr_accounts = { path = "../lib/crates/nostr_accounts", features = ["os-keyring"] } radroots_secret_vault = { path = "../lib/crates/secret_vault", default-features = false, features = ["std", "os-keyring"] } radroots_app_core = { path = "crates/shared/core", version = "0.1.0" } diff --git a/crates/launchers/desktop/Cargo.toml b/crates/launchers/desktop/Cargo.toml @@ -11,6 +11,7 @@ publish = false gpui.workspace = true gpui-component.workspace = true gpui-component-assets.workspace = true +radroots_identity.workspace = true radroots_nostr_accounts.workspace = true radroots_secret_vault.workspace = true radroots_app_core.workspace = true diff --git a/crates/launchers/desktop/src/accounts.rs b/crates/launchers/desktop/src/accounts.rs @@ -1,4 +1,7 @@ -use std::{env, fs, path::PathBuf}; +use std::{ + env, fs, + path::{Path, PathBuf}, +}; use radroots_app_core::AppSharedAccountsPaths; use radroots_app_models::{ @@ -6,6 +9,7 @@ use radroots_app_models::{ SelectedAccountProjection, SelectedSurfaceProjection, }; use radroots_app_sqlite::{AppSqliteError, AppSqliteStore}; +use radroots_identity::{IdentityError, RadrootsIdentity, RadrootsIdentityId}; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountRecord, RadrootsNostrAccountStore, RadrootsNostrAccountStoreState, RadrootsNostrAccountsError, RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, @@ -27,6 +31,52 @@ pub struct DesktopAccountsBootstrap { pub identity_projection: AppIdentityProjection, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DesktopLocalIdentityImportMode { + RawSecretKey, + EncryptedSecretKey, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DesktopLocalIdentityImportRequest { + pub mode: DesktopLocalIdentityImportMode, + pub secret_text: String, + pub password: Option<String>, +} + +impl DesktopLocalIdentityImportRequest { + pub fn new( + mode: DesktopLocalIdentityImportMode, + secret_text: impl Into<String>, + password: Option<String>, + ) -> Self { + Self { + mode, + secret_text: secret_text.into(), + password, + } + } + + pub fn raw_secret_key(secret_text: impl Into<String>) -> Self { + Self::new( + DesktopLocalIdentityImportMode::RawSecretKey, + secret_text, + None, + ) + } + + pub fn encrypted_secret_key( + secret_text: impl Into<String>, + password: impl Into<String>, + ) -> Self { + Self::new( + DesktopLocalIdentityImportMode::EncryptedSecretKey, + secret_text, + Some(password.into()), + ) + } +} + pub fn bootstrap_desktop_accounts( paths: &AppSharedAccountsPaths, sqlite_store: &AppSqliteStore, @@ -38,6 +88,72 @@ pub fn bootstrap_desktop_accounts( ) } +pub fn generate_local_account( + manager: &RadrootsNostrAccountsManager, + sqlite_store: &AppSqliteStore, + label: Option<String>, +) -> Result<AppIdentityProjection, DesktopAccountsCommandError> { + manager.generate_identity(label, true)?; + Ok(identity_projection_from_manager(manager, sqlite_store)?) +} + +pub fn import_local_account( + manager: &RadrootsNostrAccountsManager, + sqlite_store: &AppSqliteStore, + request: &DesktopLocalIdentityImportRequest, +) -> Result<AppIdentityProjection, DesktopAccountsCommandError> { + let identity = import_identity(request)?; + manager.upsert_identity(&identity, None, true)?; + Ok(identity_projection_from_manager(manager, sqlite_store)?) +} + +pub fn select_local_account( + manager: &RadrootsNostrAccountsManager, + sqlite_store: &AppSqliteStore, + account_id: &str, +) -> Result<AppIdentityProjection, DesktopAccountsCommandError> { + let account_id = RadrootsIdentityId::parse(account_id.trim())?; + manager.select_account(&account_id)?; + Ok(identity_projection_from_manager(manager, sqlite_store)?) +} + +pub fn remove_selected_local_key( + manager: &RadrootsNostrAccountsManager, + sqlite_store: &AppSqliteStore, +) -> Result<AppIdentityProjection, DesktopAccountsCommandError> { + let Some(selected_account) = manager.selected_account()? else { + return Ok(identity_projection_from_manager(manager, sqlite_store)?); + }; + let account_id = selected_account.account_id.to_string(); + + sqlite_store.clear_surface_activation(account_id.as_str())?; + manager.remove_account(&selected_account.account_id)?; + + Ok(identity_projection_from_manager(manager, sqlite_store)?) +} + +pub fn reset_local_device_state( + manager: &RadrootsNostrAccountsManager, + sqlite_store: &AppSqliteStore, + accounts_paths: &AppSharedAccountsPaths, +) -> Result<AppIdentityProjection, DesktopAccountsCommandError> { + let account_ids = manager + .list_accounts()? + .into_iter() + .map(|record| record.account_id) + .collect::<Vec<_>>(); + + for account_id in &account_ids { + sqlite_store.clear_surface_activation(account_id.as_str())?; + } + for account_id in account_ids { + manager.remove_account(&account_id)?; + } + + remove_accounts_file_if_present(accounts_paths.store_path.as_path())?; + Ok(identity_projection_from_manager(manager, sqlite_store)?) +} + fn bootstrap_desktop_accounts_with_availability( paths: &AppSharedAccountsPaths, sqlite_store: &AppSqliteStore, @@ -81,7 +197,7 @@ fn bootstrap_desktop_accounts_with_availability( } } -fn ensure_directory(path: &std::path::Path) -> Result<(), DesktopAccountsBootstrapError> { +fn ensure_directory(path: &Path) -> Result<(), DesktopAccountsBootstrapError> { fs::create_dir_all(path).map_err(|source| DesktopAccountsBootstrapError::CreateDirectory { path: path.to_path_buf(), source, @@ -139,10 +255,40 @@ fn parse_bool_value(key: &str, value: &str) -> Result<bool, DesktopAccountsBoots } } +fn import_identity( + request: &DesktopLocalIdentityImportRequest, +) -> Result<RadrootsIdentity, DesktopAccountsCommandError> { + match request.mode { + DesktopLocalIdentityImportMode::RawSecretKey => Ok(RadrootsIdentity::from_secret_key_str( + request.secret_text.trim(), + )?), + DesktopLocalIdentityImportMode::EncryptedSecretKey => { + let Some(password) = request.password.as_deref() else { + return Err(DesktopAccountsCommandError::EncryptedImportPasswordRequired); + }; + Ok(RadrootsIdentity::from_encrypted_secret_key_str( + request.secret_text.trim(), + password, + )?) + } + } +} + +fn remove_accounts_file_if_present(path: &Path) -> Result<(), DesktopAccountsCommandError> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(DesktopAccountsCommandError::RemoveAccountStore { + path: path.to_path_buf(), + source, + }), + } +} + fn blocked_identity_projection_from_store_state( state: RadrootsNostrAccountStoreState, sqlite_store: &AppSqliteStore, -) -> Result<AppIdentityProjection, DesktopAccountsBootstrapError> { +) -> Result<AppIdentityProjection, DesktopAccountsProjectionError> { let selected_account = selected_account_from_store_state(&state, sqlite_store)?; Ok(AppIdentityProjection::blocked_with_selection( @@ -155,7 +301,7 @@ fn blocked_identity_projection_from_store_state( fn identity_projection_from_manager( manager: &RadrootsNostrAccountsManager, sqlite_store: &AppSqliteStore, -) -> Result<AppIdentityProjection, DesktopAccountsBootstrapError> { +) -> Result<AppIdentityProjection, DesktopAccountsProjectionError> { let roster_records = manager.list_accounts()?; let roster = account_roster_from_records(roster_records.as_slice()); @@ -176,7 +322,7 @@ fn identity_projection_from_manager( fn selected_account_from_store_state( state: &RadrootsNostrAccountStoreState, sqlite_store: &AppSqliteStore, -) -> Result<Option<SelectedAccountProjection>, DesktopAccountsBootstrapError> { +) -> Result<Option<SelectedAccountProjection>, DesktopAccountsProjectionError> { let Some(selected_account_id) = state.selected_account_id.as_ref() else { return Ok(None); }; @@ -194,7 +340,7 @@ fn selected_account_from_store_state( fn selected_account_projection_from_record( record: &RadrootsNostrAccountRecord, sqlite_store: &AppSqliteStore, -) -> Result<SelectedAccountProjection, DesktopAccountsBootstrapError> { +) -> Result<SelectedAccountProjection, DesktopAccountsProjectionError> { let account = account_summary_from_record(record); Ok( @@ -225,6 +371,33 @@ fn account_summary_from_record(record: &RadrootsNostrAccountRecord) -> AccountSu } #[derive(Debug, Error)] +pub enum DesktopAccountsProjectionError { + #[error(transparent)] + Accounts(#[from] RadrootsNostrAccountsError), + #[error(transparent)] + Sqlite(#[from] AppSqliteError), +} + +#[derive(Debug, Error)] +pub enum DesktopAccountsCommandError { + #[error(transparent)] + Accounts(#[from] RadrootsNostrAccountsError), + #[error(transparent)] + Identity(#[from] IdentityError), + #[error(transparent)] + Sqlite(#[from] AppSqliteError), + #[error(transparent)] + Projection(#[from] DesktopAccountsProjectionError), + #[error("encrypted secret key import requires a password")] + EncryptedImportPasswordRequired, + #[error("failed to remove account store {path}: {source}")] + RemoveAccountStore { + path: PathBuf, + source: std::io::Error, + }, +} + +#[derive(Debug, Error)] pub enum DesktopAccountsBootstrapError { #[error("failed to create runtime directory {path}: {source}")] CreateDirectory { @@ -232,10 +405,10 @@ pub enum DesktopAccountsBootstrapError { source: std::io::Error, }, #[error(transparent)] - Sqlite(#[from] AppSqliteError), - #[error(transparent)] Accounts(#[from] RadrootsNostrAccountsError), #[error(transparent)] + Projection(#[from] DesktopAccountsProjectionError), + #[error(transparent)] SecretVault(#[from] RadrootsSecretVaultError), #[error("{0}")] Configuration(String), @@ -252,10 +425,11 @@ mod tests { use radroots_app_core::AppSharedAccountsPaths; use radroots_app_models::{ - ActiveSurface, AppStartupGate, IdentityBlockedReason, IdentityReadiness, - SelectedSurfaceProjection, + AccountSurfaceActivationProjection, ActiveSurface, AppStartupGate, IdentityBlockedReason, + IdentityReadiness, SelectedSurfaceProjection, }; use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget}; + use radroots_identity::RadrootsIdentity; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, @@ -263,8 +437,10 @@ mod tests { use radroots_secret_vault::RadrootsHostVaultCapabilities; use super::{ - account_summary_from_record, blocked_identity_projection_from_store_state, - bootstrap_desktop_accounts_with_availability, identity_projection_from_manager, + DesktopLocalIdentityImportRequest, account_summary_from_record, + blocked_identity_projection_from_store_state, bootstrap_desktop_accounts_with_availability, + generate_local_account, identity_projection_from_manager, import_local_account, + remove_selected_local_key, reset_local_device_state, select_local_account, selected_account_projection_from_record, }; @@ -382,7 +558,7 @@ mod tests { SelectedSurfaceProjection::default() ); - let activation = radroots_app_models::AccountSurfaceActivationProjection::new( + let activation = AccountSurfaceActivationProjection::new( account_id.as_str(), SelectedSurfaceProjection::new(ActiveSurface::Farmer), radroots_app_models::FarmerActivationProjection::active( @@ -461,6 +637,257 @@ mod tests { assert_eq!(projection.roster[0].account_id, account_id.as_str()); } + #[test] + fn command_generate_and_select_support_multiple_local_accounts() { + let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store"); + let manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrMemoryAccountStore::new()), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("memory manager should build"); + + let first_projection = + generate_local_account(&manager, &sqlite_store, Some("First".to_owned())) + .expect("first account should generate"); + let first_account_id = first_projection + .selected_account + .as_ref() + .expect("first selected account") + .account + .account_id + .clone(); + + let second_projection = + generate_local_account(&manager, &sqlite_store, Some("Second".to_owned())) + .expect("second account should generate"); + let second_account_id = second_projection + .selected_account + .as_ref() + .expect("second selected account") + .account + .account_id + .clone(); + + assert_eq!(first_projection.roster.len(), 1); + assert_eq!(second_projection.roster.len(), 2); + assert_ne!(first_account_id, second_account_id); + assert_eq!( + second_projection + .selected_account + .as_ref() + .map(|account| account.account.label.as_deref()), + Some(Some("Second")) + ); + assert_eq!(second_projection.startup_gate(), AppStartupGate::Personal); + } + + #[test] + fn command_import_supports_raw_and_encrypted_secret_keys() { + let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store"); + let manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrMemoryAccountStore::new()), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("memory manager should build"); + let raw_identity = RadrootsIdentity::generate(); + let encrypted_identity = RadrootsIdentity::generate(); + let encrypted_secret = encrypted_identity + .encrypt_secret_key_ncryptsec("radroots-password") + .expect("encrypted secret should export"); + + let raw_projection = import_local_account( + &manager, + &sqlite_store, + &DesktopLocalIdentityImportRequest::raw_secret_key(raw_identity.nsec()), + ) + .expect("raw import should succeed"); + let encrypted_projection = import_local_account( + &manager, + &sqlite_store, + &DesktopLocalIdentityImportRequest::encrypted_secret_key( + encrypted_secret, + "radroots-password", + ), + ) + .expect("encrypted import should succeed"); + + assert_eq!(raw_projection.roster.len(), 1); + assert_eq!(encrypted_projection.roster.len(), 2); + assert_eq!( + encrypted_projection + .selected_account + .as_ref() + .map(|account| account.account.account_id.as_str()), + Some(encrypted_identity.id().as_str()) + ); + } + + #[test] + fn command_select_refreshes_selected_account_activation() { + let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store"); + let manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrMemoryAccountStore::new()), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("memory manager should build"); + let first_account_id = manager + .generate_identity(Some("First".to_owned()), true) + .expect("first account should generate"); + let second_account_id = manager + .generate_identity(Some("Second".to_owned()), false) + .expect("second account should generate"); + let activation = AccountSurfaceActivationProjection::new( + second_account_id.as_str(), + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + radroots_app_models::FarmerActivationProjection::active( + radroots_app_models::FarmId::new(), + ), + ); + sqlite_store + .save_surface_activation(&activation) + .expect("surface activation should save"); + + let projection = select_local_account(&manager, &sqlite_store, second_account_id.as_str()) + .expect("selection should refresh"); + + assert_eq!( + projection + .selected_account + .as_ref() + .map(|account| account.account.account_id.as_str()), + Some(second_account_id.as_str()) + ); + assert_eq!(projection.startup_gate(), AppStartupGate::Farmer); + assert_eq!( + projection + .selected_account + .as_ref() + .map(|account| account.active_surface()), + Some(ActiveSurface::Farmer) + ); + assert_eq!( + manager.selected_account_id().expect("selected account id"), + Some(second_account_id) + ); + assert_ne!( + first_account_id, + manager + .selected_account_id() + .expect("selected account id") + .expect("selected") + ); + } + + #[test] + fn command_remove_selected_local_key_clears_activation_and_selects_next_account() { + let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store"); + let manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrMemoryAccountStore::new()), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("memory manager should build"); + let first_account_id = manager + .generate_identity(Some("First".to_owned()), true) + .expect("first account should generate"); + let second_account_id = manager + .generate_identity(Some("Second".to_owned()), false) + .expect("second account should generate"); + manager + .select_account(&first_account_id) + .expect("first account should remain selected"); + let activation = AccountSurfaceActivationProjection::new( + first_account_id.as_str(), + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + radroots_app_models::FarmerActivationProjection::active( + radroots_app_models::FarmId::new(), + ), + ); + sqlite_store + .save_surface_activation(&activation) + .expect("surface activation should save"); + + let projection = remove_selected_local_key(&manager, &sqlite_store) + .expect("selected local key should remove"); + + assert_eq!(projection.roster.len(), 1); + assert_eq!( + projection + .selected_account + .as_ref() + .map(|account| account.account.account_id.as_str()), + Some(second_account_id.as_str()) + ); + assert_eq!( + sqlite_store + .load_surface_activation(first_account_id.as_str()) + .expect("removed activation should load"), + None + ); + } + + #[test] + fn command_reset_local_device_state_removes_store_file_and_all_activations() { + let paths = temp_shared_accounts_paths("reset"); + fs::create_dir_all(paths.data_root.as_path()).expect("data root should create"); + fs::create_dir_all(paths.secrets_root.as_path()).expect("secrets root should create"); + let manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrFileAccountStore::new( + paths.store_path.as_path(), + )), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("file-backed manager should build"); + let sqlite_store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("sqlite store"); + let first_account_id = manager + .generate_identity(Some("First".to_owned()), true) + .expect("first account should generate"); + let second_account_id = manager + .generate_identity(Some("Second".to_owned()), false) + .expect("second account should generate"); + sqlite_store + .save_surface_activation(&AccountSurfaceActivationProjection::new( + first_account_id.as_str(), + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + radroots_app_models::FarmerActivationProjection::active( + radroots_app_models::FarmId::new(), + ), + )) + .expect("first activation should save"); + sqlite_store + .save_surface_activation(&AccountSurfaceActivationProjection::new( + second_account_id.as_str(), + SelectedSurfaceProjection::new(ActiveSurface::Farmer), + radroots_app_models::FarmerActivationProjection::active( + radroots_app_models::FarmId::new(), + ), + )) + .expect("second activation should save"); + assert!(paths.store_path.exists()); + + let projection = reset_local_device_state(&manager, &sqlite_store, &paths) + .expect("device state should reset"); + + assert_eq!(projection.readiness, IdentityReadiness::MissingAccount); + assert_eq!(projection.startup_gate(), AppStartupGate::SetupRequired); + assert!(projection.roster.is_empty()); + assert!(projection.selected_account.is_none()); + assert!(!paths.store_path.exists()); + assert_eq!( + sqlite_store + .load_surface_activation(first_account_id.as_str()) + .expect("first activation should load"), + None + ); + assert_eq!( + sqlite_store + .load_surface_activation(second_account_id.as_str()) + .expect("second activation should load"), + None + ); + + cleanup_paths(&paths); + } + fn cleanup_paths(paths: &AppSharedAccountsPaths) { let Some(base) = paths.data_root.ancestors().nth(3).map(PathBuf::from) else { return; diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -1,7 +1,7 @@ use std::fmt; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError}; +use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedAccountsPaths}; use radroots_app_models::{ AppActivityContext, AppActivityKind, AppStartupGate, SettingsAccountProjection, SettingsPreference, SettingsSection, TodayAgendaProjection, @@ -17,7 +17,11 @@ use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; use thiserror::Error; use tracing::error; -use crate::accounts::{DesktopAccountsBootstrapError, bootstrap_desktop_accounts}; +use crate::accounts::{ + DesktopAccountsBootstrapError, DesktopAccountsCommandError, DesktopLocalIdentityImportRequest, + bootstrap_desktop_accounts, generate_local_account, import_local_account, + remove_selected_local_key, reset_local_device_state, select_local_account, +}; const APP_DATABASE_FILE_NAME: &str = "app.sqlite3"; @@ -87,6 +91,35 @@ impl DesktopAppRuntime { changed } + pub fn generate_local_account( + &self, + label: Option<String>, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + self.lock_state_mut().generate_local_account(label) + } + + pub fn import_local_account( + &self, + request: DesktopLocalIdentityImportRequest, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + self.lock_state_mut().import_local_account(&request) + } + + pub fn select_local_account( + &self, + account_id: &str, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + self.lock_state_mut().select_local_account(account_id) + } + + pub fn remove_selected_local_key(&self) -> Result<bool, DesktopAppRuntimeCommandError> { + self.lock_state_mut().remove_selected_local_key() + } + + pub fn reset_local_device_state(&self) -> Result<bool, DesktopAppRuntimeCommandError> { + self.lock_state_mut().reset_local_device_state() + } + #[allow(dead_code)] pub fn replace_today_agenda(&self, projection: TodayAgendaProjection) -> bool { self.lock_state_mut() @@ -153,6 +186,7 @@ pub struct DesktopAppRuntimeSummary { struct DesktopAppRuntimeState { state_store: AppStateStore<InMemoryAppStateRepository>, + shared_accounts_paths: Option<AppSharedAccountsPaths>, accounts_manager: Option<RadrootsNostrAccountsManager>, sqlite_store: Option<AppSqliteStore>, startup_issue: Option<String>, @@ -164,6 +198,10 @@ impl fmt::Debug for DesktopAppRuntimeState { .debug_struct("DesktopAppRuntimeState") .field("state_store", &self.state_store) .field( + "shared_accounts_paths", + &self.shared_accounts_paths.as_ref().map(|_| "available"), + ) + .field( "accounts_manager", &self.accounts_manager.as_ref().map(|_| "available"), ) @@ -192,6 +230,7 @@ impl DesktopAppRuntimeState { Ok(Self { state_store, + shared_accounts_paths: Some(paths.shared_accounts.clone()), accounts_manager: accounts_bootstrap.accounts_manager, sqlite_store: Some(sqlite_store), startup_issue: None, @@ -201,18 +240,127 @@ impl DesktopAppRuntimeState { fn degraded(error: DesktopAppRuntimeBootstrapError) -> Self { Self { state_store: AppStateStore::in_memory(AppShellProjection::default()), + shared_accounts_paths: None, accounts_manager: None, sqlite_store: None, startup_issue: Some(error.to_string()), } } + fn generate_local_account( + &mut self, + label: Option<String>, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + let projection = { + let accounts_manager = self.accounts_manager()?; + let sqlite_store = self.sqlite_store()?; + generate_local_account(accounts_manager, sqlite_store, label)? + }; + + Ok(self.replace_identity_projection(projection)) + } + + fn import_local_account( + &mut self, + request: &DesktopLocalIdentityImportRequest, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + let projection = { + let accounts_manager = self.accounts_manager()?; + let sqlite_store = self.sqlite_store()?; + import_local_account(accounts_manager, sqlite_store, request)? + }; + + Ok(self.replace_identity_projection(projection)) + } + + fn select_local_account( + &mut self, + account_id: &str, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + let projection = { + let accounts_manager = self.accounts_manager()?; + let sqlite_store = self.sqlite_store()?; + select_local_account(accounts_manager, sqlite_store, account_id)? + }; + + Ok(self.replace_identity_projection(projection)) + } + + fn remove_selected_local_key(&mut self) -> Result<bool, DesktopAppRuntimeCommandError> { + let projection = { + let accounts_manager = self.accounts_manager()?; + let sqlite_store = self.sqlite_store()?; + remove_selected_local_key(accounts_manager, sqlite_store)? + }; + + Ok(self.replace_identity_projection(projection)) + } + + fn reset_local_device_state(&mut self) -> Result<bool, DesktopAppRuntimeCommandError> { + let projection = { + let accounts_manager = self.accounts_manager()?; + let sqlite_store = self.sqlite_store()?; + let shared_accounts_paths = self.shared_accounts_paths()?; + reset_local_device_state(accounts_manager, sqlite_store, shared_accounts_paths)? + }; + + Ok(self.replace_identity_projection(projection)) + } + fn record_activity(&self, kind: AppActivityKind) -> Result<(), AppSqliteError> { match self.sqlite_store.as_ref() { Some(store) => store.record_activity_event(&kind), None => Ok(()), } } + + fn replace_identity_projection( + &mut self, + projection: radroots_app_models::AppIdentityProjection, + ) -> bool { + self.state_store + .apply_in_memory(AppStateCommand::replace_identity_projection(projection)) + } + + fn accounts_manager( + &self, + ) -> Result<&RadrootsNostrAccountsManager, DesktopAppRuntimeCommandError> { + self.accounts_manager + .as_ref() + .ok_or_else(|| self.command_unavailable_error()) + } + + fn sqlite_store(&self) -> Result<&AppSqliteStore, DesktopAppRuntimeCommandError> { + self.sqlite_store + .as_ref() + .ok_or(DesktopAppRuntimeCommandError::RuntimeUnavailable) + } + + fn shared_accounts_paths( + &self, + ) -> Result<&AppSharedAccountsPaths, DesktopAppRuntimeCommandError> { + self.shared_accounts_paths + .as_ref() + .ok_or(DesktopAppRuntimeCommandError::RuntimeUnavailable) + } + + fn command_unavailable_error(&self) -> DesktopAppRuntimeCommandError { + if self.startup_issue.is_some() || self.sqlite_store.is_none() { + DesktopAppRuntimeCommandError::RuntimeUnavailable + } else { + DesktopAppRuntimeCommandError::HostVaultUnavailable + } + } +} + +#[derive(Debug, Error)] +pub enum DesktopAppRuntimeCommandError { + #[error("desktop runtime commands are unavailable while the runtime is degraded")] + RuntimeUnavailable, + #[error("desktop runtime commands require an available host vault")] + HostVaultUnavailable, + #[error(transparent)] + Accounts(#[from] DesktopAccountsCommandError), } #[derive(Debug, Error)] @@ -229,23 +377,39 @@ enum DesktopAppRuntimeBootstrapError { #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{ + fs, + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, + }; use radroots_app_core::{ AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, - SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITY_FILE_NAME, + AppSharedAccountsPaths, SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITY_FILE_NAME, }; use radroots_app_models::{ - AppActivityKind, AppStartupGate, FarmReadiness, FarmSummary, SettingsPreference, - SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, - TodaySummary, + AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate, FarmId, + FarmReadiness, FarmSummary, FarmerActivationProjection, SelectedSurfaceProjection, + SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, + TodaySetupTaskKind, TodaySummary, }; use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget}; use radroots_app_state::{ AppStateRepositoryError, AppStateStore, AppStateStoreError, InMemoryAppStateRepository, }; + use radroots_identity::RadrootsIdentity; + use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, + RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, + }; - use super::{APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeState}; + use crate::accounts::DesktopLocalIdentityImportRequest; + + use super::{ + APP_DATABASE_FILE_NAME, DesktopAppRuntime, DesktopAppRuntimeCommandError, + DesktopAppRuntimeState, + }; #[test] fn desktop_namespace_uses_canonical_app_and_shared_runtime_roots() { @@ -295,6 +459,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + shared_accounts_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -337,6 +502,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + shared_accounts_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -421,6 +587,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory state store should load"), + shared_accounts_paths: None, accounts_manager: None, sqlite_store: Some( AppSqliteStore::open(DatabaseTarget::InMemory) @@ -460,4 +627,320 @@ mod tests { ); assert_eq!(context.recent_events[3].kind, AppActivityKind::HomeOpened); } + + #[test] + fn runtime_account_commands_refresh_identity_projection() { + let runtime = memory_runtime(); + + assert!( + runtime + .generate_local_account(Some("First".to_owned())) + .expect("first account should generate") + ); + let first_summary = runtime.summary(); + let first_account_id = first_summary + .settings_account_projection + .selected_account + .as_ref() + .expect("first selected account") + .account + .account_id + .clone(); + + assert!( + runtime + .generate_local_account(Some("Second".to_owned())) + .expect("second account should generate") + ); + let second_summary = runtime.summary(); + let second_account_id = second_summary + .settings_account_projection + .selected_account + .as_ref() + .expect("second selected account") + .account + .account_id + .clone(); + assert_eq!(second_summary.settings_account_projection.roster.len(), 2); + assert_eq!( + second_summary + .settings_account_projection + .selected_account + .as_ref() + .and_then(|account| account.account.label.as_deref()), + Some("Second") + ); + + save_surface_activation( + &runtime, + second_account_id.as_str(), + ActiveSurface::Farmer, + true, + ); + assert!( + runtime + .select_local_account(second_account_id.as_str()) + .expect("selection should succeed") + ); + let selected_summary = runtime.summary(); + assert_eq!(selected_summary.startup_gate, AppStartupGate::Farmer); + assert_eq!( + selected_summary + .settings_account_projection + .selected_account + .as_ref() + .map(|account| account.active_surface()), + Some(ActiveSurface::Farmer) + ); + + assert!( + runtime + .remove_selected_local_key() + .expect("selected local key should remove") + ); + let removed_summary = runtime.summary(); + assert_eq!(removed_summary.settings_account_projection.roster.len(), 1); + assert_eq!( + removed_summary + .settings_account_projection + .selected_account + .as_ref() + .map(|account| account.account.account_id.as_str()), + Some(first_account_id.as_str()) + ); + assert_eq!( + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_surface_activation(second_account_id.as_str()) + .expect("removed activation should load"), + None + ); + + let imported_identity = RadrootsIdentity::generate(); + assert!( + runtime + .import_local_account(DesktopLocalIdentityImportRequest::raw_secret_key( + imported_identity.nsec(), + )) + .expect("raw import should succeed") + ); + let imported_summary = runtime.summary(); + assert_eq!(imported_summary.settings_account_projection.roster.len(), 2); + assert_eq!( + imported_summary + .settings_account_projection + .selected_account + .as_ref() + .map(|account| account.account.account_id.as_str()), + Some(imported_identity.id().as_str()) + ); + } + + #[test] + fn runtime_reset_local_device_state_clears_store_file_and_projection() { + let (runtime, paths) = file_backed_runtime("reset"); + + assert!( + runtime + .generate_local_account(Some("First".to_owned())) + .expect("first account should generate") + ); + let first_account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("first selected account") + .account + .account_id + .clone(); + assert!( + runtime + .generate_local_account(Some("Second".to_owned())) + .expect("second account should generate") + ); + let second_account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("second selected account") + .account + .account_id + .clone(); + save_surface_activation( + &runtime, + first_account_id.as_str(), + ActiveSurface::Farmer, + true, + ); + save_surface_activation( + &runtime, + second_account_id.as_str(), + ActiveSurface::Farmer, + true, + ); + assert!(paths.store_path.exists()); + + assert!( + runtime + .reset_local_device_state() + .expect("device state should reset") + ); + let summary = runtime.summary(); + + assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); + assert!(summary.settings_account_projection.roster.is_empty()); + assert!( + summary + .settings_account_projection + .selected_account + .is_none() + ); + assert!(!paths.store_path.exists()); + assert_eq!( + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_surface_activation(first_account_id.as_str()) + .expect("first activation should load"), + None + ); + assert_eq!( + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_surface_activation(second_account_id.as_str()) + .expect("second activation should load"), + None + ); + + cleanup_paths(&paths); + } + + #[test] + fn runtime_account_commands_fail_closed_without_host_vault_manager() { + let paths = temp_shared_accounts_paths("blocked"); + let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { + state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory state store should load"), + shared_accounts_paths: Some(paths), + accounts_manager: None, + sqlite_store: Some( + AppSqliteStore::open(DatabaseTarget::InMemory) + .expect("in-memory sqlite store should open"), + ), + startup_issue: None, + }); + + let error = runtime + .generate_local_account(Some("Blocked".to_owned())) + .expect_err("blocked runtime should fail closed"); + + assert!(matches!( + error, + DesktopAppRuntimeCommandError::HostVaultUnavailable + )); + } + + fn memory_runtime() -> DesktopAppRuntime { + DesktopAppRuntime::from_state(DesktopAppRuntimeState { + state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory state store should load"), + shared_accounts_paths: None, + accounts_manager: Some( + RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrMemoryAccountStore::new()), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("memory manager should build"), + ), + sqlite_store: Some( + AppSqliteStore::open(DatabaseTarget::InMemory) + .expect("in-memory sqlite store should open"), + ), + startup_issue: None, + }) + } + + fn file_backed_runtime(label: &str) -> (DesktopAppRuntime, AppSharedAccountsPaths) { + let paths = temp_shared_accounts_paths(label); + fs::create_dir_all(paths.data_root.as_path()).expect("data root should create"); + fs::create_dir_all(paths.secrets_root.as_path()).expect("secrets root should create"); + + ( + DesktopAppRuntime::from_state(DesktopAppRuntimeState { + state_store: AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory state store should load"), + shared_accounts_paths: Some(paths.clone()), + accounts_manager: Some( + RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrFileAccountStore::new( + paths.store_path.as_path(), + )), + Arc::new(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("file-backed manager should build"), + ), + sqlite_store: Some( + AppSqliteStore::open(DatabaseTarget::InMemory) + .expect("in-memory sqlite store should open"), + ), + startup_issue: None, + }), + paths, + ) + } + + fn temp_shared_accounts_paths(label: &str) -> AppSharedAccountsPaths { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let base = std::env::temp_dir().join(format!("radroots_runtime_accounts_{label}_{suffix}")); + + AppSharedAccountsPaths { + data_root: base.join("data/shared/accounts"), + secrets_root: base.join("secrets/shared/accounts"), + store_path: base.join("data/shared/accounts/store.json"), + } + } + + fn save_surface_activation( + runtime: &DesktopAppRuntime, + account_id: &str, + active_surface: ActiveSurface, + farmer_active: bool, + ) { + let activation = AccountSurfaceActivationProjection::new( + account_id, + SelectedSurfaceProjection::new(active_surface), + if farmer_active { + FarmerActivationProjection::active(FarmId::new()) + } else { + FarmerActivationProjection::inactive() + }, + ); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .save_surface_activation(&activation) + .expect("surface activation should save"); + } + + fn cleanup_paths(paths: &AppSharedAccountsPaths) { + let Some(base) = paths.data_root.ancestors().nth(3).map(PathBuf::from) else { + return; + }; + let _ = fs::remove_dir_all(base); + } }