app

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

commit 2639d04102ca1467f80230c29d6fbd0579172c3c
parent 92ba95db3c3211024dfa1cabc38c074c265df0e9
Author: triesap <triesap@radroots.dev>
Date:   Wed, 21 Jan 2026 18:51:17 +0000

app: add setup initializer and flow

- add setup module to create initial app state and keys
- write nostr key and state record during setup initialization
- provide setup UI action with status and redirect
- cover setup state helpers in unit tests

Diffstat:
Mapp/src/app.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/lib.rs | 2++
Aapp/src/setup.rs | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 374 insertions(+), 0 deletions(-)

diff --git a/app/src/app.rs b/app/src/app.rs @@ -28,6 +28,7 @@ use crate::{ app_datastore_read_state, app_state_notifications_permission_value, app_state_set_notifications_permission_value, + app_setup_initialize, app_health_check_all, RadrootsAppBackends, RadrootsAppConfig, @@ -149,9 +150,86 @@ fn SplashPage() -> impl IntoView { #[component] fn SetupPage() -> impl IntoView { + let context = app_context(); + let fallback_backends = RwSignal::new_local(None::<RadrootsAppBackends>); + let fallback_setup_required = RwSignal::new_local(None::<bool>); + let backends = context + .as_ref() + .map(|value| value.backends) + .unwrap_or(fallback_backends); + let setup_required = context + .as_ref() + .map(|value| value.setup_required) + .unwrap_or(fallback_setup_required); + let setup_running = RwSignal::new_local(false); + let setup_status = RwSignal::new_local(None::<String>); + let navigate = use_navigate(); + let navigate_guard = navigate.clone(); + Effect::new(move || { + if setup_required.get() == Some(false) { + navigate_guard("/", Default::default()); + } + }); + let setup_label = move || { + if setup_running.get() { + "setup_running" + } else { + "setup_initialize" + } + }; + let setup_status_label = + move || setup_status.get().unwrap_or_else(|| "setup_idle".to_string()); view! { <main> <div>"setup"</div> + <div style="margin-top: 12px; display: flex; align-items: center; gap: 8px;"> + <button + on:click=move |_| { + if setup_running.get_untracked() { + return; + } + let setup_ctx = backends.with_untracked(|value| { + value.as_ref().map(|backends| { + ( + backends.datastore.clone(), + backends.config.datastore.key_maps.clone(), + backends.nostr_keystore.get_config(), + ) + }) + }); + let Some((datastore, key_maps, keystore_config)) = setup_ctx else { + return; + }; + setup_running.set(true); + setup_status.set(Some("setup_start".to_string())); + let setup_required = setup_required.clone(); + let navigate = navigate.clone(); + spawn_local(async move { + let keystore = radroots_app_core::keystore::RadrootsClientWebKeystoreNostr::new( + Some(keystore_config), + ); + match app_setup_initialize(datastore.as_ref(), &keystore, &key_maps).await { + Ok(_) => { + setup_status.set(Some("setup_done".to_string())); + setup_required.set(Some(false)); + setup_running.set(false); + navigate("/", Default::default()); + } + Err(err) => { + let log_datastore = logs_datastore(); + let _ = app_log_error_store(&log_datastore, &key_maps, &err).await; + setup_status.set(Some(err.to_string())); + setup_running.set(false); + } + } + }); + } + disabled=move || setup_running.get() + > + {setup_label} + </button> + <span>{setup_status_label}</span> + </div> </main> } } diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -11,6 +11,7 @@ mod keystore; mod logging; mod logs; mod notifications; +mod setup; mod tangle; mod entry; @@ -100,6 +101,7 @@ pub use logging::{ APP_LOG_MAX_ENTRIES, }; pub use notifications::{RadrootsAppNotifications, RadrootsAppNotificationsError, RadrootsAppNotificationsResult}; +pub use setup::{app_setup_eula_date, app_setup_initialize, app_setup_state_new}; pub use tangle::{RadrootsAppTangleClient, RadrootsAppTangleClientStub, RadrootsAppTangleError, RadrootsAppTangleResult}; pub use config::{ app_config_default, diff --git a/app/src/setup.rs b/app/src/setup.rs @@ -0,0 +1,294 @@ +#![forbid(unsafe_code)] + +use radroots_app_core::datastore::RadrootsClientDatastore; +use radroots_app_core::keystore::RadrootsClientKeystoreNostr; + +#[cfg(target_arch = "wasm32")] +use js_sys::Date; + +use crate::{ + app_datastore_create_state, + app_datastore_key_nostr_key, + app_keystore_nostr_ensure_key, + app_log_debug_emit, + app_state_timestamp_ms, + RadrootsAppInitError, + RadrootsAppInitResult, + RadrootsAppKeyMapConfig, + RadrootsAppKeystoreError, + RadrootsAppRole, + RadrootsAppState, +}; + +#[cfg(target_arch = "wasm32")] +pub fn app_setup_eula_date() -> String { + Date::new_0().to_iso_string().into() +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn app_setup_eula_date() -> String { + app_state_timestamp_ms().to_string() +} + +pub fn app_setup_state_new(active_key: String, eula_date: String) -> RadrootsAppState { + RadrootsAppState { + active_key, + role: RadrootsAppRole::default(), + eula_date, + nip05_key: None, + notifications_permission: None, + } +} + +pub async fn app_setup_initialize<T: RadrootsClientDatastore, K: RadrootsClientKeystoreNostr>( + datastore: &T, + keystore: &K, + key_maps: &RadrootsAppKeyMapConfig, +) -> RadrootsAppInitResult<RadrootsAppState> { + let active_key = app_keystore_nostr_ensure_key(keystore) + .await + .map_err(|err| match err { + RadrootsAppKeystoreError::Keystore(inner) => RadrootsAppInitError::Keystore(inner), + })?; + let state = app_setup_state_new(active_key.clone(), app_setup_eula_date()); + let stored_state = app_datastore_create_state(datastore, key_maps, &state).await?; + let key_name = app_datastore_key_nostr_key(key_maps).map_err(RadrootsAppInitError::Config)?; + datastore + .set(key_name, &active_key) + .await + .map_err(RadrootsAppInitError::Datastore)?; + let _ = app_log_debug_emit("log.app.setup", "created", Some(format!("key={active_key}"))); + Ok(stored_state) +} + +#[cfg(test)] +mod tests { + use super::{app_setup_eula_date, app_setup_initialize, app_setup_state_new}; + use crate::{app_datastore_key_nostr_key, app_key_maps_default, RadrootsAppRole, RadrootsAppStateRecord}; + use async_trait::async_trait; + use radroots_app_core::backup::RadrootsClientBackupDatastorePayload; + use radroots_app_core::datastore::{ + RadrootsClientDatastore, + RadrootsClientDatastoreEntries, + RadrootsClientDatastoreError, + RadrootsClientDatastoreResult, + }; + use radroots_app_core::idb::{RadrootsClientIdbConfig, IDB_CONFIG_DATASTORE}; + use radroots_app_core::keystore::{ + RadrootsClientKeystoreError, + RadrootsClientKeystoreNostr, + RadrootsClientKeystoreResult, + }; + use serde::de::DeserializeOwned; + use serde::Serialize; + use std::cell::RefCell; + use std::collections::BTreeMap; + + struct TestKeystore { + keys_result: RadrootsClientKeystoreResult<Vec<String>>, + generate_result: RadrootsClientKeystoreResult<String>, + } + + #[async_trait(?Send)] + impl RadrootsClientKeystoreNostr for TestKeystore { + async fn generate(&self) -> RadrootsClientKeystoreResult<String> { + self.generate_result.clone() + } + + async fn add(&self, _secret_key: &str) -> RadrootsClientKeystoreResult<String> { + Err(RadrootsClientKeystoreError::IdbUndefined) + } + + async fn read(&self, _public_key: &str) -> RadrootsClientKeystoreResult<String> { + Err(RadrootsClientKeystoreError::IdbUndefined) + } + + async fn keys(&self) -> RadrootsClientKeystoreResult<Vec<String>> { + self.keys_result.clone() + } + + async fn remove(&self, _public_key: &str) -> RadrootsClientKeystoreResult<String> { + Err(RadrootsClientKeystoreError::IdbUndefined) + } + + async fn reset(&self) -> RadrootsClientKeystoreResult<()> { + Err(RadrootsClientKeystoreError::IdbUndefined) + } + } + + struct TestDatastore { + record: RefCell<Option<RadrootsAppStateRecord>>, + values: RefCell<BTreeMap<String, String>>, + } + + #[async_trait(?Send)] + impl RadrootsClientDatastore for TestDatastore { + fn get_config(&self) -> RadrootsClientIdbConfig { + IDB_CONFIG_DATASTORE + } + + fn get_store_id(&self) -> &str { + "test" + } + + async fn init(&self) -> RadrootsClientDatastoreResult<()> { + Ok(()) + } + + async fn set(&self, key: &str, value: &str) -> RadrootsClientDatastoreResult<String> { + self.values.borrow_mut().insert(key.to_string(), value.to_string()); + Ok(value.to_string()) + } + + async fn get(&self, key: &str) -> RadrootsClientDatastoreResult<String> { + self.values + .borrow() + .get(key) + .cloned() + .ok_or(RadrootsClientDatastoreError::NoResult) + } + + async fn set_obj<T>( + &self, + _key: &str, + value: &T, + ) -> RadrootsClientDatastoreResult<T> + where + T: Serialize + DeserializeOwned + Clone, + { + let encoded = serde_json::to_string(value) + .map_err(|_| RadrootsClientDatastoreError::IdbUndefined)?; + if let Ok(parsed) = serde_json::from_str::<RadrootsAppStateRecord>(&encoded) { + *self.record.borrow_mut() = Some(parsed); + return Ok(value.clone()); + } + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn update_obj<T>( + &self, + _key: &str, + _value: &T, + ) -> RadrootsClientDatastoreResult<T> + where + T: Serialize + DeserializeOwned + Clone, + { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn get_obj<T>(&self, _key: &str) -> RadrootsClientDatastoreResult<T> + where + T: DeserializeOwned, + { + if let Some(record) = self.record.borrow().as_ref() { + let encoded = serde_json::to_string(record) + .map_err(|_| RadrootsClientDatastoreError::NoResult)?; + if let Ok(parsed) = serde_json::from_str(&encoded) { + return Ok(parsed); + } + }; + Err(RadrootsClientDatastoreError::NoResult) + } + + async fn del_obj(&self, _key: &str) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn del(&self, _key: &str) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn del_pref(&self, _key_prefix: &str) -> RadrootsClientDatastoreResult<Vec<String>> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn set_param( + &self, + _key: &str, + _key_param: &str, + _value: &str, + ) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn get_param( + &self, + _key: &str, + _key_param: &str, + ) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn keys(&self) -> RadrootsClientDatastoreResult<Vec<String>> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn entries(&self) -> RadrootsClientDatastoreResult<RadrootsClientDatastoreEntries> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn entries_pref( + &self, + _key_prefix: &str, + ) -> RadrootsClientDatastoreResult<RadrootsClientDatastoreEntries> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn reset(&self) -> RadrootsClientDatastoreResult<()> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn export_backup( + &self, + ) -> RadrootsClientDatastoreResult<RadrootsClientBackupDatastorePayload> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn import_backup( + &self, + _payload: RadrootsClientBackupDatastorePayload, + ) -> RadrootsClientDatastoreResult<()> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + } + + #[test] + fn setup_state_new_populates_defaults() { + let state = app_setup_state_new("pub".to_string(), "2025-01-01T00:00:00Z".to_string()); + assert_eq!(state.active_key, "pub"); + assert_eq!(state.role, RadrootsAppRole::Public); + assert_eq!(state.eula_date, "2025-01-01T00:00:00Z"); + assert!(state.nip05_key.is_none()); + assert!(state.notifications_permission.is_none()); + } + + #[test] + fn setup_eula_date_is_non_empty() { + let value = app_setup_eula_date(); + assert!(!value.is_empty()); + } + + #[test] + fn setup_initialize_creates_state_and_key() { + let datastore = TestDatastore { + record: RefCell::new(None), + values: RefCell::new(BTreeMap::new()), + }; + let keystore = TestKeystore { + keys_result: Err(RadrootsClientKeystoreError::NostrNoResults), + generate_result: Ok("pub".to_string()), + }; + let key_maps = app_key_maps_default(); + let state = futures::executor::block_on(app_setup_initialize( + &datastore, + &keystore, + &key_maps, + )) + .expect("setup"); + assert_eq!(state.active_key, "pub"); + let key_name = app_datastore_key_nostr_key(&key_maps).expect("key name"); + let stored = futures::executor::block_on(datastore.get(key_name)).expect("stored"); + assert_eq!(stored, "pub"); + assert!(datastore.record.borrow().is_some()); + } +}