commit 2ca15c6d9a018e462ec8cde2d5d208e4926798f0
parent 7e67a4b1726f17764678a9cf3455668deb9893b4
Author: triesap <tyson@radroots.org>
Date: Mon, 3 Aug 2026 23:57:19 +0000
custody: make generated recovery exclusive
- replace the account surface with a one-key recovery route
- support explicit acknowledgement and cancellation paths
- clear transient recovery material after either terminal action
- cover exclusive presentation commit and cancellation behavior
Diffstat:
5 files changed, 75 insertions(+), 8 deletions(-)
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
@@ -41,6 +41,7 @@ data class StudioUiActions(
val importSecretKey: () -> Unit = {},
val copyText: (String) -> Unit = {},
val acknowledgeGeneratedKeyBackup: () -> Unit = {},
+ val cancelGeneratedKeyBackup: () -> Unit = {},
val selectAccount: (String) -> Unit = {},
val activateAccount: (String) -> Unit = {},
val requestAccountRemoval: (String) -> Unit = {},
@@ -72,6 +73,10 @@ fun StudioScreen(
model: StudioUiModel,
actions: StudioUiActions,
) {
+ model.generatedKeyBackup?.let { backup ->
+ GeneratedKeyRecoveryScreen(backup, actions)
+ return
+ }
when (model.route) {
StudioRoute.OPENING -> LifecycleScreen("Opening local account store", "lifecycle-opening")
StudioRoute.CHECKING_COMPATIBILITY -> LifecycleScreen(
@@ -215,10 +220,6 @@ private fun InactiveAccountsScreen(
AccountEntry(model, actions)
- model.generatedKeyBackup?.let { backup ->
- GeneratedKeyBackupPanel(backup, actions)
- }
-
model.problem?.let {
BasicText(it, Modifier.testTag("accounts-problem"))
}
@@ -366,15 +367,15 @@ private fun ColumnScope.SavedAccountList(
}
@Composable
-private fun GeneratedKeyBackupPanel(
+private fun GeneratedKeyRecoveryScreen(
backup: GeneratedKeyBackupUiModel,
actions: StudioUiActions,
) {
Column(
modifier = Modifier
- .fillMaxWidth()
- .background(InputBackgroundColor)
- .padding(12.dp)
+ .fillMaxSize()
+ .background(WindowBackgroundColor)
+ .padding(24.dp)
.testTag("generated-key-backup"),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -389,6 +390,12 @@ private fun GeneratedKeyBackupPanel(
onClick = { actions.copyText(backup.nsec) },
)
TextAction(
+ text = "Cancel",
+ testTag = "cancel-generated-key",
+ contentDescription = "Cancel generated account",
+ onClick = actions.cancelGeneratedKeyBackup,
+ )
+ TextAction(
text = "I have saved this key",
testTag = "acknowledge-key-backup",
contentDescription = "Confirm generated key backup",
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
@@ -49,6 +49,7 @@ fun RadrootsApplication(
importSecretKey = store::importSecretKey,
copyText = { value -> clipboard.copy(value) },
acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup,
+ cancelGeneratedKeyBackup = store::cancelGeneratedKeyBackup,
selectAccount = store::selectAccount,
activateAccount = store::activateAccount,
requestAccountRemoval = store::requestAccountRemoval,
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
@@ -141,6 +141,23 @@ class StudioAppStore(
}
}
+ fun cancelGeneratedKeyBackup() {
+ val recovery = pendingGeneratedRecovery ?: run {
+ rejectUnavailableIntent("Generated-key recovery is not available.")
+ return
+ }
+ pendingGeneratedRecovery = null
+ launchCommand {
+ try {
+ recovery.cancel()
+ generatedRecovery.acknowledge()
+ mutableState.value = mutableState.value.copy(generatedKeyBackup = null)
+ } finally {
+ recovery.close()
+ }
+ }
+ }
+
fun importSecretKey() {
if (rejectIfUnavailable()) return
val input = mutableState.value.importDraft.encodeToByteArray()
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
@@ -118,6 +118,8 @@ class StudioScreenTest {
}
onNodeWithTag("generated-key-backup").assertIsDisplayed()
+ onAllNodesWithTag("accounts-screen").assertCountEquals(0)
+ onAllNodesWithTag("generate-key").assertCountEquals(0)
onNodeWithTag("generated-nsec").assertIsDisplayed()
onNodeWithTag("copy-generated-key").performClick()
assertEquals("nsec1generated", copied)
@@ -128,6 +130,29 @@ class StudioScreenTest {
}
@Test
+ fun generatedKeyRecoveryCanBeCancelledWithoutExposingAccountControls() = runComposeUiTest {
+ var backup: GeneratedKeyBackupUiModel? by mutableStateOf(
+ GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"),
+ )
+ var cancelled = 0
+ setContent {
+ StudioScreen(
+ model = emptyUiModel().copy(generatedKeyBackup = backup),
+ actions = StudioUiActions(
+ cancelGeneratedKeyBackup = {
+ cancelled += 1
+ backup = null
+ },
+ ),
+ )
+ }
+
+ onNodeWithTag("cancel-generated-key").performClick()
+ assertEquals(1, cancelled)
+ onAllNodesWithTag("generated-key-backup").assertCountEquals(0)
+ }
+
+ @Test
fun savedAccountsSelectActivateAndRequireRemovalConfirmation() = runComposeUiTest {
val first = accountUi("11".repeat(32), selected = true)
val second = accountUi("22".repeat(32), selected = false)
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
@@ -57,6 +57,23 @@ class StudioAppStoreTest {
}
@Test
+ fun `cancels staged generated account without committing it`() = runTest {
+ val gateway = FakeStudioCoreGateway(snapshot(0UL))
+ val store = StudioAppStore(gateway, this)
+ advanceUntilIdle()
+ val revisionBeforeGeneration = store.state.value.snapshot.revision
+ store.generateAccount()
+ advanceUntilIdle()
+
+ store.cancelGeneratedKeyBackup()
+ advanceUntilIdle()
+
+ assertNull(store.state.value.generatedKeyBackup)
+ assertEquals(revisionBeforeGeneration, store.state.value.snapshot.revision)
+ store.close()
+ }
+
+ @Test
fun `ignores observer delivery after close`() = runTest {
val gateway = FakeStudioCoreGateway(snapshot(0UL))
val store = StudioAppStore(gateway, this)