commit 13b48aa4c3982fed105105ace491c4c3a6bee85b
parent 69364b3909123918e9c731bfa7288e628a511206
Author: triesap <tyson@radroots.org>
Date: Mon, 2 Feb 2026 21:33:36 +0000
app: gate app shell on setup status
- replace setup_required context with setup_status gating
- add recovery view copy and setup status redirects
- remove setup nav route from configured app shell
- update setup completion state and rebuild i18n assets
Diffstat:
8 files changed, 151 insertions(+), 78 deletions(-)
diff --git a/app/i18n/build/i18n.catalog.json b/app/i18n/build/i18n.catalog.json
@@ -1127,8 +1127,8 @@
}
},
{
- "key": "app.nav.setup",
- "id": 2066290074,
+ "key": "app.nav.ui",
+ "id": 2416341108,
"args": [],
"features": {
"select": false,
@@ -1138,8 +1138,8 @@
}
},
{
- "key": "app.nav.ui",
- "id": 2416341108,
+ "key": "app.not_found",
+ "id": 3182331848,
"args": [],
"features": {
"select": false,
@@ -1149,8 +1149,19 @@
}
},
{
- "key": "app.not_found",
- "id": 3182331848,
+ "key": "app.recovery.body",
+ "id": 3376325333,
+ "args": [],
+ "features": {
+ "select": false,
+ "plural_cardinal": false,
+ "plural_ordinal": false,
+ "formatters": []
+ }
+ },
+ {
+ "key": "app.recovery.title",
+ "id": 853393776,
"args": [],
"features": {
"select": false,
diff --git a/app/i18n/build/id_map.json b/app/i18n/build/id_map.json
@@ -101,9 +101,10 @@
"app.nav.logs": 952998791,
"app.nav.primary_aria": 3495744422,
"app.nav.settings": 326721352,
- "app.nav.setup": 2066290074,
"app.nav.ui": 2416341108,
"app.not_found": 3182331848,
+ "app.recovery.body": 3376325333,
+ "app.recovery.title": 853393776,
"app.settings.actions.export_db": 4012031673,
"app.settings.actions.logout": 1314061244,
"app.settings.appearance.color_mode.label": 4036414159,
diff --git a/app/i18n/build/id_map_hash b/app/i18n/build/id_map_hash
@@ -1 +1 @@
-sha256:12c477901a2928e333411451927acbbb09fdfa691d95854f2b0b2e33d1dd0187
+sha256:414ba68526effd986360e614ed3e75adba48b0ad41b41aa80b7cf623d77b078b
diff --git a/app/i18n/build/manifest.json b/app/i18n/build/manifest.json
@@ -1 +1 @@
-{"schema":1,"release_id":"dev","generated_at":"2026-02-02T00:00:00Z","default_locale":"en","supported_locales":["en"],"id_map_hash":"sha256:12c477901a2928e333411451927acbbb09fdfa691d95854f2b0b2e33d1dd0187","mf2_packs":{"en":{"kind":"base","url":"packs/en.mf2pack","hash":"sha256:d74714c870a916f602007488377db7173e5d6c61f39163963e26e2efb2ea8148","size":11128,"content_encoding":"identity","pack_schema":0}}}
-\ No newline at end of file
+{"schema":1,"release_id":"dev","generated_at":"2026-02-02T00:00:00Z","default_locale":"en","supported_locales":["en"],"id_map_hash":"sha256:414ba68526effd986360e614ed3e75adba48b0ad41b41aa80b7cf623d77b078b","mf2_packs":{"en":{"kind":"base","url":"packs/en.mf2pack","hash":"sha256:aeed28bc97e29d8ddddcd91072c2b5eb1c3e4fd5078906282f9bdaa5fce9bde1","size":11253,"content_encoding":"identity","pack_schema":0}}}
+\ No newline at end of file
diff --git a/app/i18n/build/packs/en.mf2pack b/app/i18n/build/packs/en.mf2pack
Binary files differ.
diff --git a/app/i18n/locales/en/messages.mf2 b/app/i18n/locales/en/messages.mf2
@@ -26,11 +26,14 @@ app.nav.ui = ui
app.nav.settings = settings
-app.nav.setup = setup
-
# not found
app.not_found = not found
+# recovery
+app.recovery.title = Recovery required
+
+app.recovery.body = This device could not verify its setup state. Reset the device to continue.
+
# init stages
app.init.stage.idle = idle
diff --git a/app/src/app.rs b/app/src/app.rs
@@ -27,7 +27,7 @@ use crate::{
app_init_assets,
app_init_backends,
app_init_has_completed,
- app_init_needs_setup,
+ app_init_setup_status,
app_init_state_default,
app_init_mark_completed,
app_init_reset,
@@ -58,6 +58,7 @@ use crate::{
app_state_timestamp_ms,
app_setup_eula_date,
app_setup_finalize_with_key,
+ app_setup_gate_from_status,
app_setup_step_default,
app_health_check_all,
RadrootsAppBackends,
@@ -80,6 +81,7 @@ use crate::{
RadrootsAppSetupBusinessChoice,
RadrootsAppSetupLock,
RadrootsAppSetupLockStatus,
+ RadrootsAppSetupStatus,
RadrootsAppUiDemoPage,
RadrootsAppSetupStep,
RadrootsAppTangleClientStub,
@@ -301,11 +303,11 @@ fn SetupPage() -> impl IntoView {
.as_ref()
.map(|value| value.backends)
.unwrap_or(fallback_backends);
- let fallback_setup_required = RwSignal::new_local(None::<bool>);
- let setup_required = context
+ let fallback_setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown);
+ let setup_status = context
.as_ref()
- .map(|value| value.setup_required)
- .unwrap_or(fallback_setup_required);
+ .map(|value| value.setup_status)
+ .unwrap_or(fallback_setup_status);
let navigate = use_navigate();
let navigate_guard = navigate.clone();
let navigate_home = navigate.clone();
@@ -358,8 +360,14 @@ fn SetupPage() -> impl IntoView {
let on_generate_key = setup_touch_callback("generate_key");
let on_add_key = setup_touch_callback("add_key");
Effect::new(move || {
- if setup_required.get() == Some(false) {
- navigate_guard("/", Default::default());
+ match setup_status.get() {
+ RadrootsAppSetupStatus::Configured => {
+ navigate_guard("/", Default::default());
+ }
+ RadrootsAppSetupStatus::Corrupt => {
+ navigate_guard("/recovery", Default::default());
+ }
+ _ => {}
}
});
Effect::new({
@@ -516,7 +524,7 @@ fn SetupPage() -> impl IntoView {
let setup_lock_status = setup_lock_status.clone();
let nostr_key_add = nostr_key_add.clone();
let profile_name = profile_name.clone();
- let setup_required = setup_required.clone();
+ let setup_status = setup_status.clone();
Callback::new(move |_| {
if app_setup_lock_enabled()
&& !matches!(
@@ -547,7 +555,7 @@ fn SetupPage() -> impl IntoView {
let profile_name = draft.profile_name;
let profile_nip05 = draft.profile_nip05;
let eula_date = app_setup_eula_date();
- let setup_required = setup_required.clone();
+ let setup_status = setup_status.clone();
let backends = backends.clone();
spawn_local(async move {
let Some((datastore, key_maps, keystore_config)) = backends.with_untracked(|value| {
@@ -642,7 +650,7 @@ fn SetupPage() -> impl IntoView {
if app_setup_lock_enabled() {
let _ = app_setup_lock_release(datastore.as_ref(), &key_maps).await;
}
- setup_required.set(Some(false));
+ setup_status.set(RadrootsAppSetupStatus::Configured);
});
return;
}
@@ -1405,12 +1413,36 @@ fn SetupPage() -> impl IntoView {
}
#[component]
+fn RecoveryPage() -> impl IntoView {
+ view! {
+ <main id="app-recovery" class="app-page app-page-fixed relative w-full flex flex-col">
+ <section
+ id="app-recovery-view"
+ class="app-view app-view-enter flex flex-col h-[100dvh] w-full px-6 pt-10 pb-16"
+ >
+ <div
+ id="app-recovery-body"
+ class="flex flex-1 w-full flex-col justify-center items-center gap-4"
+ >
+ <p class="font-sans font-[600] text-ly0-gl text-2xl text-center">
+ {t!("app.recovery.title")}
+ </p>
+ <p class="font-mono font-[400] text-ly0-gl text-base text-center">
+ {t!("app.recovery.body")}
+ </p>
+ </div>
+ </section>
+ </main>
+ }
+}
+
+#[component]
fn HomePage() -> impl IntoView {
let context = app_context();
let fallback_backends = RwSignal::new_local(None::<RadrootsAppBackends>);
let fallback_init_error = RwSignal::new_local(None::<RadrootsAppInitError>);
let fallback_init_state = RwSignal::new_local(app_init_state_default());
- let fallback_setup_required = RwSignal::new_local(None::<bool>);
+ let fallback_setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown);
let backends = context
.as_ref()
.map(|value| value.backends)
@@ -1419,10 +1451,10 @@ fn HomePage() -> impl IntoView {
.as_ref()
.map(|value| value.init_state)
.unwrap_or(fallback_init_state);
- let setup_required = context
+ let setup_status = context
.as_ref()
- .map(|value| value.setup_required)
- .unwrap_or(fallback_setup_required);
+ .map(|value| value.setup_status)
+ .unwrap_or(fallback_setup_status);
let _init_error = context
.as_ref()
.map(|value| value.init_error)
@@ -1441,10 +1473,11 @@ fn HomePage() -> impl IntoView {
if health_autorun.get() {
return;
}
- let setup_required = setup_required.get();
- let Some(setup_required_value) = setup_required else {
+ let setup_status = setup_status.get();
+ if matches!(setup_status, RadrootsAppSetupStatus::Unknown) {
return;
- };
+ }
+ let setup_required_value = !matches!(setup_status, RadrootsAppSetupStatus::Configured);
let config = backends.with_untracked(|value| value.as_ref().map(|backends| backends.config.clone()));
let Some(config) = config else {
return;
@@ -1484,7 +1517,7 @@ fn HomePage() -> impl IntoView {
let health_disabled = move || {
backends.with(|value| value.is_none())
|| health_running.get()
- || setup_required.get().is_none()
+ || matches!(setup_status.get(), RadrootsAppSetupStatus::Unknown)
};
let notifications_disabled = move || {
backends.with(|value| value.is_none()) || notifications_requesting.get()
@@ -1528,7 +1561,7 @@ fn HomePage() -> impl IntoView {
health_report.set(RadrootsAppHealthReport::empty());
active_key.set(None);
notifications_status.set(None);
- setup_required.set(Some(true));
+ setup_status.set(RadrootsAppSetupStatus::Required);
spawn_local(async move {
let Some(config) = config else {
reset_status.set(Some("reset_missing_backends".to_string()));
@@ -1654,9 +1687,8 @@ fn HomePage() -> impl IntoView {
let Some(config) = config else {
return;
};
- let setup_required_value = setup_required
- .get()
- .unwrap_or(false);
+ let setup_required_value =
+ !matches!(setup_status.get(), RadrootsAppSetupStatus::Configured);
spawn_health_checks(
config,
setup_required_value,
@@ -1772,12 +1804,12 @@ fn AppShell() -> impl IntoView {
let backends = RwSignal::new_local(None::<RadrootsAppBackends>);
let init_error = RwSignal::new_local(None::<RadrootsAppInitError>);
let init_state = RwSignal::new_local(app_init_state_default());
- let setup_required = RwSignal::new_local(None::<bool>);
+ let setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown);
let navigate = use_navigate();
provide_context(backends);
provide_context(init_error);
provide_context(init_state);
- provide_context(setup_required);
+ provide_context(setup_status);
provide_context(app_i18n_init());
Effect::new(move || {
let navigate = navigate.clone();
@@ -1832,22 +1864,28 @@ fn AppShell() -> impl IntoView {
init_state.update(|state| app_init_stage_set(state, stage));
log_init_stage(stage);
let navigate = navigate.clone();
- let setup_required = setup_required.clone();
+ let setup_status = setup_status.clone();
spawn_local(async move {
let keystore = radroots_app_core::keystore::RadrootsClientWebKeystoreNostr::new(
Some(keystore_config),
);
- match app_init_needs_setup(datastore.as_ref(), &keystore, &key_maps).await {
- Ok(needs_setup) => {
- setup_required.set(Some(needs_setup));
- if needs_setup {
- navigate("/setup", Default::default());
+ match app_init_setup_status(datastore.as_ref(), &keystore, &key_maps).await {
+ Ok(status) => {
+ setup_status.set(status);
+ match status {
+ RadrootsAppSetupStatus::Required | RadrootsAppSetupStatus::Locked => {
+ navigate("/setup", Default::default());
+ }
+ RadrootsAppSetupStatus::Corrupt => {
+ navigate("/recovery", Default::default());
+ }
+ _ => {}
}
}
Err(err) => {
let _ = app_log_error_emit(&err);
- setup_required.set(Some(true));
- navigate("/setup", Default::default());
+ setup_status.set(RadrootsAppSetupStatus::Corrupt);
+ navigate("/recovery", Default::default());
}
}
});
@@ -1875,42 +1913,51 @@ fn AppShell() -> impl IntoView {
}
})
});
+ let setup_gate = move || app_setup_gate_from_status(setup_status.get());
view! {
<Show
when=move || {
init_state.get().stage == RadrootsAppInitStage::Ready
- && setup_required.get().is_some()
+ && !matches!(setup_status.get(), RadrootsAppSetupStatus::Unknown)
}
fallback=|| view! { <SplashPage /> }
>
- <Show
- when=move || setup_required.get() == Some(false)
- fallback=|| view! { <SetupPage /> }
- >
- <div id="app-shell">
- <nav id="app-nav" aria-label=t!("app.nav.primary_aria") style="display:flex;gap:12px;margin-bottom:12px;">
- <A href="/" exact=true>{t!("app.nav.home")}</A>
- <A href="/logs">{t!("app.nav.logs")}</A>
- <A href="/ui">{t!("app.nav.ui")}</A>
- <A href="/settings">{t!("app.nav.settings")}</A>
- <A href="/setup">{t!("app.nav.setup")}</A>
- </nav>
- <Routes
- fallback=|| view! {
- <main id="app-not-found" class="app-page app-page-fixed">
- <p id="app-not-found-label">{t!("app.not_found")}</p>
- </main>
+ {move || {
+ let gate = setup_gate();
+ if gate.show_recovery {
+ return view! { <RecoveryPage /> }.into_any();
+ }
+ if gate.show_setup {
+ return view! { <SetupPage /> }.into_any();
+ }
+ if gate.show_app {
+ return view! {
+ <div id="app-shell">
+ <nav id="app-nav" aria-label=t!("app.nav.primary_aria") style="display:flex;gap:12px;margin-bottom:12px;">
+ <A href="/" exact=true>{t!("app.nav.home")}</A>
+ <A href="/logs">{t!("app.nav.logs")}</A>
+ <A href="/ui">{t!("app.nav.ui")}</A>
+ <A href="/settings">{t!("app.nav.settings")}</A>
+ </nav>
+ <Routes
+ fallback=|| view! {
+ <main id="app-not-found" class="app-page app-page-fixed">
+ <p id="app-not-found-label">{t!("app.not_found")}</p>
+ </main>
+ }
+ >
+ <Route path=path!("") view=HomePage />
+ <Route path=path!("logs") view=RadrootsAppLogsPage />
+ <Route path=path!("ui") view=RadrootsAppUiDemoPage />
+ <Route path=path!("settings") view=RadrootsAppSettingsPage />
+ </Routes>
+ </div>
}
- >
- <Route path=path!("") view=HomePage />
- <Route path=path!("logs") view=RadrootsAppLogsPage />
- <Route path=path!("ui") view=RadrootsAppUiDemoPage />
- <Route path=path!("settings") view=RadrootsAppSettingsPage />
- <Route path=path!("setup") view=SetupPage />
- </Routes>
- </div>
+ .into_any();
+ }
+ view! { <SplashPage /> }.into_any()
+ }}
</Show>
- </Show>
}
}
diff --git a/app/src/context.rs b/app/src/context.rs
@@ -2,14 +2,14 @@
use leptos::prelude::{use_context, LocalStorage, RwSignal};
-use crate::{RadrootsAppBackends, RadrootsAppInitError, RadrootsAppInitState};
+use crate::{RadrootsAppBackends, RadrootsAppInitError, RadrootsAppInitState, RadrootsAppSetupStatus};
#[derive(Clone)]
pub struct RadrootsAppContext {
pub backends: RwSignal<Option<RadrootsAppBackends>, LocalStorage>,
pub init_error: RwSignal<Option<RadrootsAppInitError>, LocalStorage>,
pub init_state: RwSignal<RadrootsAppInitState, LocalStorage>,
- pub setup_required: RwSignal<Option<bool>, LocalStorage>,
+ pub setup_status: RwSignal<RadrootsAppSetupStatus, LocalStorage>,
}
pub fn app_context() -> Option<RadrootsAppContext> {
@@ -17,14 +17,20 @@ pub fn app_context() -> Option<RadrootsAppContext> {
backends: use_context::<RwSignal<Option<RadrootsAppBackends>, LocalStorage>>()?,
init_error: use_context::<RwSignal<Option<RadrootsAppInitError>, LocalStorage>>()?,
init_state: use_context::<RwSignal<RadrootsAppInitState, LocalStorage>>()?,
- setup_required: use_context::<RwSignal<Option<bool>, LocalStorage>>()?,
+ setup_status: use_context::<RwSignal<RadrootsAppSetupStatus, LocalStorage>>()?,
})
}
#[cfg(test)]
mod tests {
use super::app_context;
- use crate::{app_init_state_default, RadrootsAppBackends, RadrootsAppInitError, RadrootsAppInitStage};
+ use crate::{
+ app_init_state_default,
+ RadrootsAppBackends,
+ RadrootsAppInitError,
+ RadrootsAppInitStage,
+ RadrootsAppSetupStatus,
+ };
use leptos::prelude::{provide_context, Owner, RwSignal, WithUntracked};
#[test]
@@ -41,11 +47,11 @@ mod tests {
let backends = RwSignal::new_local(None::<RadrootsAppBackends>);
let init_error = RwSignal::new_local(None::<RadrootsAppInitError>);
let init_state = RwSignal::new_local(app_init_state_default());
- let setup_required = RwSignal::new_local(None::<bool>);
+ let setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown);
provide_context(backends);
provide_context(init_error);
provide_context(init_state);
- provide_context(setup_required);
+ provide_context(setup_status);
let context = app_context().expect("context");
assert!(context.backends.with_untracked(|value| value.is_none()));
assert!(context.init_error.with_untracked(|value| value.is_none()));
@@ -53,6 +59,11 @@ mod tests {
context.init_state.with_untracked(|state| state.stage),
RadrootsAppInitStage::Idle
);
- assert!(context.setup_required.with_untracked(|value| value.is_none()));
+ assert_eq!(
+ context
+ .setup_status
+ .with_untracked(|value| *value),
+ RadrootsAppSetupStatus::Unknown
+ );
}
}