app

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

commit d19ae7a1206b5ca8ee89ed3e54f74b99f4dbcc1b
parent 62788649fa9fa01457939024f0d7c41d7bdd5ed5
Author: triesap <tyson@radroots.org>
Date:   Sun,  9 Aug 2026 19:03:39 +0000

desktop: move Kotlin to the HarvestCircle namespace

- relocate main and test sources into org.radroots.harvestcircle
- align imports, the main class, and generated UniFFI package
- separate source namespace from persisted package identity
- verify checks and bindings through both contributor lanes

Diffstat:
MMakefile | 2+-
Mapp/desktop/build.gradle.kts | 4++--
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/AccountsUiModel.kt | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/StudioScreen.kt | 495+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/GeneratedKeyBackup.kt | 19+++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/NativeCompatibility.kt | 36++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/RadrootsApplication.kt | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/SecretClipboardController.kt | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/StudioAppStore.kt | 508+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/application/StudioCoreGateway.kt | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/harvestcircle/desktop/Main.kt | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt | 168-------------------------------------------------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 495-------------------------------------------------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedKeyBackup.kt | 19-------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/application/NativeCompatibility.kt | 36------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 76----------------------------------------------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/application/SecretClipboardController.kt | 71-----------------------------------------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 508-------------------------------------------------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt | 307-------------------------------------------------------------------------------
Dapp/desktop/src/main/kotlin/org/radroots/studio/desktop/Main.kt | 81-------------------------------------------------------------------------------
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/AccountsUiModelTest.kt | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/StudioScreenTest.kt | 384+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/GeneratedKeyBackupTest.kt | 18++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/NativeCompatibilityTest.kt | 40++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/NativeGeneratedRecoveryTest.kt | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/RadrootsApplicationTest.kt | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/SecretClipboardControllerTest.kt | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/StudioAppStoreTest.kt | 481+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/application/StudioCoreGatewayTest.kt | 24++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/architecture/NostrOnlySourceGuardTest.kt | 46++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/architecture/V5CompatibilityBaselineTest.kt | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/desktop/MainTest.kt | 13+++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/harvestcircle/ffi/NativeLoaderTest.kt | 11+++++++++++
Dapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt | 127-------------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt | 384-------------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/application/GeneratedKeyBackupTest.kt | 18------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/application/NativeCompatibilityTest.kt | 40----------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/application/NativeGeneratedRecoveryTest.kt | 53-----------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt | 115-------------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/application/SecretClipboardControllerTest.kt | 118-------------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 481-------------------------------------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioCoreGatewayTest.kt | 24------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/architecture/NostrOnlySourceGuardTest.kt | 46----------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/architecture/V5CompatibilityBaselineTest.kt | 50--------------------------------------------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/desktop/MainTest.kt | 13-------------
Dapp/desktop/src/test/kotlin/org/radroots/studio/ffi/NativeLoaderTest.kt | 11-----------
Mcore/compatibility/v5-baseline.properties | 1+
47 files changed, 3246 insertions(+), 3244 deletions(-)

