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:
| M | app/src/app.rs | | | 78 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | app/src/lib.rs | | | 2 | ++ |
| A | app/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());
+ }
+}