app

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

commit dc600fb455af305ca8c316eeca2df35a6d8c68de
parent 743921106b95f57cafd9e9c2e54ac99d4edfc799
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 18:38:31 +0000

app: add typed startup identity choice contracts

- add logged out startup phase and signer entry projection types
- freeze bunker uri and discovery url parsing in the shared models crate
- extend app state with explicit in-memory startup flow commands
- add english startup choice copy keys and tests for the next launcher slice

Diffstat:
Mcrates/shared/i18n/src/keys.rs | 6++++++
Mcrates/shared/i18n/src/lib.rs | 41+++++++++++++++++++++++++++++++++++++++++
Mcrates/shared/models/Cargo.toml | 1+
Mcrates/shared/models/src/lib.rs | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/shared/state/src/lib.rs | 238++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mi18n/locales/en/messages.json | 6++++++
6 files changed, 550 insertions(+), 12 deletions(-)

diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -45,6 +45,12 @@ define_app_text_keys! { HomeSetupTagline => "home.setup.tagline", HomeSetupStarting => "home.setup.starting", HomeSetupCreateAccountAction => "home.setup.create_account", + HomeSetupContinueAction => "home.setup.continue_action", + HomeSetupGenerateKeyAction => "home.setup.generate_key_action", + HomeSetupConnectSignerAction => "home.setup.connect_signer_action", + HomeSetupSignerSourcePlaceholder => "home.setup.signer_source.placeholder", + HomeSetupSignerConnectAction => "home.setup.signer_connect_action", + HomeSetupBackAction => "home.setup.back_action", HomeFarmSetupOnboardingTitle => "home.farm_setup.onboarding.title", HomeFarmSetupOnboardingBody => "home.farm_setup.onboarding.body", HomeFarmSetupOnboardingAction => "home.farm_setup.onboarding.action", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -168,6 +168,47 @@ mod tests { } #[test] + fn startup_identity_choice_keys_remain_defined_in_the_typed_registry_source() { + let source = include_str!("keys.rs"); + + for entry in [ + "HomeSetupContinueAction => \"home.setup.continue_action\"", + "HomeSetupGenerateKeyAction => \"home.setup.generate_key_action\"", + "HomeSetupConnectSignerAction => \"home.setup.connect_signer_action\"", + "HomeSetupSignerSourcePlaceholder => \"home.setup.signer_source.placeholder\"", + "HomeSetupSignerConnectAction => \"home.setup.signer_connect_action\"", + "HomeSetupBackAction => \"home.setup.back_action\"", + ] { + assert!( + source.contains(entry), + "typed startup identity-choice registry is missing {entry}" + ); + } + } + + #[test] + fn english_startup_identity_choice_copy_matches_the_next_launcher_contract() { + assert_eq!(app_text(AppTextKey::HomeSetupContinueAction), "Continue"); + assert_eq!( + app_text(AppTextKey::HomeSetupGenerateKeyAction), + "Generate key" + ); + assert_eq!( + app_text(AppTextKey::HomeSetupConnectSignerAction), + "Connect signer" + ); + assert_eq!( + app_text(AppTextKey::HomeSetupSignerSourcePlaceholder), + "Paste bunker URI or discovery URL" + ); + assert_eq!( + app_text(AppTextKey::HomeSetupSignerConnectAction), + "Connect signer" + ); + assert_eq!(app_text(AppTextKey::HomeSetupBackAction), "Back"); + } + + #[test] fn english_products_workflow_copy_matches_the_editor_contract() { assert_eq!(app_text(AppTextKey::ProductsAddAction), "Add product"); assert_eq!(app_text(AppTextKey::ProductsEditorTitle), "Product details"); diff --git a/crates/shared/models/Cargo.toml b/crates/shared/models/Cargo.toml @@ -9,6 +9,7 @@ publish = false [dependencies] serde.workspace = true +url = "2" uuid.workspace = true [lints] diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use std::{collections::BTreeSet, error::Error, fmt, str::FromStr}; +use url::Url; use uuid::Uuid; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] @@ -401,6 +402,150 @@ impl AppStartupGate { } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoggedOutStartupPhase { + #[default] + ContinuePrompt, + IdentityChoice, + GenerateKeyStarting, + SignerEntry, +} + +impl LoggedOutStartupPhase { + pub const fn storage_key(self) -> &'static str { + match self { + Self::ContinuePrompt => "continue_prompt", + Self::IdentityChoice => "identity_choice", + Self::GenerateKeyStarting => "generate_key_starting", + Self::SignerEntry => "signer_entry", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StartupSignerSourceKind { + BunkerUri, + DiscoveryUrl, +} + +impl StartupSignerSourceKind { + pub const fn storage_key(self) -> &'static str { + match self { + Self::BunkerUri => "bunker_uri", + Self::DiscoveryUrl => "discovery_url", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ParseStartupSignerSourceError { + EmptyInput, + UnsupportedClientUri, + UnsupportedSource, + MissingDiscoveryUri, +} + +impl fmt::Display for ParseStartupSignerSourceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyInput => formatter.write_str("signer source input must not be empty"), + Self::UnsupportedClientUri => formatter.write_str( + "client nostrconnect URIs are not accepted by the app signer entry flow", + ), + Self::UnsupportedSource => { + formatter.write_str("signer source input must be a bunker URI or discovery URL") + } + Self::MissingDiscoveryUri => { + formatter.write_str("discovery URL must include a non-empty uri query parameter") + } + } + } +} + +impl Error for ParseStartupSignerSourceError {} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum StartupSignerSource { + BunkerUri(String), + DiscoveryUrl(String), +} + +impl StartupSignerSource { + pub const fn kind(&self) -> StartupSignerSourceKind { + match self { + Self::BunkerUri(_) => StartupSignerSourceKind::BunkerUri, + Self::DiscoveryUrl(_) => StartupSignerSourceKind::DiscoveryUrl, + } + } + + pub fn value(&self) -> &str { + match self { + Self::BunkerUri(value) | Self::DiscoveryUrl(value) => value, + } + } +} + +impl FromStr for StartupSignerSource { + type Err = ParseStartupSignerSourceError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(ParseStartupSignerSourceError::EmptyInput); + } + + if trimmed.starts_with("nostrconnect://") { + return Err(ParseStartupSignerSourceError::UnsupportedClientUri); + } + + if trimmed.starts_with("bunker://") { + return Ok(Self::BunkerUri(trimmed.to_owned())); + } + + let url = + Url::parse(trimmed).map_err(|_| ParseStartupSignerSourceError::UnsupportedSource)?; + let has_discovery_uri = url + .query_pairs() + .any(|(key, value)| key == "uri" && !value.trim().is_empty()); + + if !has_discovery_uri { + return Err(ParseStartupSignerSourceError::MissingDiscoveryUri); + } + + Ok(Self::DiscoveryUrl(trimmed.to_owned())) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct StartupSignerEntryProjection { + pub source_input: String, +} + +impl StartupSignerEntryProjection { + pub fn new(source_input: impl Into<String>) -> Self { + Self { + source_input: source_input.into(), + } + } + + pub fn parsed_source(&self) -> Result<StartupSignerSource, ParseStartupSignerSourceError> { + self.source_input.parse() + } + + pub fn set_source_input(&mut self, source_input: impl Into<String>) { + self.source_input = source_input.into(); + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct LoggedOutStartupProjection { + pub phase: LoggedOutStartupPhase, + pub signer_entry: StartupSignerEntryProjection, +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub struct AppIdentityProjection { pub readiness: IdentityReadiness, @@ -1134,12 +1279,14 @@ mod tests { AppIdentityProjection, AppStartupGate, FarmId, FarmOrderMethod, FarmSetupBlocker, FarmSetupDraft, FarmSetupProjection, FarmSetupReadiness, FarmSetupSection, FarmerActivationProjection, FarmerSection, IdentityBlockedReason, IdentityReadiness, - OrderListRow, ProductAttentionState, ProductAvailabilityState, ProductAvailabilitySummary, - ProductEditorDraft, ProductListRow, ProductPricePresentation, ProductPublishBlocker, - ProductStatus, ProductStockState, ProductStockSummary, ProductsFilter, - ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort, + LoggedOutStartupPhase, LoggedOutStartupProjection, OrderListRow, + ParseStartupSignerSourceError, ProductAttentionState, ProductAvailabilityState, + ProductAvailabilitySummary, ProductEditorDraft, ProductListRow, ProductPricePresentation, + ProductPublishBlocker, ProductStatus, ProductStockState, ProductStockSummary, + ProductsFilter, ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort, SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, - ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, + ShellSection, StartupSignerEntryProjection, StartupSignerSource, StartupSignerSourceKind, + TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use std::{collections::BTreeSet, str::FromStr}; use uuid::Uuid; @@ -1361,6 +1508,119 @@ mod tests { } #[test] + fn logged_out_startup_defaults_to_continue_prompt_with_empty_signer_entry() { + assert_eq!( + LoggedOutStartupProjection::default(), + LoggedOutStartupProjection { + phase: LoggedOutStartupPhase::ContinuePrompt, + signer_entry: StartupSignerEntryProjection::default(), + } + ); + } + + #[test] + fn logged_out_startup_phase_and_signer_source_kind_storage_keys_are_stable() { + assert_eq!( + LoggedOutStartupPhase::ContinuePrompt.storage_key(), + "continue_prompt" + ); + assert_eq!( + LoggedOutStartupPhase::IdentityChoice.storage_key(), + "identity_choice" + ); + assert_eq!( + LoggedOutStartupPhase::GenerateKeyStarting.storage_key(), + "generate_key_starting" + ); + assert_eq!( + LoggedOutStartupPhase::SignerEntry.storage_key(), + "signer_entry" + ); + assert_eq!( + StartupSignerSourceKind::BunkerUri.storage_key(), + "bunker_uri" + ); + assert_eq!( + StartupSignerSourceKind::DiscoveryUrl.storage_key(), + "discovery_url" + ); + } + + #[test] + fn startup_signer_source_parses_direct_bunker_uri_and_discovery_url() { + let bunker_uri = + "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example&secret=test-secret"; + let discovery_url = + format!("https://signer.radroots.example/connect?uri={bunker_uri}&label=field"); + + let bunker_source = bunker_uri + .parse::<StartupSignerSource>() + .expect("bunker uri should parse"); + let discovery_source = discovery_url + .parse::<StartupSignerSource>() + .expect("discovery url should parse"); + + assert_eq!( + bunker_source, + StartupSignerSource::BunkerUri(bunker_uri.to_owned()) + ); + assert_eq!(bunker_source.kind(), StartupSignerSourceKind::BunkerUri); + assert_eq!(bunker_source.value(), bunker_uri); + assert_eq!( + discovery_source, + StartupSignerSource::DiscoveryUrl(discovery_url.clone()) + ); + assert_eq!( + discovery_source.kind(), + StartupSignerSourceKind::DiscoveryUrl + ); + assert_eq!(discovery_source.value(), discovery_url); + } + + #[test] + fn startup_signer_source_rejects_empty_client_uri_and_missing_discovery_uri() { + assert_eq!( + "".parse::<StartupSignerSource>(), + Err(ParseStartupSignerSourceError::EmptyInput) + ); + assert_eq!( + "nostrconnect://npub1client?relay=wss%3A%2F%2Frelay.radroots.example&secret=test" + .parse::<StartupSignerSource>(), + Err(ParseStartupSignerSourceError::UnsupportedClientUri) + ); + assert_eq!( + "https://signer.radroots.example/connect".parse::<StartupSignerSource>(), + Err(ParseStartupSignerSourceError::MissingDiscoveryUri) + ); + assert_eq!( + "not a signer source".parse::<StartupSignerSource>(), + Err(ParseStartupSignerSourceError::UnsupportedSource) + ); + } + + #[test] + fn signer_entry_projection_exposes_the_typed_source_contract() { + let mut projection = StartupSignerEntryProjection::new( + " bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example ", + ); + + assert_eq!( + projection.parsed_source(), + Ok(StartupSignerSource::BunkerUri( + "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example".to_owned() + )) + ); + + projection.set_source_input("https://signer.radroots.example/connect?uri=bunker://npub1"); + assert_eq!( + projection.parsed_source(), + Ok(StartupSignerSource::DiscoveryUrl( + "https://signer.radroots.example/connect?uri=bunker://npub1".to_owned() + )) + ); + } + + #[test] fn typed_ids_round_trip_through_strings() { let uuid = Uuid::parse_str("018f4d61-19b0-7cc4-9d4e-6d0df7c0aa11") .expect("test uuid should parse"); diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -2,9 +2,10 @@ use radroots_app_models::{ ActiveSurface, AppIdentityProjection, AppStartupGate, FarmSetupProjection, FarmSetupReadiness, - ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, - ProductsSort, SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, - SettingsSection, ShellSection, TodayAgendaProjection, + LoggedOutStartupPhase, LoggedOutStartupProjection, ProductEditorDraft, ProductId, + ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, + SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection, + ShellSection, TodayAgendaProjection, }; use thiserror::Error; @@ -281,6 +282,7 @@ pub struct AppProjection { pub shell: AppShellProjection, pub identity: AppIdentityProjection, pub startup_gate: AppStartupGate, + pub logged_out_startup: LoggedOutStartupProjection, pub today: TodayAgendaProjection, pub products: ProductsScreenProjection, pub farm_setup: FarmSetupProjection, @@ -306,6 +308,7 @@ impl AppProjection { shell, identity, startup_gate: AppStartupGate::default(), + logged_out_startup: LoggedOutStartupProjection::default(), today, products: ProductsScreenProjection::default(), farm_setup, @@ -348,6 +351,11 @@ pub enum AppStateCommand { SelectActiveSurface(ActiveSurface), SelectSection(ShellSection), SelectSettingsSection(SettingsSection), + ShowStartupIdentityChoice, + BeginGenerateKeyStartup, + ShowStartupSignerEntry, + SetStartupSignerSourceInput(String), + ResetLoggedOutStartup, ReplaceIdentityProjection(AppIdentityProjection), ReplaceFarmSetupProjection(FarmSetupProjection), SelectFarmSetupFlowStage(FarmSetupFlowStage), @@ -378,6 +386,26 @@ impl AppStateCommand { Self::SelectSettingsSection(section) } + pub const fn show_startup_identity_choice() -> Self { + Self::ShowStartupIdentityChoice + } + + pub const fn begin_generate_key_startup() -> Self { + Self::BeginGenerateKeyStartup + } + + pub const fn show_startup_signer_entry() -> Self { + Self::ShowStartupSignerEntry + } + + pub fn set_startup_signer_source_input(source_input: impl Into<String>) -> Self { + Self::SetStartupSignerSourceInput(source_input.into()) + } + + pub const fn reset_logged_out_startup() -> Self { + Self::ResetLoggedOutStartup + } + pub fn replace_identity_projection(projection: AppIdentityProjection) -> Self { Self::ReplaceIdentityProjection(projection) } @@ -543,6 +571,10 @@ impl<R: AppStateRepository> AppStateStore<R> { &self.projection.farm_setup } + pub fn logged_out_startup_projection(&self) -> &LoggedOutStartupProjection { + &self.projection.logged_out_startup + } + pub fn products_projection(&self) -> &ProductsScreenProjection { &self.projection.products } @@ -580,6 +612,11 @@ impl<R: AppStateRepository> AppStateStore<R> { Ok(true) } + AppStateMutation::StartupChanged => { + self.projection = next_projection; + + Ok(true) + } AppStateMutation::TodayChanged => { self.projection = next_projection; @@ -622,6 +659,11 @@ impl AppStateStore<InMemoryAppStateRepository> { true } + AppStateMutation::StartupChanged => { + self.projection = next_projection; + + true + } AppStateMutation::TodayChanged => { self.projection = next_projection; @@ -641,6 +683,7 @@ enum AppStateMutation { NoChange, ShellChanged, FarmSetupChanged, + StartupChanged, TodayChanged, ProductsChanged, } @@ -667,6 +710,32 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap AppStateCommand::SelectSettingsSection(selected_section) => { projection.shell.select_settings_section(selected_section); } + AppStateCommand::ShowStartupIdentityChoice => { + if projection.startup_gate == AppStartupGate::SetupRequired { + projection.logged_out_startup.phase = LoggedOutStartupPhase::IdentityChoice; + } + } + AppStateCommand::BeginGenerateKeyStartup => { + if projection.startup_gate == AppStartupGate::SetupRequired { + projection.logged_out_startup.phase = LoggedOutStartupPhase::GenerateKeyStarting; + } + } + AppStateCommand::ShowStartupSignerEntry => { + if projection.startup_gate == AppStartupGate::SetupRequired { + projection.logged_out_startup.phase = LoggedOutStartupPhase::SignerEntry; + } + } + AppStateCommand::SetStartupSignerSourceInput(source_input) => { + if projection.startup_gate == AppStartupGate::SetupRequired { + projection + .logged_out_startup + .signer_entry + .set_source_input(source_input); + } + } + AppStateCommand::ResetLoggedOutStartup => { + projection.logged_out_startup = LoggedOutStartupProjection::default(); + } AppStateCommand::ReplaceIdentityProjection(identity_projection) => { projection.identity = identity_projection; } @@ -725,6 +794,8 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap || projection.farm_setup_flow_stage != before.farm_setup_flow_stage { AppStateMutation::FarmSetupChanged + } else if projection.logged_out_startup != before.logged_out_startup { + AppStateMutation::StartupChanged } else if projection.products != before.products { AppStateMutation::ProductsChanged } else { @@ -736,6 +807,7 @@ fn sync_projection(projection: &mut AppProjection) { sync_shell_to_identity(&mut projection.shell, &projection.identity); sync_farm_setup_to_today(&mut projection.farm_setup, &projection.today); projection.startup_gate = projection.identity.startup_gate(); + sync_logged_out_startup(&mut projection.logged_out_startup, projection.startup_gate); sync_farm_setup_flow_stage( &mut projection.farm_setup_flow_stage, projection.startup_gate, @@ -778,6 +850,15 @@ fn sync_farm_setup_flow_stage( } } +fn sync_logged_out_startup( + logged_out_startup: &mut LoggedOutStartupProjection, + startup_gate: AppStartupGate, +) { + if startup_gate != AppStartupGate::SetupRequired { + *logged_out_startup = LoggedOutStartupProjection::default(); + } +} + #[cfg(test)] mod tests { use super::{ @@ -789,10 +870,11 @@ mod tests { use radroots_app_models::{ AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, - FarmerActivationProjection, FarmerSection, FulfillmentWindowId, ProductEditorDraft, - ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, - SelectedAccountProjection, SelectedSurfaceProjection, SettingsSection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + FarmerActivationProjection, FarmerSection, FulfillmentWindowId, LoggedOutStartupPhase, + LoggedOutStartupProjection, ProductEditorDraft, ProductId, ProductPublishBlocker, + ProductsFilter, ProductsListProjection, ProductsSort, SelectedAccountProjection, + SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection, + TodaySetupTask, TodaySetupTaskKind, }; struct FailingRepository; @@ -835,6 +917,10 @@ mod tests { assert_eq!(projection.identity, AppIdentityProjection::default()); assert_eq!(projection.startup_gate, AppStartupGate::SetupRequired); assert_eq!( + projection.logged_out_startup, + LoggedOutStartupProjection::default() + ); + assert_eq!( projection.shell.settings.selected_section, SettingsSection::Account ); @@ -873,6 +959,10 @@ mod tests { SettingsSection::About ); assert_eq!(store.startup_gate(), AppStartupGate::SetupRequired); + assert_eq!( + store.logged_out_startup_projection(), + &LoggedOutStartupProjection::default() + ); assert_eq!(store.projection().today, TodayAgendaProjection::default()); assert_eq!( store.projection().products, @@ -932,6 +1022,71 @@ mod tests { } #[test] + fn startup_identity_choice_flow_is_explicit_and_in_memory_only() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + + assert_eq!( + store.logged_out_startup_projection(), + &LoggedOutStartupProjection::default() + ); + + assert_eq!( + store.apply(AppStateCommand::show_startup_identity_choice()), + Ok(true) + ); + assert_eq!( + store.logged_out_startup_projection().phase, + LoggedOutStartupPhase::IdentityChoice + ); + + assert_eq!( + store.apply(AppStateCommand::show_startup_signer_entry()), + Ok(true) + ); + assert_eq!( + store.logged_out_startup_projection().phase, + LoggedOutStartupPhase::SignerEntry + ); + + assert_eq!( + store.apply(AppStateCommand::set_startup_signer_source_input( + "https://signer.radroots.example/connect?uri=bunker://npub1signer", + )), + Ok(true) + ); + assert_eq!( + store + .logged_out_startup_projection() + .signer_entry + .source_input, + "https://signer.radroots.example/connect?uri=bunker://npub1signer" + ); + + assert_eq!( + store.apply(AppStateCommand::begin_generate_key_startup()), + Ok(true) + ); + assert_eq!( + store.logged_out_startup_projection().phase, + LoggedOutStartupPhase::GenerateKeyStarting + ); + assert_eq!( + store.repository().projection(), + &AppShellProjection::default() + ); + + assert_eq!( + store.apply(AppStateCommand::reset_logged_out_startup()), + Ok(true) + ); + assert_eq!( + store.logged_out_startup_projection(), + &LoggedOutStartupProjection::default() + ); + } + + #[test] fn product_editor_state_transitions_are_explicit() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load"); @@ -1087,6 +1242,75 @@ mod tests { } #[test] + fn startup_identity_choice_state_resets_once_identity_leaves_setup_required() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + + assert_eq!( + store.apply(AppStateCommand::show_startup_identity_choice()), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::show_startup_signer_entry()), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::set_startup_signer_source_input( + "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example", + )), + Ok(true) + ); + + assert_eq!( + store.apply(AppStateCommand::replace_identity_projection( + ready_identity(ActiveSurface::Personal), + )), + Ok(true) + ); + assert_eq!(store.startup_gate(), AppStartupGate::Personal); + assert_eq!( + store.logged_out_startup_projection(), + &LoggedOutStartupProjection::default() + ); + } + + #[test] + fn startup_identity_choice_commands_are_rejected_after_setup_gate_is_cleared() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + + assert_eq!( + store.apply(AppStateCommand::replace_identity_projection( + ready_identity(ActiveSurface::Personal), + )), + Ok(true) + ); + + assert_eq!( + store.apply(AppStateCommand::show_startup_identity_choice()), + Ok(false) + ); + assert_eq!( + store.apply(AppStateCommand::show_startup_signer_entry()), + Ok(false) + ); + assert_eq!( + store.apply(AppStateCommand::begin_generate_key_startup()), + Ok(false) + ); + assert_eq!( + store.apply(AppStateCommand::set_startup_signer_source_input( + "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example", + )), + Ok(false) + ); + assert_eq!( + store.logged_out_startup_projection(), + &LoggedOutStartupProjection::default() + ); + } + + #[test] fn select_active_surface_moves_personal_home_to_farmer_today() { let repository = InMemoryAppStateRepository::new(AppShellProjection::for_surface( ActiveSurface::Personal, diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -24,6 +24,12 @@ "home.setup.tagline": "Grow from the root", "home.setup.starting": "Starting...", "home.setup.create_account": "Create account", + "home.setup.continue_action": "Continue", + "home.setup.generate_key_action": "Generate key", + "home.setup.connect_signer_action": "Connect signer", + "home.setup.signer_source.placeholder": "Paste bunker URI or discovery URL", + "home.setup.signer_connect_action": "Connect signer", + "home.setup.back_action": "Back", "home.farm_setup.onboarding.title": "Set up your farm", "home.farm_setup.onboarding.body": "Add the basics now. You can change them later.", "home.farm_setup.onboarding.action": "Set up your farm",