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