app

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

commit 8b5aaf5f7a084a25fd8fd1f7731b89f7922bb35c
parent f300c50132a008822ace4b45aae2871619030f4c
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 00:02:15 +0000

accounts: expose removal impact and recovery

- carry credential session and expiry impact through UniFFI
- project removal confirmation progress and terminal state
- describe exact local effects before destructive confirmation
- retain safe retry actions only for replayable commands

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt | 10++++++++++
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 25++++++++++++++++++++++++-
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 1+
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt | 12+++++++++++-
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt | 13+++++++++++++
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 9+++++++++
Mcore/crates/ffi/src/commands.rs | 19+++++++++++++++++++
8 files changed, 144 insertions(+), 5 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 @@ -3,6 +3,8 @@ package org.radroots.studio.accounts.ui import org.radroots.studio.application.StudioStoreState import org.radroots.studio.application.StudioRoute import org.radroots.studio.application.AccountEntryMode +import org.radroots.studio.application.RemovalImpactState +import org.radroots.studio.application.RemovalStatus import org.radroots.studio.ffi.AccountDto import org.radroots.studio.ffi.ActiveAccountDto import org.radroots.studio.ffi.ProfileLoadStateDto @@ -52,12 +54,16 @@ data class StudioUiModel( val importDraft: String, val generatedKeyBackup: GeneratedKeyBackupUiModel?, val pendingRemovalPublicKeyHex: String?, + val removalImpact: RemovalImpactState?, + val removalStatus: RemovalStatus, + val lastRemovedPublicKeyHex: String?, val accountChooserVisible: Boolean, val accountEntryMode: AccountEntryMode, val session: SessionStateDto, val busy: Boolean, val problem: String?, val importGuidance: String?, + val recoveryAction: WireRecoveryAction, ) fun StudioStoreState.toUiModel(): StudioUiModel { @@ -79,6 +85,9 @@ fun StudioStoreState.toUiModel(): StudioUiModel { GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.revealNsec()) }, pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex, + removalImpact = removalImpact, + removalStatus = removalStatus, + lastRemovedPublicKeyHex = lastRemovedPublicKeyHex, accountChooserVisible = accountChooserVisible, accountEntryMode = accountEntryMode, session = snapshot.session, @@ -88,6 +97,7 @@ fun StudioStoreState.toUiModel(): StudioUiModel { ?: snapshot.sessionError?.message ?: snapshot.lifecycleError?.message, importGuidance = importGuidance(lastFailureCode, recoveryAction), + recoveryAction = recoveryAction, ) } 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 @@ -48,6 +48,7 @@ data class StudioUiActions( val cancelAccountRemoval: () -> Unit = {}, val confirmAccountRemoval: () -> Unit = {}, val refreshActiveProfile: () -> Unit = {}, + val retryLastCommand: () -> Unit = {}, val signOut: () -> Unit = {}, val showAccountChooser: () -> Unit = {}, val hideAccountChooser: () -> Unit = {}, @@ -185,6 +186,7 @@ private fun ActiveAccountHome( onClick = actions.signOut, ) model.problem?.let { BasicText(it, Modifier.testTag("home-problem")) } + RecoveryAction(model, actions) } } @@ -223,6 +225,7 @@ private fun InactiveAccountsScreen( model.problem?.let { BasicText(it, Modifier.testTag("accounts-problem")) } + RecoveryAction(model, actions) if (model.accounts.isEmpty()) { BasicText("No saved accounts.", Modifier.testTag("accounts-empty")) @@ -233,6 +236,19 @@ private fun InactiveAccountsScreen( } @Composable +private fun RecoveryAction(model: StudioUiModel, actions: StudioUiActions) { + if (model.recoveryAction == org.radroots.studio.ffi.WireRecoveryAction.RETRY) { + TextAction( + text = "Retry", + testTag = "retry-last-command", + contentDescription = "Retry the last failed action", + enabled = !model.busy, + onClick = actions.retryLastCommand, + ) + } +} + +@Composable private fun AccountEntry(model: StudioUiModel, actions: StudioUiActions) { when (model.accountEntryMode) { AccountEntryMode.CHOICE -> { @@ -351,7 +367,13 @@ private fun ColumnScope.SavedAccountList( onClick = { actions.requestAccountRemoval(account.publicKeyHex) }, ) if (model.pendingRemovalPublicKeyHex == account.publicKeyHex) { - BasicText("Remove this saved account and its local credential?") + BasicText("Remove this saved account?") + if (model.removalImpact?.deletesLocalCredential == true) { + BasicText("Its local credential will be deleted from the operating-system keyring.") + } + if (model.removalImpact?.signsOut == true) { + BasicText("The active session will be signed out before removal.") + } TextAction( text = "Cancel", testTag = "remove-cancel", @@ -362,6 +384,7 @@ private fun ColumnScope.SavedAccountList( text = "Confirm removal", testTag = "remove-confirm", contentDescription = "Confirm account removal", + enabled = !model.busy, onClick = actions.confirmAccountRemoval, ) } diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt @@ -56,6 +56,7 @@ fun RadrootsApplication( cancelAccountRemoval = store::cancelAccountRemoval, confirmAccountRemoval = store::confirmAccountRemoval, refreshActiveProfile = store::refreshActiveProfile, + retryLastCommand = store::retryLastCommand, signOut = store::signOut, showAccountChooser = store::showAccountChooser, hideAccountChooser = store::hideAccountChooser, 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 @@ -42,12 +42,30 @@ enum class AccountEntryMode { IMPORT, } +enum class RemovalStatus { + NONE, + AWAITING_CONFIRMATION, + CONFIRMING, + COMPLETED, + FAILED, +} + +data class RemovalImpactState( + val publicKeyHex: String, + val deletesLocalCredential: Boolean, + val signsOut: Boolean, + val expiresAtSeconds: Long, +) + data class StudioStoreState( val snapshot: AppSnapshotDto, val route: StudioRoute = snapshot.toStudioRoute(), val importDraft: String = "", val generatedKeyBackup: GeneratedKeyBackup? = null, val pendingRemovalPublicKeyHex: String? = null, + val removalImpact: RemovalImpactState? = null, + val removalStatus: RemovalStatus = RemovalStatus.NONE, + val lastRemovedPublicKeyHex: String? = null, val accountChooserVisible: Boolean = false, val accountEntryMode: AccountEntryMode = AccountEntryMode.CHOICE, val busy: Boolean = false, @@ -71,6 +89,7 @@ class StudioAppStore( private var pendingRemoval: RemovalTicket? = null private var pendingGeneratedRecovery: GeneratedRecoveryTicket? = null private var command: Job? = null + private var retryableCommand: StudioCommand? = null val state: State<StudioStoreState> get() = mutableState @@ -195,6 +214,14 @@ class StudioAppStore( runTypedCommand(StudioCommand.RefreshProfile) } + fun retryLastCommand() { + val retry = retryableCommand ?: run { + rejectUnavailableIntent("This action cannot be retried safely.") + return + } + runTypedCommand(retry) + } + fun requestAccountRemoval(publicKeyHex: String) { launchCommand { runCatching { @@ -208,6 +235,13 @@ class StudioAppStore( pendingRemoval = ticket mutableState.value = mutableState.value.copy( pendingRemovalPublicKeyHex = publicKeyHex, + removalImpact = RemovalImpactState( + ticket.publicKeyHex, + ticket.deletesLocalCredential, + ticket.signsOut, + ticket.expiresAtSeconds, + ), + removalStatus = RemovalStatus.AWAITING_CONFIRMATION, ) }.getOrThrow() } @@ -216,7 +250,11 @@ class StudioAppStore( fun cancelAccountRemoval() { pendingRemoval?.close() pendingRemoval = null - mutableState.value = mutableState.value.copy(pendingRemovalPublicKeyHex = null) + mutableState.value = mutableState.value.copy( + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.NONE, + ) } fun confirmAccountRemoval() { @@ -225,14 +263,26 @@ class StudioAppStore( return } pendingRemoval = null + mutableState.value = mutableState.value.copy(removalStatus = RemovalStatus.CONFIRMING) runSnapshotCommand { try { gateway.confirmAccountRemoval(ticket).also { - mutableState.value = mutableState.value.copy(pendingRemovalPublicKeyHex = null) + mutableState.value = mutableState.value.copy( + pendingRemovalPublicKeyHex = null, + lastRemovedPublicKeyHex = ticket.publicKeyHex, + removalImpact = null, + removalStatus = RemovalStatus.COMPLETED, + ) } } finally { ticket.close() - mutableState.value = mutableState.value.copy(pendingRemovalPublicKeyHex = null) + if (mutableState.value.removalStatus != RemovalStatus.COMPLETED) { + mutableState.value = mutableState.value.copy( + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.FAILED, + ) + } } } } @@ -249,6 +299,7 @@ class StudioAppStore( launchCommand { when (val result = gateway.execute(command)) { is StudioCommandResult.Accepted -> { + retryableCommand = null acceptSnapshot(result.receipt.snapshot) mutableState.value = mutableState.value.copy( commandStatus = CommandStatus.ACCEPTED, @@ -261,6 +312,9 @@ class StudioAppStore( } } is StudioCommandResult.Rejected -> { + retryableCommand = command.takeIf { + result.failure.retryable && it !is StudioCommand.ImportAccount + } mutableState.value = mutableState.value.copy( commandStatus = if (result.failure.retryable) { CommandStatus.FAILED_RETRYABLE diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt @@ -16,7 +16,12 @@ import org.radroots.studio.ffi.WireErrorCategory import org.radroots.studio.ffi.WireErrorCode import org.radroots.studio.ffi.WireRecoveryAction -interface RemovalTicket : AutoCloseable +interface RemovalTicket : AutoCloseable { + val publicKeyHex: String + val deletesLocalCredential: Boolean + val signsOut: Boolean + val expiresAtSeconds: Long +} interface GeneratedRecoveryTicket : AutoCloseable { val account: AccountDto @@ -184,6 +189,11 @@ private class NativeSubscription( private class NativeRemovalTicket( val request: RemovalRequest, ) : RemovalTicket { + override val publicKeyHex: String = request.publicKeyHex() + override val deletesLocalCredential: Boolean = request.deletesLocalCredential() + override val signsOut: Boolean = request.signsOut() + override val expiresAtSeconds: Long = request.expiresAtSeconds() + override fun close() { request.close() } 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 @@ -20,6 +20,9 @@ import kotlin.test.assertTrue import org.radroots.studio.ffi.SessionStateDto import org.radroots.studio.application.StudioRoute import org.radroots.studio.application.AccountEntryMode +import org.radroots.studio.application.RemovalImpactState +import org.radroots.studio.application.RemovalStatus +import org.radroots.studio.ffi.WireRecoveryAction @OptIn(ExperimentalTestApi::class) class StudioScreenTest { @@ -166,6 +169,9 @@ class StudioScreenTest { model = emptyUiModel().copy( accounts = listOf(first, second), pendingRemovalPublicKeyHex = pendingRemoval, + removalImpact = pendingRemoval?.let { + RemovalImpactState(it, deletesLocalCredential = true, signsOut = true, expiresAtSeconds = 60) + }, ), actions = StudioUiActions( selectAccount = selected::add, @@ -185,6 +191,8 @@ class StudioScreenTest { assertEquals(listOf(second.publicKeyHex), activated) onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithText("Its local credential will be deleted from the operating-system keyring.").assertIsDisplayed() + onNodeWithText("The active session will be signed out before removal.").assertIsDisplayed() onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() assertEquals(null, pendingRemoval) onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() @@ -289,6 +297,7 @@ private fun emptyUiModel( importDraft: String = "", problem: String? = null, importGuidance: String? = null, + recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, ) = StudioUiModel( route = StudioRoute.ACCOUNTS, accounts = emptyList(), @@ -297,12 +306,16 @@ private fun emptyUiModel( importDraft = importDraft, generatedKeyBackup = null, pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.NONE, + lastRemovedPublicKeyHex = null, accountChooserVisible = false, accountEntryMode = AccountEntryMode.CHOICE, session = SessionStateDto.SIGNED_OUT, busy = false, problem = problem, importGuidance = importGuidance, + recoveryAction = recoveryAction, ) private fun accountUi( 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 @@ -103,6 +103,7 @@ class StudioAppStoreTest { assertNull(store.state.value.pendingRemovalPublicKeyHex) assertTrue(gateway.lastRemovalTicket?.closed == true) + assertEquals(RemovalStatus.FAILED, store.state.value.removalStatus) assertEquals("The application command failed.", store.state.value.problem) store.close() } @@ -145,6 +146,10 @@ class StudioAppStoreTest { assertEquals(CommandStatus.FAILED_RETRYABLE, store.state.value.commandStatus) assertEquals("request-retry", store.state.value.lastCommandRequestId) assertEquals("Storage is temporarily unavailable.", store.state.value.problem) + store.retryLastCommand() + advanceUntilIdle() + assertEquals(1, gateway.signOutCalls) + assertEquals(CommandStatus.ACCEPTED, store.state.value.commandStatus) store.close() } @@ -313,6 +318,10 @@ private class FakeGeneratedRecoveryTicket( } private class FakeRemovalTicket : RemovalTicket { + override val publicKeyHex: String = "00".repeat(32) + override val deletesLocalCredential: Boolean = true + override val signsOut: Boolean = false + override val expiresAtSeconds: Long = 60 var closed = false override fun close() { diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs @@ -154,6 +154,9 @@ impl GeneratedRecoveryRequest { #[derive(uniffi::Object)] pub struct RemovalRequest { public_key_hex: String, + deletes_local_credential: bool, + signs_out: bool, + expires_at_seconds: i64, token: Mutex<Option<RemovalConfirmationToken>>, } @@ -162,6 +165,18 @@ impl RemovalRequest { pub fn public_key_hex(&self) -> String { self.public_key_hex.clone() } + + pub fn deletes_local_credential(&self) -> bool { + self.deletes_local_credential + } + + pub fn signs_out(&self) -> bool { + self.signs_out + } + + pub fn expires_at_seconds(&self) -> i64 { + self.expires_at_seconds + } } pub(crate) struct RuntimeCore { @@ -402,8 +417,12 @@ impl StudioAppCore { .request_account_removal(public_key) .await .map(|token| { + let impact = token.impact(); Arc::new(RemovalRequest { public_key_hex, + deletes_local_credential: impact.deletes_local_credential(), + signs_out: impact.signs_out(), + expires_at_seconds: token.expires_at().as_seconds(), token: Mutex::new(Some(token)), }) })