diff --git a/Makefile b/Makefile @@ -49,7 +49,7 @@ bindings: doctor $(EXTBUILD) $(GRADLE) --no-daemon :app:desktop:verifyUniFfiBindings :app:desktop:verifyReleaseNativeLibrary dev: doctor - $(EXTBUILD) $(GRADLE) :app:desktop:hotRun --mainClass org.radroots.studio.desktop.MainKt + $(EXTBUILD) $(GRADLE) :app:desktop:hotRun --mainClass org.radroots.harvestcircle.desktop.MainKt run: doctor $(EXTBUILD) $(GRADLE) :app:desktop:run diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -127,7 +127,7 @@ check(Regex("""[1-9]\d*(\.\d+){0,2}""").matches(installableVersion)) { } val applicationName = baselineValue("package.name") val bundleId = baselineValue("package.bundle_id") -val applicationNamespace = baselineValue("package.namespace") +val applicationNamespace = baselineValue("source.namespace") version = appVersion val rustSources = @@ -270,7 +270,7 @@ abstract class VerifyUniFfiBindings : DefaultTask() { val verifyUniFfiBindings by tasks.registering(VerifyUniFfiBindings::class) { dependsOn(generateUniFfiKotlin) generatedDirectory.set(generatedUniFfiKotlin) - expectedPackage.set("org.radroots.studio.ffi") + expectedPackage.set("org.radroots.harvestcircle.ffi") } val cleanReleaseNativeResources by tasks.registering(Delete::class) { delete(generatedReleaseNativeResources) 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 @@ -0,0 +1,168 @@ +package org.radroots.harvestcircle.accounts.ui + +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.application.StudioStoreState +import org.radroots.harvestcircle.ffi.AccountDto +import org.radroots.harvestcircle.ffi.ActiveAccountDto +import org.radroots.harvestcircle.ffi.ProfileLoadStateDto +import org.radroots.harvestcircle.ffi.RelayConnectionStateDto +import org.radroots.harvestcircle.ffi.SessionStateDto +import org.radroots.harvestcircle.ffi.WireErrorCode +import org.radroots.harvestcircle.ffi.WireRecoveryAction + +data class AccountUiModel( + val publicKeyHex: String, + val npub: String, + val shortNpub: String, + val label: String, + val keyAvailability: String, + val selected: Boolean, + val active: Boolean, +) + +data class ProfileUiModel( + val name: String, + val displayName: String, + val nip05: String, + val about: String, + val picture: String, +) + +data class ActiveAccountUiModel( + val account: AccountUiModel, + val heading: String, + val relayState: String, + val profileState: String, + val profile: ProfileUiModel, +) + +class GeneratedKeyBackupUiModel( + val npub: String, + val nsec: String, +) { + override fun toString(): String = "GeneratedKeyBackupUiModel(npub=$npub, nsec=[REDACTED])" +} + +data class StudioUiModel( + val route: StudioRoute, + val accounts: List<AccountUiModel>, + val activeAccount: ActiveAccountUiModel?, + val configuredRelays: List<String>, + val importDraft: String, + val generatedKeyBackup: GeneratedKeyBackupUiModel?, + val pendingRemovalPublicKeyHex: String?, + val removalImpact: RemovalImpactState?, + val removalStatus: RemovalStatus, + val lastRemovedPublicKeyHex: String?, + val accountChooserVisible: Boolean, + val accountEntryMode: AccountEntryMode, + val session: SessionStateDto, + val busy: Boolean, + val problem: String?, + val importGuidance: String?, + val recoveryAction: WireRecoveryAction, +) + +fun StudioStoreState.toUiModel(): StudioUiModel { + val selectedPublicKeyHex = snapshot.selectedPublicKeyHex + val activePublicKeyHex = snapshot.activeAccount?.account?.publicKeyHex + val accounts = + snapshot.accounts.map { + it.toUiModel( + selected = it.publicKeyHex == selectedPublicKeyHex, + active = it.publicKeyHex == activePublicKeyHex, + ) + } + return StudioUiModel( + route = route, + accounts = accounts, + activeAccount = snapshot.activeAccount?.toUiModel(selectedPublicKeyHex), + configuredRelays = snapshot.configuredRelays, + importDraft = importDraft, + generatedKeyBackup = + generatedKeyBackup?.let { + GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.revealNsec()) + }, + pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex, + removalImpact = removalImpact, + removalStatus = removalStatus, + lastRemovedPublicKeyHex = lastRemovedPublicKeyHex, + accountChooserVisible = accountChooserVisible, + accountEntryMode = accountEntryMode, + session = snapshot.session, + busy = busy, + problem = + problem + ?: snapshot.recoverableProblem?.message + ?: snapshot.sessionError?.message + ?: snapshot.lifecycleError?.message, + importGuidance = importGuidance(lastFailureCode, recoveryAction), + recoveryAction = recoveryAction, + ) +} + +private fun importGuidance( + code: WireErrorCode?, + recoveryAction: WireRecoveryAction, +): String? = + when { + code == WireErrorCode.INVALID_SECRET_KEY -> "Enter a valid nsec or 64-character hexadecimal secret key." + code == WireErrorCode.ACCOUNT_ALREADY_EXISTS -> "This Nostr account is already saved." + code == WireErrorCode.CREDENTIAL_MISSING || recoveryAction == WireRecoveryAction.REPAIR_CREDENTIAL -> + "This saved account is missing its local credential. Re-enter its secret key to repair it." + else -> null + } + +private const val SHORT_NPUB_MAX_LENGTH = 24 +private const val SHORT_NPUB_PREFIX_LENGTH = 14 +private const val SHORT_NPUB_SUFFIX_LENGTH = 8 + +fun shortenNpub(npub: String): String = + if (npub.length <= SHORT_NPUB_MAX_LENGTH) { + npub + } else { + "${npub.take(SHORT_NPUB_PREFIX_LENGTH)}…${npub.takeLast(SHORT_NPUB_SUFFIX_LENGTH)}" + } + +private fun AccountDto.toUiModel( + selected: Boolean, + active: Boolean = false, +) = AccountUiModel( + publicKeyHex = publicKeyHex, + npub = npub, + shortNpub = shortenNpub(npub), + label = displayLabel.ifBlank { shortenNpub(npub) }, + keyAvailability = keyAvailability.name.lowercase().replace('_', ' '), + selected = selected, + active = active, +) + +private fun ActiveAccountDto.toUiModel(selectedPublicKeyHex: String?) = + ActiveAccountUiModel( + account = + account.toUiModel( + selected = account.publicKeyHex == selectedPublicKeyHex, + active = true, + ), + heading = + profile?.displayName?.takeIf(String::isNotBlank) + ?: profile?.name?.takeIf(String::isNotBlank) + ?: account.displayLabel.ifBlank { shortenNpub(account.npub) }, + relayState = relayState.toDisplayText(), + profileState = profileState.toDisplayText(), + profile = + ProfileUiModel( + name = profile?.name.orEmpty(), + displayName = profile?.displayName.orEmpty(), + nip05 = profile?.nip05.orEmpty(), + about = profile?.about.orEmpty(), + picture = profile?.picture.orEmpty(), + ), + ) + +private fun RelayConnectionStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') + +private fun ProfileLoadStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') 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 @@ -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.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/GeneratedKeyBackup.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/GeneratedKeyBackup.kt @@ -0,0 +1,19 @@ +package org.radroots.harvestcircle.application + +class GeneratedKeyBackup internal constructor( + val npub: String, + nsec: String, +) { + private var recoveryText: String? = nsec + + internal fun revealNsec(): String = + checkNotNull(recoveryText) { + "Generated recovery material is no longer available" + } + + internal fun clear() { + recoveryText = null + } + + override fun toString(): String = "GeneratedKeyBackup(npub=$npub, nsec=[REDACTED])" +} diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/NativeCompatibility.kt @@ -0,0 +1,36 @@ +package org.radroots.harvestcircle.application + +import org.radroots.harvestcircle.ffi.CompatibilityDescriptor +import org.radroots.harvestcircle.ffi.CompatibilityExpectation + +internal const val EXPECTED_PRODUCT_VERSION = "0.1.0-alpha" +internal const val EXPECTED_CARGO_PACKAGE_VERSION = "0.1.0-alpha" +internal const val EXPECTED_FFI_CONTRACT_HASH = "249b8f9ec8ce64e33d5b88dd52d7dc646a2d5b6993c738e71253964531d6d210" +internal val EXPECTED_FFI_CONTRACT_MAJOR: UShort = 3.toUShort() +internal val MINIMUM_FFI_CONTRACT_MINOR: UShort = 0.toUShort() +internal const val MINIMUM_STORAGE_SCHEMA: UInt = 5U +internal const val MAXIMUM_STORAGE_SCHEMA: UInt = 10U + +internal class NativeCompatibilityException : + IllegalStateException( + "The application and native runtime are incompatible.", + ) + +internal fun verifyNativeCompatibility(descriptor: CompatibilityDescriptor): CompatibilityExpectation { + val compatible = + descriptor.productVersion == EXPECTED_PRODUCT_VERSION && + descriptor.cargoPackageVersion == EXPECTED_CARGO_PACKAGE_VERSION && + descriptor.contractMajor == EXPECTED_FFI_CONTRACT_MAJOR && + descriptor.contractMinor >= MINIMUM_FFI_CONTRACT_MINOR && + descriptor.contractHash == EXPECTED_FFI_CONTRACT_HASH && + descriptor.currentSchemaVersion >= MINIMUM_STORAGE_SCHEMA && + descriptor.minimumSchemaVersion <= MAXIMUM_STORAGE_SCHEMA + if (!compatible) throw NativeCompatibilityException() + return CompatibilityExpectation( + contractMajor = EXPECTED_FFI_CONTRACT_MAJOR, + minimumContractMinor = MINIMUM_FFI_CONTRACT_MINOR, + contractHash = EXPECTED_FFI_CONTRACT_HASH, + minimumSchemaVersion = MINIMUM_STORAGE_SCHEMA, + maximumSchemaVersion = MAXIMUM_STORAGE_SCHEMA, + ) +} 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 @@ -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.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/SecretClipboardController.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/SecretClipboardController.kt @@ -0,0 +1,71 @@ +package org.radroots.harvestcircle.application + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection + +internal interface TextClipboard { + fun readText(): String? + + fun writeText(value: String) +} + +internal sealed interface SecretClipboardResult { + data object Copied : SecretClipboardResult + + data object Unavailable : SecretClipboardResult +} + +internal class SecretClipboardController( + private val scope: CoroutineScope, + private val clipboard: TextClipboard = SystemTextClipboard, + private val clearDelayMillis: Long = 60_000, +) : AutoCloseable { + private var clearJob: Job? = null + private var copiedValue: String? = null + + fun copy(value: String): SecretClipboardResult { + if (runCatching { clipboard.writeText(value) }.isFailure) { + return SecretClipboardResult.Unavailable + } + copiedValue = value + clearJob?.cancel() + clearJob = + scope.launch { + delay(clearDelayMillis) + runCatching { + if (clipboard.readText() == value) clipboard.writeText("") + } + if (copiedValue == value) copiedValue = null + } + return SecretClipboardResult.Copied + } + + override fun close() { + clearJob?.cancel() + clearJob = null + val value = copiedValue + runCatching { + if (value != null && clipboard.readText() == value) clipboard.writeText("") + } + copiedValue = null + } +} + +private object SystemTextClipboard : TextClipboard { + private val clipboard + get() = Toolkit.getDefaultToolkit().systemClipboard + + override fun readText(): String? = + runCatching { + clipboard.getData(DataFlavor.stringFlavor) as? String + }.getOrNull() + + override fun writeText(value: String) { + clipboard.setContents(StringSelection(value), null) + } +} 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 @@ -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 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 @@ -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 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 @@ -0,0 +1,81 @@ +package org.radroots.harvestcircle.desktop + +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.unit.dp +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 java.awt.Dimension +import java.awt.Taskbar +import javax.imageio.ImageIO + +private const val APPLICATION_NAME = "Radroots" +private const val INITIAL_WINDOW_WIDTH = 1284 +private const val INITIAL_WINDOW_HEIGHT = 795 +private const val MINIMUM_WINDOW_WIDTH = 1080 +private const val MINIMUM_WINDOW_HEIGHT = 720 + +private val isMacOs: Boolean = + System + .getProperty("os.name", "") + .startsWith("Mac", ignoreCase = true) + +fun main() { + val nativeStartupProblem = if (isMacOs) configureMacOsApplication() else null + + application { + Window( + onCloseRequest = ::exitApplication, + title = APPLICATION_NAME, + state = + rememberWindowState( + width = INITIAL_WINDOW_WIDTH.dp, + height = INITIAL_WINDOW_HEIGHT.dp, + ), + ) { + DisposableEffect(window) { + window.minimumSize = Dimension(MINIMUM_WINDOW_WIDTH, MINIMUM_WINDOW_HEIGHT) + + if (isMacOs) { + val rootPane = window.rootPane + rootPane.putClientProperty("apple.awt.fullWindowContent", true) + rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + rootPane.putClientProperty("apple.awt.windowTitleVisible", false) + } + + onDispose { } + } + + if (nativeStartupProblem == null) { + RadrootsApplication() + } else { + StartupFailureScreen(nativeStartupProblem) + } + } + } +} + +private fun configureMacOsApplication(): String? { + System.setProperty("apple.awt.application.name", APPLICATION_NAME) + System.setProperty("apple.awt.application.appearance", "system") + + if (!Taskbar.isTaskbarSupported()) return null + + val taskbar = Taskbar.getTaskbar() + if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) return null + + val icon = + loadRuntimeIcon { + Thread.currentThread().contextClassLoader.getResourceAsStream("icons/radroots.png") + } ?: return "The application icon resource is unavailable." + return runCatching { taskbar.iconImage = icon } + .fold( + onSuccess = { null }, + onFailure = { "The application icon could not be configured." }, + ) +} + +internal fun loadRuntimeIcon(openResource: () -> java.io.InputStream?): java.awt.Image? = + runCatching { openResource()?.use(ImageIO::read) }.getOrNull() diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt @@ -1,168 +0,0 @@ -package org.radroots.studio.accounts.ui - -import org.radroots.studio.application.AccountEntryMode -import org.radroots.studio.application.RemovalImpactState -import org.radroots.studio.application.RemovalStatus -import org.radroots.studio.application.StudioRoute -import org.radroots.studio.application.StudioStoreState -import org.radroots.studio.ffi.AccountDto -import org.radroots.studio.ffi.ActiveAccountDto -import org.radroots.studio.ffi.ProfileLoadStateDto -import org.radroots.studio.ffi.RelayConnectionStateDto -import org.radroots.studio.ffi.SessionStateDto -import org.radroots.studio.ffi.WireErrorCode -import org.radroots.studio.ffi.WireRecoveryAction - -data class AccountUiModel( - val publicKeyHex: String, - val npub: String, - val shortNpub: String, - val label: String, - val keyAvailability: String, - val selected: Boolean, - val active: Boolean, -) - -data class ProfileUiModel( - val name: String, - val displayName: String, - val nip05: String, - val about: String, - val picture: String, -) - -data class ActiveAccountUiModel( - val account: AccountUiModel, - val heading: String, - val relayState: String, - val profileState: String, - val profile: ProfileUiModel, -) - -class GeneratedKeyBackupUiModel( - val npub: String, - val nsec: String, -) { - override fun toString(): String = "GeneratedKeyBackupUiModel(npub=$npub, nsec=[REDACTED])" -} - -data class StudioUiModel( - val route: StudioRoute, - val accounts: List<AccountUiModel>, - val activeAccount: ActiveAccountUiModel?, - val configuredRelays: List<String>, - val importDraft: String, - val generatedKeyBackup: GeneratedKeyBackupUiModel?, - val pendingRemovalPublicKeyHex: String?, - val removalImpact: RemovalImpactState?, - val removalStatus: RemovalStatus, - val lastRemovedPublicKeyHex: String?, - val accountChooserVisible: Boolean, - val accountEntryMode: AccountEntryMode, - val session: SessionStateDto, - val busy: Boolean, - val problem: String?, - val importGuidance: String?, - val recoveryAction: WireRecoveryAction, -) - -fun StudioStoreState.toUiModel(): StudioUiModel { - val selectedPublicKeyHex = snapshot.selectedPublicKeyHex - val activePublicKeyHex = snapshot.activeAccount?.account?.publicKeyHex - val accounts = - snapshot.accounts.map { - it.toUiModel( - selected = it.publicKeyHex == selectedPublicKeyHex, - active = it.publicKeyHex == activePublicKeyHex, - ) - } - return StudioUiModel( - route = route, - accounts = accounts, - activeAccount = snapshot.activeAccount?.toUiModel(selectedPublicKeyHex), - configuredRelays = snapshot.configuredRelays, - importDraft = importDraft, - generatedKeyBackup = - generatedKeyBackup?.let { - GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.revealNsec()) - }, - pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex, - removalImpact = removalImpact, - removalStatus = removalStatus, - lastRemovedPublicKeyHex = lastRemovedPublicKeyHex, - accountChooserVisible = accountChooserVisible, - accountEntryMode = accountEntryMode, - session = snapshot.session, - busy = busy, - problem = - problem - ?: snapshot.recoverableProblem?.message - ?: snapshot.sessionError?.message - ?: snapshot.lifecycleError?.message, - importGuidance = importGuidance(lastFailureCode, recoveryAction), - recoveryAction = recoveryAction, - ) -} - -private fun importGuidance( - code: WireErrorCode?, - recoveryAction: WireRecoveryAction, -): String? = - when { - code == WireErrorCode.INVALID_SECRET_KEY -> "Enter a valid nsec or 64-character hexadecimal secret key." - code == WireErrorCode.ACCOUNT_ALREADY_EXISTS -> "This Nostr account is already saved." - code == WireErrorCode.CREDENTIAL_MISSING || recoveryAction == WireRecoveryAction.REPAIR_CREDENTIAL -> - "This saved account is missing its local credential. Re-enter its secret key to repair it." - else -> null - } - -private const val SHORT_NPUB_MAX_LENGTH = 24 -private const val SHORT_NPUB_PREFIX_LENGTH = 14 -private const val SHORT_NPUB_SUFFIX_LENGTH = 8 - -fun shortenNpub(npub: String): String = - if (npub.length <= SHORT_NPUB_MAX_LENGTH) { - npub - } else { - "${npub.take(SHORT_NPUB_PREFIX_LENGTH)}…${npub.takeLast(SHORT_NPUB_SUFFIX_LENGTH)}" - } - -private fun AccountDto.toUiModel( - selected: Boolean, - active: Boolean = false, -) = AccountUiModel( - publicKeyHex = publicKeyHex, - npub = npub, - shortNpub = shortenNpub(npub), - label = displayLabel.ifBlank { shortenNpub(npub) }, - keyAvailability = keyAvailability.name.lowercase().replace('_', ' '), - selected = selected, - active = active, -) - -private fun ActiveAccountDto.toUiModel(selectedPublicKeyHex: String?) = - ActiveAccountUiModel( - account = - account.toUiModel( - selected = account.publicKeyHex == selectedPublicKeyHex, - active = true, - ), - heading = - profile?.displayName?.takeIf(String::isNotBlank) - ?: profile?.name?.takeIf(String::isNotBlank) - ?: account.displayLabel.ifBlank { shortenNpub(account.npub) }, - relayState = relayState.toDisplayText(), - profileState = profileState.toDisplayText(), - profile = - ProfileUiModel( - name = profile?.name.orEmpty(), - displayName = profile?.displayName.orEmpty(), - nip05 = profile?.nip05.orEmpty(), - about = profile?.about.orEmpty(), - picture = profile?.picture.orEmpty(), - ), - ) - -private fun RelayConnectionStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') - -private fun ProfileLoadStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt @@ -1,495 +0,0 @@ -package org.radroots.studio.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.studio.application.AccountEntryMode -import org.radroots.studio.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.studio.ffi.WireRecoveryAction.RETRY) { - TextAction( - text = "Retry", - testTag = "retry-last-command", - contentDescription = "Retry the last failed action", - enabled = !model.busy, - onClick = actions.retryLastCommand, - ) - } -} - -@Composable -private fun AccountEntry( - model: StudioUiModel, - actions: StudioUiActions, -) { - when (model.accountEntryMode) { - AccountEntryMode.CHOICE -> { - 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/studio/application/GeneratedKeyBackup.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedKeyBackup.kt @@ -1,19 +0,0 @@ -package org.radroots.studio.application - -class GeneratedKeyBackup internal constructor( - val npub: String, - nsec: String, -) { - private var recoveryText: String? = nsec - - internal fun revealNsec(): String = - checkNotNull(recoveryText) { - "Generated recovery material is no longer available" - } - - internal fun clear() { - recoveryText = null - } - - override fun toString(): String = "GeneratedKeyBackup(npub=$npub, nsec=[REDACTED])" -} diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/NativeCompatibility.kt @@ -1,36 +0,0 @@ -package org.radroots.studio.application - -import org.radroots.studio.ffi.CompatibilityDescriptor -import org.radroots.studio.ffi.CompatibilityExpectation - -internal const val EXPECTED_PRODUCT_VERSION = "0.1.0-alpha" -internal const val EXPECTED_CARGO_PACKAGE_VERSION = "0.1.0-alpha" -internal const val EXPECTED_FFI_CONTRACT_HASH = "249b8f9ec8ce64e33d5b88dd52d7dc646a2d5b6993c738e71253964531d6d210" -internal val EXPECTED_FFI_CONTRACT_MAJOR: UShort = 3.toUShort() -internal val MINIMUM_FFI_CONTRACT_MINOR: UShort = 0.toUShort() -internal const val MINIMUM_STORAGE_SCHEMA: UInt = 5U -internal const val MAXIMUM_STORAGE_SCHEMA: UInt = 10U - -internal class NativeCompatibilityException : - IllegalStateException( - "The application and native runtime are incompatible.", - ) - -internal fun verifyNativeCompatibility(descriptor: CompatibilityDescriptor): CompatibilityExpectation { - val compatible = - descriptor.productVersion == EXPECTED_PRODUCT_VERSION && - descriptor.cargoPackageVersion == EXPECTED_CARGO_PACKAGE_VERSION && - descriptor.contractMajor == EXPECTED_FFI_CONTRACT_MAJOR && - descriptor.contractMinor >= MINIMUM_FFI_CONTRACT_MINOR && - descriptor.contractHash == EXPECTED_FFI_CONTRACT_HASH && - descriptor.currentSchemaVersion >= MINIMUM_STORAGE_SCHEMA && - descriptor.minimumSchemaVersion <= MAXIMUM_STORAGE_SCHEMA - if (!compatible) throw NativeCompatibilityException() - return CompatibilityExpectation( - contractMajor = EXPECTED_FFI_CONTRACT_MAJOR, - minimumContractMinor = MINIMUM_FFI_CONTRACT_MINOR, - contractHash = EXPECTED_FFI_CONTRACT_HASH, - minimumSchemaVersion = MINIMUM_STORAGE_SCHEMA, - maximumSchemaVersion = MAXIMUM_STORAGE_SCHEMA, - ) -} diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt @@ -1,76 +0,0 @@ -package org.radroots.studio.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.studio.accounts.ui.StartupFailureScreen -import org.radroots.studio.accounts.ui.StudioScreen -import org.radroots.studio.accounts.ui.StudioUiActions -import org.radroots.studio.accounts.ui.toUiModel -import org.radroots.studio.ffi.StudioAppCore -import org.radroots.studio.ffi.StudioException -import org.radroots.studio.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/studio/application/SecretClipboardController.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/SecretClipboardController.kt @@ -1,71 +0,0 @@ -package org.radroots.studio.application - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.awt.Toolkit -import java.awt.datatransfer.DataFlavor -import java.awt.datatransfer.StringSelection - -internal interface TextClipboard { - fun readText(): String? - - fun writeText(value: String) -} - -internal sealed interface SecretClipboardResult { - data object Copied : SecretClipboardResult - - data object Unavailable : SecretClipboardResult -} - -internal class SecretClipboardController( - private val scope: CoroutineScope, - private val clipboard: TextClipboard = SystemTextClipboard, - private val clearDelayMillis: Long = 60_000, -) : AutoCloseable { - private var clearJob: Job? = null - private var copiedValue: String? = null - - fun copy(value: String): SecretClipboardResult { - if (runCatching { clipboard.writeText(value) }.isFailure) { - return SecretClipboardResult.Unavailable - } - copiedValue = value - clearJob?.cancel() - clearJob = - scope.launch { - delay(clearDelayMillis) - runCatching { - if (clipboard.readText() == value) clipboard.writeText("") - } - if (copiedValue == value) copiedValue = null - } - return SecretClipboardResult.Copied - } - - override fun close() { - clearJob?.cancel() - clearJob = null - val value = copiedValue - runCatching { - if (value != null && clipboard.readText() == value) clipboard.writeText("") - } - copiedValue = null - } -} - -private object SystemTextClipboard : TextClipboard { - private val clipboard - get() = Toolkit.getDefaultToolkit().systemClipboard - - override fun readText(): String? = - runCatching { - clipboard.getData(DataFlavor.stringFlavor) as? String - }.getOrNull() - - override fun writeText(value: String) { - clipboard.setContents(StringSelection(value), null) - } -} diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt @@ -1,508 +0,0 @@ -package org.radroots.studio.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.studio.ffi.AppLifecycleDto -import org.radroots.studio.ffi.AppSnapshotDto -import org.radroots.studio.ffi.StudioException -import org.radroots.studio.ffi.WireErrorCode -import org.radroots.studio.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.studio.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/studio/application/StudioCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt @@ -1,307 +0,0 @@ -package org.radroots.studio.application - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.runBlocking -import org.radroots.studio.ffi.AccountDto -import org.radroots.studio.ffi.AppSnapshotDto -import org.radroots.studio.ffi.GeneratedRecoveryRequest -import org.radroots.studio.ffi.ObserverSubscription -import org.radroots.studio.ffi.RemovalRequest -import org.radroots.studio.ffi.RequestContextDto -import org.radroots.studio.ffi.SnapshotChangeDto -import org.radroots.studio.ffi.StudioAppCore -import org.radroots.studio.ffi.StudioChangeObserver -import org.radroots.studio.ffi.StudioException -import org.radroots.studio.ffi.WireErrorCategory -import org.radroots.studio.ffi.WireErrorCode -import org.radroots.studio.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/studio/desktop/Main.kt b/app/desktop/src/main/kotlin/org/radroots/studio/desktop/Main.kt @@ -1,81 +0,0 @@ -package org.radroots.studio.desktop - -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState -import org.radroots.studio.accounts.ui.StartupFailureScreen -import org.radroots.studio.application.RadrootsApplication -import java.awt.Dimension -import java.awt.Taskbar -import javax.imageio.ImageIO - -private const val APPLICATION_NAME = "Radroots" -private const val INITIAL_WINDOW_WIDTH = 1284 -private const val INITIAL_WINDOW_HEIGHT = 795 -private const val MINIMUM_WINDOW_WIDTH = 1080 -private const val MINIMUM_WINDOW_HEIGHT = 720 - -private val isMacOs: Boolean = - System - .getProperty("os.name", "") - .startsWith("Mac", ignoreCase = true) - -fun main() { - val nativeStartupProblem = if (isMacOs) configureMacOsApplication() else null - - application { - Window( - onCloseRequest = ::exitApplication, - title = APPLICATION_NAME, - state = - rememberWindowState( - width = INITIAL_WINDOW_WIDTH.dp, - height = INITIAL_WINDOW_HEIGHT.dp, - ), - ) { - DisposableEffect(window) { - window.minimumSize = Dimension(MINIMUM_WINDOW_WIDTH, MINIMUM_WINDOW_HEIGHT) - - if (isMacOs) { - val rootPane = window.rootPane - rootPane.putClientProperty("apple.awt.fullWindowContent", true) - rootPane.putClientProperty("apple.awt.transparentTitleBar", true) - rootPane.putClientProperty("apple.awt.windowTitleVisible", false) - } - - onDispose { } - } - - if (nativeStartupProblem == null) { - RadrootsApplication() - } else { - StartupFailureScreen(nativeStartupProblem) - } - } - } -} - -private fun configureMacOsApplication(): String? { - System.setProperty("apple.awt.application.name", APPLICATION_NAME) - System.setProperty("apple.awt.application.appearance", "system") - - if (!Taskbar.isTaskbarSupported()) return null - - val taskbar = Taskbar.getTaskbar() - if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) return null - - val icon = - loadRuntimeIcon { - Thread.currentThread().contextClassLoader.getResourceAsStream("icons/radroots.png") - } ?: return "The application icon resource is unavailable." - return runCatching { taskbar.iconImage = icon } - .fold( - onSuccess = { null }, - onFailure = { "The application icon could not be configured." }, - ) -} - -internal fun loadRuntimeIcon(openResource: () -> java.io.InputStream?): java.awt.Image? = - runCatching { openResource()?.use(ImageIO::read) }.getOrNull() 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 @@ -0,0 +1,127 @@ +package org.radroots.harvestcircle.accounts.ui + +import org.radroots.harvestcircle.application.GeneratedKeyBackup +import org.radroots.harvestcircle.application.StudioStoreState +import org.radroots.harvestcircle.ffi.AccountDto +import org.radroots.harvestcircle.ffi.ActiveAccountDto +import org.radroots.harvestcircle.ffi.AppLifecycleDto +import org.radroots.harvestcircle.ffi.AppSnapshotDto +import org.radroots.harvestcircle.ffi.KeyAvailabilityDto +import org.radroots.harvestcircle.ffi.ProfileDto +import org.radroots.harvestcircle.ffi.ProfileLoadStateDto +import org.radroots.harvestcircle.ffi.RelayConnectionStateDto +import org.radroots.harvestcircle.ffi.SessionStateDto +import org.radroots.harvestcircle.ffi.SignerKindDto +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 + +class AccountsUiModelTest { + @Test + fun mapsPublicNostrIdentityAndProfileState() { + val account = account() + val snapshot = + snapshot( + account = account, + active = + ActiveAccountDto( + account = account, + relayState = RelayConnectionStateDto.CONNECTED, + profileState = ProfileLoadStateDto.FRESH, + profile = ProfileDto("alice", "Alice", "alice@example.com", "Farmer", "https://example.com/a.png"), + ), + ) + + val model = StudioStoreState(snapshot).toUiModel() + + assertEquals("Alice", model.activeAccount?.heading) + assertEquals("connected", model.activeAccount?.relayState) + assertEquals("fresh", model.activeAccount?.profileState) + assertEquals("alice@example.com", model.activeAccount?.profile?.nip05) + assertEquals(listOf("ws://localhost:8080"), model.configuredRelays) + assertFalse(model.accountChooserVisible) + assertFalse( + model.accounts + .single() + .label + .contains("server", ignoreCase = true), + ) + assertTrue(model.accounts.single().selected) + assertTrue(model.accounts.single().active) + } + + @Test + fun mapsSafeProblemAndTransientBackupSeparatelyFromSnapshot() { + val state = + StudioStoreState( + snapshot = snapshot(), + generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated"), + problem = "Try again.", + ) + + val model = state.toUiModel() + + assertEquals("Try again.", model.problem) + assertEquals("nsec1generated", model.generatedKeyBackup?.nsec) + assertNull(state.snapshot.recoverableProblem) + } + + @Test + fun shortensOnlyLongNpubValues() { + assertEquals("npub1short", shortenNpub("npub1short")) + assertEquals("npub1abcdefghi…34567890", shortenNpub("npub1abcdefghijklmnopqrstuvwxyz1234567890")) + } + + @Test + fun mapsTypedImportFailuresToSpecificRepairGuidance() { + val invalid = + StudioStoreState( + snapshot = snapshot(), + lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, + ).toUiModel() + val repair = + StudioStoreState( + snapshot = snapshot(), + lastFailureCode = WireErrorCode.CREDENTIAL_MISSING, + recoveryAction = WireRecoveryAction.REPAIR_CREDENTIAL, + ).toUiModel() + + assertEquals("Enter a valid nsec or 64-character hexadecimal secret key.", invalid.importGuidance) + assertEquals( + "This saved account is missing its local credential. Re-enter its secret key to repair it.", + repair.importGuidance, + ) + } +} + +private fun snapshot( + account: AccountDto? = null, + active: ActiveAccountDto? = null, +) = AppSnapshotDto( + revision = 1UL, + lifecycle = AppLifecycleDto.READY, + lifecycleError = null, + configuredRelays = listOf("ws://localhost:8080"), + accounts = listOfNotNull(account), + selectedPublicKeyHex = account?.publicKeyHex, + session = if (active == null) SessionStateDto.SIGNED_OUT else SessionStateDto.ACTIVE, + sessionSubjectPublicKeyHex = active?.account?.publicKeyHex, + sessionError = null, + activeAccount = active, + recoverableProblem = null, +) + +private fun account() = + AccountDto( + publicKeyHex = "12".repeat(32), + npub = "npub1abcdefghijklmnopqrstuvwxyz1234567890", + displayLabel = "Alice", + signerKind = SignerKindDto.LOCAL_SECRET, + keyAvailability = KeyAvailabilityDto.AVAILABLE, + createdAtSeconds = 1, + lastUsedAtSeconds = null, + ) 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 @@ -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.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/GeneratedKeyBackupTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/GeneratedKeyBackupTest.kt @@ -0,0 +1,18 @@ +package org.radroots.harvestcircle.application + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class GeneratedKeyBackupTest { + @Test + fun recoveryIsRedactedAndCanBeCleared() { + val recovery = GeneratedKeyBackup("npub1generated", "nsec1generated") + + assertFalse(recovery.toString().contains("nsec1generated")) + assertEquals("nsec1generated", recovery.revealNsec()) + recovery.clear() + assertFailsWith<IllegalStateException> { recovery.revealNsec() } + } +} diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/NativeCompatibilityTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/NativeCompatibilityTest.kt @@ -0,0 +1,40 @@ +package org.radroots.harvestcircle.application + +import org.radroots.harvestcircle.ffi.CompatibilityDescriptor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class NativeCompatibilityTest { + @Test + fun acceptsOnlyTheDeclaredNativeContractAndSchemaWindow() { + val descriptor = compatibleDescriptor() + val expectation = verifyNativeCompatibility(descriptor) + assertEquals(EXPECTED_FFI_CONTRACT_MAJOR, expectation.contractMajor) + assertEquals(EXPECTED_FFI_CONTRACT_HASH, expectation.contractHash) + + listOf( + descriptor.copy(productVersion = "wrong"), + descriptor.copy(cargoPackageVersion = "wrong"), + descriptor.copy(contractMajor = 4.toUShort()), + descriptor.copy(contractHash = "wrong"), + descriptor.copy(currentSchemaVersion = 4U), + descriptor.copy(minimumSchemaVersion = 11U), + ).forEach { incompatible -> + assertFailsWith<NativeCompatibilityException> { + verifyNativeCompatibility(incompatible) + } + } + } + + private fun compatibleDescriptor() = + CompatibilityDescriptor( + productVersion = EXPECTED_PRODUCT_VERSION, + cargoPackageVersion = EXPECTED_CARGO_PACKAGE_VERSION, + contractMajor = EXPECTED_FFI_CONTRACT_MAJOR, + contractMinor = MINIMUM_FFI_CONTRACT_MINOR, + contractHash = EXPECTED_FFI_CONTRACT_HASH, + minimumSchemaVersion = MINIMUM_STORAGE_SCHEMA, + currentSchemaVersion = MAXIMUM_STORAGE_SCHEMA, + ) +} 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 @@ -0,0 +1,53 @@ +package org.radroots.harvestcircle.application + +import kotlinx.coroutines.test.runTest +import org.radroots.harvestcircle.ffi.StudioAppCore +import org.radroots.harvestcircle.ffi.compatibilityDescriptor +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NativeGeneratedRecoveryTest { + private val dataDirectory = + Path.of(checkNotNull(System.getenv("RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR"))) + + @BeforeTest + fun prepareDataDirectory() { + dataDirectory.toFile().deleteRecursively() + Files.createDirectories(dataDirectory) + } + + @AfterTest + fun removeDataDirectory() { + dataDirectory.toFile().deleteRecursively() + } + + @Test + fun generatedRecoveryCrossesTheNativeBoundaryAndCancelsWithoutPersistence() = + runTest { + val core = + StudioAppCore.openCompatible( + expectation = verifyNativeCompatibility(compatibilityDescriptor()), + developmentMode = true, + ) + val gateway = NativeStudioCoreGateway(core) + try { + gateway.bootstrap() + val recovery = gateway.beginGeneratedAccount() + + assertTrue(recovery.account.npub.startsWith("npub1")) + assertTrue(recovery.takeRecoveryNsec().startsWith("nsec1")) + assertTrue(recovery.cancel()) + assertFalse(recovery.cancel()) + assertEquals(0, gateway.snapshot().accounts.size) + recovery.close() + } finally { + gateway.shutdown() + } + } +} 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 @@ -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 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/SecretClipboardControllerTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/SecretClipboardControllerTest.kt @@ -0,0 +1,118 @@ +package org.radroots.harvestcircle.application + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class SecretClipboardControllerTest { + @Test + fun clearsCopiedSecretAfterDelayWhenClipboardIsUnchanged() = + runTest { + val clipboard = FakeTextClipboard() + val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) + + assertIs<SecretClipboardResult.Copied>(controller.copy("nsec1generated")) + advanceTimeBy(60_000) + runCurrent() + + assertEquals("", clipboard.value) + controller.close() + } + + @Test + fun preservesClipboardContentReplacedByUserBeforeDelay() = + runTest { + val clipboard = FakeTextClipboard() + val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) + + controller.copy("nsec1generated") + clipboard.writeText("replacement") + advanceTimeBy(60_000) + runCurrent() + + assertEquals("replacement", clipboard.value) + controller.close() + } + + @Test + fun replacingCopiedSecretCancelsEarlierClearTimer() = + runTest { + val clipboard = FakeTextClipboard() + val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) + + controller.copy("nsec1first") + advanceTimeBy(30_000) + controller.copy("nsec1second") + advanceTimeBy(30_000) + runCurrent() + assertEquals("nsec1second", clipboard.value) + advanceTimeBy(30_000) + runCurrent() + assertEquals("", clipboard.value) + controller.close() + } + + @Test + fun disposalClearsOnlyClipboardTextOwnedByController() = + runTest { + val clipboard = FakeTextClipboard() + val controller = SecretClipboardController(this, clipboard) + controller.copy("nsec1generated") + + controller.close() + + assertEquals("", clipboard.value) + + val replacedClipboard = FakeTextClipboard() + val replacedController = SecretClipboardController(this, replacedClipboard) + replacedController.copy("nsec1generated") + replacedClipboard.writeText("replacement") + replacedController.close() + assertEquals("replacement", replacedClipboard.value) + } + + @Test + fun clipboardFailuresReturnTypedUnavailableAndNeverCrashCleanup() = + runTest { + val unavailable = ThrowingTextClipboard(failWrites = true) + val controller = SecretClipboardController(this, unavailable, clearDelayMillis = 1) + assertIs<SecretClipboardResult.Unavailable>(controller.copy("nsec1generated")) + controller.close() + + val failsDuringCleanup = ThrowingTextClipboard(failReads = true) + val cleanupController = SecretClipboardController(this, failsDuringCleanup, clearDelayMillis = 1) + assertIs<SecretClipboardResult.Copied>(cleanupController.copy("nsec1generated")) + advanceTimeBy(1) + runCurrent() + cleanupController.close() + } +} + +private class FakeTextClipboard : TextClipboard { + var value: String? = null + + override fun readText(): String? = value + + override fun writeText(value: String) { + this.value = value + } +} + +private class ThrowingTextClipboard( + private val failReads: Boolean = false, + private val failWrites: Boolean = false, +) : TextClipboard { + override fun readText(): String? { + if (failReads) error("injected clipboard read failure") + return null + } + + override fun writeText(value: String) { + if (failWrites) error("injected clipboard write failure") + } +} 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 @@ -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 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 @@ -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 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/app/desktop/src/test/kotlin/org/radroots/harvestcircle/architecture/NostrOnlySourceGuardTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/architecture/NostrOnlySourceGuardTest.kt @@ -0,0 +1,46 @@ +package org.radroots.harvestcircle.architecture + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.extension +import kotlin.io.path.name +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals + +class NostrOnlySourceGuardTest { + @Test + fun activeKotlinSourcesContainNoRetiredAccountArchitecture() { + val sourceRoot = findSourceRoot() + val forbidden = + listOf( + "server" + "url", + "account" + " server", + "editadd" + "server" + "url", + "login" + "status", + "java.util." + "uuid", + "accounts" + "reducer", + "accounts" + "store", + ) + val findings = + Files.walk(sourceRoot).use { paths -> + paths + .filter { it.extension == "kt" && it.name != "NostrOnlySourceGuardTest.kt" } + .flatMap { path -> + val text = path.readText().lowercase() + forbidden + .stream() + .filter(text::contains) + .map { term -> "${sourceRoot.relativize(path)}: $term" } + }.sorted() + .toList() + } + + assertEquals(emptyList(), findings) + } +} + +private fun findSourceRoot(): Path = + generateSequence(Path.of("").toAbsolutePath()) { it.parent } + .map { it.resolve("app/desktop/src") } + .first(Files::isDirectory) diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/architecture/V5CompatibilityBaselineTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/architecture/V5CompatibilityBaselineTest.kt @@ -0,0 +1,51 @@ +package org.radroots.harvestcircle.architecture + +import java.nio.file.Files +import java.nio.file.Path +import java.util.Properties +import kotlin.io.path.inputStream +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class V5CompatibilityBaselineTest { + @Test + fun baselineFreezesPackageAndNativeCoordinates() { + val root = findRepositoryRoot() + val baseline = + Properties().apply { + root.resolve("core/compatibility/v5-baseline.properties").inputStream().use(::load) + } + + assertEquals("studio-runtime-v5", baseline.getProperty("baseline.id")) + assertEquals("5", baseline.getProperty("schema.version")) + assertEquals("legacy-unversioned-v1", baseline.getProperty("ffi.contract")) + assertEquals("1", baseline.getProperty("ffi.snapshot.schema")) + assertEquals("0.1.0-alpha", baseline.getProperty("ffi.runtime.version")) + assertEquals("org.radroots.harvestcircle", baseline.getProperty("source.namespace")) + assertEquals("org.radroots.studio", baseline.getProperty("package.namespace")) + assertEquals("org.radroots.studio", baseline.getProperty("package.application_id")) + assertEquals("Radroots", baseline.getProperty("package.name")) + assertEquals("org.radroots.studio", baseline.getProperty("package.bundle_id")) + assertEquals("1.0.0", baseline.getProperty("package.version")) + assertEquals("org.radroots.studio.nostr", baseline.getProperty("keyring.service")) + assertEquals( + "canonical-lowercase-public-key-hex", + baseline.getProperty("keyring.account"), + ) + + val manifest = root.resolve("core/Cargo.toml").readText() + val workspacePackage = manifest.substringAfter("[workspace.package]").substringBefore("\n[") + assertTrue(workspacePackage.contains("version = \"${baseline.getProperty("ffi.runtime.version")}\"")) + val build = root.resolve("app/desktop/build.gradle.kts").readText() + assertTrue(build.contains("version = appVersion")) + assertTrue(build.contains("packageName = applicationName")) + assertTrue(build.contains("bundleID = bundleId")) + assertTrue(build.contains("packageVersion = installableVersion")) + } +} + +private fun findRepositoryRoot(): Path = + generateSequence(Path.of("").toAbsolutePath()) { it.parent } + .first { Files.isRegularFile(it.resolve("core/compatibility/v5-baseline.properties")) } diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/desktop/MainTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/desktop/MainTest.kt @@ -0,0 +1,13 @@ +package org.radroots.harvestcircle.desktop + +import java.io.ByteArrayInputStream +import kotlin.test.Test +import kotlin.test.assertNull + +class MainTest { + @Test + fun missingOrInvalidRuntimeIconFailsSafely() { + assertNull(loadRuntimeIcon { null }) + assertNull(loadRuntimeIcon { ByteArrayInputStream("not an image".encodeToByteArray()) }) + } +} diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/ffi/NativeLoaderTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/ffi/NativeLoaderTest.kt @@ -0,0 +1,11 @@ +package org.radroots.harvestcircle.ffi + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NativeLoaderTest { + @Test + fun generatedBindingLoadsTheCurrentHostLibrary() { + assertEquals("0.1.0-alpha", nativeRuntimeVersion()) + } +} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt @@ -1,127 +0,0 @@ -package org.radroots.studio.accounts.ui - -import org.radroots.studio.application.GeneratedKeyBackup -import org.radroots.studio.application.StudioStoreState -import org.radroots.studio.ffi.AccountDto -import org.radroots.studio.ffi.ActiveAccountDto -import org.radroots.studio.ffi.AppLifecycleDto -import org.radroots.studio.ffi.AppSnapshotDto -import org.radroots.studio.ffi.KeyAvailabilityDto -import org.radroots.studio.ffi.ProfileDto -import org.radroots.studio.ffi.ProfileLoadStateDto -import org.radroots.studio.ffi.RelayConnectionStateDto -import org.radroots.studio.ffi.SessionStateDto -import org.radroots.studio.ffi.SignerKindDto -import org.radroots.studio.ffi.WireErrorCode -import org.radroots.studio.ffi.WireRecoveryAction -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class AccountsUiModelTest { - @Test - fun mapsPublicNostrIdentityAndProfileState() { - val account = account() - val snapshot = - snapshot( - account = account, - active = - ActiveAccountDto( - account = account, - relayState = RelayConnectionStateDto.CONNECTED, - profileState = ProfileLoadStateDto.FRESH, - profile = ProfileDto("alice", "Alice", "alice@example.com", "Farmer", "https://example.com/a.png"), - ), - ) - - val model = StudioStoreState(snapshot).toUiModel() - - assertEquals("Alice", model.activeAccount?.heading) - assertEquals("connected", model.activeAccount?.relayState) - assertEquals("fresh", model.activeAccount?.profileState) - assertEquals("alice@example.com", model.activeAccount?.profile?.nip05) - assertEquals(listOf("ws://localhost:8080"), model.configuredRelays) - assertFalse(model.accountChooserVisible) - assertFalse( - model.accounts - .single() - .label - .contains("server", ignoreCase = true), - ) - assertTrue(model.accounts.single().selected) - assertTrue(model.accounts.single().active) - } - - @Test - fun mapsSafeProblemAndTransientBackupSeparatelyFromSnapshot() { - val state = - StudioStoreState( - snapshot = snapshot(), - generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated"), - problem = "Try again.", - ) - - val model = state.toUiModel() - - assertEquals("Try again.", model.problem) - assertEquals("nsec1generated", model.generatedKeyBackup?.nsec) - assertNull(state.snapshot.recoverableProblem) - } - - @Test - fun shortensOnlyLongNpubValues() { - assertEquals("npub1short", shortenNpub("npub1short")) - assertEquals("npub1abcdefghi…34567890", shortenNpub("npub1abcdefghijklmnopqrstuvwxyz1234567890")) - } - - @Test - fun mapsTypedImportFailuresToSpecificRepairGuidance() { - val invalid = - StudioStoreState( - snapshot = snapshot(), - lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, - ).toUiModel() - val repair = - StudioStoreState( - snapshot = snapshot(), - lastFailureCode = WireErrorCode.CREDENTIAL_MISSING, - recoveryAction = WireRecoveryAction.REPAIR_CREDENTIAL, - ).toUiModel() - - assertEquals("Enter a valid nsec or 64-character hexadecimal secret key.", invalid.importGuidance) - assertEquals( - "This saved account is missing its local credential. Re-enter its secret key to repair it.", - repair.importGuidance, - ) - } -} - -private fun snapshot( - account: AccountDto? = null, - active: ActiveAccountDto? = null, -) = AppSnapshotDto( - revision = 1UL, - lifecycle = AppLifecycleDto.READY, - lifecycleError = null, - configuredRelays = listOf("ws://localhost:8080"), - accounts = listOfNotNull(account), - selectedPublicKeyHex = account?.publicKeyHex, - session = if (active == null) SessionStateDto.SIGNED_OUT else SessionStateDto.ACTIVE, - sessionSubjectPublicKeyHex = active?.account?.publicKeyHex, - sessionError = null, - activeAccount = active, - recoverableProblem = null, -) - -private fun account() = - AccountDto( - publicKeyHex = "12".repeat(32), - npub = "npub1abcdefghijklmnopqrstuvwxyz1234567890", - displayLabel = "Alice", - signerKind = SignerKindDto.LOCAL_SECRET, - keyAvailability = KeyAvailabilityDto.AVAILABLE, - createdAtSeconds = 1, - lastUsedAtSeconds = null, - ) diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt @@ -1,384 +0,0 @@ -package org.radroots.studio.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.studio.application.AccountEntryMode -import org.radroots.studio.application.RemovalImpactState -import org.radroots.studio.application.RemovalStatus -import org.radroots.studio.application.StudioRoute -import org.radroots.studio.ffi.SessionStateDto -import org.radroots.studio.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/studio/application/GeneratedKeyBackupTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/GeneratedKeyBackupTest.kt @@ -1,18 +0,0 @@ -package org.radroots.studio.application - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse - -class GeneratedKeyBackupTest { - @Test - fun recoveryIsRedactedAndCanBeCleared() { - val recovery = GeneratedKeyBackup("npub1generated", "nsec1generated") - - assertFalse(recovery.toString().contains("nsec1generated")) - assertEquals("nsec1generated", recovery.revealNsec()) - recovery.clear() - assertFailsWith<IllegalStateException> { recovery.revealNsec() } - } -} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/NativeCompatibilityTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/NativeCompatibilityTest.kt @@ -1,40 +0,0 @@ -package org.radroots.studio.application - -import org.radroots.studio.ffi.CompatibilityDescriptor -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -class NativeCompatibilityTest { - @Test - fun acceptsOnlyTheDeclaredNativeContractAndSchemaWindow() { - val descriptor = compatibleDescriptor() - val expectation = verifyNativeCompatibility(descriptor) - assertEquals(EXPECTED_FFI_CONTRACT_MAJOR, expectation.contractMajor) - assertEquals(EXPECTED_FFI_CONTRACT_HASH, expectation.contractHash) - - listOf( - descriptor.copy(productVersion = "wrong"), - descriptor.copy(cargoPackageVersion = "wrong"), - descriptor.copy(contractMajor = 4.toUShort()), - descriptor.copy(contractHash = "wrong"), - descriptor.copy(currentSchemaVersion = 4U), - descriptor.copy(minimumSchemaVersion = 11U), - ).forEach { incompatible -> - assertFailsWith<NativeCompatibilityException> { - verifyNativeCompatibility(incompatible) - } - } - } - - private fun compatibleDescriptor() = - CompatibilityDescriptor( - productVersion = EXPECTED_PRODUCT_VERSION, - cargoPackageVersion = EXPECTED_CARGO_PACKAGE_VERSION, - contractMajor = EXPECTED_FFI_CONTRACT_MAJOR, - contractMinor = MINIMUM_FFI_CONTRACT_MINOR, - contractHash = EXPECTED_FFI_CONTRACT_HASH, - minimumSchemaVersion = MINIMUM_STORAGE_SCHEMA, - currentSchemaVersion = MAXIMUM_STORAGE_SCHEMA, - ) -} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/NativeGeneratedRecoveryTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/NativeGeneratedRecoveryTest.kt @@ -1,53 +0,0 @@ -package org.radroots.studio.application - -import kotlinx.coroutines.test.runTest -import org.radroots.studio.ffi.StudioAppCore -import org.radroots.studio.ffi.compatibilityDescriptor -import java.nio.file.Files -import java.nio.file.Path -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class NativeGeneratedRecoveryTest { - private val dataDirectory = - Path.of(checkNotNull(System.getenv("RADROOTS_STUDIO_DEVELOPMENT_DATA_DIR"))) - - @BeforeTest - fun prepareDataDirectory() { - dataDirectory.toFile().deleteRecursively() - Files.createDirectories(dataDirectory) - } - - @AfterTest - fun removeDataDirectory() { - dataDirectory.toFile().deleteRecursively() - } - - @Test - fun generatedRecoveryCrossesTheNativeBoundaryAndCancelsWithoutPersistence() = - runTest { - val core = - StudioAppCore.openCompatible( - expectation = verifyNativeCompatibility(compatibilityDescriptor()), - developmentMode = true, - ) - val gateway = NativeStudioCoreGateway(core) - try { - gateway.bootstrap() - val recovery = gateway.beginGeneratedAccount() - - assertTrue(recovery.account.npub.startsWith("npub1")) - assertTrue(recovery.takeRecoveryNsec().startsWith("nsec1")) - assertTrue(recovery.cancel()) - assertFalse(recovery.cancel()) - assertEquals(0, gateway.snapshot().accounts.size) - recovery.close() - } finally { - gateway.shutdown() - } - } -} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt @@ -1,115 +0,0 @@ -package org.radroots.studio.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.studio.ffi.AppLifecycleDto -import org.radroots.studio.ffi.AppSnapshotDto -import org.radroots.studio.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/studio/application/SecretClipboardControllerTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/SecretClipboardControllerTest.kt @@ -1,118 +0,0 @@ -package org.radroots.studio.application - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs - -@OptIn(ExperimentalCoroutinesApi::class) -class SecretClipboardControllerTest { - @Test - fun clearsCopiedSecretAfterDelayWhenClipboardIsUnchanged() = - runTest { - val clipboard = FakeTextClipboard() - val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) - - assertIs<SecretClipboardResult.Copied>(controller.copy("nsec1generated")) - advanceTimeBy(60_000) - runCurrent() - - assertEquals("", clipboard.value) - controller.close() - } - - @Test - fun preservesClipboardContentReplacedByUserBeforeDelay() = - runTest { - val clipboard = FakeTextClipboard() - val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) - - controller.copy("nsec1generated") - clipboard.writeText("replacement") - advanceTimeBy(60_000) - runCurrent() - - assertEquals("replacement", clipboard.value) - controller.close() - } - - @Test - fun replacingCopiedSecretCancelsEarlierClearTimer() = - runTest { - val clipboard = FakeTextClipboard() - val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) - - controller.copy("nsec1first") - advanceTimeBy(30_000) - controller.copy("nsec1second") - advanceTimeBy(30_000) - runCurrent() - assertEquals("nsec1second", clipboard.value) - advanceTimeBy(30_000) - runCurrent() - assertEquals("", clipboard.value) - controller.close() - } - - @Test - fun disposalClearsOnlyClipboardTextOwnedByController() = - runTest { - val clipboard = FakeTextClipboard() - val controller = SecretClipboardController(this, clipboard) - controller.copy("nsec1generated") - - controller.close() - - assertEquals("", clipboard.value) - - val replacedClipboard = FakeTextClipboard() - val replacedController = SecretClipboardController(this, replacedClipboard) - replacedController.copy("nsec1generated") - replacedClipboard.writeText("replacement") - replacedController.close() - assertEquals("replacement", replacedClipboard.value) - } - - @Test - fun clipboardFailuresReturnTypedUnavailableAndNeverCrashCleanup() = - runTest { - val unavailable = ThrowingTextClipboard(failWrites = true) - val controller = SecretClipboardController(this, unavailable, clearDelayMillis = 1) - assertIs<SecretClipboardResult.Unavailable>(controller.copy("nsec1generated")) - controller.close() - - val failsDuringCleanup = ThrowingTextClipboard(failReads = true) - val cleanupController = SecretClipboardController(this, failsDuringCleanup, clearDelayMillis = 1) - assertIs<SecretClipboardResult.Copied>(cleanupController.copy("nsec1generated")) - advanceTimeBy(1) - runCurrent() - cleanupController.close() - } -} - -private class FakeTextClipboard : TextClipboard { - var value: String? = null - - override fun readText(): String? = value - - override fun writeText(value: String) { - this.value = value - } -} - -private class ThrowingTextClipboard( - private val failReads: Boolean = false, - private val failWrites: Boolean = false, -) : TextClipboard { - override fun readText(): String? { - if (failReads) error("injected clipboard read failure") - return null - } - - override fun writeText(value: String) { - if (failWrites) error("injected clipboard write failure") - } -} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt @@ -1,481 +0,0 @@ -package org.radroots.studio.application - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.radroots.studio.ffi.AccountDto -import org.radroots.studio.ffi.AppLifecycleDto -import org.radroots.studio.ffi.AppSnapshotDto -import org.radroots.studio.ffi.KeyAvailabilityDto -import org.radroots.studio.ffi.SessionStateDto -import org.radroots.studio.ffi.SignerKindDto -import org.radroots.studio.ffi.WireErrorCategory -import org.radroots.studio.ffi.WireErrorCode -import org.radroots.studio.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/studio/application/StudioCoreGatewayTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioCoreGatewayTest.kt @@ -1,24 +0,0 @@ -package org.radroots.studio.application - -import org.radroots.studio.ffi.WireErrorCategory -import org.radroots.studio.ffi.WireErrorCode -import org.radroots.studio.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/app/desktop/src/test/kotlin/org/radroots/studio/architecture/NostrOnlySourceGuardTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/architecture/NostrOnlySourceGuardTest.kt @@ -1,46 +0,0 @@ -package org.radroots.studio.architecture - -import java.nio.file.Files -import java.nio.file.Path -import kotlin.io.path.extension -import kotlin.io.path.name -import kotlin.io.path.readText -import kotlin.test.Test -import kotlin.test.assertEquals - -class NostrOnlySourceGuardTest { - @Test - fun activeKotlinSourcesContainNoRetiredAccountArchitecture() { - val sourceRoot = findSourceRoot() - val forbidden = - listOf( - "server" + "url", - "account" + " server", - "editadd" + "server" + "url", - "login" + "status", - "java.util." + "uuid", - "accounts" + "reducer", - "accounts" + "store", - ) - val findings = - Files.walk(sourceRoot).use { paths -> - paths - .filter { it.extension == "kt" && it.name != "NostrOnlySourceGuardTest.kt" } - .flatMap { path -> - val text = path.readText().lowercase() - forbidden - .stream() - .filter(text::contains) - .map { term -> "${sourceRoot.relativize(path)}: $term" } - }.sorted() - .toList() - } - - assertEquals(emptyList(), findings) - } -} - -private fun findSourceRoot(): Path = - generateSequence(Path.of("").toAbsolutePath()) { it.parent } - .map { it.resolve("app/desktop/src") } - .first(Files::isDirectory) diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/architecture/V5CompatibilityBaselineTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/architecture/V5CompatibilityBaselineTest.kt @@ -1,50 +0,0 @@ -package org.radroots.studio.architecture - -import java.nio.file.Files -import java.nio.file.Path -import java.util.Properties -import kotlin.io.path.inputStream -import kotlin.io.path.readText -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class V5CompatibilityBaselineTest { - @Test - fun baselineFreezesPackageAndNativeCoordinates() { - val root = findRepositoryRoot() - val baseline = - Properties().apply { - root.resolve("core/compatibility/v5-baseline.properties").inputStream().use(::load) - } - - assertEquals("studio-runtime-v5", baseline.getProperty("baseline.id")) - assertEquals("5", baseline.getProperty("schema.version")) - assertEquals("legacy-unversioned-v1", baseline.getProperty("ffi.contract")) - assertEquals("1", baseline.getProperty("ffi.snapshot.schema")) - assertEquals("0.1.0-alpha", baseline.getProperty("ffi.runtime.version")) - assertEquals("org.radroots.studio", baseline.getProperty("package.namespace")) - assertEquals("org.radroots.studio", baseline.getProperty("package.application_id")) - assertEquals("Radroots", baseline.getProperty("package.name")) - assertEquals("org.radroots.studio", baseline.getProperty("package.bundle_id")) - assertEquals("1.0.0", baseline.getProperty("package.version")) - assertEquals("org.radroots.studio.nostr", baseline.getProperty("keyring.service")) - assertEquals( - "canonical-lowercase-public-key-hex", - baseline.getProperty("keyring.account"), - ) - - val manifest = root.resolve("core/Cargo.toml").readText() - val workspacePackage = manifest.substringAfter("[workspace.package]").substringBefore("\n[") - assertTrue(workspacePackage.contains("version = \"${baseline.getProperty("ffi.runtime.version")}\"")) - val build = root.resolve("app/desktop/build.gradle.kts").readText() - assertTrue(build.contains("version = appVersion")) - assertTrue(build.contains("packageName = applicationName")) - assertTrue(build.contains("bundleID = bundleId")) - assertTrue(build.contains("packageVersion = installableVersion")) - } -} - -private fun findRepositoryRoot(): Path = - generateSequence(Path.of("").toAbsolutePath()) { it.parent } - .first { Files.isRegularFile(it.resolve("core/compatibility/v5-baseline.properties")) } diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/desktop/MainTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/desktop/MainTest.kt @@ -1,13 +0,0 @@ -package org.radroots.studio.desktop - -import java.io.ByteArrayInputStream -import kotlin.test.Test -import kotlin.test.assertNull - -class MainTest { - @Test - fun missingOrInvalidRuntimeIconFailsSafely() { - assertNull(loadRuntimeIcon { null }) - assertNull(loadRuntimeIcon { ByteArrayInputStream("not an image".encodeToByteArray()) }) - } -} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/ffi/NativeLoaderTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/ffi/NativeLoaderTest.kt @@ -1,11 +0,0 @@ -package org.radroots.studio.ffi - -import kotlin.test.Test -import kotlin.test.assertEquals - -class NativeLoaderTest { - @Test - fun generatedBindingLoadsTheCurrentHostLibrary() { - assertEquals("0.1.0-alpha", nativeRuntimeVersion()) - } -} diff --git a/core/compatibility/v5-baseline.properties b/core/compatibility/v5-baseline.properties @@ -3,6 +3,7 @@ schema.version=5 ffi.contract=legacy-unversioned-v1 ffi.snapshot.schema=1 ffi.runtime.version=0.1.0-alpha +source.namespace=org.radroots.harvestcircle package.namespace=org.radroots.studio package.application_id=org.radroots.studio package.name=Radroots