app

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

commit 0eab5a3e38002dab01e7a7405c79532dbe241d2c
parent d19ae7a1206b5ca8ee89ed3e54f74b99f4dbcc1b
Author: triesap <tyson@radroots.org>
Date:   Sun,  9 Aug 2026 19:05:58 +0000

desktop: rename the HarvestCircle product API

- rename application stores, routes, commands, receipts, and gateways
- rename Compose models, actions, screens, and application entry points
- align product-owned tests, helpers, and detekt configuration
- retain transitional generated UniFFI type names for the native checkpoint

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/AccountsUiModel.kt | 12++++++------
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/HarvestCircleScreen.kt | 495+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/StudioScreen.kt | 495-------------------------------------------------------------------------------
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleAppStore.kt | 508+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleApplication.kt | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleCoreGateway.kt | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/RadrootsApplication.kt | 76----------------------------------------------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/StudioAppStore.kt | 508-------------------------------------------------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/StudioCoreGateway.kt | 307-------------------------------------------------------------------------------
Mapp/desktop/src/main/kotlin/org/radroots/harvestcircle/desktop/Main.kt | 4++--
Mapp/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/AccountsUiModelTest.kt | 10+++++-----
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/HarvestCircleScreenTest.kt | 384+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/StudioScreenTest.kt | 384-------------------------------------------------------------------------------
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleAppStoreTest.kt | 481+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleApplicationTest.kt | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleCoreGatewayTest.kt | 24++++++++++++++++++++++++
Mapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/NativeGeneratedRecoveryTest.kt | 2+-
Dapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/RadrootsApplicationTest.kt | 115-------------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/StudioAppStoreTest.kt | 481-------------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/StudioCoreGatewayTest.kt | 24------------------------
Mconfig/detekt/detekt.yml | 4++--
21 files changed, 2406 insertions(+), 2406 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/AccountsUiModel.kt @@ -1,10 +1,10 @@ package org.radroots.harvestcircle.accounts.ui import org.radroots.harvestcircle.application.AccountEntryMode +import org.radroots.harvestcircle.application.HarvestCircleRoute +import org.radroots.harvestcircle.application.HarvestCircleStoreState import org.radroots.harvestcircle.application.RemovalImpactState import org.radroots.harvestcircle.application.RemovalStatus -import org.radroots.harvestcircle.application.StudioRoute -import org.radroots.harvestcircle.application.StudioStoreState import org.radroots.harvestcircle.ffi.AccountDto import org.radroots.harvestcircle.ffi.ActiveAccountDto import org.radroots.harvestcircle.ffi.ProfileLoadStateDto @@ -46,8 +46,8 @@ class GeneratedKeyBackupUiModel( override fun toString(): String = "GeneratedKeyBackupUiModel(npub=$npub, nsec=[REDACTED])" } -data class StudioUiModel( - val route: StudioRoute, +data class HarvestCircleUiModel( + val route: HarvestCircleRoute, val accounts: List<AccountUiModel>, val activeAccount: ActiveAccountUiModel?, val configuredRelays: List<String>, @@ -66,7 +66,7 @@ data class StudioUiModel( val recoveryAction: WireRecoveryAction, ) -fun StudioStoreState.toUiModel(): StudioUiModel { +fun HarvestCircleStoreState.toUiModel(): HarvestCircleUiModel { val selectedPublicKeyHex = snapshot.selectedPublicKeyHex val activePublicKeyHex = snapshot.activeAccount?.account?.publicKeyHex val accounts = @@ -76,7 +76,7 @@ fun StudioStoreState.toUiModel(): StudioUiModel { active = it.publicKeyHex == activePublicKeyHex, ) } - return StudioUiModel( + return HarvestCircleUiModel( route = route, accounts = accounts, activeAccount = snapshot.activeAccount?.toUiModel(selectedPublicKeyHex), diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/HarvestCircleScreen.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/HarvestCircleScreen.kt @@ -0,0 +1,495 @@ +package org.radroots.harvestcircle.accounts.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.password +import androidx.compose.ui.semantics.role +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.harvestcircle.application.AccountEntryMode +import org.radroots.harvestcircle.application.HarvestCircleRoute + +private val WindowBackgroundColor = Color(0xFFF5F5F2) +private val ButtonBackgroundColor = Color(0xFFE7E7E2) +private val InputBackgroundColor = Color(0xFFFEFDF8) + +data class HarvestCircleUiActions( + val chooseCreateAccount: () -> Unit = {}, + val chooseImportAccount: () -> Unit = {}, + val cancelAccountEntry: () -> Unit = {}, + val editImportDraft: (String) -> Unit = {}, + val generateAccount: () -> Unit = {}, + 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 = {}, + val cancelAccountRemoval: () -> Unit = {}, + val confirmAccountRemoval: () -> Unit = {}, + val refreshActiveProfile: () -> Unit = {}, + val retryLastCommand: () -> Unit = {}, + val signOut: () -> Unit = {}, + val showAccountChooser: () -> Unit = {}, + val hideAccountChooser: () -> Unit = {}, +) + +@Composable +fun StartupFailureScreen(problem: String) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("startup-failure"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("radroots") + BasicText(problem, Modifier.testTag("startup-problem")) + } +} + +@Composable +fun HarvestCircleScreen( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + model.generatedKeyBackup?.let { backup -> + GeneratedKeyRecoveryScreen(backup, actions) + return + } + when (model.route) { + HarvestCircleRoute.OPENING -> LifecycleScreen("Opening local account store", "lifecycle-opening") + HarvestCircleRoute.CHECKING_COMPATIBILITY -> + LifecycleScreen( + "Checking native compatibility", + "lifecycle-compatibility", + ) + HarvestCircleRoute.ACQUIRING_OWNERSHIP -> + LifecycleScreen( + "Acquiring local account store", + "lifecycle-ownership", + ) + HarvestCircleRoute.MIGRATING -> + LifecycleScreen( + "Updating local account store", + "lifecycle-migrating", + ) + HarvestCircleRoute.RECOVERING -> + LifecycleScreen( + "Recovering local account state", + "lifecycle-recovering", + ) + HarvestCircleRoute.SHUTTING_DOWN -> LifecycleScreen("Shutting down", "lifecycle-shutting-down") + HarvestCircleRoute.CLOSED -> LifecycleScreen("Closed", "lifecycle-closed") + HarvestCircleRoute.BLOCKED -> + LifecycleScreen( + model.problem ?: "Local account access is blocked.", + "lifecycle-blocked", + ) + HarvestCircleRoute.FATAL -> + LifecycleScreen( + model.problem ?: "The application could not continue.", + "lifecycle-fatal", + ) + HarvestCircleRoute.DEGRADED -> InactiveAccountsScreen(model, actions, degraded = true) + HarvestCircleRoute.ACTIVE_ACCOUNT -> { + if (model.activeAccount != null && !model.accountChooserVisible) { + ActiveAccountHome(model, model.activeAccount, actions) + } else { + InactiveAccountsScreen(model, actions) + } + } + HarvestCircleRoute.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), + ) { + BasicText("radroots") + BasicText(message) + } +} + +@Composable +private fun ActiveAccountHome( + model: HarvestCircleUiModel, + active: ActiveAccountUiModel, + actions: HarvestCircleUiActions, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("home-screen"), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + BasicText("radroots") + BasicText(active.heading) + BasicText(active.account.npub, Modifier.testTag("active-npub")) + BasicText(active.account.publicKeyHex, Modifier.testTag("active-pubkey-hex")) + BasicText("Name: ${active.profile.name}", Modifier.testTag("active-profile-name")) + BasicText("Display name: ${active.profile.displayName}") + BasicText("NIP-05 (unverified): ${active.profile.nip05}") + BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) + BasicText("Picture: ${active.profile.picture}") + BasicText("Relay: ${active.relayState}", Modifier.testTag("relay-state")) + BasicText("Profile: ${active.profileState}", Modifier.testTag("profile-state")) + BasicText("Configured relays") + if (model.configuredRelays.isEmpty()) { + BasicText("None") + } else { + model.configuredRelays.forEach { relay -> BasicText(relay) } + } + TextAction( + text = "Switch account", + testTag = "switch-account", + contentDescription = "Choose another saved account", + enabled = !model.busy, + onClick = actions.showAccountChooser, + ) + TextAction( + text = "Refresh metadata", + testTag = "refresh-profile", + contentDescription = "Refresh active Nostr profile metadata", + enabled = !model.busy, + onClick = actions.refreshActiveProfile, + ) + TextAction( + text = "Sign out", + testTag = "sign-out", + contentDescription = "Sign out of the active account", + enabled = !model.busy, + onClick = actions.signOut, + ) + model.problem?.let { BasicText(it, Modifier.testTag("home-problem")) } + RecoveryAction(model, actions) + } +} + +@Composable +private fun InactiveAccountsScreen( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, + degraded: Boolean = false, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag("accounts-screen"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + 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.") + TextAction( + text = "Back to active account", + testTag = "return-home", + contentDescription = "Return to the active account", + onClick = actions.hideAccountChooser, + ) + } + + AccountEntry(model, actions) + + model.problem?.let { + BasicText(it, Modifier.testTag("accounts-problem")) + } + RecoveryAction(model, actions) + + if (model.accounts.isEmpty()) { + BasicText("No saved accounts.", Modifier.testTag("accounts-empty")) + } else { + SavedAccountList(model, actions) + } + } +} + +@Composable +private fun RecoveryAction( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + if (model.recoveryAction == org.radroots.harvestcircle.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: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + when (model.accountEntryMode) { + AccountEntryMode.CHOICE -> { + TextAction( + text = "Create account", + testTag = "choose-create-account", + contentDescription = "Create a new Nostr account", + enabled = !model.busy, + onClick = actions.chooseCreateAccount, + ) + TextAction( + text = "Import key", + testTag = "choose-import-account", + contentDescription = "Import an existing Nostr secret key", + enabled = !model.busy, + onClick = actions.chooseImportAccount, + ) + } + AccountEntryMode.CREATE -> { + TextAction( + text = "Back", + testTag = "cancel-account-entry", + contentDescription = "Return to account choices", + enabled = !model.busy, + onClick = actions.cancelAccountEntry, + ) + TextAction( + text = "Generate new key", + testTag = "generate-key", + contentDescription = "Generate a new Nostr key", + enabled = !model.busy && model.generatedKeyBackup == null, + onClick = actions.generateAccount, + ) + } + AccountEntryMode.IMPORT -> { + val importFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { importFocusRequester.requestFocus() } + TextAction( + text = "Back", + testTag = "cancel-account-entry", + contentDescription = "Return to account choices", + enabled = !model.busy, + onClick = actions.cancelAccountEntry, + ) + BasicTextField( + value = model.importDraft, + onValueChange = actions.editImportDraft, + enabled = !model.busy, + visualTransformation = PasswordVisualTransformation(), + modifier = + Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Nostr secret key" + password() + }.focusRequester(importFocusRequester) + .testTag("import-nsec-input") + .background(InputBackgroundColor) + .padding(8.dp), + decorationBox = { innerTextField -> + if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex") + innerTextField() + }, + ) + model.importGuidance?.let { guidance -> + BasicText(guidance, Modifier.testTag("import-guidance")) + } + TextAction( + text = "Add existing key", + testTag = "import-key", + contentDescription = "Import an existing Nostr secret key", + enabled = !model.busy && model.importDraft.isNotBlank(), + onClick = actions.importSecretKey, + ) + } + } +} + +@Composable +private fun ColumnScope.SavedAccountList( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .testTag("saved-account-list"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(model.accounts, key = AccountUiModel::publicKeyHex) { account -> + Column( + modifier = + Modifier + .fillMaxWidth() + .semantics { selected = account.selected } + .testTag("account-row:${account.publicKeyHex}") + .background(InputBackgroundColor) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + BasicText(account.label) + BasicText(account.npub) + BasicText("Key: ${account.keyAvailability}") + if (account.selected) BasicText("Selected") + if (account.active) BasicText("Active") + TextAction( + text = if (account.selected) "Selected account" else "Select", + testTag = "select-account:${account.publicKeyHex}", + contentDescription = "Select ${account.label}", + enabled = !model.busy && !account.selected, + onClick = { actions.selectAccount(account.publicKeyHex) }, + ) + TextAction( + text = if (account.active) "Active account" else "Activate", + testTag = "activate-account:${account.publicKeyHex}", + contentDescription = "Activate ${account.label}", + enabled = !model.busy && !account.active, + onClick = { actions.activateAccount(account.publicKeyHex) }, + ) + TextAction( + text = "Remove", + testTag = "remove-account:${account.publicKeyHex}", + contentDescription = "Remove ${account.label}", + enabled = !model.busy, + onClick = { actions.requestAccountRemoval(account.publicKeyHex) }, + ) + if (model.pendingRemovalPublicKeyHex == account.publicKeyHex) { + 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", + contentDescription = "Cancel account removal", + onClick = actions.cancelAccountRemoval, + ) + TextAction( + text = "Confirm removal", + testTag = "remove-confirm", + contentDescription = "Confirm account removal", + enabled = !model.busy, + onClick = actions.confirmAccountRemoval, + ) + } + } + } + } +} + +@Composable +private fun GeneratedKeyRecoveryScreen( + backup: GeneratedKeyBackupUiModel, + actions: HarvestCircleUiActions, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("generated-key-backup"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + BasicText("Save this key") + BasicText("Losing this secret key means losing access to the account.") + BasicText(backup.npub) + BasicText(backup.nsec, Modifier.testTag("generated-nsec")) + TextAction( + text = "Copy", + testTag = "copy-generated-key", + contentDescription = "Copy generated Nostr secret key", + 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", + onClick = actions.acknowledgeGeneratedKeyBackup, + ) + } +} + +@Composable +internal fun TextAction( + text: String, + testTag: String, + contentDescription: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + BasicText( + text = text, + modifier = + Modifier + .semantics { + role = Role.Button + this.contentDescription = contentDescription + if (!enabled) disabled() + }.testTag(testTag) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .background(ButtonBackgroundColor) + .padding(8.dp), + ) +} diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/StudioScreen.kt @@ -1,495 +0,0 @@ -package org.radroots.harvestcircle.accounts.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.password -import androidx.compose.ui.semantics.role -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.harvestcircle.application.AccountEntryMode -import org.radroots.harvestcircle.application.StudioRoute - -private val WindowBackgroundColor = Color(0xFFF5F5F2) -private val ButtonBackgroundColor = Color(0xFFE7E7E2) -private val InputBackgroundColor = Color(0xFFFEFDF8) - -data class StudioUiActions( - val chooseCreateAccount: () -> Unit = {}, - val chooseImportAccount: () -> Unit = {}, - val cancelAccountEntry: () -> Unit = {}, - val editImportDraft: (String) -> Unit = {}, - val generateAccount: () -> Unit = {}, - 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 = {}, - val cancelAccountRemoval: () -> Unit = {}, - val confirmAccountRemoval: () -> Unit = {}, - val refreshActiveProfile: () -> Unit = {}, - val retryLastCommand: () -> Unit = {}, - val signOut: () -> Unit = {}, - val showAccountChooser: () -> Unit = {}, - val hideAccountChooser: () -> Unit = {}, -) - -@Composable -fun StartupFailureScreen(problem: String) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("startup-failure"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("radroots") - BasicText(problem, Modifier.testTag("startup-problem")) - } -} - -@Composable -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( - "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), - ) { - BasicText("radroots") - BasicText(message) - } -} - -@Composable -private fun ActiveAccountHome( - model: StudioUiModel, - active: ActiveAccountUiModel, - actions: StudioUiActions, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("home-screen"), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - BasicText("radroots") - BasicText(active.heading) - BasicText(active.account.npub, Modifier.testTag("active-npub")) - BasicText(active.account.publicKeyHex, Modifier.testTag("active-pubkey-hex")) - BasicText("Name: ${active.profile.name}", Modifier.testTag("active-profile-name")) - BasicText("Display name: ${active.profile.displayName}") - BasicText("NIP-05 (unverified): ${active.profile.nip05}") - BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) - BasicText("Picture: ${active.profile.picture}") - BasicText("Relay: ${active.relayState}", Modifier.testTag("relay-state")) - BasicText("Profile: ${active.profileState}", Modifier.testTag("profile-state")) - BasicText("Configured relays") - if (model.configuredRelays.isEmpty()) { - BasicText("None") - } else { - model.configuredRelays.forEach { relay -> BasicText(relay) } - } - TextAction( - text = "Switch account", - testTag = "switch-account", - contentDescription = "Choose another saved account", - enabled = !model.busy, - onClick = actions.showAccountChooser, - ) - TextAction( - text = "Refresh metadata", - testTag = "refresh-profile", - contentDescription = "Refresh active Nostr profile metadata", - enabled = !model.busy, - onClick = actions.refreshActiveProfile, - ) - TextAction( - text = "Sign out", - testTag = "sign-out", - contentDescription = "Sign out of the active account", - enabled = !model.busy, - onClick = actions.signOut, - ) - model.problem?.let { BasicText(it, Modifier.testTag("home-problem")) } - RecoveryAction(model, actions) - } -} - -@Composable -private fun InactiveAccountsScreen( - model: StudioUiModel, - actions: StudioUiActions, - degraded: Boolean = false, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag("accounts-screen"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - 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.") - TextAction( - text = "Back to active account", - testTag = "return-home", - contentDescription = "Return to the active account", - onClick = actions.hideAccountChooser, - ) - } - - AccountEntry(model, actions) - - model.problem?.let { - BasicText(it, Modifier.testTag("accounts-problem")) - } - RecoveryAction(model, actions) - - if (model.accounts.isEmpty()) { - BasicText("No saved accounts.", Modifier.testTag("accounts-empty")) - } else { - SavedAccountList(model, actions) - } - } -} - -@Composable -private fun RecoveryAction( - model: StudioUiModel, - actions: StudioUiActions, -) { - if (model.recoveryAction == org.radroots.harvestcircle.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 -> { - TextAction( - text = "Create account", - testTag = "choose-create-account", - contentDescription = "Create a new Nostr account", - enabled = !model.busy, - onClick = actions.chooseCreateAccount, - ) - TextAction( - text = "Import key", - testTag = "choose-import-account", - contentDescription = "Import an existing Nostr secret key", - enabled = !model.busy, - onClick = actions.chooseImportAccount, - ) - } - AccountEntryMode.CREATE -> { - TextAction( - text = "Back", - testTag = "cancel-account-entry", - contentDescription = "Return to account choices", - enabled = !model.busy, - onClick = actions.cancelAccountEntry, - ) - TextAction( - text = "Generate new key", - testTag = "generate-key", - contentDescription = "Generate a new Nostr key", - enabled = !model.busy && model.generatedKeyBackup == null, - onClick = actions.generateAccount, - ) - } - AccountEntryMode.IMPORT -> { - val importFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { importFocusRequester.requestFocus() } - TextAction( - text = "Back", - testTag = "cancel-account-entry", - contentDescription = "Return to account choices", - enabled = !model.busy, - onClick = actions.cancelAccountEntry, - ) - BasicTextField( - value = model.importDraft, - onValueChange = actions.editImportDraft, - enabled = !model.busy, - visualTransformation = PasswordVisualTransformation(), - modifier = - Modifier - .fillMaxWidth() - .semantics { - contentDescription = "Nostr secret key" - password() - }.focusRequester(importFocusRequester) - .testTag("import-nsec-input") - .background(InputBackgroundColor) - .padding(8.dp), - decorationBox = { innerTextField -> - if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex") - innerTextField() - }, - ) - model.importGuidance?.let { guidance -> - BasicText(guidance, Modifier.testTag("import-guidance")) - } - TextAction( - text = "Add existing key", - testTag = "import-key", - contentDescription = "Import an existing Nostr secret key", - enabled = !model.busy && model.importDraft.isNotBlank(), - onClick = actions.importSecretKey, - ) - } - } -} - -@Composable -private fun ColumnScope.SavedAccountList( - model: StudioUiModel, - actions: StudioUiActions, -) { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .testTag("saved-account-list"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(model.accounts, key = AccountUiModel::publicKeyHex) { account -> - Column( - modifier = - Modifier - .fillMaxWidth() - .semantics { selected = account.selected } - .testTag("account-row:${account.publicKeyHex}") - .background(InputBackgroundColor) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - BasicText(account.label) - BasicText(account.npub) - BasicText("Key: ${account.keyAvailability}") - if (account.selected) BasicText("Selected") - if (account.active) BasicText("Active") - TextAction( - text = if (account.selected) "Selected account" else "Select", - testTag = "select-account:${account.publicKeyHex}", - contentDescription = "Select ${account.label}", - enabled = !model.busy && !account.selected, - onClick = { actions.selectAccount(account.publicKeyHex) }, - ) - TextAction( - text = if (account.active) "Active account" else "Activate", - testTag = "activate-account:${account.publicKeyHex}", - contentDescription = "Activate ${account.label}", - enabled = !model.busy && !account.active, - onClick = { actions.activateAccount(account.publicKeyHex) }, - ) - TextAction( - text = "Remove", - testTag = "remove-account:${account.publicKeyHex}", - contentDescription = "Remove ${account.label}", - enabled = !model.busy, - onClick = { actions.requestAccountRemoval(account.publicKeyHex) }, - ) - if (model.pendingRemovalPublicKeyHex == account.publicKeyHex) { - 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", - contentDescription = "Cancel account removal", - onClick = actions.cancelAccountRemoval, - ) - TextAction( - text = "Confirm removal", - testTag = "remove-confirm", - contentDescription = "Confirm account removal", - enabled = !model.busy, - onClick = actions.confirmAccountRemoval, - ) - } - } - } - } -} - -@Composable -private fun GeneratedKeyRecoveryScreen( - backup: GeneratedKeyBackupUiModel, - actions: StudioUiActions, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("generated-key-backup"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - BasicText("Save this key") - BasicText("Losing this secret key means losing access to the account.") - BasicText(backup.npub) - BasicText(backup.nsec, Modifier.testTag("generated-nsec")) - TextAction( - text = "Copy", - testTag = "copy-generated-key", - contentDescription = "Copy generated Nostr secret key", - 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", - onClick = actions.acknowledgeGeneratedKeyBackup, - ) - } -} - -@Composable -internal fun TextAction( - text: String, - testTag: String, - contentDescription: String, - enabled: Boolean = true, - onClick: () -> Unit, -) { - BasicText( - text = text, - modifier = - Modifier - .semantics { - role = Role.Button - this.contentDescription = contentDescription - if (!enabled) disabled() - }.testTag(testTag) - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .background(ButtonBackgroundColor) - .padding(8.dp), - ) -} diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleAppStore.kt @@ -0,0 +1,508 @@ +package org.radroots.harvestcircle.application + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.radroots.harvestcircle.ffi.AppLifecycleDto +import org.radroots.harvestcircle.ffi.AppSnapshotDto +import org.radroots.harvestcircle.ffi.StudioException +import org.radroots.harvestcircle.ffi.WireErrorCode +import org.radroots.harvestcircle.ffi.WireRecoveryAction + +enum class HarvestCircleRoute { + OPENING, + CHECKING_COMPATIBILITY, + ACQUIRING_OWNERSHIP, + MIGRATING, + RECOVERING, + ACCOUNTS, + ACTIVE_ACCOUNT, + DEGRADED, + BLOCKED, + SHUTTING_DOWN, + FATAL, + CLOSED, +} + +enum class CommandStatus { + IDLE, + RUNNING, + ACCEPTED, + REJECTED_BUSY, + REJECTED_CLOSED, + FAILED_RETRYABLE, + FAILED_TERMINAL, +} + +enum class AccountEntryMode { + CHOICE, + CREATE, + 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 HarvestCircleStoreState( + val snapshot: AppSnapshotDto, + val route: HarvestCircleRoute = snapshot.toHarvestCircleRoute(), + 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, + val commandStatus: CommandStatus = CommandStatus.IDLE, + val lastCommandRequestId: String? = null, + val lastFailureCode: WireErrorCode? = null, + val recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, + val problem: String? = null, +) + +const val MAX_IMPORT_SECRET_CHARS: Int = 128 + +class HarvestCircleAppStore( + private val gateway: HarvestCircleCoreGateway, + private val scope: CoroutineScope, +) : AutoCloseable { + private val mutableState = mutableStateOf(HarvestCircleStoreState(snapshot = gateway.snapshot())) + private var closed = false + private var subscription: AutoCloseable? = null + private var pendingRemoval: RemovalTicket? = null + private var pendingGeneratedRecovery: PendingGeneratedRecovery? = null + private var command: Job? = null + private var retryableCommand: HarvestCircleCommand? = null + + val state: State<HarvestCircleStoreState> + get() = mutableState + + init { + launchCommand { + val registered = + gateway.subscribeChanges { change -> + scope.launch { + if (!closed) acceptSnapshot(change.snapshot) + } + } + if (closed) { + registered.close() + return@launchCommand + } + subscription = registered + acceptSnapshot(gateway.bootstrap()) + } + } + + fun editImportDraft(value: String) { + mutableState.value = + mutableState.value.copy( + importDraft = value.take(MAX_IMPORT_SECRET_CHARS), + lastFailureCode = null, + recoveryAction = WireRecoveryAction.NONE, + problem = null, + ) + } + + fun chooseCreateAccount() { + mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.CREATE, problem = null) + } + + fun chooseImportAccount() { + mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.IMPORT, problem = null) + } + + fun cancelAccountEntry() { + mutableState.value = + mutableState.value.copy( + accountEntryMode = AccountEntryMode.CHOICE, + importDraft = "", + problem = null, + ) + } + + fun generateAccount() { + launchCommand { + val recovery = gateway.beginGeneratedAccount() + var installed = false + try { + val backup = GeneratedKeyBackup(recovery.account.npub, recovery.takeRecoveryNsec()) + pendingGeneratedRecovery = PendingGeneratedRecovery(recovery, backup) + mutableState.value = mutableState.value.copy(generatedKeyBackup = backup) + installed = true + } finally { + if (!installed) { + withContext(NonCancellable) { + runCatching { recovery.cancel() } + recovery.close() + } + } + } + } + } + + fun acknowledgeGeneratedKeyBackup() { + val recovery = + pendingGeneratedRecovery ?: run { + rejectUnavailableIntent("Generated-key recovery is not available.") + return + } + runSnapshotCommand { + try { + recovery.ticket.acknowledge() + } finally { + releaseGeneratedRecovery(recovery) + } + } + } + + fun cancelGeneratedKeyBackup() { + val recovery = + pendingGeneratedRecovery ?: run { + rejectUnavailableIntent("Generated-key recovery is not available.") + return + } + launchCommand { + try { + if (!recovery.ticket.cancel()) { + throw HarvestCircleGatewayException( + HarvestCircleCommandFailure( + code = WireErrorCode.INVALID_APPLICATION_STATE, + category = org.radroots.harvestcircle.ffi.WireErrorCategory.LIFECYCLE, + retryable = false, + recoveryAction = WireRecoveryAction.NONE, + correlationId = recovery.ticket.requestId, + safeMessage = "The generated-key recovery step was already closed.", + ), + ) + } + } finally { + releaseGeneratedRecovery(recovery) + } + } + } + + fun importSecretKey() { + if (rejectIfUnavailable()) return + val input = mutableState.value.importDraft.encodeToByteArray() + mutableState.value = mutableState.value.copy(importDraft = "") + runTypedCommand(HarvestCircleCommand.ImportAccount(input)) + } + + fun selectAccount(publicKeyHex: String) { + runTypedCommand(HarvestCircleCommand.SelectAccount(publicKeyHex)) + } + + fun activateAccount(publicKeyHex: String) { + runTypedCommand(HarvestCircleCommand.ActivateAccount(publicKeyHex), hideChooser = true) + } + + fun signOut() { + runTypedCommand(HarvestCircleCommand.SignOut, hideChooser = true) + } + + fun showAccountChooser() { + mutableState.value = mutableState.value.copy(accountChooserVisible = true, problem = null) + } + + fun hideAccountChooser() { + mutableState.value = mutableState.value.copy(accountChooserVisible = false) + } + + fun refreshActiveProfile() { + runTypedCommand(HarvestCircleCommand.RefreshProfile) + } + + fun retryLastCommand() { + val retry = + retryableCommand ?: run { + rejectUnavailableIntent("This action cannot be retried safely.") + return + } + runTypedCommand(retry) + } + + fun requestAccountRemoval(publicKeyHex: String) { + launchCommand { + runCatching { + pendingRemoval?.close() + pendingRemoval = null + val ticket = gateway.requestAccountRemoval(publicKeyHex) + if (closed) { + ticket.close() + return@runCatching + } + pendingRemoval = ticket + mutableState.value = + mutableState.value.copy( + pendingRemovalPublicKeyHex = publicKeyHex, + removalImpact = + RemovalImpactState( + ticket.publicKeyHex, + ticket.deletesLocalCredential, + ticket.signsOut, + ticket.expiresAtSeconds, + ), + removalStatus = RemovalStatus.AWAITING_CONFIRMATION, + ) + }.getOrThrow() + } + } + + fun cancelAccountRemoval() { + pendingRemoval?.close() + pendingRemoval = null + mutableState.value = + mutableState.value.copy( + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.NONE, + ) + } + + fun confirmAccountRemoval() { + val ticket = + pendingRemoval ?: run { + rejectUnavailableIntent("Account removal confirmation is not available.") + return + } + pendingRemoval = null + mutableState.value = mutableState.value.copy(removalStatus = RemovalStatus.CONFIRMING) + runSnapshotCommand { + try { + gateway.confirmAccountRemoval(ticket).also { + mutableState.value = + mutableState.value.copy( + pendingRemovalPublicKeyHex = null, + lastRemovedPublicKeyHex = ticket.publicKeyHex, + removalImpact = null, + removalStatus = RemovalStatus.COMPLETED, + ) + } + } finally { + ticket.close() + if (mutableState.value.removalStatus != RemovalStatus.COMPLETED) { + mutableState.value = + mutableState.value.copy( + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.FAILED, + ) + } + } + } + } + + fun dismissProblem() { + mutableState.value = mutableState.value.copy(problem = null) + } + + private fun runSnapshotCommand(operation: suspend () -> AppSnapshotDto) { + launchCommand { acceptSnapshot(operation()) } + } + + private fun runTypedCommand( + command: HarvestCircleCommand, + hideChooser: Boolean = false, + ) { + launchCommand { + when (val result = gateway.execute(command)) { + is HarvestCircleCommandResult.Accepted -> { + retryableCommand = null + acceptSnapshot(result.receipt.snapshot) + mutableState.value = + mutableState.value.copy( + commandStatus = CommandStatus.ACCEPTED, + lastCommandRequestId = result.receipt.requestId, + lastFailureCode = null, + recoveryAction = WireRecoveryAction.NONE, + ) + if (hideChooser) { + mutableState.value = mutableState.value.copy(accountChooserVisible = false) + } + } + is HarvestCircleCommandResult.Rejected -> { + retryableCommand = + command.takeIf { + result.failure.retryable && it !is HarvestCircleCommand.ImportAccount + } + mutableState.value = + mutableState.value.copy( + commandStatus = + if (result.failure.retryable) { + CommandStatus.FAILED_RETRYABLE + } else { + CommandStatus.FAILED_TERMINAL + }, + lastCommandRequestId = result.failure.correlationId, + lastFailureCode = result.failure.code, + recoveryAction = result.failure.recoveryAction, + problem = result.failure.safeMessage, + ) + } + } + } + } + + private fun launchCommand(operation: suspend () -> Unit) { + if (rejectIfUnavailable()) return + mutableState.value = + mutableState.value.copy( + busy = true, + commandStatus = CommandStatus.RUNNING, + problem = null, + ) + command = + scope.launch { + try { + operation() + if (mutableState.value.commandStatus == CommandStatus.RUNNING) { + mutableState.value = mutableState.value.copy(commandStatus = CommandStatus.ACCEPTED) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + acceptFailure(error) + } finally { + mutableState.value = mutableState.value.copy(busy = false) + } + } + } + + private fun rejectIfUnavailable(): Boolean { + if (closed) { + mutableState.value = + mutableState.value.copy( + commandStatus = CommandStatus.REJECTED_CLOSED, + problem = "The application runtime is closed.", + ) + return true + } + if (command?.isActive == true) { + mutableState.value = + mutableState.value.copy( + commandStatus = CommandStatus.REJECTED_BUSY, + problem = "The application is busy. Try again.", + ) + return true + } + if (mutableState.value.route !in setOf(HarvestCircleRoute.ACCOUNTS, HarvestCircleRoute.ACTIVE_ACCOUNT)) { + mutableState.value = + mutableState.value.copy( + commandStatus = CommandStatus.FAILED_TERMINAL, + problem = "The application runtime is not ready for this action.", + ) + return true + } + return false + } + + private fun rejectUnavailableIntent(message: String) { + mutableState.value = + mutableState.value.copy( + commandStatus = if (closed) CommandStatus.REJECTED_CLOSED else CommandStatus.FAILED_TERMINAL, + problem = message, + ) + } + + private fun acceptSnapshot(snapshot: AppSnapshotDto) { + if (snapshot.revision >= mutableState.value.snapshot.revision) { + mutableState.value = + mutableState.value.copy( + snapshot = snapshot, + route = snapshot.toHarvestCircleRoute(), + ) + } + } + + private fun acceptFailure(error: Throwable) { + val native = error as? StudioException.Failure + val gatewayFailure = (error as? HarvestCircleGatewayException)?.failure + mutableState.value = + mutableState.value.copy( + busy = false, + commandStatus = + if (native?.retryable == true || gatewayFailure?.retryable == true) { + CommandStatus.FAILED_RETRYABLE + } else { + CommandStatus.FAILED_TERMINAL + }, + lastCommandRequestId = gatewayFailure?.correlationId ?: native?.correlationId, + lastFailureCode = gatewayFailure?.code ?: native?.code, + recoveryAction = + gatewayFailure?.recoveryAction + ?: native?.recoveryAction + ?: WireRecoveryAction.NONE, + problem = gatewayFailure?.safeMessage ?: native?.safeMessage ?: "The application command failed.", + ) + } + + private fun releaseGeneratedRecovery(recovery: PendingGeneratedRecovery) { + if (pendingGeneratedRecovery === recovery) { + pendingGeneratedRecovery = null + } + recovery.backup.clear() + recovery.ticket.close() + mutableState.value = mutableState.value.copy(generatedKeyBackup = null) + } + + override fun close() { + if (closed) return + closed = true + command?.cancel() + pendingRemoval?.close() + pendingGeneratedRecovery?.let(::releaseGeneratedRecovery) + subscription?.close() + runCatching { gateway.shutdown() } + .onSuccess { receipt -> + mutableState.value = + mutableState.value.copy( + route = if (receipt.closed) HarvestCircleRoute.CLOSED else HarvestCircleRoute.FATAL, + busy = false, + problem = if (receipt.closed) null else "The application could not shut down safely.", + ) + }.onFailure { error -> + acceptFailure(error) + mutableState.value = mutableState.value.copy(route = HarvestCircleRoute.FATAL, busy = false) + } + } +} + +private data class PendingGeneratedRecovery( + val ticket: GeneratedRecoveryTicket, + val backup: GeneratedKeyBackup, +) + +internal fun AppSnapshotDto.toHarvestCircleRoute(): HarvestCircleRoute = + when (lifecycle) { + AppLifecycleDto.OPENING -> HarvestCircleRoute.OPENING + AppLifecycleDto.COMPATIBILITY_CHECKING -> HarvestCircleRoute.CHECKING_COMPATIBILITY + AppLifecycleDto.ACQUIRING_OWNERSHIP -> HarvestCircleRoute.ACQUIRING_OWNERSHIP + AppLifecycleDto.MIGRATING -> HarvestCircleRoute.MIGRATING + AppLifecycleDto.RECOVERING -> HarvestCircleRoute.RECOVERING + AppLifecycleDto.READY -> if (activeAccount != null) HarvestCircleRoute.ACTIVE_ACCOUNT else HarvestCircleRoute.ACCOUNTS + AppLifecycleDto.DEGRADED -> HarvestCircleRoute.DEGRADED + AppLifecycleDto.BLOCKED -> HarvestCircleRoute.BLOCKED + AppLifecycleDto.SHUTTING_DOWN -> HarvestCircleRoute.SHUTTING_DOWN + AppLifecycleDto.CLOSED -> HarvestCircleRoute.CLOSED + AppLifecycleDto.FATAL -> HarvestCircleRoute.FATAL + } diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleApplication.kt @@ -0,0 +1,76 @@ +package org.radroots.harvestcircle.application + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.CoroutineScope +import org.radroots.harvestcircle.accounts.ui.HarvestCircleScreen +import org.radroots.harvestcircle.accounts.ui.HarvestCircleUiActions +import org.radroots.harvestcircle.accounts.ui.StartupFailureScreen +import org.radroots.harvestcircle.accounts.ui.toUiModel +import org.radroots.harvestcircle.ffi.StudioAppCore +import org.radroots.harvestcircle.ffi.StudioException +import org.radroots.harvestcircle.ffi.compatibilityDescriptor + +internal typealias HarvestCircleStoreFactory = (CoroutineScope) -> HarvestCircleAppStore + +@Composable +fun HarvestCircleApplication(storeFactory: HarvestCircleStoreFactory = ::createHarvestCircleAppStore) { + val scope = rememberCoroutineScope() + val storeResult = remember { runCatching { storeFactory(scope) } } + val store = storeResult.getOrNull() + if (store == null) { + val error = storeResult.exceptionOrNull() + val message = + (error as? StudioException.Failure)?.safeMessage + ?: "The application could not start." + StartupFailureScreen(message) + return + } + val clipboard = remember { SecretClipboardController(scope) } + + DisposableEffect(store, clipboard) { + onDispose { + clipboard.close() + store.close() + } + } + + HarvestCircleScreen( + model = store.state.value.toUiModel(), + actions = + HarvestCircleUiActions( + chooseCreateAccount = store::chooseCreateAccount, + chooseImportAccount = store::chooseImportAccount, + cancelAccountEntry = store::cancelAccountEntry, + editImportDraft = store::editImportDraft, + generateAccount = store::generateAccount, + importSecretKey = store::importSecretKey, + copyText = { value -> clipboard.copy(value) }, + acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, + cancelGeneratedKeyBackup = store::cancelGeneratedKeyBackup, + selectAccount = store::selectAccount, + activateAccount = store::activateAccount, + requestAccountRemoval = store::requestAccountRemoval, + cancelAccountRemoval = store::cancelAccountRemoval, + confirmAccountRemoval = store::confirmAccountRemoval, + refreshActiveProfile = store::refreshActiveProfile, + retryLastCommand = store::retryLastCommand, + signOut = store::signOut, + showAccountChooser = store::showAccountChooser, + hideAccountChooser = store::hideAccountChooser, + ), + ) +} + +internal fun createHarvestCircleAppStore(scope: CoroutineScope): HarvestCircleAppStore { + val developmentMode = java.lang.Boolean.getBoolean("radroots.studio.development") + val descriptor = compatibilityDescriptor() + val core = + StudioAppCore.openCompatible( + expectation = verifyNativeCompatibility(descriptor), + developmentMode = developmentMode, + ) + return HarvestCircleAppStore(NativeHarvestCircleCoreGateway(core), scope) +} diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleCoreGateway.kt @@ -0,0 +1,307 @@ +package org.radroots.harvestcircle.application + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.radroots.harvestcircle.ffi.AccountDto +import org.radroots.harvestcircle.ffi.AppSnapshotDto +import org.radroots.harvestcircle.ffi.GeneratedRecoveryRequest +import org.radroots.harvestcircle.ffi.ObserverSubscription +import org.radroots.harvestcircle.ffi.RemovalRequest +import org.radroots.harvestcircle.ffi.RequestContextDto +import org.radroots.harvestcircle.ffi.SnapshotChangeDto +import org.radroots.harvestcircle.ffi.StudioAppCore +import org.radroots.harvestcircle.ffi.StudioChangeObserver +import org.radroots.harvestcircle.ffi.StudioException +import org.radroots.harvestcircle.ffi.WireErrorCategory +import org.radroots.harvestcircle.ffi.WireErrorCode +import org.radroots.harvestcircle.ffi.WireRecoveryAction +import java.util.concurrent.atomic.AtomicLong + +interface RemovalTicket : AutoCloseable { + val publicKeyHex: String + val deletesLocalCredential: Boolean + val signsOut: Boolean + val expiresAtSeconds: Long +} + +interface GeneratedRecoveryTicket : AutoCloseable { + val requestId: String + val account: AccountDto + + fun takeRecoveryNsec(): String + + suspend fun acknowledge(): AppSnapshotDto + + suspend fun cancel(): Boolean +} + +data class HarvestCircleChange( + val snapshot: AppSnapshotDto, + val previousRevision: ULong?, +) + +sealed interface HarvestCircleCommand { + data class ImportAccount( + val bytes: ByteArray, + ) : HarvestCircleCommand + + data class SelectAccount( + val publicKeyHex: String, + ) : HarvestCircleCommand + + data class ActivateAccount( + val publicKeyHex: String, + ) : HarvestCircleCommand + + data object SignOut : HarvestCircleCommand + + data object RefreshProfile : HarvestCircleCommand +} + +data class HarvestCircleCommandReceipt( + val requestId: String, + val committedRevision: ULong, + val snapshot: AppSnapshotDto, +) + +data class HarvestCircleCommandFailure( + val code: WireErrorCode, + val category: WireErrorCategory, + val retryable: Boolean, + val recoveryAction: WireRecoveryAction, + val correlationId: String?, + val safeMessage: String, +) + +data class HarvestCircleShutdownReceipt( + val finalRevision: ULong, + val closed: Boolean, +) + +sealed interface HarvestCircleCommandResult { + data class Accepted( + val receipt: HarvestCircleCommandReceipt, + ) : HarvestCircleCommandResult + + data class Rejected( + val failure: HarvestCircleCommandFailure, + ) : HarvestCircleCommandResult +} + +interface HarvestCircleCoreGateway : AutoCloseable { + fun snapshot(): AppSnapshotDto + + suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit): AutoCloseable + + suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult + + suspend fun bootstrap(): AppSnapshotDto + + suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket + + suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket + + suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto + + fun shutdown(): HarvestCircleShutdownReceipt +} + +class NativeHarvestCircleCoreGateway( + private val core: StudioAppCore, +) : HarvestCircleCoreGateway { + private val nextRequest = AtomicLong(1) + private val shutdownLock = Any() + private var shutdownReceipt: HarvestCircleShutdownReceipt? = null + + override fun snapshot(): AppSnapshotDto = core.snapshot() + + override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit): AutoCloseable { + val subscription = + core.subscribeChangesV2( + object : StudioChangeObserver { + override fun onChange(change: SnapshotChangeDto) { + onChange(HarvestCircleChange(change.snapshot, change.previousRevision)) + } + }, + ) + return NativeSubscription(subscription) + } + + override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult { + val context = requestContext() + return try { + val snapshot = + when (command) { + is HarvestCircleCommand.ImportAccount -> + try { + core.importAccountV2(context, command.bytes).snapshot + } finally { + command.bytes.fill(0) + } + is HarvestCircleCommand.SelectAccount -> core.selectAccount(command.publicKeyHex) + is HarvestCircleCommand.ActivateAccount -> core.activateAccount(command.publicKeyHex) + HarvestCircleCommand.SignOut -> core.signOut() + HarvestCircleCommand.RefreshProfile -> core.refreshActiveProfile() + } + HarvestCircleCommandResult.Accepted( + HarvestCircleCommandReceipt(context.requestId, snapshot.revision, snapshot), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + HarvestCircleCommandResult.Rejected(error.toHarvestCircleCommandFailure(context.requestId)) + } + } + + override suspend fun bootstrap(): AppSnapshotDto = core.bootstrap() + + override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket { + val requestId = nextRequestId() + return try { + val request = core.beginGeneratedAccountV2() + try { + NativeGeneratedRecoveryTicket(core, request, ::requestContext, requestId, request.account()) + } catch (error: Exception) { + request.close() + throw error + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + throw HarvestCircleGatewayException( + error.toHarvestCircleCommandFailure( + requestId, + "The generated key could not be prepared.", + ), + ) + } + } + + override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = + NativeRemovalTicket(core.requestAccountRemoval(publicKeyHex)) + + override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { + require(ticket is NativeRemovalTicket) { "Removal ticket does not belong to native core" } + return core.confirmAccountRemoval(requestContext(), ticket.request) + } + + override fun shutdown(): HarvestCircleShutdownReceipt = + synchronized(shutdownLock) { + shutdownReceipt ?: run { + val receipt = runBlocking { core.shutdownV2() } + HarvestCircleShutdownReceipt(receipt.finalRevision, receipt.closed).also { + check(it.closed) { "Native runtime returned an incomplete shutdown receipt" } + shutdownReceipt = it + core.close() + } + } + } + + override fun close() { + shutdown() + } + + private fun requestContext(): RequestContextDto = + RequestContextDto( + requestId = nextRequestId(), + expectedRevision = core.snapshot().revision, + deadlineMillis = 30_000UL, + ) + + private fun nextRequestId(): String = "kotlin:${nextRequest.getAndIncrement()}" +} + +internal class HarvestCircleGatewayException( + val failure: HarvestCircleCommandFailure, +) : Exception(failure.safeMessage) + +internal fun Throwable.toHarvestCircleCommandFailure( + fallbackCorrelationId: String, + fallbackSafeMessage: String = "The application command failed.", +): HarvestCircleCommandFailure { + val native = this as? StudioException.Failure + return HarvestCircleCommandFailure( + code = native?.code ?: WireErrorCode.INTERNAL, + category = native?.category ?: WireErrorCategory.INTERNAL, + retryable = native?.retryable ?: false, + recoveryAction = native?.recoveryAction ?: WireRecoveryAction.NONE, + correlationId = native?.correlationId ?: fallbackCorrelationId, + safeMessage = native?.safeMessage ?: fallbackSafeMessage, + ) +} + +private class NativeSubscription( + private val subscription: ObserverSubscription, +) : AutoCloseable { + override fun close() { + try { + runBlocking { subscription.unsubscribe() } + } finally { + subscription.close() + } + } +} + +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() + } +} + +private class NativeGeneratedRecoveryTicket( + private val core: StudioAppCore, + private val request: GeneratedRecoveryRequest, + private val requestContext: () -> RequestContextDto, + override val requestId: String, + override val account: AccountDto, +) : GeneratedRecoveryTicket { + override fun takeRecoveryNsec(): String = + try { + request.takeRecoveryNsec() + } catch (error: Exception) { + throw HarvestCircleGatewayException( + error.toHarvestCircleCommandFailure( + requestId, + "The generated recovery key could not be read.", + ), + ) + } + + override suspend fun acknowledge(): AppSnapshotDto { + val context = requestContext() + return call("The generated account could not be saved.", context.requestId) { + core.acknowledgeGeneratedAccountV2(context, request) + } + } + + override suspend fun cancel(): Boolean = + call("The generated key could not be cancelled safely.") { + core.cancelGeneratedAccountV2(request) + } + + override fun close() { + request.close() + } + + private suspend fun <T> call( + fallbackSafeMessage: String, + correlationId: String = requestId, + operation: suspend () -> T, + ): T = + try { + operation() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + throw HarvestCircleGatewayException( + error.toHarvestCircleCommandFailure(correlationId, fallbackSafeMessage), + ) + } +} diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/RadrootsApplication.kt @@ -1,76 +0,0 @@ -package org.radroots.harvestcircle.application - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import kotlinx.coroutines.CoroutineScope -import org.radroots.harvestcircle.accounts.ui.StartupFailureScreen -import org.radroots.harvestcircle.accounts.ui.StudioScreen -import org.radroots.harvestcircle.accounts.ui.StudioUiActions -import org.radroots.harvestcircle.accounts.ui.toUiModel -import org.radroots.harvestcircle.ffi.StudioAppCore -import org.radroots.harvestcircle.ffi.StudioException -import org.radroots.harvestcircle.ffi.compatibilityDescriptor - -internal typealias StudioStoreFactory = (CoroutineScope) -> StudioAppStore - -@Composable -fun RadrootsApplication(storeFactory: StudioStoreFactory = ::createStudioAppStore) { - val scope = rememberCoroutineScope() - val storeResult = remember { runCatching { storeFactory(scope) } } - val store = storeResult.getOrNull() - if (store == null) { - val error = storeResult.exceptionOrNull() - val message = - (error as? StudioException.Failure)?.safeMessage - ?: "The application could not start." - StartupFailureScreen(message) - return - } - val clipboard = remember { SecretClipboardController(scope) } - - DisposableEffect(store, clipboard) { - onDispose { - clipboard.close() - store.close() - } - } - - StudioScreen( - model = store.state.value.toUiModel(), - actions = - StudioUiActions( - chooseCreateAccount = store::chooseCreateAccount, - chooseImportAccount = store::chooseImportAccount, - cancelAccountEntry = store::cancelAccountEntry, - editImportDraft = store::editImportDraft, - generateAccount = store::generateAccount, - importSecretKey = store::importSecretKey, - copyText = { value -> clipboard.copy(value) }, - acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, - cancelGeneratedKeyBackup = store::cancelGeneratedKeyBackup, - selectAccount = store::selectAccount, - activateAccount = store::activateAccount, - requestAccountRemoval = store::requestAccountRemoval, - cancelAccountRemoval = store::cancelAccountRemoval, - confirmAccountRemoval = store::confirmAccountRemoval, - refreshActiveProfile = store::refreshActiveProfile, - retryLastCommand = store::retryLastCommand, - signOut = store::signOut, - showAccountChooser = store::showAccountChooser, - hideAccountChooser = store::hideAccountChooser, - ), - ) -} - -internal fun createStudioAppStore(scope: CoroutineScope): StudioAppStore { - val developmentMode = java.lang.Boolean.getBoolean("radroots.studio.development") - val descriptor = compatibilityDescriptor() - val core = - StudioAppCore.openCompatible( - expectation = verifyNativeCompatibility(descriptor), - developmentMode = developmentMode, - ) - return StudioAppStore(NativeStudioCoreGateway(core), scope) -} diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/StudioAppStore.kt @@ -1,508 +0,0 @@ -package org.radroots.harvestcircle.application - -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.radroots.harvestcircle.ffi.AppLifecycleDto -import org.radroots.harvestcircle.ffi.AppSnapshotDto -import org.radroots.harvestcircle.ffi.StudioException -import org.radroots.harvestcircle.ffi.WireErrorCode -import org.radroots.harvestcircle.ffi.WireRecoveryAction - -enum class StudioRoute { - OPENING, - CHECKING_COMPATIBILITY, - ACQUIRING_OWNERSHIP, - MIGRATING, - RECOVERING, - ACCOUNTS, - ACTIVE_ACCOUNT, - DEGRADED, - BLOCKED, - SHUTTING_DOWN, - FATAL, - CLOSED, -} - -enum class CommandStatus { - IDLE, - RUNNING, - ACCEPTED, - REJECTED_BUSY, - REJECTED_CLOSED, - FAILED_RETRYABLE, - FAILED_TERMINAL, -} - -enum class AccountEntryMode { - CHOICE, - CREATE, - 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, - val commandStatus: CommandStatus = CommandStatus.IDLE, - val lastCommandRequestId: String? = null, - val lastFailureCode: WireErrorCode? = null, - val recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, - val problem: String? = null, -) - -const val MAX_IMPORT_SECRET_CHARS: Int = 128 - -class StudioAppStore( - private val gateway: StudioCoreGateway, - private val scope: CoroutineScope, -) : AutoCloseable { - private val mutableState = mutableStateOf(StudioStoreState(snapshot = gateway.snapshot())) - private var closed = false - private var subscription: AutoCloseable? = null - private var pendingRemoval: RemovalTicket? = null - private var pendingGeneratedRecovery: PendingGeneratedRecovery? = null - private var command: Job? = null - private var retryableCommand: StudioCommand? = null - - val state: State<StudioStoreState> - get() = mutableState - - init { - launchCommand { - val registered = - gateway.subscribeChanges { change -> - scope.launch { - if (!closed) acceptSnapshot(change.snapshot) - } - } - if (closed) { - registered.close() - return@launchCommand - } - subscription = registered - acceptSnapshot(gateway.bootstrap()) - } - } - - fun editImportDraft(value: String) { - mutableState.value = - mutableState.value.copy( - importDraft = value.take(MAX_IMPORT_SECRET_CHARS), - lastFailureCode = null, - recoveryAction = WireRecoveryAction.NONE, - problem = null, - ) - } - - fun chooseCreateAccount() { - mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.CREATE, problem = null) - } - - fun chooseImportAccount() { - mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.IMPORT, problem = null) - } - - fun cancelAccountEntry() { - mutableState.value = - mutableState.value.copy( - accountEntryMode = AccountEntryMode.CHOICE, - importDraft = "", - problem = null, - ) - } - - fun generateAccount() { - launchCommand { - val recovery = gateway.beginGeneratedAccount() - var installed = false - try { - val backup = GeneratedKeyBackup(recovery.account.npub, recovery.takeRecoveryNsec()) - pendingGeneratedRecovery = PendingGeneratedRecovery(recovery, backup) - mutableState.value = mutableState.value.copy(generatedKeyBackup = backup) - installed = true - } finally { - if (!installed) { - withContext(NonCancellable) { - runCatching { recovery.cancel() } - recovery.close() - } - } - } - } - } - - fun acknowledgeGeneratedKeyBackup() { - val recovery = - pendingGeneratedRecovery ?: run { - rejectUnavailableIntent("Generated-key recovery is not available.") - return - } - runSnapshotCommand { - try { - recovery.ticket.acknowledge() - } finally { - releaseGeneratedRecovery(recovery) - } - } - } - - fun cancelGeneratedKeyBackup() { - val recovery = - pendingGeneratedRecovery ?: run { - rejectUnavailableIntent("Generated-key recovery is not available.") - return - } - launchCommand { - try { - if (!recovery.ticket.cancel()) { - throw StudioGatewayException( - StudioCommandFailure( - code = WireErrorCode.INVALID_APPLICATION_STATE, - category = org.radroots.harvestcircle.ffi.WireErrorCategory.LIFECYCLE, - retryable = false, - recoveryAction = WireRecoveryAction.NONE, - correlationId = recovery.ticket.requestId, - safeMessage = "The generated-key recovery step was already closed.", - ), - ) - } - } finally { - releaseGeneratedRecovery(recovery) - } - } - } - - fun importSecretKey() { - if (rejectIfUnavailable()) return - val input = mutableState.value.importDraft.encodeToByteArray() - mutableState.value = mutableState.value.copy(importDraft = "") - runTypedCommand(StudioCommand.ImportAccount(input)) - } - - fun selectAccount(publicKeyHex: String) { - runTypedCommand(StudioCommand.SelectAccount(publicKeyHex)) - } - - fun activateAccount(publicKeyHex: String) { - runTypedCommand(StudioCommand.ActivateAccount(publicKeyHex), hideChooser = true) - } - - fun signOut() { - runTypedCommand(StudioCommand.SignOut, hideChooser = true) - } - - fun showAccountChooser() { - mutableState.value = mutableState.value.copy(accountChooserVisible = true, problem = null) - } - - fun hideAccountChooser() { - mutableState.value = mutableState.value.copy(accountChooserVisible = false) - } - - fun refreshActiveProfile() { - 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 { - pendingRemoval?.close() - pendingRemoval = null - val ticket = gateway.requestAccountRemoval(publicKeyHex) - if (closed) { - ticket.close() - return@runCatching - } - pendingRemoval = ticket - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = publicKeyHex, - removalImpact = - RemovalImpactState( - ticket.publicKeyHex, - ticket.deletesLocalCredential, - ticket.signsOut, - ticket.expiresAtSeconds, - ), - removalStatus = RemovalStatus.AWAITING_CONFIRMATION, - ) - }.getOrThrow() - } - } - - fun cancelAccountRemoval() { - pendingRemoval?.close() - pendingRemoval = null - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = null, - removalImpact = null, - removalStatus = RemovalStatus.NONE, - ) - } - - fun confirmAccountRemoval() { - val ticket = - pendingRemoval ?: run { - rejectUnavailableIntent("Account removal confirmation is not available.") - return - } - pendingRemoval = null - mutableState.value = mutableState.value.copy(removalStatus = RemovalStatus.CONFIRMING) - runSnapshotCommand { - try { - gateway.confirmAccountRemoval(ticket).also { - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = null, - lastRemovedPublicKeyHex = ticket.publicKeyHex, - removalImpact = null, - removalStatus = RemovalStatus.COMPLETED, - ) - } - } finally { - ticket.close() - if (mutableState.value.removalStatus != RemovalStatus.COMPLETED) { - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = null, - removalImpact = null, - removalStatus = RemovalStatus.FAILED, - ) - } - } - } - } - - fun dismissProblem() { - mutableState.value = mutableState.value.copy(problem = null) - } - - private fun runSnapshotCommand(operation: suspend () -> AppSnapshotDto) { - launchCommand { acceptSnapshot(operation()) } - } - - private fun runTypedCommand( - command: StudioCommand, - hideChooser: Boolean = false, - ) { - launchCommand { - when (val result = gateway.execute(command)) { - is StudioCommandResult.Accepted -> { - retryableCommand = null - acceptSnapshot(result.receipt.snapshot) - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.ACCEPTED, - lastCommandRequestId = result.receipt.requestId, - lastFailureCode = null, - recoveryAction = WireRecoveryAction.NONE, - ) - if (hideChooser) { - mutableState.value = mutableState.value.copy(accountChooserVisible = false) - } - } - 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 - } else { - CommandStatus.FAILED_TERMINAL - }, - lastCommandRequestId = result.failure.correlationId, - lastFailureCode = result.failure.code, - recoveryAction = result.failure.recoveryAction, - problem = result.failure.safeMessage, - ) - } - } - } - } - - private fun launchCommand(operation: suspend () -> Unit) { - if (rejectIfUnavailable()) return - mutableState.value = - mutableState.value.copy( - busy = true, - commandStatus = CommandStatus.RUNNING, - problem = null, - ) - command = - scope.launch { - try { - operation() - if (mutableState.value.commandStatus == CommandStatus.RUNNING) { - mutableState.value = mutableState.value.copy(commandStatus = CommandStatus.ACCEPTED) - } - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - acceptFailure(error) - } finally { - mutableState.value = mutableState.value.copy(busy = false) - } - } - } - - private fun rejectIfUnavailable(): Boolean { - if (closed) { - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.REJECTED_CLOSED, - problem = "The application runtime is closed.", - ) - return true - } - if (command?.isActive == true) { - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.REJECTED_BUSY, - problem = "The application is busy. Try again.", - ) - return true - } - if (mutableState.value.route !in setOf(StudioRoute.ACCOUNTS, StudioRoute.ACTIVE_ACCOUNT)) { - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.FAILED_TERMINAL, - problem = "The application runtime is not ready for this action.", - ) - return true - } - return false - } - - private fun rejectUnavailableIntent(message: String) { - mutableState.value = - mutableState.value.copy( - commandStatus = if (closed) CommandStatus.REJECTED_CLOSED else CommandStatus.FAILED_TERMINAL, - problem = message, - ) - } - - private fun acceptSnapshot(snapshot: AppSnapshotDto) { - if (snapshot.revision >= mutableState.value.snapshot.revision) { - mutableState.value = - mutableState.value.copy( - snapshot = snapshot, - route = snapshot.toStudioRoute(), - ) - } - } - - private fun acceptFailure(error: Throwable) { - val native = error as? StudioException.Failure - val gatewayFailure = (error as? StudioGatewayException)?.failure - mutableState.value = - mutableState.value.copy( - busy = false, - commandStatus = - if (native?.retryable == true || gatewayFailure?.retryable == true) { - CommandStatus.FAILED_RETRYABLE - } else { - CommandStatus.FAILED_TERMINAL - }, - lastCommandRequestId = gatewayFailure?.correlationId ?: native?.correlationId, - lastFailureCode = gatewayFailure?.code ?: native?.code, - recoveryAction = - gatewayFailure?.recoveryAction - ?: native?.recoveryAction - ?: WireRecoveryAction.NONE, - problem = gatewayFailure?.safeMessage ?: native?.safeMessage ?: "The application command failed.", - ) - } - - private fun releaseGeneratedRecovery(recovery: PendingGeneratedRecovery) { - if (pendingGeneratedRecovery === recovery) { - pendingGeneratedRecovery = null - } - recovery.backup.clear() - recovery.ticket.close() - mutableState.value = mutableState.value.copy(generatedKeyBackup = null) - } - - override fun close() { - if (closed) return - closed = true - command?.cancel() - pendingRemoval?.close() - pendingGeneratedRecovery?.let(::releaseGeneratedRecovery) - subscription?.close() - runCatching { gateway.shutdown() } - .onSuccess { receipt -> - mutableState.value = - mutableState.value.copy( - route = if (receipt.closed) StudioRoute.CLOSED else StudioRoute.FATAL, - busy = false, - problem = if (receipt.closed) null else "The application could not shut down safely.", - ) - }.onFailure { error -> - acceptFailure(error) - mutableState.value = mutableState.value.copy(route = StudioRoute.FATAL, busy = false) - } - } -} - -private data class PendingGeneratedRecovery( - val ticket: GeneratedRecoveryTicket, - val backup: GeneratedKeyBackup, -) - -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/main/kotlin/org/radroots/harvestcircle/application/StudioCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/StudioCoreGateway.kt @@ -1,307 +0,0 @@ -package org.radroots.harvestcircle.application - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.runBlocking -import org.radroots.harvestcircle.ffi.AccountDto -import org.radroots.harvestcircle.ffi.AppSnapshotDto -import org.radroots.harvestcircle.ffi.GeneratedRecoveryRequest -import org.radroots.harvestcircle.ffi.ObserverSubscription -import org.radroots.harvestcircle.ffi.RemovalRequest -import org.radroots.harvestcircle.ffi.RequestContextDto -import org.radroots.harvestcircle.ffi.SnapshotChangeDto -import org.radroots.harvestcircle.ffi.StudioAppCore -import org.radroots.harvestcircle.ffi.StudioChangeObserver -import org.radroots.harvestcircle.ffi.StudioException -import org.radroots.harvestcircle.ffi.WireErrorCategory -import org.radroots.harvestcircle.ffi.WireErrorCode -import org.radroots.harvestcircle.ffi.WireRecoveryAction -import java.util.concurrent.atomic.AtomicLong - -interface RemovalTicket : AutoCloseable { - val publicKeyHex: String - val deletesLocalCredential: Boolean - val signsOut: Boolean - val expiresAtSeconds: Long -} - -interface GeneratedRecoveryTicket : AutoCloseable { - val requestId: String - val account: AccountDto - - fun takeRecoveryNsec(): String - - suspend fun acknowledge(): AppSnapshotDto - - suspend fun cancel(): Boolean -} - -data class StudioChange( - val snapshot: AppSnapshotDto, - val previousRevision: ULong?, -) - -sealed interface StudioCommand { - data class ImportAccount( - val bytes: ByteArray, - ) : StudioCommand - - data class SelectAccount( - val publicKeyHex: String, - ) : StudioCommand - - data class ActivateAccount( - val publicKeyHex: String, - ) : StudioCommand - - data object SignOut : StudioCommand - - data object RefreshProfile : StudioCommand -} - -data class StudioCommandReceipt( - val requestId: String, - val committedRevision: ULong, - val snapshot: AppSnapshotDto, -) - -data class StudioCommandFailure( - val code: WireErrorCode, - val category: WireErrorCategory, - val retryable: Boolean, - val recoveryAction: WireRecoveryAction, - val correlationId: String?, - val safeMessage: String, -) - -data class StudioShutdownReceipt( - val finalRevision: ULong, - val closed: Boolean, -) - -sealed interface StudioCommandResult { - data class Accepted( - val receipt: StudioCommandReceipt, - ) : StudioCommandResult - - data class Rejected( - val failure: StudioCommandFailure, - ) : StudioCommandResult -} - -interface StudioCoreGateway : AutoCloseable { - fun snapshot(): AppSnapshotDto - - suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable - - suspend fun execute(command: StudioCommand): StudioCommandResult - - suspend fun bootstrap(): AppSnapshotDto - - suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket - - suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket - - suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto - - fun shutdown(): StudioShutdownReceipt -} - -class NativeStudioCoreGateway( - private val core: StudioAppCore, -) : StudioCoreGateway { - private val nextRequest = AtomicLong(1) - private val shutdownLock = Any() - private var shutdownReceipt: StudioShutdownReceipt? = null - - override fun snapshot(): AppSnapshotDto = core.snapshot() - - override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable { - val subscription = - core.subscribeChangesV2( - object : StudioChangeObserver { - override fun onChange(change: SnapshotChangeDto) { - onChange(StudioChange(change.snapshot, change.previousRevision)) - } - }, - ) - return NativeSubscription(subscription) - } - - override suspend fun execute(command: StudioCommand): StudioCommandResult { - val context = requestContext() - return try { - val snapshot = - when (command) { - is StudioCommand.ImportAccount -> - try { - core.importAccountV2(context, command.bytes).snapshot - } finally { - command.bytes.fill(0) - } - is StudioCommand.SelectAccount -> core.selectAccount(command.publicKeyHex) - is StudioCommand.ActivateAccount -> core.activateAccount(command.publicKeyHex) - StudioCommand.SignOut -> core.signOut() - StudioCommand.RefreshProfile -> core.refreshActiveProfile() - } - StudioCommandResult.Accepted( - StudioCommandReceipt(context.requestId, snapshot.revision, snapshot), - ) - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - StudioCommandResult.Rejected(error.toStudioCommandFailure(context.requestId)) - } - } - - override suspend fun bootstrap(): AppSnapshotDto = core.bootstrap() - - override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket { - val requestId = nextRequestId() - return try { - val request = core.beginGeneratedAccountV2() - try { - NativeGeneratedRecoveryTicket(core, request, ::requestContext, requestId, request.account()) - } catch (error: Exception) { - request.close() - throw error - } - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - throw StudioGatewayException( - error.toStudioCommandFailure( - requestId, - "The generated key could not be prepared.", - ), - ) - } - } - - override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = - NativeRemovalTicket(core.requestAccountRemoval(publicKeyHex)) - - override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { - require(ticket is NativeRemovalTicket) { "Removal ticket does not belong to native core" } - return core.confirmAccountRemoval(requestContext(), ticket.request) - } - - override fun shutdown(): StudioShutdownReceipt = - synchronized(shutdownLock) { - shutdownReceipt ?: run { - val receipt = runBlocking { core.shutdownV2() } - StudioShutdownReceipt(receipt.finalRevision, receipt.closed).also { - check(it.closed) { "Native runtime returned an incomplete shutdown receipt" } - shutdownReceipt = it - core.close() - } - } - } - - override fun close() { - shutdown() - } - - private fun requestContext(): RequestContextDto = - RequestContextDto( - requestId = nextRequestId(), - expectedRevision = core.snapshot().revision, - deadlineMillis = 30_000UL, - ) - - private fun nextRequestId(): String = "kotlin:${nextRequest.getAndIncrement()}" -} - -internal class StudioGatewayException( - val failure: StudioCommandFailure, -) : Exception(failure.safeMessage) - -internal fun Throwable.toStudioCommandFailure( - fallbackCorrelationId: String, - fallbackSafeMessage: String = "The application command failed.", -): StudioCommandFailure { - val native = this as? StudioException.Failure - return StudioCommandFailure( - code = native?.code ?: WireErrorCode.INTERNAL, - category = native?.category ?: WireErrorCategory.INTERNAL, - retryable = native?.retryable ?: false, - recoveryAction = native?.recoveryAction ?: WireRecoveryAction.NONE, - correlationId = native?.correlationId ?: fallbackCorrelationId, - safeMessage = native?.safeMessage ?: fallbackSafeMessage, - ) -} - -private class NativeSubscription( - private val subscription: ObserverSubscription, -) : AutoCloseable { - override fun close() { - try { - runBlocking { subscription.unsubscribe() } - } finally { - subscription.close() - } - } -} - -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() - } -} - -private class NativeGeneratedRecoveryTicket( - private val core: StudioAppCore, - private val request: GeneratedRecoveryRequest, - private val requestContext: () -> RequestContextDto, - override val requestId: String, - override val account: AccountDto, -) : GeneratedRecoveryTicket { - override fun takeRecoveryNsec(): String = - try { - request.takeRecoveryNsec() - } catch (error: Exception) { - throw StudioGatewayException( - error.toStudioCommandFailure( - requestId, - "The generated recovery key could not be read.", - ), - ) - } - - override suspend fun acknowledge(): AppSnapshotDto { - val context = requestContext() - return call("The generated account could not be saved.", context.requestId) { - core.acknowledgeGeneratedAccountV2(context, request) - } - } - - override suspend fun cancel(): Boolean = - call("The generated key could not be cancelled safely.") { - core.cancelGeneratedAccountV2(request) - } - - override fun close() { - request.close() - } - - private suspend fun <T> call( - fallbackSafeMessage: String, - correlationId: String = requestId, - operation: suspend () -> T, - ): T = - try { - operation() - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - throw StudioGatewayException( - error.toStudioCommandFailure(correlationId, fallbackSafeMessage), - ) - } -} diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/desktop/Main.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/desktop/Main.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import org.radroots.harvestcircle.accounts.ui.StartupFailureScreen -import org.radroots.harvestcircle.application.RadrootsApplication +import org.radroots.harvestcircle.application.HarvestCircleApplication import java.awt.Dimension import java.awt.Taskbar import javax.imageio.ImageIO @@ -49,7 +49,7 @@ fun main() { } if (nativeStartupProblem == null) { - RadrootsApplication() + HarvestCircleApplication() } else { StartupFailureScreen(nativeStartupProblem) } diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/AccountsUiModelTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/AccountsUiModelTest.kt @@ -1,7 +1,7 @@ package org.radroots.harvestcircle.accounts.ui import org.radroots.harvestcircle.application.GeneratedKeyBackup -import org.radroots.harvestcircle.application.StudioStoreState +import org.radroots.harvestcircle.application.HarvestCircleStoreState import org.radroots.harvestcircle.ffi.AccountDto import org.radroots.harvestcircle.ffi.ActiveAccountDto import org.radroots.harvestcircle.ffi.AppLifecycleDto @@ -36,7 +36,7 @@ class AccountsUiModelTest { ), ) - val model = StudioStoreState(snapshot).toUiModel() + val model = HarvestCircleStoreState(snapshot).toUiModel() assertEquals("Alice", model.activeAccount?.heading) assertEquals("connected", model.activeAccount?.relayState) @@ -57,7 +57,7 @@ class AccountsUiModelTest { @Test fun mapsSafeProblemAndTransientBackupSeparatelyFromSnapshot() { val state = - StudioStoreState( + HarvestCircleStoreState( snapshot = snapshot(), generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated"), problem = "Try again.", @@ -79,12 +79,12 @@ class AccountsUiModelTest { @Test fun mapsTypedImportFailuresToSpecificRepairGuidance() { val invalid = - StudioStoreState( + HarvestCircleStoreState( snapshot = snapshot(), lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, ).toUiModel() val repair = - StudioStoreState( + HarvestCircleStoreState( snapshot = snapshot(), lastFailureCode = WireErrorCode.CREDENTIAL_MISSING, recoveryAction = WireRecoveryAction.REPAIR_CREDENTIAL, diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/HarvestCircleScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/HarvestCircleScreenTest.kt @@ -0,0 +1,384 @@ +package org.radroots.harvestcircle.accounts.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.v2.runComposeUiTest +import org.radroots.harvestcircle.application.AccountEntryMode +import org.radroots.harvestcircle.application.HarvestCircleRoute +import org.radroots.harvestcircle.application.RemovalImpactState +import org.radroots.harvestcircle.application.RemovalStatus +import org.radroots.harvestcircle.ffi.SessionStateDto +import org.radroots.harvestcircle.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class HarvestCircleScreenTest { + @Test + fun rendersEveryNonReadyLifecycleRouteWithoutAccountControls() = + runComposeUiTest { + var model by mutableStateOf(emptyUiModel().copy(route = HarvestCircleRoute.OPENING)) + setContent { HarvestCircleScreen(model, HarvestCircleUiActions()) } + + val routes = + listOf( + HarvestCircleRoute.OPENING to "lifecycle-opening", + HarvestCircleRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", + HarvestCircleRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", + HarvestCircleRoute.MIGRATING to "lifecycle-migrating", + HarvestCircleRoute.RECOVERING to "lifecycle-recovering", + HarvestCircleRoute.BLOCKED to "lifecycle-blocked", + HarvestCircleRoute.SHUTTING_DOWN to "lifecycle-shutting-down", + HarvestCircleRoute.FATAL to "lifecycle-fatal", + HarvestCircleRoute.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 = HarvestCircleRoute.DEGRADED) + waitForIdle() + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithTag("accounts-problem").assertIsDisplayed() + } + + @Test + fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = + runComposeUiTest { + var importDraft by mutableStateOf("") + var accountEntryMode by mutableStateOf(AccountEntryMode.CHOICE) + var generateCalls = 0 + var importCalls = 0 + setContent { + HarvestCircleScreen( + model = emptyUiModel(importDraft = importDraft).copy(accountEntryMode = accountEntryMode), + actions = + HarvestCircleUiActions( + chooseCreateAccount = { accountEntryMode = AccountEntryMode.CREATE }, + chooseImportAccount = { accountEntryMode = AccountEntryMode.IMPORT }, + cancelAccountEntry = { accountEntryMode = AccountEntryMode.CHOICE }, + editImportDraft = { importDraft = it }, + generateAccount = { generateCalls += 1 }, + importSecretKey = { importCalls += 1 }, + ), + ) + } + + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithText("radroots").assertIsDisplayed() + onNodeWithTag("choose-create-account").performClick() + onNodeWithTag("generate-key").performClick() + onNodeWithTag("cancel-account-entry").performClick() + onNodeWithTag("choose-import-account").performClick() + onNodeWithTag("import-nsec-input").assertIsFocused() + onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") + onNodeWithTag("import-key").performClick() + + assertEquals(1, generateCalls) + assertEquals(1, importCalls) + assertEquals("nsec1secret", importDraft) + assertTrue( + onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { + it.key.name == "Password" && it.value == Unit + }, + ) + } + + @Test + fun inactiveScreenShowsSafeFailureAndNoGenericFields() = + runComposeUiTest { + setContent { + HarvestCircleScreen( + model = emptyUiModel(problem = "The secret key is invalid."), + actions = HarvestCircleUiActions(), + ) + } + + onNodeWithText("The secret key is invalid.").assertIsDisplayed() + onNodeWithTag("accounts-empty").assertIsDisplayed() + } + + @Test + fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), + ) + var copied: String? = null + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = + HarvestCircleUiActions( + copyText = { copied = it }, + acknowledgeGeneratedKeyBackup = { backup = null }, + ), + ) + } + + 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) + + onNodeWithTag("acknowledge-key-backup").performClick() + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + onAllNodesWithTag("generated-nsec").assertCountEquals(0) + } + + @Test + fun generatedKeyRecoveryCanBeCancelledWithoutExposingAccountControls() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), + ) + var cancelled = 0 + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = + HarvestCircleUiActions( + 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) + var pendingRemoval: String? by mutableStateOf(null) + val selected = mutableListOf<String>() + val activated = mutableListOf<String>() + var confirmations = 0 + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + accounts = listOf(first, second), + pendingRemovalPublicKeyHex = pendingRemoval, + removalImpact = + pendingRemoval?.let { + RemovalImpactState(it, deletesLocalCredential = true, signsOut = true, expiresAtSeconds = 60) + }, + ), + actions = + HarvestCircleUiActions( + selectAccount = selected::add, + activateAccount = activated::add, + requestAccountRemoval = { pendingRemoval = it }, + cancelAccountRemoval = { pendingRemoval = null }, + confirmAccountRemoval = { confirmations += 1 }, + ), + ) + } + + onNodeWithTag("saved-account-list").assertIsDisplayed() + onNodeWithTag("account-row:${first.publicKeyHex}").assertIsSelected() + onNodeWithTag("select-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(listOf(second.publicKeyHex), selected) + 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() + onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() + assertEquals(1, confirmations) + } + + @Test + fun savedAccountListRemainsReachableForLargeRegistries() = + runComposeUiTest { + val accounts = + (0 until 100).map { index -> + accountUi(index.toString(16).padStart(64, '0'), selected = index == 0) + } + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(accounts = accounts), + actions = HarvestCircleUiActions(), + ) + } + + val lastTag = "account-row:${accounts.last().publicKeyHex}" + onNodeWithTag("saved-account-list").performScrollToNode(hasTestTag(lastTag)) + onNodeWithTag(lastTag).assertIsDisplayed() + } + + @Test + fun activeHomeShowsIdentityProfileRelayAndCommands() = + runComposeUiTest { + var refreshCalls = 0 + var signOutCalls = 0 + val account = accountUi("33".repeat(32), selected = true) + val active = + ActiveAccountUiModel( + account = account, + heading = "Alice", + relayState = "connected", + profileState = "fresh", + profile = + ProfileUiModel( + name = "alice", + displayName = "Alice", + nip05 = "alice@example.com", + about = "Local grower", + picture = "https://example.com/alice.png", + ), + ) + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + route = HarvestCircleRoute.ACTIVE_ACCOUNT, + accounts = listOf(account), + activeAccount = active, + configuredRelays = listOf("ws://localhost:8080"), + session = SessionStateDto.ACTIVE, + ), + actions = + HarvestCircleUiActions( + refreshActiveProfile = { refreshCalls += 1 }, + signOut = { signOutCalls += 1 }, + ), + ) + } + + onNodeWithTag("home-screen").assertIsDisplayed() + onNodeWithTag("active-npub").assertIsDisplayed() + onNodeWithTag("active-pubkey-hex").assertIsDisplayed() + onNodeWithTag("active-profile-name").assertIsDisplayed() + onNodeWithTag("active-profile-about").assertIsDisplayed() + onNodeWithTag("relay-state").assertIsDisplayed() + onNodeWithTag("profile-state").assertIsDisplayed() + onNodeWithText("ws://localhost:8080").assertIsDisplayed() + onNodeWithTag("refresh-profile").performClick() + onNodeWithTag("sign-out").performClick() + assertEquals(1, refreshCalls) + assertEquals(1, signOutCalls) + } + + @Test + fun activeAccountCanOpenChooserWithoutDroppingCurrentSession() = + runComposeUiTest { + val first = accountUi("44".repeat(32), selected = true, active = true) + val second = accountUi("55".repeat(32), selected = false) + val active = + ActiveAccountUiModel( + account = first, + heading = first.label, + relayState = "connected", + profileState = "cached", + profile = ProfileUiModel("", "", "", "", ""), + ) + var chooserVisible by mutableStateOf(false) + var activated: String? = null + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + route = HarvestCircleRoute.ACTIVE_ACCOUNT, + accounts = listOf(first, second), + activeAccount = active, + session = SessionStateDto.ACTIVE, + accountChooserVisible = chooserVisible, + ), + actions = + HarvestCircleUiActions( + showAccountChooser = { chooserVisible = true }, + hideAccountChooser = { chooserVisible = false }, + activateAccount = { activated = it }, + ), + ) + } + + onNodeWithTag("switch-account").performClick() + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithTag("activate-account:${first.publicKeyHex}", useUnmergedTree = true).assertIsNotEnabled() + onNodeWithText("Active").assertIsDisplayed() + onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(second.publicKeyHex, activated) + assertEquals( + SessionStateDto.ACTIVE, + emptyUiModel() + .copy( + activeAccount = active, + session = SessionStateDto.ACTIVE, + ).session, + ) + onNodeWithTag("return-home").performClick() + onNodeWithTag("home-screen").assertIsDisplayed() + } +} + +private fun emptyUiModel( + importDraft: String = "", + problem: String? = null, + importGuidance: String? = null, + recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, +) = HarvestCircleUiModel( + route = HarvestCircleRoute.ACCOUNTS, + accounts = emptyList(), + activeAccount = null, + configuredRelays = emptyList(), + 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( + publicKeyHex: String, + selected: Boolean, + active: Boolean = false, +) = AccountUiModel( + publicKeyHex = publicKeyHex, + npub = "npub1${publicKeyHex.take(12)}", + shortNpub = "npub1${publicKeyHex.take(12)}", + label = "Account ${publicKeyHex.take(2)}", + keyAvailability = "available", + selected = selected, + active = active, +) diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/StudioScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/StudioScreenTest.kt @@ -1,384 +0,0 @@ -package org.radroots.harvestcircle.accounts.ui - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollToNode -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.v2.runComposeUiTest -import org.radroots.harvestcircle.application.AccountEntryMode -import org.radroots.harvestcircle.application.RemovalImpactState -import org.radroots.harvestcircle.application.RemovalStatus -import org.radroots.harvestcircle.application.StudioRoute -import org.radroots.harvestcircle.ffi.SessionStateDto -import org.radroots.harvestcircle.ffi.WireRecoveryAction -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@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 accountEntryMode by mutableStateOf(AccountEntryMode.CHOICE) - var generateCalls = 0 - var importCalls = 0 - setContent { - StudioScreen( - model = emptyUiModel(importDraft = importDraft).copy(accountEntryMode = accountEntryMode), - actions = - StudioUiActions( - chooseCreateAccount = { accountEntryMode = AccountEntryMode.CREATE }, - chooseImportAccount = { accountEntryMode = AccountEntryMode.IMPORT }, - cancelAccountEntry = { accountEntryMode = AccountEntryMode.CHOICE }, - editImportDraft = { importDraft = it }, - generateAccount = { generateCalls += 1 }, - importSecretKey = { importCalls += 1 }, - ), - ) - } - - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithText("radroots").assertIsDisplayed() - onNodeWithTag("choose-create-account").performClick() - onNodeWithTag("generate-key").performClick() - onNodeWithTag("cancel-account-entry").performClick() - onNodeWithTag("choose-import-account").performClick() - onNodeWithTag("import-nsec-input").assertIsFocused() - onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") - onNodeWithTag("import-key").performClick() - - assertEquals(1, generateCalls) - assertEquals(1, importCalls) - assertEquals("nsec1secret", importDraft) - assertTrue( - onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { - it.key.name == "Password" && it.value == Unit - }, - ) - } - - @Test - fun inactiveScreenShowsSafeFailureAndNoGenericFields() = - runComposeUiTest { - setContent { - StudioScreen( - model = emptyUiModel(problem = "The secret key is invalid."), - actions = StudioUiActions(), - ) - } - - onNodeWithText("The secret key is invalid.").assertIsDisplayed() - onNodeWithTag("accounts-empty").assertIsDisplayed() - } - - @Test - fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = - runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var copied: String? = null - setContent { - StudioScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = - StudioUiActions( - copyText = { copied = it }, - acknowledgeGeneratedKeyBackup = { backup = null }, - ), - ) - } - - 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) - - onNodeWithTag("acknowledge-key-backup").performClick() - onAllNodesWithTag("generated-key-backup").assertCountEquals(0) - onAllNodesWithTag("generated-nsec").assertCountEquals(0) - } - - @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) - var pendingRemoval: String? by mutableStateOf(null) - val selected = mutableListOf<String>() - val activated = mutableListOf<String>() - var confirmations = 0 - setContent { - StudioScreen( - 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, - activateAccount = activated::add, - requestAccountRemoval = { pendingRemoval = it }, - cancelAccountRemoval = { pendingRemoval = null }, - confirmAccountRemoval = { confirmations += 1 }, - ), - ) - } - - onNodeWithTag("saved-account-list").assertIsDisplayed() - onNodeWithTag("account-row:${first.publicKeyHex}").assertIsSelected() - onNodeWithTag("select-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(listOf(second.publicKeyHex), selected) - 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() - onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() - assertEquals(1, confirmations) - } - - @Test - fun savedAccountListRemainsReachableForLargeRegistries() = - runComposeUiTest { - val accounts = - (0 until 100).map { index -> - accountUi(index.toString(16).padStart(64, '0'), selected = index == 0) - } - setContent { - StudioScreen( - model = emptyUiModel().copy(accounts = accounts), - actions = StudioUiActions(), - ) - } - - val lastTag = "account-row:${accounts.last().publicKeyHex}" - onNodeWithTag("saved-account-list").performScrollToNode(hasTestTag(lastTag)) - onNodeWithTag(lastTag).assertIsDisplayed() - } - - @Test - fun activeHomeShowsIdentityProfileRelayAndCommands() = - runComposeUiTest { - var refreshCalls = 0 - var signOutCalls = 0 - val account = accountUi("33".repeat(32), selected = true) - val active = - ActiveAccountUiModel( - account = account, - heading = "Alice", - relayState = "connected", - profileState = "fresh", - profile = - ProfileUiModel( - name = "alice", - displayName = "Alice", - nip05 = "alice@example.com", - about = "Local grower", - picture = "https://example.com/alice.png", - ), - ) - setContent { - StudioScreen( - model = - emptyUiModel().copy( - route = StudioRoute.ACTIVE_ACCOUNT, - accounts = listOf(account), - activeAccount = active, - configuredRelays = listOf("ws://localhost:8080"), - session = SessionStateDto.ACTIVE, - ), - actions = - StudioUiActions( - refreshActiveProfile = { refreshCalls += 1 }, - signOut = { signOutCalls += 1 }, - ), - ) - } - - onNodeWithTag("home-screen").assertIsDisplayed() - onNodeWithTag("active-npub").assertIsDisplayed() - onNodeWithTag("active-pubkey-hex").assertIsDisplayed() - onNodeWithTag("active-profile-name").assertIsDisplayed() - onNodeWithTag("active-profile-about").assertIsDisplayed() - onNodeWithTag("relay-state").assertIsDisplayed() - onNodeWithTag("profile-state").assertIsDisplayed() - onNodeWithText("ws://localhost:8080").assertIsDisplayed() - onNodeWithTag("refresh-profile").performClick() - onNodeWithTag("sign-out").performClick() - assertEquals(1, refreshCalls) - assertEquals(1, signOutCalls) - } - - @Test - fun activeAccountCanOpenChooserWithoutDroppingCurrentSession() = - runComposeUiTest { - val first = accountUi("44".repeat(32), selected = true, active = true) - val second = accountUi("55".repeat(32), selected = false) - val active = - ActiveAccountUiModel( - account = first, - heading = first.label, - relayState = "connected", - profileState = "cached", - profile = ProfileUiModel("", "", "", "", ""), - ) - var chooserVisible by mutableStateOf(false) - var activated: String? = null - setContent { - StudioScreen( - model = - emptyUiModel().copy( - route = StudioRoute.ACTIVE_ACCOUNT, - accounts = listOf(first, second), - activeAccount = active, - session = SessionStateDto.ACTIVE, - accountChooserVisible = chooserVisible, - ), - actions = - StudioUiActions( - showAccountChooser = { chooserVisible = true }, - hideAccountChooser = { chooserVisible = false }, - activateAccount = { activated = it }, - ), - ) - } - - onNodeWithTag("switch-account").performClick() - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithTag("activate-account:${first.publicKeyHex}", useUnmergedTree = true).assertIsNotEnabled() - onNodeWithText("Active").assertIsDisplayed() - onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(second.publicKeyHex, activated) - assertEquals( - SessionStateDto.ACTIVE, - emptyUiModel() - .copy( - activeAccount = active, - session = SessionStateDto.ACTIVE, - ).session, - ) - onNodeWithTag("return-home").performClick() - onNodeWithTag("home-screen").assertIsDisplayed() - } -} - -private fun emptyUiModel( - importDraft: String = "", - problem: String? = null, - importGuidance: String? = null, - recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, -) = StudioUiModel( - route = StudioRoute.ACCOUNTS, - accounts = emptyList(), - activeAccount = null, - configuredRelays = emptyList(), - 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( - publicKeyHex: String, - selected: Boolean, - active: Boolean = false, -) = AccountUiModel( - publicKeyHex = publicKeyHex, - npub = "npub1${publicKeyHex.take(12)}", - shortNpub = "npub1${publicKeyHex.take(12)}", - label = "Account ${publicKeyHex.take(2)}", - keyAvailability = "available", - selected = selected, - active = active, -) diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleAppStoreTest.kt @@ -0,0 +1,481 @@ +package org.radroots.harvestcircle.application + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.radroots.harvestcircle.ffi.AccountDto +import org.radroots.harvestcircle.ffi.AppLifecycleDto +import org.radroots.harvestcircle.ffi.AppSnapshotDto +import org.radroots.harvestcircle.ffi.KeyAvailabilityDto +import org.radroots.harvestcircle.ffi.SessionStateDto +import org.radroots.harvestcircle.ffi.SignerKindDto +import org.radroots.harvestcircle.ffi.WireErrorCategory +import org.radroots.harvestcircle.ffi.WireErrorCode +import org.radroots.harvestcircle.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HarvestCircleAppStoreTest { + @Test + fun `bootstraps and ignores stale observer snapshots`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + + advanceUntilIdle() + gateway.emit(snapshot(1UL)) + gateway.emit(snapshot(0UL)) + advanceUntilIdle() + + assertEquals(1UL, store.state.value.snapshot.revision) + assertFalse(store.state.value.busy) + store.close() + assertTrue(gateway.closed) + assertTrue(gateway.shutdownCompleted) + assertTrue(gateway.subscriptionClosed) + assertEquals(HarvestCircleRoute.CLOSED, store.state.value.route) + } + + @Test + fun `holds generated secret only until explicit acknowledgement`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + + store.generateAccount() + advanceUntilIdle() + + assertEquals( + "nsec1secret", + store.state.value.generatedKeyBackup + ?.revealNsec(), + ) + assertEquals( + "npub1account", + store.state.value.generatedKeyBackup + ?.npub, + ) + store.acknowledgeGeneratedKeyBackup() + advanceUntilIdle() + assertNull(store.state.value.generatedKeyBackup) + store.close() + } + + @Test + fun `cancels staged generated account without committing it`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(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 `partial generated recovery acquisition cancels and closes its native ticket`() = + runTest { + val gateway = + FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { + failGeneratedRecoveryRead = true + } + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + + store.generateAccount() + advanceUntilIdle() + + assertNull(store.state.value.generatedKeyBackup) + assertEquals(1, gateway.lastGeneratedRecoveryTicket?.cancelCalls) + assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) + store.close() + } + + @Test + fun `failed generated acknowledgement releases one-shot recovery ownership`() = + runTest { + val gateway = + FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { + failGeneratedAcknowledgement = true + } + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + store.generateAccount() + advanceUntilIdle() + + store.acknowledgeGeneratedKeyBackup() + advanceUntilIdle() + + assertNull(store.state.value.generatedKeyBackup) + assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) + assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) + assertEquals( + "The generated account could not be saved. Import the recovery key you saved to try again.", + store.state.value.problem, + ) + store.close() + } + + @Test + fun `already resolved cancellation clears recovery and reports the state mismatch`() = + runTest { + val gateway = + FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { + generatedCancellationResult = false + } + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + store.generateAccount() + advanceUntilIdle() + + store.cancelGeneratedKeyBackup() + advanceUntilIdle() + + assertNull(store.state.value.generatedKeyBackup) + assertEquals(WireErrorCode.INVALID_APPLICATION_STATE, store.state.value.lastFailureCode) + assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) + assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) + store.close() + } + + @Test + fun `ignores observer delivery after close`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + val revisionAtClose = store.state.value.snapshot.revision + + store.close() + gateway.emit(snapshot(revisionAtClose + 1UL)) + advanceUntilIdle() + + assertEquals(revisionAtClose, store.state.value.snapshot.revision) + } + + @Test + fun `failed removal confirmation clears consumed presentation state`() = + runTest { + val gateway = + FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { + failRemovalConfirmation = true + } + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + + store.requestAccountRemoval("00".repeat(32)) + advanceUntilIdle() + assertEquals("00".repeat(32), store.state.value.pendingRemovalPublicKeyHex) + store.confirmAccountRemoval() + advanceUntilIdle() + + 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() + } + + @Test + fun `serializes commands while one is active`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + + store.signOut() + assertEquals(CommandStatus.REJECTED_BUSY, store.state.value.commandStatus) + advanceUntilIdle() + + assertEquals(0, gateway.signOutCalls) + store.signOut() + advanceUntilIdle() + assertEquals(1, gateway.signOutCalls) + store.close() + } + + @Test + fun `projects retryable command rejection without dropping intent`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + gateway.nextCommandResult = + HarvestCircleCommandResult.Rejected( + HarvestCircleCommandFailure( + WireErrorCode.STORAGE_UNAVAILABLE, + WireErrorCategory.STORAGE, + retryable = true, + WireRecoveryAction.RETRY, + "request-retry", + "Storage is temporarily unavailable.", + ), + ) + + store.signOut() + advanceUntilIdle() + + 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() + } + + @Test + fun `clears imported secret draft as soon as command is accepted`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + store.editImportDraft("nsec1secret") + + store.importSecretKey() + + assertEquals("", store.state.value.importDraft) + assertEquals(emptyList(), gateway.importedSecrets) + advanceUntilIdle() + assertEquals(listOf("nsec1secret"), gateway.importedSecrets) + assertEquals(true, gateway.lastImportBuffer?.all { it == 0.toByte() }) + store.close() + } + + @Test + fun `bounds imported secret presentation input before transport`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + + store.editImportDraft("x".repeat(MAX_IMPORT_SECRET_CHARS + 50)) + + assertEquals(MAX_IMPORT_SECRET_CHARS, store.state.value.importDraft.length) + store.close() + } + + @Test + fun `projects boot fatal and terminal lifecycle failures`() = + runTest { + val booting = snapshot(0UL, AppLifecycleDto.OPENING) + val bootGateway = FakeHarvestCircleCoreGateway(booting, booting) + val bootStore = HarvestCircleAppStore(bootGateway, this) + advanceUntilIdle() + assertEquals(HarvestCircleRoute.OPENING, bootStore.state.value.route) + bootStore.close() + + val fatal = snapshot(1UL, AppLifecycleDto.FATAL) + val gateway = FakeHarvestCircleCoreGateway(fatal, fatal) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + assertEquals(HarvestCircleRoute.FATAL, store.state.value.route) + store.signOut() + advanceUntilIdle() + assertEquals(CommandStatus.FAILED_TERMINAL, store.state.value.commandStatus) + assertEquals(0, gateway.signOutCalls) + + store.close() + store.signOut() + assertEquals(CommandStatus.REJECTED_CLOSED, store.state.value.commandStatus) + } + + @Test + fun `disposal waits for native shutdown and fails closed on an incomplete receipt`() = + runTest { + val gateway = + FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { + shutdownReceipt = HarvestCircleShutdownReceipt(1UL, closed = false) + } + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + + store.close() + + assertTrue(gateway.shutdownCompleted) + assertEquals(HarvestCircleRoute.FATAL, store.state.value.route) + assertEquals("The application could not shut down safely.", store.state.value.problem) + } +} + +private class FakeHarvestCircleCoreGateway( + private var current: AppSnapshotDto, + private val bootstrapSnapshot: AppSnapshotDto = snapshot(1UL), +) : HarvestCircleCoreGateway { + private var observer: ((AppSnapshotDto) -> Unit)? = null + var closed = false + var shutdownCompleted = false + var shutdownReceipt = HarvestCircleShutdownReceipt(current.revision, closed = true) + var subscriptionClosed = false + var signOutCalls = 0 + val importedSecrets = mutableListOf<String>() + var lastImportBuffer: ByteArray? = null + var failRemovalConfirmation = false + var lastRemovalTicket: FakeRemovalTicket? = null + var nextCommandResult: HarvestCircleCommandResult? = null + var failGeneratedRecoveryRead = false + var failGeneratedAcknowledgement = false + var generatedCancellationResult = true + var lastGeneratedRecoveryTicket: FakeGeneratedRecoveryTicket? = null + + override fun snapshot(): AppSnapshotDto = current + + override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit): AutoCloseable { + observer = { snapshot -> onChange(HarvestCircleChange(snapshot, null)) } + return AutoCloseable { subscriptionClosed = true } + } + + override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult { + nextCommandResult?.let { + nextCommandResult = null + return it + } + when (command) { + is HarvestCircleCommand.ImportAccount -> { + lastImportBuffer = command.bytes + importedSecrets += command.bytes.decodeToString() + command.bytes.fill(0) + } + HarvestCircleCommand.SignOut -> signOutCalls += 1 + else -> Unit + } + return HarvestCircleCommandResult.Accepted( + HarvestCircleCommandReceipt("fake-request", current.revision, current), + ) + } + + fun emit(snapshot: AppSnapshotDto) { + current = snapshot + observer?.invoke(snapshot) + } + + override suspend fun bootstrap(): AppSnapshotDto = bootstrapSnapshot.also(::emit) + + override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = + FakeGeneratedRecoveryTicket( + account = account(), + failRecoveryRead = failGeneratedRecoveryRead, + failAcknowledgement = failGeneratedAcknowledgement, + cancellationResult = generatedCancellationResult, + ) { committed -> + current = snapshot(current.revision + 1UL) + emit(current) + committed(current) + }.also { lastGeneratedRecoveryTicket = it } + + override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = FakeRemovalTicket().also { lastRemovalTicket = it } + + override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { + if (failRemovalConfirmation) error("injected confirmation failure") + return current + } + + override fun shutdown(): HarvestCircleShutdownReceipt { + shutdownCompleted = true + closed = true + return shutdownReceipt + } + + override fun close() { + shutdown() + } +} + +private class FakeGeneratedRecoveryTicket( + override val account: AccountDto, + private val failRecoveryRead: Boolean, + private val failAcknowledgement: Boolean, + private val cancellationResult: Boolean, + private val commit: (((AppSnapshotDto) -> Unit) -> Unit), +) : GeneratedRecoveryTicket { + override val requestId: String = "fake-generated-request" + private var available = true + var cancelCalls = 0 + var closed = false + + override fun takeRecoveryNsec(): String { + if (failRecoveryRead) error("injected recovery read failure") + return "nsec1secret" + } + + override suspend fun acknowledge(): AppSnapshotDto { + if (failAcknowledgement) { + available = false + throw HarvestCircleGatewayException( + HarvestCircleCommandFailure( + WireErrorCode.KEYRING_UNAVAILABLE, + WireErrorCategory.CREDENTIAL, + retryable = false, + WireRecoveryAction.NONE, + requestId, + "The generated account could not be saved. Import the recovery key you saved to try again.", + ), + ) + } + lateinit var snapshot: AppSnapshotDto + commit { snapshot = it } + available = false + return snapshot + } + + override suspend fun cancel(): Boolean { + cancelCalls += 1 + return (available && cancellationResult).also { available = false } + } + + override fun close() { + closed = true + } +} + +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() { + closed = true + } +} + +private fun snapshot( + revision: ULong, + lifecycle: AppLifecycleDto = AppLifecycleDto.READY, +) = AppSnapshotDto( + revision = revision, + lifecycle = lifecycle, + lifecycleError = null, + configuredRelays = emptyList(), + accounts = emptyList(), + selectedPublicKeyHex = null, + session = SessionStateDto.SIGNED_OUT, + sessionSubjectPublicKeyHex = null, + sessionError = null, + activeAccount = null, + recoverableProblem = null, +) + +private fun account() = + AccountDto( + publicKeyHex = "00".repeat(32), + npub = "npub1account", + displayLabel = "Account", + signerKind = SignerKindDto.LOCAL_SECRET, + keyAvailability = KeyAvailabilityDto.AVAILABLE, + createdAtSeconds = 0, + lastUsedAtSeconds = null, + ) diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleApplicationTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleApplicationTest.kt @@ -0,0 +1,115 @@ +package org.radroots.harvestcircle.application + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import org.radroots.harvestcircle.ffi.AppLifecycleDto +import org.radroots.harvestcircle.ffi.AppSnapshotDto +import org.radroots.harvestcircle.ffi.SessionStateDto +import kotlin.test.Test +import kotlin.test.assertEquals + +class HarvestCircleApplicationTest { + @OptIn(ExperimentalTestApi::class) + @Test + fun applicationCreatesOneStoreAcrossRecompositionAndClosesItOnDisposal() = + runComposeUiTest { + var applicationVisible by mutableStateOf(true) + var factoryCalls = 0 + var gateway: ApplicationGateway? = null + + setContent { + if (applicationVisible) { + HarvestCircleApplication { scope -> + factoryCalls += 1 + val createdGateway = ApplicationGateway() + gateway = createdGateway + HarvestCircleAppStore(createdGateway, scope) + } + } + BasicText( + text = "Toggle", + modifier = + Modifier + .testTag("toggle-application") + .clickable { applicationVisible = !applicationVisible }, + ) + } + + onNodeWithText("radroots").assertIsDisplayed() + onNodeWithTag("toggle-application").performClick() + waitForIdle() + + assertEquals(1, factoryCalls) + assertEquals(true, gateway?.closed) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun applicationRendersSafeStartupFailureWithoutLeakingInternalMessage() = + runComposeUiTest { + setContent { + HarvestCircleApplication { + error("sensitive internal startup detail") + } + } + + onNodeWithTag("startup-failure").assertIsDisplayed() + onNodeWithText("The application could not start.").assertIsDisplayed() + onAllNodesWithText("sensitive internal startup detail").assertCountEquals(0) + } +} + +private class ApplicationGateway : HarvestCircleCoreGateway { + var closed = false + + override fun snapshot() = applicationSnapshot(0UL) + + override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit) = AutoCloseable {} + + override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult = error("unused") + + override suspend fun bootstrap() = applicationSnapshot(1UL) + + override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = error("unused") + + override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = error("unused") + + override suspend fun confirmAccountRemoval(ticket: RemovalTicket) = error("unused") + + override fun shutdown(): HarvestCircleShutdownReceipt { + closed = true + return HarvestCircleShutdownReceipt(1UL, closed = true) + } + + override fun close() { + shutdown() + } +} + +private fun applicationSnapshot(revision: ULong) = + AppSnapshotDto( + revision = revision, + lifecycle = AppLifecycleDto.READY, + lifecycleError = null, + configuredRelays = emptyList(), + accounts = emptyList(), + selectedPublicKeyHex = null, + session = SessionStateDto.SIGNED_OUT, + sessionSubjectPublicKeyHex = null, + sessionError = null, + activeAccount = null, + recoverableProblem = null, + ) diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleCoreGatewayTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleCoreGatewayTest.kt @@ -0,0 +1,24 @@ +package org.radroots.harvestcircle.application + +import org.radroots.harvestcircle.ffi.WireErrorCategory +import org.radroots.harvestcircle.ffi.WireErrorCode +import org.radroots.harvestcircle.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class HarvestCircleCoreGatewayTest { + @Test + fun unknownFailuresBecomeSanitizedTypedRejections() { + val failure = + IllegalStateException("sensitive detail") + .toHarvestCircleCommandFailure("request-7") + + assertEquals(WireErrorCode.INTERNAL, failure.code) + assertEquals(WireErrorCategory.INTERNAL, failure.category) + assertEquals(WireRecoveryAction.NONE, failure.recoveryAction) + assertEquals("request-7", failure.correlationId) + assertEquals("The application command failed.", failure.safeMessage) + assertFalse(failure.toString().contains("sensitive detail")) + } +} diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/NativeGeneratedRecoveryTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/NativeGeneratedRecoveryTest.kt @@ -35,7 +35,7 @@ class NativeGeneratedRecoveryTest { expectation = verifyNativeCompatibility(compatibilityDescriptor()), developmentMode = true, ) - val gateway = NativeStudioCoreGateway(core) + val gateway = NativeHarvestCircleCoreGateway(core) try { gateway.bootstrap() val recovery = gateway.beginGeneratedAccount() diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/RadrootsApplicationTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/RadrootsApplicationTest.kt @@ -1,115 +0,0 @@ -package org.radroots.harvestcircle.application - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.v2.runComposeUiTest -import org.radroots.harvestcircle.ffi.AppLifecycleDto -import org.radroots.harvestcircle.ffi.AppSnapshotDto -import org.radroots.harvestcircle.ffi.SessionStateDto -import kotlin.test.Test -import kotlin.test.assertEquals - -class RadrootsApplicationTest { - @OptIn(ExperimentalTestApi::class) - @Test - fun applicationCreatesOneStoreAcrossRecompositionAndClosesItOnDisposal() = - runComposeUiTest { - var applicationVisible by mutableStateOf(true) - var factoryCalls = 0 - var gateway: ApplicationGateway? = null - - setContent { - if (applicationVisible) { - RadrootsApplication { scope -> - factoryCalls += 1 - val createdGateway = ApplicationGateway() - gateway = createdGateway - StudioAppStore(createdGateway, scope) - } - } - BasicText( - text = "Toggle", - modifier = - Modifier - .testTag("toggle-application") - .clickable { applicationVisible = !applicationVisible }, - ) - } - - onNodeWithText("radroots").assertIsDisplayed() - onNodeWithTag("toggle-application").performClick() - waitForIdle() - - assertEquals(1, factoryCalls) - assertEquals(true, gateway?.closed) - } - - @OptIn(ExperimentalTestApi::class) - @Test - fun applicationRendersSafeStartupFailureWithoutLeakingInternalMessage() = - runComposeUiTest { - setContent { - RadrootsApplication { - error("sensitive internal startup detail") - } - } - - onNodeWithTag("startup-failure").assertIsDisplayed() - onNodeWithText("The application could not start.").assertIsDisplayed() - onAllNodesWithText("sensitive internal startup detail").assertCountEquals(0) - } -} - -private class ApplicationGateway : StudioCoreGateway { - var closed = false - - override fun snapshot() = applicationSnapshot(0UL) - - override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit) = AutoCloseable {} - - override suspend fun execute(command: StudioCommand): StudioCommandResult = error("unused") - - override suspend fun bootstrap() = applicationSnapshot(1UL) - - override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = error("unused") - - override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = error("unused") - - override suspend fun confirmAccountRemoval(ticket: RemovalTicket) = error("unused") - - override fun shutdown(): StudioShutdownReceipt { - closed = true - return StudioShutdownReceipt(1UL, closed = true) - } - - override fun close() { - shutdown() - } -} - -private fun applicationSnapshot(revision: ULong) = - AppSnapshotDto( - revision = revision, - lifecycle = AppLifecycleDto.READY, - lifecycleError = null, - configuredRelays = emptyList(), - accounts = emptyList(), - selectedPublicKeyHex = null, - session = SessionStateDto.SIGNED_OUT, - sessionSubjectPublicKeyHex = null, - sessionError = null, - activeAccount = null, - recoverableProblem = null, - ) diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/StudioAppStoreTest.kt @@ -1,481 +0,0 @@ -package org.radroots.harvestcircle.application - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.radroots.harvestcircle.ffi.AccountDto -import org.radroots.harvestcircle.ffi.AppLifecycleDto -import org.radroots.harvestcircle.ffi.AppSnapshotDto -import org.radroots.harvestcircle.ffi.KeyAvailabilityDto -import org.radroots.harvestcircle.ffi.SessionStateDto -import org.radroots.harvestcircle.ffi.SignerKindDto -import org.radroots.harvestcircle.ffi.WireErrorCategory -import org.radroots.harvestcircle.ffi.WireErrorCode -import org.radroots.harvestcircle.ffi.WireRecoveryAction -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -class StudioAppStoreTest { - @Test - fun `bootstraps and ignores stale observer snapshots`() = - runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - - advanceUntilIdle() - gateway.emit(snapshot(1UL)) - gateway.emit(snapshot(0UL)) - advanceUntilIdle() - - assertEquals(1UL, store.state.value.snapshot.revision) - assertFalse(store.state.value.busy) - store.close() - assertTrue(gateway.closed) - assertTrue(gateway.shutdownCompleted) - assertTrue(gateway.subscriptionClosed) - assertEquals(StudioRoute.CLOSED, store.state.value.route) - } - - @Test - fun `holds generated secret only until explicit acknowledgement`() = - runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - - store.generateAccount() - advanceUntilIdle() - - assertEquals( - "nsec1secret", - store.state.value.generatedKeyBackup - ?.revealNsec(), - ) - assertEquals( - "npub1account", - store.state.value.generatedKeyBackup - ?.npub, - ) - store.acknowledgeGeneratedKeyBackup() - advanceUntilIdle() - assertNull(store.state.value.generatedKeyBackup) - store.close() - } - - @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 `partial generated recovery acquisition cancels and closes its native ticket`() = - runTest { - val gateway = - FakeStudioCoreGateway(snapshot(0UL)).apply { - failGeneratedRecoveryRead = true - } - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - - store.generateAccount() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertEquals(1, gateway.lastGeneratedRecoveryTicket?.cancelCalls) - assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) - store.close() - } - - @Test - fun `failed generated acknowledgement releases one-shot recovery ownership`() = - runTest { - val gateway = - FakeStudioCoreGateway(snapshot(0UL)).apply { - failGeneratedAcknowledgement = true - } - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - store.generateAccount() - advanceUntilIdle() - - store.acknowledgeGeneratedKeyBackup() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) - assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) - assertEquals( - "The generated account could not be saved. Import the recovery key you saved to try again.", - store.state.value.problem, - ) - store.close() - } - - @Test - fun `already resolved cancellation clears recovery and reports the state mismatch`() = - runTest { - val gateway = - FakeStudioCoreGateway(snapshot(0UL)).apply { - generatedCancellationResult = false - } - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - store.generateAccount() - advanceUntilIdle() - - store.cancelGeneratedKeyBackup() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertEquals(WireErrorCode.INVALID_APPLICATION_STATE, store.state.value.lastFailureCode) - assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) - assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) - store.close() - } - - @Test - fun `ignores observer delivery after close`() = - runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - val revisionAtClose = store.state.value.snapshot.revision - - store.close() - gateway.emit(snapshot(revisionAtClose + 1UL)) - advanceUntilIdle() - - assertEquals(revisionAtClose, store.state.value.snapshot.revision) - } - - @Test - fun `failed removal confirmation clears consumed presentation state`() = - runTest { - val gateway = - FakeStudioCoreGateway(snapshot(0UL)).apply { - failRemovalConfirmation = true - } - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - - store.requestAccountRemoval("00".repeat(32)) - advanceUntilIdle() - assertEquals("00".repeat(32), store.state.value.pendingRemovalPublicKeyHex) - store.confirmAccountRemoval() - advanceUntilIdle() - - 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() - } - - @Test - fun `serializes commands while one is active`() = - runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - - store.signOut() - assertEquals(CommandStatus.REJECTED_BUSY, store.state.value.commandStatus) - advanceUntilIdle() - - assertEquals(0, gateway.signOutCalls) - store.signOut() - advanceUntilIdle() - assertEquals(1, gateway.signOutCalls) - store.close() - } - - @Test - fun `projects retryable command rejection without dropping intent`() = - runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - gateway.nextCommandResult = - StudioCommandResult.Rejected( - StudioCommandFailure( - WireErrorCode.STORAGE_UNAVAILABLE, - WireErrorCategory.STORAGE, - retryable = true, - WireRecoveryAction.RETRY, - "request-retry", - "Storage is temporarily unavailable.", - ), - ) - - store.signOut() - advanceUntilIdle() - - 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() - } - - @Test - fun `clears imported secret draft as soon as command is accepted`() = - runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - store.editImportDraft("nsec1secret") - - store.importSecretKey() - - assertEquals("", store.state.value.importDraft) - assertEquals(emptyList(), gateway.importedSecrets) - advanceUntilIdle() - assertEquals(listOf("nsec1secret"), gateway.importedSecrets) - assertEquals(true, gateway.lastImportBuffer?.all { it == 0.toByte() }) - store.close() - } - - @Test - fun `bounds imported secret presentation input before transport`() = - runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - - store.editImportDraft("x".repeat(MAX_IMPORT_SECRET_CHARS + 50)) - - assertEquals(MAX_IMPORT_SECRET_CHARS, store.state.value.importDraft.length) - store.close() - } - - @Test - fun `projects boot fatal and terminal lifecycle failures`() = - runTest { - val booting = snapshot(0UL, AppLifecycleDto.OPENING) - val bootGateway = FakeStudioCoreGateway(booting, booting) - val bootStore = StudioAppStore(bootGateway, this) - advanceUntilIdle() - assertEquals(StudioRoute.OPENING, bootStore.state.value.route) - bootStore.close() - - val fatal = snapshot(1UL, AppLifecycleDto.FATAL) - val gateway = FakeStudioCoreGateway(fatal, fatal) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - assertEquals(StudioRoute.FATAL, store.state.value.route) - store.signOut() - advanceUntilIdle() - assertEquals(CommandStatus.FAILED_TERMINAL, store.state.value.commandStatus) - assertEquals(0, gateway.signOutCalls) - - store.close() - store.signOut() - assertEquals(CommandStatus.REJECTED_CLOSED, store.state.value.commandStatus) - } - - @Test - fun `disposal waits for native shutdown and fails closed on an incomplete receipt`() = - runTest { - val gateway = - FakeStudioCoreGateway(snapshot(0UL)).apply { - shutdownReceipt = StudioShutdownReceipt(1UL, closed = false) - } - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - - store.close() - - assertTrue(gateway.shutdownCompleted) - assertEquals(StudioRoute.FATAL, store.state.value.route) - assertEquals("The application could not shut down safely.", store.state.value.problem) - } -} - -private class FakeStudioCoreGateway( - private var current: AppSnapshotDto, - private val bootstrapSnapshot: AppSnapshotDto = snapshot(1UL), -) : StudioCoreGateway { - private var observer: ((AppSnapshotDto) -> Unit)? = null - var closed = false - var shutdownCompleted = false - var shutdownReceipt = StudioShutdownReceipt(current.revision, closed = true) - var subscriptionClosed = false - var signOutCalls = 0 - val importedSecrets = mutableListOf<String>() - var lastImportBuffer: ByteArray? = null - var failRemovalConfirmation = false - var lastRemovalTicket: FakeRemovalTicket? = null - var nextCommandResult: StudioCommandResult? = null - var failGeneratedRecoveryRead = false - var failGeneratedAcknowledgement = false - var generatedCancellationResult = true - var lastGeneratedRecoveryTicket: FakeGeneratedRecoveryTicket? = null - - override fun snapshot(): AppSnapshotDto = current - - override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable { - observer = { snapshot -> onChange(StudioChange(snapshot, null)) } - return AutoCloseable { subscriptionClosed = true } - } - - override suspend fun execute(command: StudioCommand): StudioCommandResult { - nextCommandResult?.let { - nextCommandResult = null - return it - } - when (command) { - is StudioCommand.ImportAccount -> { - lastImportBuffer = command.bytes - importedSecrets += command.bytes.decodeToString() - command.bytes.fill(0) - } - StudioCommand.SignOut -> signOutCalls += 1 - else -> Unit - } - return StudioCommandResult.Accepted( - StudioCommandReceipt("fake-request", current.revision, current), - ) - } - - fun emit(snapshot: AppSnapshotDto) { - current = snapshot - observer?.invoke(snapshot) - } - - override suspend fun bootstrap(): AppSnapshotDto = bootstrapSnapshot.also(::emit) - - override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = - FakeGeneratedRecoveryTicket( - account = account(), - failRecoveryRead = failGeneratedRecoveryRead, - failAcknowledgement = failGeneratedAcknowledgement, - cancellationResult = generatedCancellationResult, - ) { committed -> - current = snapshot(current.revision + 1UL) - emit(current) - committed(current) - }.also { lastGeneratedRecoveryTicket = it } - - override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = FakeRemovalTicket().also { lastRemovalTicket = it } - - override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { - if (failRemovalConfirmation) error("injected confirmation failure") - return current - } - - override fun shutdown(): StudioShutdownReceipt { - shutdownCompleted = true - closed = true - return shutdownReceipt - } - - override fun close() { - shutdown() - } -} - -private class FakeGeneratedRecoveryTicket( - override val account: AccountDto, - private val failRecoveryRead: Boolean, - private val failAcknowledgement: Boolean, - private val cancellationResult: Boolean, - private val commit: (((AppSnapshotDto) -> Unit) -> Unit), -) : GeneratedRecoveryTicket { - override val requestId: String = "fake-generated-request" - private var available = true - var cancelCalls = 0 - var closed = false - - override fun takeRecoveryNsec(): String { - if (failRecoveryRead) error("injected recovery read failure") - return "nsec1secret" - } - - override suspend fun acknowledge(): AppSnapshotDto { - if (failAcknowledgement) { - available = false - throw StudioGatewayException( - StudioCommandFailure( - WireErrorCode.KEYRING_UNAVAILABLE, - WireErrorCategory.CREDENTIAL, - retryable = false, - WireRecoveryAction.NONE, - requestId, - "The generated account could not be saved. Import the recovery key you saved to try again.", - ), - ) - } - lateinit var snapshot: AppSnapshotDto - commit { snapshot = it } - available = false - return snapshot - } - - override suspend fun cancel(): Boolean { - cancelCalls += 1 - return (available && cancellationResult).also { available = false } - } - - override fun close() { - closed = true - } -} - -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() { - closed = true - } -} - -private fun snapshot( - revision: ULong, - lifecycle: AppLifecycleDto = AppLifecycleDto.READY, -) = AppSnapshotDto( - revision = revision, - lifecycle = lifecycle, - lifecycleError = null, - configuredRelays = emptyList(), - accounts = emptyList(), - selectedPublicKeyHex = null, - session = SessionStateDto.SIGNED_OUT, - sessionSubjectPublicKeyHex = null, - sessionError = null, - activeAccount = null, - recoverableProblem = null, -) - -private fun account() = - AccountDto( - publicKeyHex = "00".repeat(32), - npub = "npub1account", - displayLabel = "Account", - signerKind = SignerKindDto.LOCAL_SECRET, - keyAvailability = KeyAvailabilityDto.AVAILABLE, - createdAtSeconds = 0, - lastUsedAtSeconds = null, - ) diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/StudioCoreGatewayTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/StudioCoreGatewayTest.kt @@ -1,24 +0,0 @@ -package org.radroots.harvestcircle.application - -import org.radroots.harvestcircle.ffi.WireErrorCategory -import org.radroots.harvestcircle.ffi.WireErrorCode -import org.radroots.harvestcircle.ffi.WireRecoveryAction -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse - -class StudioCoreGatewayTest { - @Test - fun unknownFailuresBecomeSanitizedTypedRejections() { - val failure = - IllegalStateException("sensitive detail") - .toStudioCommandFailure("request-7") - - assertEquals(WireErrorCode.INTERNAL, failure.code) - assertEquals(WireErrorCategory.INTERNAL, failure.category) - assertEquals(WireRecoveryAction.NONE, failure.recoveryAction) - assertEquals("request-7", failure.correlationId) - assertEquals("The application command failed.", failure.safeMessage) - assertFalse(failure.toString().contains("sensitive detail")) - } -} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml @@ -13,8 +13,8 @@ complexity: exceptions: TooGenericExceptionCaught: excludes: - - '**/StudioAppStore.kt' - - '**/StudioCoreGateway.kt' + - '**/HarvestCircleAppStore.kt' + - '**/HarvestCircleCoreGateway.kt' naming: FunctionNaming: