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