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:
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",