commit 69364b3909123918e9c731bfa7288e628a511206
parent b711153efeb5403eeef5ce6dc0f3908efa140e1b
Author: triesap <tyson@radroots.org>
Date: Mon, 2 Feb 2026 21:10:33 +0000
app: wire setup flow into setup ui
- use setup flow validation for continue and back state
- gate setup ui on lock acquisition with pending and locked views
- refresh lock owner on retry and guard navigation
- add setup lock copy and rebuild i18n assets
Diffstat:
7 files changed, 317 insertions(+), 103 deletions(-)
diff --git a/app/i18n/build/i18n.catalog.json b/app/i18n/build/i18n.catalog.json
@@ -1644,6 +1644,61 @@
}
},
{
+ "key": "app.setup.lock.pending.body",
+ "id": 1475586291,
+ "args": [],
+ "features": {
+ "select": false,
+ "plural_cardinal": false,
+ "plural_ordinal": false,
+ "formatters": []
+ }
+ },
+ {
+ "key": "app.setup.lock.pending.title",
+ "id": 2337040532,
+ "args": [],
+ "features": {
+ "select": false,
+ "plural_cardinal": false,
+ "plural_ordinal": false,
+ "formatters": []
+ }
+ },
+ {
+ "key": "app.setup.lock.retry",
+ "id": 2739457561,
+ "args": [],
+ "features": {
+ "select": false,
+ "plural_cardinal": false,
+ "plural_ordinal": false,
+ "formatters": []
+ }
+ },
+ {
+ "key": "app.setup.locked.body",
+ "id": 3219932589,
+ "args": [],
+ "features": {
+ "select": false,
+ "plural_cardinal": false,
+ "plural_ordinal": false,
+ "formatters": []
+ }
+ },
+ {
+ "key": "app.setup.locked.title",
+ "id": 2703779876,
+ "args": [],
+ "features": {
+ "select": false,
+ "plural_cardinal": false,
+ "plural_ordinal": false,
+ "formatters": []
+ }
+ },
+ {
"key": "app.setup.profile.confirm_no_name",
"id": 420411302,
"args": [],
diff --git a/app/i18n/build/id_map.json b/app/i18n/build/id_map.json
@@ -148,6 +148,11 @@
"app.setup.key_choice.create": 963716841,
"app.setup.key_choice.title": 2829853649,
"app.setup.key_choice.use_existing": 3945623834,
+ "app.setup.lock.pending.body": 1475586291,
+ "app.setup.lock.pending.title": 2337040532,
+ "app.setup.lock.retry": 2739457561,
+ "app.setup.locked.body": 3219932589,
+ "app.setup.locked.title": 2703779876,
"app.setup.profile.confirm_no_name": 420411302,
"app.setup.profile.nip05.prefix": 3344734641,
"app.setup.profile.nip05.suffix": 853057844,
diff --git a/app/i18n/build/id_map_hash b/app/i18n/build/id_map_hash
@@ -1 +1 @@
-sha256:1e03c508c7169c9adae1a42d2067051774c96ed67e7396703a0ec3e56173a014
+sha256:12c477901a2928e333411451927acbbb09fdfa691d95854f2b0b2e33d1dd0187
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:1e03c508c7169c9adae1a42d2067051774c96ed67e7396703a0ec3e56173a014","mf2_packs":{"en":{"kind":"base","url":"packs/en.mf2pack","hash":"sha256:e78181c9838bd04d3863f9e047a1705d885ad3faab2d3d64893fa479f52ac8e3","size":10765,"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: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
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
@@ -146,6 +146,16 @@ app.setup.profile.nip05.suffix = NIP-05 address
app.setup.profile.confirm_no_name = Your profile will be created without a name. You can change this later in Settings > Profile
+app.setup.lock.pending.title = Preparing setup
+
+app.setup.lock.pending.body = Checking if this device is already being configured.
+
+app.setup.locked.title = Setup in progress
+
+app.setup.locked.body = This device is being configured in another session. Wait a moment and try again.
+
+app.setup.lock.retry = Try again
+
app.setup.farmer.title = Setup for Farmer
app.setup.business.title = Setup for Business
diff --git a/app/src/app.rs b/app/src/app.rs
@@ -18,7 +18,9 @@ use radroots_app_ui_components::{
RadrootsAppUiButtonLayoutAction,
RadrootsAppUiButtonLayoutBackAction,
RadrootsAppUiButtonLayoutPair,
+ RadrootsAppUiSpinner,
};
+use uuid::Uuid;
use crate::t;
use crate::{
@@ -45,8 +47,15 @@ use crate::{
app_datastore_write_profile_seed,
app_datastore_write_setup_draft,
app_keystore_nostr_ensure_key,
+ app_setup_flow_role_from_choices,
+ app_setup_flow_validate,
+ app_setup_lock_acquire,
+ app_setup_lock_enabled,
+ app_setup_lock_release,
+ app_setup_lock_ttl_ms,
app_state_notifications_permission_value,
app_state_set_notifications_permission_value,
+ app_state_timestamp_ms,
app_setup_eula_date,
app_setup_finalize_with_key,
app_setup_step_default,
@@ -65,6 +74,12 @@ use crate::{
RadrootsAppRole,
RadrootsAppSettingsPage,
RadrootsAppSetupDraft,
+ RadrootsAppSetupFlowDraft,
+ RadrootsAppSetupKeyChoice,
+ RadrootsAppSetupFarmerChoice,
+ RadrootsAppSetupBusinessChoice,
+ RadrootsAppSetupLock,
+ RadrootsAppSetupLockStatus,
RadrootsAppUiDemoPage,
RadrootsAppSetupStep,
RadrootsAppTangleClientStub,
@@ -162,37 +177,6 @@ fn setup_touch_callback(action: &'static str) -> Callback<MouseEvent> {
})
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RadrootsAppSetupKeyChoice {
- Generate,
- AddExisting,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RadrootsAppSetupFarmerChoice {
- Yes,
- No,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RadrootsAppSetupBusinessChoice {
- Yes,
- No,
-}
-
-fn setup_role_from_choices(
- farmer_choice: Option<RadrootsAppSetupFarmerChoice>,
- business_choice: Option<RadrootsAppSetupBusinessChoice>,
-) -> Option<RadrootsAppRole> {
- match farmer_choice? {
- RadrootsAppSetupFarmerChoice::Yes => Some(RadrootsAppRole::Farm),
- RadrootsAppSetupFarmerChoice::No => match business_choice? {
- RadrootsAppSetupBusinessChoice::Yes => Some(RadrootsAppRole::Business),
- RadrootsAppSetupBusinessChoice::No => Some(RadrootsAppRole::Individual),
- },
- }
-}
-
fn active_key_label(value: Option<String>) -> String {
let Some(value) = value else {
return t!("app.common.missing");
@@ -335,6 +319,42 @@ fn SetupPage() -> impl IntoView {
let profile_name = RwSignal::new_local(String::new());
let profile_nip05 = RwSignal::new_local(true);
let setup_draft_loaded = RwSignal::new_local(false);
+ let setup_lock_owner = RwSignal::new_local(Uuid::new_v4().to_string());
+ let setup_lock_status = RwSignal::new_local(None::<RadrootsAppSetupLockStatus>);
+ let setup_lock_attempted = RwSignal::new_local(false);
+ let setup_flow = move || RadrootsAppSetupFlowDraft {
+ step: setup_step.get(),
+ key_choice: setup_key_choice.get(),
+ farmer_choice: setup_farmer_choice.get(),
+ business_choice: setup_business_choice.get(),
+ profile_name: profile_name.get(),
+ profile_nip05: profile_nip05.get(),
+ };
+ let setup_validation = move || app_setup_flow_validate(&setup_flow());
+ let setup_lock_ready = move || {
+ !app_setup_lock_enabled()
+ || matches!(
+ setup_lock_status.get(),
+ Some(RadrootsAppSetupLockStatus::Acquired(_))
+ )
+ };
+ let setup_locked = move || {
+ matches!(
+ setup_lock_status.get(),
+ Some(RadrootsAppSetupLockStatus::Locked(_))
+ )
+ };
+ let setup_lock_pending = move || app_setup_lock_enabled() && setup_lock_status.get().is_none();
+ let retry_setup_lock: Callback<MouseEvent> = {
+ let setup_lock_status = setup_lock_status.clone();
+ let setup_lock_attempted = setup_lock_attempted.clone();
+ let setup_lock_owner = setup_lock_owner.clone();
+ Callback::new(move |_| {
+ setup_lock_status.set(None);
+ setup_lock_attempted.set(false);
+ setup_lock_owner.set(Uuid::new_v4().to_string());
+ })
+ };
let on_generate_key = setup_touch_callback("generate_key");
let on_add_key = setup_touch_callback("add_key");
Effect::new(move || {
@@ -344,7 +364,54 @@ fn SetupPage() -> impl IntoView {
});
Effect::new({
let backends = backends.clone();
+ let setup_lock_status = setup_lock_status.clone();
+ let setup_lock_attempted = setup_lock_attempted.clone();
+ let setup_lock_owner = setup_lock_owner.clone();
+ move |_| {
+ if setup_lock_attempted.get() {
+ return;
+ }
+ if !app_setup_lock_enabled() {
+ setup_lock_attempted.set(true);
+ return;
+ }
+ let Some((datastore, key_maps)) = backends
+ .with(|value| value.as_ref().map(|backends| (backends.datastore.clone(), backends.config.datastore.key_maps.clone())))
+ else {
+ return;
+ };
+ setup_lock_attempted.set(true);
+ let owner = setup_lock_owner.get();
+ let setup_lock_status = setup_lock_status.clone();
+ spawn_local(async move {
+ let now_ms = u64::try_from(app_state_timestamp_ms()).unwrap_or(0);
+ let ttl_ms = app_setup_lock_ttl_ms();
+ match app_setup_lock_acquire(
+ datastore.as_ref(),
+ &key_maps,
+ &owner,
+ now_ms,
+ ttl_ms,
+ )
+ .await
+ {
+ Ok(status) => setup_lock_status.set(Some(status)),
+ Err(err) => {
+ let _ = app_log_error_emit(&err);
+ let fallback = RadrootsAppSetupLock {
+ owner,
+ expires_at_ms: now_ms.saturating_add(ttl_ms),
+ };
+ setup_lock_status.set(Some(RadrootsAppSetupLockStatus::Acquired(fallback)));
+ }
+ }
+ });
+ }
+ });
+ Effect::new({
+ let backends = backends.clone();
let setup_draft_loaded = setup_draft_loaded.clone();
+ let setup_lock_status = setup_lock_status.clone();
let setup_key_choice = setup_key_choice.clone();
let setup_farmer_choice = setup_farmer_choice.clone();
let setup_business_choice = setup_business_choice.clone();
@@ -352,6 +419,14 @@ fn SetupPage() -> impl IntoView {
let profile_name = profile_name.clone();
let profile_nip05 = profile_nip05.clone();
move |_| {
+ if app_setup_lock_enabled()
+ && !matches!(
+ setup_lock_status.get(),
+ Some(RadrootsAppSetupLockStatus::Acquired(_))
+ )
+ {
+ return;
+ }
if setup_draft_loaded.get() {
return;
}
@@ -375,6 +450,7 @@ fn SetupPage() -> impl IntoView {
Effect::new({
let backends = backends.clone();
let setup_draft_loaded = setup_draft_loaded.clone();
+ let setup_lock_status = setup_lock_status.clone();
let setup_key_choice = setup_key_choice.clone();
let setup_farmer_choice = setup_farmer_choice.clone();
let setup_business_choice = setup_business_choice.clone();
@@ -382,6 +458,14 @@ fn SetupPage() -> impl IntoView {
let profile_name = profile_name.clone();
let profile_nip05 = profile_nip05.clone();
move |_| {
+ if app_setup_lock_enabled()
+ && !matches!(
+ setup_lock_status.get(),
+ Some(RadrootsAppSetupLockStatus::Acquired(_))
+ )
+ {
+ return;
+ }
if !setup_draft_loaded.get() {
return;
}
@@ -408,7 +492,7 @@ fn SetupPage() -> impl IntoView {
} else {
Some(profile_value)
};
- let role = setup_role_from_choices(
+ let role = app_setup_flow_role_from_choices(
setup_farmer_choice.get(),
setup_business_choice.get(),
);
@@ -429,21 +513,39 @@ fn SetupPage() -> impl IntoView {
let setup_key_choice = setup_key_choice.clone();
let setup_farmer_choice = setup_farmer_choice.clone();
let setup_business_choice = setup_business_choice.clone();
+ 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();
Callback::new(move |_| {
- let current_step = setup_step.get();
+ if app_setup_lock_enabled()
+ && !matches!(
+ setup_lock_status.get(),
+ Some(RadrootsAppSetupLockStatus::Acquired(_))
+ )
+ {
+ return;
+ }
+ let draft = RadrootsAppSetupFlowDraft {
+ step: setup_step.get(),
+ key_choice: setup_key_choice.get(),
+ farmer_choice: setup_farmer_choice.get(),
+ business_choice: setup_business_choice.get(),
+ profile_name: profile_name.get(),
+ profile_nip05: profile_nip05.get(),
+ };
+ let validation = app_setup_flow_validate(&draft);
+ let current_step = draft.step;
if matches!(current_step, RadrootsAppSetupStep::Eula) {
- let key_choice = setup_key_choice.get();
- let setup_role = setup_role_from_choices(
+ let key_choice = draft.key_choice;
+ let setup_role = app_setup_flow_role_from_choices(
setup_farmer_choice.get(),
setup_business_choice.get(),
)
.unwrap_or_else(RadrootsAppRole::default);
let nostr_key_add = nostr_key_add.get();
- let profile_name = profile_name.get();
- let profile_nip05 = profile_nip05.get();
+ 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 backends = backends.clone();
@@ -537,55 +639,32 @@ fn SetupPage() -> impl IntoView {
return;
}
let _ = app_datastore_clear_setup_draft(datastore.as_ref(), &key_maps).await;
+ if app_setup_lock_enabled() {
+ let _ = app_setup_lock_release(datastore.as_ref(), &key_maps).await;
+ }
setup_required.set(Some(false));
});
return;
}
+ if !validation.can_continue {
+ return;
+ }
if matches!(current_step, RadrootsAppSetupStep::Profile) {
- let profile_name = profile_name.get();
- if profile_nip05.get() && profile_name.trim().is_empty() {
- return;
- }
- if profile_name.trim().is_empty() {
+ if draft.profile_name.trim().is_empty() {
let setup_step = setup_step.clone();
let confirm_message = t!("app.setup.profile.confirm_no_name");
+ let next_step = validation.next_step;
spawn_local(async move {
let notifications = RadrootsAppNotifications::new(None);
let confirm = notifications.confirm_message(&confirm_message).await;
if confirm {
- setup_step.set(RadrootsAppSetupStep::FarmerSetup);
+ setup_step.set(next_step);
}
});
return;
}
- setup_step.set(RadrootsAppSetupStep::FarmerSetup);
- return;
}
- setup_step.update(|step| {
- *step = match *step {
- RadrootsAppSetupStep::Intro => RadrootsAppSetupStep::KeyChoice,
- RadrootsAppSetupStep::KeyChoice => {
- match setup_key_choice.get() {
- Some(RadrootsAppSetupKeyChoice::Generate) => {
- RadrootsAppSetupStep::Profile
- }
- Some(RadrootsAppSetupKeyChoice::AddExisting) => {
- RadrootsAppSetupStep::KeyAddExisting
- }
- None => RadrootsAppSetupStep::KeyChoice,
- }
- }
- RadrootsAppSetupStep::KeyAddExisting => RadrootsAppSetupStep::Profile,
- RadrootsAppSetupStep::Profile => RadrootsAppSetupStep::FarmerSetup,
- RadrootsAppSetupStep::FarmerSetup => match setup_farmer_choice.get() {
- Some(RadrootsAppSetupFarmerChoice::Yes) => RadrootsAppSetupStep::Eula,
- Some(RadrootsAppSetupFarmerChoice::No) => RadrootsAppSetupStep::BusinessSetup,
- None => RadrootsAppSetupStep::FarmerSetup,
- },
- RadrootsAppSetupStep::BusinessSetup => RadrootsAppSetupStep::Eula,
- RadrootsAppSetupStep::Eula => RadrootsAppSetupStep::Eula,
- };
- });
+ setup_step.set(validation.next_step);
})
};
let advance_step_click: Callback<MouseEvent> = {
@@ -598,27 +677,34 @@ fn SetupPage() -> impl IntoView {
let setup_step = setup_step.clone();
let setup_key_choice = setup_key_choice.clone();
let setup_farmer_choice = setup_farmer_choice.clone();
+ let setup_business_choice = setup_business_choice.clone();
+ let setup_lock_status = setup_lock_status.clone();
+ let profile_name = profile_name.clone();
+ let profile_nip05 = profile_nip05.clone();
Callback::new(move |_| {
- let current_step = setup_step.get();
- let next_step = match current_step {
- RadrootsAppSetupStep::Intro => RadrootsAppSetupStep::Intro,
- RadrootsAppSetupStep::KeyChoice => RadrootsAppSetupStep::Intro,
- RadrootsAppSetupStep::KeyAddExisting => RadrootsAppSetupStep::KeyChoice,
- RadrootsAppSetupStep::Profile => match setup_key_choice.get() {
- Some(RadrootsAppSetupKeyChoice::AddExisting) => {
- RadrootsAppSetupStep::KeyAddExisting
- }
- _ => RadrootsAppSetupStep::KeyChoice,
- },
- RadrootsAppSetupStep::FarmerSetup => RadrootsAppSetupStep::Profile,
- RadrootsAppSetupStep::BusinessSetup => RadrootsAppSetupStep::FarmerSetup,
- RadrootsAppSetupStep::Eula => match setup_farmer_choice.get() {
- Some(RadrootsAppSetupFarmerChoice::No) => RadrootsAppSetupStep::BusinessSetup,
- _ => RadrootsAppSetupStep::FarmerSetup,
- },
+ if app_setup_lock_enabled()
+ && !matches!(
+ setup_lock_status.get(),
+ Some(RadrootsAppSetupLockStatus::Acquired(_))
+ )
+ {
+ return;
+ }
+ let draft = RadrootsAppSetupFlowDraft {
+ step: setup_step.get(),
+ key_choice: setup_key_choice.get(),
+ farmer_choice: setup_farmer_choice.get(),
+ business_choice: setup_business_choice.get(),
+ profile_name: profile_name.get(),
+ profile_nip05: profile_nip05.get(),
};
- setup_step.set(next_step);
- if matches!(next_step, RadrootsAppSetupStep::Intro) {
+ let validation = app_setup_flow_validate(&draft);
+ if !validation.can_back {
+ return;
+ }
+ let prev_step = validation.prev_step;
+ setup_step.set(prev_step);
+ if matches!(prev_step, RadrootsAppSetupStep::Intro) {
setup_key_choice.set(None);
}
})
@@ -655,7 +741,68 @@ fn SetupPage() -> impl IntoView {
id="app-setup"
class="app-page app-page-fixed relative w-full flex flex-col"
>
- {move || match setup_step.get() {
+ {move || {
+ if setup_lock_pending() {
+ return view! {
+ <section
+ id="app-setup-lock-pending"
+ class="app-view app-view-enter flex flex-col h-[100dvh] w-full px-6 pt-10 pb-16"
+ >
+ <div
+ id="app-setup-lock-pending-body"
+ class="flex flex-1 w-full flex-col justify-center items-center gap-4"
+ >
+ <RadrootsAppUiSpinner class="text-[24px]".to_string() />
+ <p class="font-sans font-[600] text-ly0-gl text-2xl text-center">
+ {t!("app.setup.lock.pending.title")}
+ </p>
+ <p class="font-mono font-[400] text-ly0-gl text-base text-center">
+ {t!("app.setup.lock.pending.body")}
+ </p>
+ </div>
+ </section>
+ }
+ .into_any();
+ }
+ if setup_locked() {
+ return view! {
+ <section
+ id="app-setup-lock"
+ class="app-view app-view-enter flex flex-col h-[100dvh] w-full px-6 pt-10 pb-16"
+ >
+ <div
+ id="app-setup-lock-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.setup.locked.title")}
+ </p>
+ <p class="font-mono font-[400] text-ly0-gl text-base text-center">
+ {t!("app.setup.locked.body")}
+ </p>
+ </div>
+ <div
+ id="app-setup-lock-actions"
+ class="flex flex-col w-full pt-4 justify-center items-center"
+ >
+ {{
+ let retry_action = RadrootsAppUiButtonLayoutAction {
+ label: t!("app.setup.lock.retry"),
+ disabled: false,
+ loading: false,
+ on_click: retry_setup_lock.clone(),
+ class: None,
+ class_label: None,
+ style: None,
+ };
+ view! { <RadrootsAppUiButtonLayoutPair continue_action=retry_action class="gap-2".to_string() /> }
+ }}
+ </div>
+ </section>
+ }
+ .into_any();
+ }
+ match setup_step.get() {
RadrootsAppSetupStep::Intro => {
let navigate_home = navigate_home.clone();
view! {
@@ -1211,25 +1358,22 @@ fn SetupPage() -> impl IntoView {
</div>
</section>
}.into_any(),
+ }
}}
<footer
id="app-setup-actions"
class="z-10 absolute bottom-4 left-0 flex flex-col w-full justify-center items-center se-compact:bottom-0"
>
{move || {
+ if !setup_lock_ready() {
+ return view! { <></> }.into_any();
+ }
let step = setup_step.get();
if matches!(step, RadrootsAppSetupStep::Eula) {
return view! { <></> }.into_any();
}
- let continue_disabled = (matches!(step, RadrootsAppSetupStep::KeyChoice)
- && setup_key_choice.get().is_none())
- || (matches!(step, RadrootsAppSetupStep::FarmerSetup)
- && setup_farmer_choice.get().is_none())
- || (matches!(step, RadrootsAppSetupStep::BusinessSetup)
- && setup_business_choice.get().is_none())
- || (matches!(step, RadrootsAppSetupStep::Profile)
- && profile_nip05.get()
- && profile_name.get().trim().is_empty());
+ let validation = setup_validation();
+ let continue_disabled = !validation.can_continue;
let continue_label = t!("app.common.continue");
let back_label = t!("app.common.back");
let continue_action = RadrootsAppUiButtonLayoutAction {
@@ -1242,7 +1386,7 @@ fn SetupPage() -> impl IntoView {
style: None,
};
let back_action = RadrootsAppUiButtonLayoutBackAction {
- visible: !matches!(step, RadrootsAppSetupStep::Intro),
+ visible: validation.can_back,
label: Some(back_label),
disabled: false,
on_click: rewind_step.clone(),