app

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

commit 1e42dfa35d40caa78446638be03fefba504ff062
parent b051762edc891986f437dba9252e0dd38c5d8b69
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:54:30 +0000

lifecycle: project exhaustive native routes

- expose actor lifecycle phases through every snapshot DTO
- map opening migration recovery degradation and terminal routes
- gate account controls outside admitted lifecycle states
- cover every non-ready route in Compose UI tests

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt | 3+++
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 26++++++++++++++++++++------
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt | 33+++++++++++++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 4++--
Mcore/crates/ffi/src/commands.rs | 31++++++++++++++++++++++---------
Mcore/crates/ffi/src/dto.rs | 44+++++++++++++++++++++++++++++++++++++++++---
Mcore/crates/ffi/src/observer.rs | 6+++++-
8 files changed, 182 insertions(+), 28 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt @@ -1,6 +1,7 @@ package org.radroots.studio.accounts.ui import org.radroots.studio.application.StudioStoreState +import org.radroots.studio.application.StudioRoute import org.radroots.studio.ffi.AccountDto import org.radroots.studio.ffi.ActiveAccountDto import org.radroots.studio.ffi.ProfileLoadStateDto @@ -40,6 +41,7 @@ class GeneratedKeyBackupUiModel( } data class StudioUiModel( + val route: StudioRoute, val accounts: List<AccountUiModel>, val activeAccount: ActiveAccountUiModel?, val configuredRelays: List<String>, @@ -56,6 +58,7 @@ fun StudioStoreState.toUiModel(): StudioUiModel { val selectedPublicKeyHex = snapshot.selectedPublicKeyHex val accounts = snapshot.accounts.map { it.toUiModel(it.publicKeyHex == selectedPublicKeyHex) } return StudioUiModel( + route = route, accounts = accounts, activeAccount = snapshot.activeAccount?.toUiModel(selectedPublicKeyHex), configuredRelays = snapshot.configuredRelays, diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import org.radroots.studio.application.StudioRoute private val WindowBackgroundColor = Color(0xFFF5F5F2) private val ButtonBackgroundColor = Color(0xFFE7E7E2) @@ -67,14 +68,58 @@ fun StudioScreen( model: StudioUiModel, actions: StudioUiActions, ) { - if ( - model.session == org.radroots.studio.ffi.SessionStateDto.ACTIVE && - model.activeAccount != null && - !model.accountChooserVisible + when (model.route) { + StudioRoute.OPENING -> LifecycleScreen("Opening local account store", "lifecycle-opening") + StudioRoute.CHECKING_COMPATIBILITY -> LifecycleScreen( + "Checking native compatibility", + "lifecycle-compatibility", + ) + StudioRoute.ACQUIRING_OWNERSHIP -> LifecycleScreen( + "Acquiring local account store", + "lifecycle-ownership", + ) + StudioRoute.MIGRATING -> LifecycleScreen( + "Updating local account store", + "lifecycle-migrating", + ) + StudioRoute.RECOVERING -> LifecycleScreen( + "Recovering local account state", + "lifecycle-recovering", + ) + StudioRoute.SHUTTING_DOWN -> LifecycleScreen("Shutting down", "lifecycle-shutting-down") + StudioRoute.CLOSED -> LifecycleScreen("Closed", "lifecycle-closed") + StudioRoute.BLOCKED -> LifecycleScreen( + model.problem ?: "Local account access is blocked.", + "lifecycle-blocked", + ) + StudioRoute.FATAL -> LifecycleScreen( + model.problem ?: "The application could not continue.", + "lifecycle-fatal", + ) + StudioRoute.DEGRADED -> InactiveAccountsScreen(model, actions, degraded = true) + StudioRoute.ACTIVE_ACCOUNT -> { + if (model.activeAccount != null && !model.accountChooserVisible) { + ActiveAccountHome(model, model.activeAccount, actions) + } else { + InactiveAccountsScreen(model, actions) + } + } + StudioRoute.ACCOUNTS -> InactiveAccountsScreen(model, actions) + } +} + +@Composable +private fun LifecycleScreen(message: String, testTag: String) { + Column( + modifier = Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag(testTag), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - ActiveAccountHome(model, model.activeAccount, actions) - } else { - InactiveAccountsScreen(model, actions) + BasicText("radroots") + BasicText(message) } } @@ -138,6 +183,7 @@ private fun ActiveAccountHome( private fun InactiveAccountsScreen( model: StudioUiModel, actions: StudioUiActions, + degraded: Boolean = false, ) { Column( modifier = Modifier @@ -149,6 +195,9 @@ private fun InactiveAccountsScreen( ) { BasicText("radroots") BasicText("Accounts") + if (degraded) { + BasicText(model.problem ?: "Nostr relay access is unavailable. Local accounts remain available.") + } if (model.activeAccount != null) { BasicText("Choose an account to activate. The current account remains active until replacement succeeds.") diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt @@ -10,9 +10,16 @@ import org.radroots.studio.ffi.AppLifecycleDto import org.radroots.studio.ffi.StudioException enum class StudioRoute { - BOOTING, + OPENING, + CHECKING_COMPATIBILITY, + ACQUIRING_OWNERSHIP, + MIGRATING, + RECOVERING, ACCOUNTS, ACTIVE_ACCOUNT, + DEGRADED, + BLOCKED, + SHUTTING_DOWN, FATAL, CLOSED, } @@ -319,9 +326,16 @@ class StudioAppStore( } } -private fun AppSnapshotDto.toStudioRoute(): StudioRoute = when { - lifecycle == AppLifecycleDto.BOOTING -> StudioRoute.BOOTING - lifecycle == AppLifecycleDto.FATAL -> StudioRoute.FATAL - activeAccount != null -> StudioRoute.ACTIVE_ACCOUNT - else -> StudioRoute.ACCOUNTS +internal fun AppSnapshotDto.toStudioRoute(): StudioRoute = when (lifecycle) { + AppLifecycleDto.OPENING -> StudioRoute.OPENING + AppLifecycleDto.COMPATIBILITY_CHECKING -> StudioRoute.CHECKING_COMPATIBILITY + AppLifecycleDto.ACQUIRING_OWNERSHIP -> StudioRoute.ACQUIRING_OWNERSHIP + AppLifecycleDto.MIGRATING -> StudioRoute.MIGRATING + AppLifecycleDto.RECOVERING -> StudioRoute.RECOVERING + AppLifecycleDto.READY -> if (activeAccount != null) StudioRoute.ACTIVE_ACCOUNT else StudioRoute.ACCOUNTS + AppLifecycleDto.DEGRADED -> StudioRoute.DEGRADED + AppLifecycleDto.BLOCKED -> StudioRoute.BLOCKED + AppLifecycleDto.SHUTTING_DOWN -> StudioRoute.SHUTTING_DOWN + AppLifecycleDto.CLOSED -> StudioRoute.CLOSED + AppLifecycleDto.FATAL -> StudioRoute.FATAL } diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt @@ -17,10 +17,40 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import org.radroots.studio.ffi.SessionStateDto +import org.radroots.studio.application.StudioRoute @OptIn(ExperimentalTestApi::class) class StudioScreenTest { @Test + fun rendersEveryNonReadyLifecycleRouteWithoutAccountControls() = runComposeUiTest { + var model by mutableStateOf(emptyUiModel().copy(route = StudioRoute.OPENING)) + setContent { StudioScreen(model, StudioUiActions()) } + + val routes = listOf( + StudioRoute.OPENING to "lifecycle-opening", + StudioRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", + StudioRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", + StudioRoute.MIGRATING to "lifecycle-migrating", + StudioRoute.RECOVERING to "lifecycle-recovering", + StudioRoute.BLOCKED to "lifecycle-blocked", + StudioRoute.SHUTTING_DOWN to "lifecycle-shutting-down", + StudioRoute.FATAL to "lifecycle-fatal", + StudioRoute.CLOSED to "lifecycle-closed", + ) + routes.forEach { (route, tag) -> + model = emptyUiModel(problem = "Safe lifecycle problem").copy(route = route) + waitForIdle() + onNodeWithTag(tag).assertIsDisplayed() + onAllNodesWithTag("generate-key").assertCountEquals(0) + } + + model = emptyUiModel(problem = "Relay access is unavailable.").copy(route = StudioRoute.DEGRADED) + waitForIdle() + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithTag("accounts-problem").assertIsDisplayed() + } + + @Test fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = runComposeUiTest { var importDraft by mutableStateOf("") var generateCalls = 0 @@ -149,6 +179,7 @@ class StudioScreenTest { setContent { StudioScreen( model = emptyUiModel().copy( + route = StudioRoute.ACTIVE_ACCOUNT, accounts = listOf(account), activeAccount = active, configuredRelays = listOf("ws://localhost:8080"), @@ -191,6 +222,7 @@ class StudioScreenTest { setContent { StudioScreen( model = emptyUiModel().copy( + route = StudioRoute.ACTIVE_ACCOUNT, accounts = listOf(first, second), activeAccount = active, session = SessionStateDto.ACTIVE, @@ -221,6 +253,7 @@ private fun emptyUiModel( importDraft: String = "", problem: String? = null, ) = StudioUiModel( + route = StudioRoute.ACCOUNTS, accounts = emptyList(), activeAccount = null, configuredRelays = emptyList(), diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt @@ -150,11 +150,11 @@ class StudioAppStoreTest { @Test fun `projects boot fatal and terminal lifecycle failures`() = runTest { - val booting = snapshot(0UL, AppLifecycleDto.BOOTING) + val booting = snapshot(0UL, AppLifecycleDto.OPENING) val bootGateway = FakeStudioCoreGateway(booting, booting) val bootStore = StudioAppStore(bootGateway, this) advanceUntilIdle() - assertEquals(StudioRoute.BOOTING, bootStore.state.value.route) + assertEquals(StudioRoute.OPENING, bootStore.state.value.route) bootStore.close() val fatal = snapshot(1UL, AppLifecycleDto.FATAL) diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs @@ -172,6 +172,19 @@ pub(crate) struct RuntimeCore { pub(crate) closed: AtomicBool, } +impl RuntimeCore { + pub(crate) fn snapshot_dto(&self) -> AppSnapshotDto { + AppSnapshotDto::from_runtime(&self.actor.snapshot(), self.actor.lifecycle()) + } + + pub(crate) fn dto_for( + &self, + snapshot: &radroots_studio_application::AppSnapshot, + ) -> AppSnapshotDto { + AppSnapshotDto::from_runtime(snapshot, self.actor.lifecycle()) + } +} + #[derive(uniffi::Object)] pub struct StudioAppCore { pub(crate) inner: Arc<RuntimeCore>, @@ -204,13 +217,13 @@ impl StudioAppCore { .actor .bootstrap() .await - .map(|snapshot| (&snapshot).into()) + .map(|snapshot| self.inner.dto_for(&snapshot)) .map_err(StudioError::from) } #[must_use] pub fn snapshot(&self) -> AppSnapshotDto { - (&self.inner.actor.snapshot()).into() + self.inner.snapshot_dto() } /// Begins the exclusive generated-account recovery flow without persistence. @@ -250,7 +263,7 @@ impl StudioAppCore { .actor .acknowledge_generated_key_stage(request.handle.id()) .await - .map(|snapshot| (&snapshot).into()) + .map(|snapshot| self.inner.dto_for(&snapshot)) .map_err(StudioError::from) } @@ -300,7 +313,7 @@ impl StudioAppCore { ) .await .map(|_| { - let snapshot = AppSnapshotDto::from(&self.inner.actor.snapshot()); + let snapshot = self.inner.snapshot_dto(); AccountCommandReceiptDto { request_id: context.request_id.clone(), committed_revision: snapshot.revision, @@ -324,7 +337,7 @@ impl StudioAppCore { .actor .select_account(public_key) .await - .map(|snapshot| (&snapshot).into()) + .map(|snapshot| self.inner.dto_for(&snapshot)) .map_err(StudioError::from) } @@ -342,7 +355,7 @@ impl StudioAppCore { .actor .activate_account(public_key) .await - .map(|snapshot| (&snapshot).into()) + .map(|snapshot| self.inner.dto_for(&snapshot)) .map_err(StudioError::from) } @@ -356,7 +369,7 @@ impl StudioAppCore { .actor .sign_out() .await - .map(|snapshot| (&snapshot).into()) + .map(|snapshot| self.inner.dto_for(&snapshot)) .map_err(StudioError::from) } @@ -370,7 +383,7 @@ impl StudioAppCore { .actor .refresh_active_profile() .await - .map(|snapshot| (&snapshot).into()) + .map(|snapshot| self.inner.dto_for(&snapshot)) .map_err(StudioError::from) } @@ -416,7 +429,7 @@ impl StudioAppCore { .actor .confirm_account_removal(token) .await - .map(|snapshot| (&snapshot).into()) + .map(|snapshot| self.inner.dto_for(&snapshot)) .map_err(StudioError::from) } } diff --git a/core/crates/ffi/src/dto.rs b/core/crates/ffi/src/dto.rs @@ -1,6 +1,6 @@ use radroots_studio_application::{ ActiveAccountSnapshot, AppLifecycle, AppSnapshot, ProfileLoadState, RelayConnectionState, - SessionState, + RuntimeLifecycle, SessionState, }; use radroots_studio_domain::{ AccountSummary, BindingAvailability, ProfileMetadata, SafeError, SafeErrorCode, @@ -62,8 +62,16 @@ pub struct SafeErrorDto { #[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)] pub enum AppLifecycleDto { - Booting, + Opening, + CompatibilityChecking, + AcquiringOwnership, + Migrating, + Recovering, Ready, + Degraded, + Blocked, + ShuttingDown, + Closed, Fatal, } @@ -155,7 +163,7 @@ pub struct AppSnapshotDto { impl From<&AppSnapshot> for AppSnapshotDto { fn from(snapshot: &AppSnapshot) -> Self { let (lifecycle, lifecycle_error) = match snapshot.lifecycle() { - AppLifecycle::Booting => (AppLifecycleDto::Booting, None), + AppLifecycle::Booting => (AppLifecycleDto::Opening, None), AppLifecycle::Ready => (AppLifecycleDto::Ready, None), AppLifecycle::Fatal(error) => (AppLifecycleDto::Fatal, Some(error.into())), }; @@ -191,6 +199,36 @@ impl From<&AppSnapshot> for AppSnapshotDto { } } +impl AppSnapshotDto { + pub(crate) fn from_runtime(snapshot: &AppSnapshot, runtime: RuntimeLifecycle) -> Self { + let mut dto = Self::from(snapshot); + let (lifecycle, problem) = match runtime { + RuntimeLifecycle::Opening => (AppLifecycleDto::Opening, None), + RuntimeLifecycle::CompatibilityChecking => { + (AppLifecycleDto::CompatibilityChecking, None) + } + RuntimeLifecycle::AcquiringOwnership => (AppLifecycleDto::AcquiringOwnership, None), + RuntimeLifecycle::Migrating => (AppLifecycleDto::Migrating, None), + RuntimeLifecycle::Recovering => (AppLifecycleDto::Recovering, None), + RuntimeLifecycle::Ready => (AppLifecycleDto::Ready, None), + RuntimeLifecycle::Degraded(error) => { + (AppLifecycleDto::Degraded, Some(SafeErrorDto::from(error))) + } + RuntimeLifecycle::Blocked(error) => { + (AppLifecycleDto::Blocked, Some(SafeErrorDto::from(error))) + } + RuntimeLifecycle::ShuttingDown => (AppLifecycleDto::ShuttingDown, None), + RuntimeLifecycle::Closed => (AppLifecycleDto::Closed, None), + RuntimeLifecycle::Fatal(error) => { + (AppLifecycleDto::Fatal, Some(SafeErrorDto::from(error))) + } + }; + dto.lifecycle = lifecycle; + dto.lifecycle_error = problem; + dto + } +} + impl From<&AccountSummary> for AccountDto { fn from(account: &AccountSummary) -> Self { Self { diff --git a/core/crates/ffi/src/observer.rs b/core/crates/ffi/src/observer.rs @@ -89,10 +89,14 @@ impl StudioAppCore { .map_err(StudioError::from)?; let id = subscription.id(); let observer: Arc<dyn StudioChangeObserver> = Arc::from(observer); + let runtime_core = Arc::clone(&self.inner); let task = crate::commands::runtime().spawn(async move { while let Some(change) = subscription.receive().await { observer.on_change(SnapshotChangeDto { - snapshot: change.snapshot().into(), + snapshot: AppSnapshotDto::from_runtime( + change.snapshot(), + runtime_core.actor.lifecycle(), + ), previous_revision: change .previous_revision() .map(radroots_studio_application::SnapshotRevision::value),