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:
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);
+ }
}