commit 56e04c1f29a268ef5a0d8285f302ff5b2b21fd96 parent 698f1eda2f8d4b49892f83f203f810c0c18074b8 Author: triesap <tyson@radroots.org> Date: Mon, 10 Aug 2026 01:21:29 +0000 product: cut over final HarvestCircle coordinates - move Kotlin and UniFFI packages to the final org.harvestcircle namespace - derive desktop, database, environment, and keyring identity from product authority - remove the superseded v5 baseline and enforce final coordinate consumers - verify bindings, checks, builds, and macOS packaging in governed lanes Diffstat:
64 files changed, 3489 insertions(+), 3447 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md @@ -23,7 +23,7 @@ Product-owned names use the HarvestCircle identity consistently: - Human-facing product name: `HarvestCircle`. - Rust crate directories, Cargo packages, and dependency keys: `harvestcircle_*`. -- Kotlin source namespace: `org.radroots.harvestcircle`. +- Kotlin source namespace: `org.harvestcircle`. - Product-owned Kotlin and UniFFI types: `HarvestCircle*`. - Product-owned environment variables: `HARVESTCIRCLE_*`. @@ -41,9 +41,9 @@ local artifacts, absolute host paths, or an enclosing monorepo layout. - `core/Cargo.toml`, `core/Cargo.lock`, `core/rust-toolchain.toml`, and the product crates under `core/crates/**` own the Rust workspace inputs. - Gradle settings, build scripts, the version catalog, wrapper properties, - policy configuration, and `core/compatibility/v5-baseline.properties` own - the desktop build, dependency, compatibility, and package inputs. Kotlin and - Rust source and tests are implementation evidence. + policy configuration, and `config/product/harvestcircle-v1.properties` own + the desktop build, dependency, product-coordinate, and package inputs. + Kotlin and Rust source and tests are implementation evidence. - `gradlew`, `gradlew.bat`, and `gradle/wrapper/gradle-wrapper.jar` are checked-in command implementation and supply-chain inputs, not policy authority. Review them with `gradle-wrapper.properties`; keep the launcher, 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.harvestcircle.desktop.MainKt + $(EXTBUILD) $(GRADLE) :app:desktop:hotRun run: doctor $(EXTBUILD) $(GRADLE) :app:desktop:run diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -16,11 +16,11 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.testing.Test import org.gradle.jvm.tasks.Jar +import org.harvestcircle.gradle.ProductCoordinates import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.File -import java.util.Properties import java.util.jar.JarFile plugins { @@ -106,28 +106,25 @@ fun workspacePackageValue(key: String): String { ?: throw GradleException("Cargo workspace package metadata is missing $key") } -val compatibilityBaseline = rootProject.layout.projectDirectory.file("core/compatibility/v5-baseline.properties") +val productCoordinatesFile = + rootProject.layout.projectDirectory.file("config/product/harvestcircle-v1.properties") +val productCoordinates = + ProductCoordinates.parse(providers.fileContents(productCoordinatesFile).asText.get()) val appVersion = workspacePackageValue("version") val macOsBuildVersion = "1" -val baseline = - Properties().apply { - compatibilityBaseline.asFile.inputStream().use(::load) - } - -fun baselineValue(key: String): String = - baseline.getProperty(key)?.takeIf(String::isNotBlank) - ?: throw GradleException("Compatibility baseline is missing $key") - -check(baselineValue("ffi.runtime.version") == appVersion) { - "Compatibility runtime version must match the Cargo workspace version" -} -val installableVersion = baselineValue("package.version") +val installableVersion = "1.0.0" check(Regex("""[1-9]\d*(\.\d+){0,2}""").matches(installableVersion)) { - "Compatibility package version must satisfy the macOS jpackage contract" -} -val applicationName = baselineValue("package.name") -val bundleId = baselineValue("package.bundle_id") -val applicationNamespace = baselineValue("source.namespace") + "Package version must satisfy the macOS jpackage contract" +} +val applicationName = productCoordinates["product.name"] +val bundleId = productCoordinates["desktop.bundle_id"] +val desktopMainClass = productCoordinates["desktop.main_class"] +val ffiKotlinPackage = productCoordinates["ffi.kotlin_package"] +val developmentDataDirectoryEnvironment = + productCoordinates["environment.prefix"] + "DEVELOPMENT_DATA_DIR" +val copyrightNotice = productCoordinates["copyright.notice"] +val vendorName = productCoordinates["vendor.name"] +group = productCoordinates["desktop.application_id"] version = appVersion val rustSources = @@ -150,29 +147,35 @@ data class NativeTarget( fun resolveNativeTarget( osName: String, architecture: String, + cdylibName: String, ): NativeTarget { val os = osName.lowercase() val arch = architecture.lowercase() return when { os.startsWith("mac") && arch in setOf("aarch64", "arm64") -> - NativeTarget("libharvestcircle_ffi.dylib", "darwin-aarch64") + NativeTarget("lib$cdylibName.dylib", "darwin-aarch64") os.startsWith("mac") && arch in setOf("x86_64", "amd64") -> - NativeTarget("libharvestcircle_ffi.dylib", "darwin-x86-64") + NativeTarget("lib$cdylibName.dylib", "darwin-x86-64") os.startsWith("windows") && arch in setOf("aarch64", "arm64") -> - NativeTarget("harvestcircle_ffi.dll", "win32-aarch64") + NativeTarget("$cdylibName.dll", "win32-aarch64") os.startsWith("windows") && arch in setOf("x86_64", "amd64") -> - NativeTarget("harvestcircle_ffi.dll", "win32-x86-64") + NativeTarget("$cdylibName.dll", "win32-x86-64") os.startsWith("linux") && arch in setOf("aarch64", "arm64") -> - NativeTarget("libharvestcircle_ffi.so", "linux-aarch64") + NativeTarget("lib$cdylibName.so", "linux-aarch64") os.startsWith("linux") && arch in setOf("x86_64", "amd64") -> - NativeTarget("libharvestcircle_ffi.so", "linux-x86-64") + NativeTarget("lib$cdylibName.so", "linux-x86-64") else -> throw GradleException("Unsupported native desktop host: $osName/$architecture") } } val nativeOsName = providers.gradleProperty("nativeOs").getOrElse(System.getProperty("os.name")) val nativeArchitecture = providers.gradleProperty("nativeArch").getOrElse(System.getProperty("os.arch")) -val nativeTarget = resolveNativeTarget(nativeOsName, nativeArchitecture) +val nativeTarget = + resolveNativeTarget( + nativeOsName, + nativeArchitecture, + productCoordinates["ffi.cdylib_name"], + ) val isMacOsHost = nativeOsName.lowercase().startsWith("mac") val isLinuxHost = nativeOsName.lowercase().startsWith("linux") val isWindowsHost = nativeOsName.lowercase().startsWith("windows") @@ -270,7 +273,7 @@ abstract class VerifyUniFfiBindings : DefaultTask() { val verifyUniFfiBindings by tasks.registering(VerifyUniFfiBindings::class) { dependsOn(generateUniFfiKotlin) generatedDirectory.set(generatedUniFfiKotlin) - expectedPackage.set("org.radroots.harvestcircle.ffi") + expectedPackage.set(ffiKotlinPackage) } val cleanReleaseNativeResources by tasks.registering(Delete::class) { delete(generatedReleaseNativeResources) @@ -666,13 +669,16 @@ tasks.named("runKtlintFormatOverMainSourceSet") { } tasks.withType<Test>().configureEach { dependsOn(buildRustCoreDebug) - environment( - "HARVESTCIRCLE_DEVELOPMENT_DATA_DIR", + val nativeTestData = layout.buildDirectory .dir("native-test-data") .get() - .asFile.absolutePath, + .asFile.absolutePath + environment( + developmentDataDirectoryEnvironment, + nativeTestData, ) + systemProperty("harvestcircle.development.data.dir", nativeTestData) systemProperty( "jna.library.path", rustDebugLibrary.parentFile.absolutePath, @@ -695,7 +701,7 @@ compose.desktop { val desktopJar = tasks.named<Jar>("jar").flatMap { it.archiveFile } mainJar.set(desktopJar) fromFiles(desktopJar, configurations.runtimeClasspath, releaseNativeRuntimeJar) - mainClass = "$applicationNamespace.desktop.MainKt" + mainClass = desktopMainClass if (isMacOsHost) { jvmArgs += @@ -718,8 +724,8 @@ compose.desktop { packageName = applicationName packageVersion = installableVersion description = "HarvestCircle $appVersion" - copyright = "Copyright © 2024 Radroots, Inc." - vendor = "Radroots, Inc" + copyright = copyrightNotice + vendor = vendorName macOS { bundleID = bundleId diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/harvestcircle/accounts/ui/AccountsUiModel.kt @@ -0,0 +1,168 @@ +package org.harvestcircle.accounts.ui + +import org.harvestcircle.application.AccountEntryMode +import org.harvestcircle.application.HarvestCircleRoute +import org.harvestcircle.application.HarvestCircleStoreState +import org.harvestcircle.application.RemovalImpactState +import org.harvestcircle.application.RemovalStatus +import org.harvestcircle.ffi.AccountDto +import org.harvestcircle.ffi.ActiveAccountDto +import org.harvestcircle.ffi.ProfileLoadStateDto +import org.harvestcircle.ffi.RelayConnectionStateDto +import org.harvestcircle.ffi.SessionStateDto +import org.harvestcircle.ffi.WireErrorCode +import org.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 HarvestCircleUiModel( + val route: HarvestCircleRoute, + 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 HarvestCircleStoreState.toUiModel(): HarvestCircleUiModel { + 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 HarvestCircleUiModel( + 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/harvestcircle/accounts/ui/HarvestCircleScreen.kt b/app/desktop/src/main/kotlin/org/harvestcircle/accounts/ui/HarvestCircleScreen.kt @@ -0,0 +1,495 @@ +package org.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.harvestcircle.application.AccountEntryMode +import org.harvestcircle.application.HarvestCircleRoute + +private val WindowBackgroundColor = Color(0xFFF5F5F2) +private val ButtonBackgroundColor = Color(0xFFE7E7E2) +private val InputBackgroundColor = Color(0xFFFEFDF8) + +data class HarvestCircleUiActions( + val chooseCreateAccount: () -> Unit = {}, + val chooseImportAccount: () -> Unit = {}, + val cancelAccountEntry: () -> Unit = {}, + val editImportDraft: (String) -> Unit = {}, + val generateAccount: () -> Unit = {}, + val importSecretKey: () -> Unit = {}, + val copyText: (String) -> Unit = {}, + val acknowledgeGeneratedKeyBackup: () -> Unit = {}, + val cancelGeneratedKeyBackup: () -> Unit = {}, + val selectAccount: (String) -> Unit = {}, + val activateAccount: (String) -> Unit = {}, + val requestAccountRemoval: (String) -> Unit = {}, + val cancelAccountRemoval: () -> Unit = {}, + val confirmAccountRemoval: () -> Unit = {}, + val refreshActiveProfile: () -> Unit = {}, + val retryLastCommand: () -> Unit = {}, + val signOut: () -> Unit = {}, + val showAccountChooser: () -> Unit = {}, + val hideAccountChooser: () -> Unit = {}, +) + +@Composable +fun StartupFailureScreen(problem: String) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("startup-failure"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("HarvestCircle") + BasicText(problem, Modifier.testTag("startup-problem")) + } +} + +@Composable +fun HarvestCircleScreen( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + model.generatedKeyBackup?.let { backup -> + GeneratedKeyRecoveryScreen(backup, actions) + return + } + when (model.route) { + HarvestCircleRoute.OPENING -> LifecycleScreen("Opening local account store", "lifecycle-opening") + HarvestCircleRoute.CHECKING_COMPATIBILITY -> + LifecycleScreen( + "Checking native compatibility", + "lifecycle-compatibility", + ) + HarvestCircleRoute.ACQUIRING_OWNERSHIP -> + LifecycleScreen( + "Acquiring local account store", + "lifecycle-ownership", + ) + HarvestCircleRoute.MIGRATING -> + LifecycleScreen( + "Updating local account store", + "lifecycle-migrating", + ) + HarvestCircleRoute.RECOVERING -> + LifecycleScreen( + "Recovering local account state", + "lifecycle-recovering", + ) + HarvestCircleRoute.SHUTTING_DOWN -> LifecycleScreen("Shutting down", "lifecycle-shutting-down") + HarvestCircleRoute.CLOSED -> LifecycleScreen("Closed", "lifecycle-closed") + HarvestCircleRoute.BLOCKED -> + LifecycleScreen( + model.problem ?: "Local account access is blocked.", + "lifecycle-blocked", + ) + HarvestCircleRoute.FATAL -> + LifecycleScreen( + model.problem ?: "The application could not continue.", + "lifecycle-fatal", + ) + HarvestCircleRoute.DEGRADED -> InactiveAccountsScreen(model, actions, degraded = true) + HarvestCircleRoute.ACTIVE_ACCOUNT -> { + if (model.activeAccount != null && !model.accountChooserVisible) { + ActiveAccountHome(model, model.activeAccount, actions) + } else { + InactiveAccountsScreen(model, actions) + } + } + HarvestCircleRoute.ACCOUNTS -> InactiveAccountsScreen(model, actions) + } +} + +@Composable +private fun LifecycleScreen( + message: String, + testTag: String, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag(testTag), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("HarvestCircle") + BasicText(message) + } +} + +@Composable +private fun ActiveAccountHome( + model: HarvestCircleUiModel, + active: ActiveAccountUiModel, + actions: HarvestCircleUiActions, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("home-screen"), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + BasicText("HarvestCircle") + BasicText(active.heading) + BasicText(active.account.npub, Modifier.testTag("active-npub")) + BasicText(active.account.publicKeyHex, Modifier.testTag("active-pubkey-hex")) + BasicText("Name: ${active.profile.name}", Modifier.testTag("active-profile-name")) + BasicText("Display name: ${active.profile.displayName}") + BasicText("NIP-05 (unverified): ${active.profile.nip05}") + BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) + BasicText("Picture: ${active.profile.picture}") + BasicText("Relay: ${active.relayState}", Modifier.testTag("relay-state")) + BasicText("Profile: ${active.profileState}", Modifier.testTag("profile-state")) + BasicText("Configured relays") + if (model.configuredRelays.isEmpty()) { + BasicText("None") + } else { + model.configuredRelays.forEach { relay -> BasicText(relay) } + } + TextAction( + text = "Switch account", + testTag = "switch-account", + contentDescription = "Choose another saved account", + enabled = !model.busy, + onClick = actions.showAccountChooser, + ) + TextAction( + text = "Refresh metadata", + testTag = "refresh-profile", + contentDescription = "Refresh active Nostr profile metadata", + enabled = !model.busy, + onClick = actions.refreshActiveProfile, + ) + TextAction( + text = "Sign out", + testTag = "sign-out", + contentDescription = "Sign out of the active account", + enabled = !model.busy, + onClick = actions.signOut, + ) + model.problem?.let { BasicText(it, Modifier.testTag("home-problem")) } + RecoveryAction(model, actions) + } +} + +@Composable +private fun InactiveAccountsScreen( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, + degraded: Boolean = false, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag("accounts-screen"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + BasicText("HarvestCircle") + BasicText("Accounts") + if (degraded) { + BasicText(model.problem ?: "Nostr relay access is unavailable. Local accounts remain available.") + } + + if (model.activeAccount != null) { + BasicText("Choose an account to activate. The current account remains active until replacement succeeds.") + TextAction( + text = "Back to active account", + testTag = "return-home", + contentDescription = "Return to the active account", + onClick = actions.hideAccountChooser, + ) + } + + AccountEntry(model, actions) + + model.problem?.let { + BasicText(it, Modifier.testTag("accounts-problem")) + } + RecoveryAction(model, actions) + + if (model.accounts.isEmpty()) { + BasicText("No saved accounts.", Modifier.testTag("accounts-empty")) + } else { + SavedAccountList(model, actions) + } + } +} + +@Composable +private fun RecoveryAction( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + if (model.recoveryAction == org.harvestcircle.ffi.WireRecoveryAction.RETRY) { + TextAction( + text = "Retry", + testTag = "retry-last-command", + contentDescription = "Retry the last failed action", + enabled = !model.busy, + onClick = actions.retryLastCommand, + ) + } +} + +@Composable +private fun AccountEntry( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + when (model.accountEntryMode) { + AccountEntryMode.CHOICE -> { + TextAction( + text = "Create account", + testTag = "choose-create-account", + contentDescription = "Create a new Nostr account", + enabled = !model.busy, + onClick = actions.chooseCreateAccount, + ) + TextAction( + text = "Import key", + testTag = "choose-import-account", + contentDescription = "Import an existing Nostr secret key", + enabled = !model.busy, + onClick = actions.chooseImportAccount, + ) + } + AccountEntryMode.CREATE -> { + TextAction( + text = "Back", + testTag = "cancel-account-entry", + contentDescription = "Return to account choices", + enabled = !model.busy, + onClick = actions.cancelAccountEntry, + ) + TextAction( + text = "Generate new key", + testTag = "generate-key", + contentDescription = "Generate a new Nostr key", + enabled = !model.busy && model.generatedKeyBackup == null, + onClick = actions.generateAccount, + ) + } + AccountEntryMode.IMPORT -> { + val importFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { importFocusRequester.requestFocus() } + TextAction( + text = "Back", + testTag = "cancel-account-entry", + contentDescription = "Return to account choices", + enabled = !model.busy, + onClick = actions.cancelAccountEntry, + ) + BasicTextField( + value = model.importDraft, + onValueChange = actions.editImportDraft, + enabled = !model.busy, + visualTransformation = PasswordVisualTransformation(), + modifier = + Modifier + .fillMaxWidth() + .semantics { + contentDescription = "Nostr secret key" + password() + }.focusRequester(importFocusRequester) + .testTag("import-nsec-input") + .background(InputBackgroundColor) + .padding(8.dp), + decorationBox = { innerTextField -> + if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex") + innerTextField() + }, + ) + model.importGuidance?.let { guidance -> + BasicText(guidance, Modifier.testTag("import-guidance")) + } + TextAction( + text = "Add existing key", + testTag = "import-key", + contentDescription = "Import an existing Nostr secret key", + enabled = !model.busy && model.importDraft.isNotBlank(), + onClick = actions.importSecretKey, + ) + } + } +} + +@Composable +private fun ColumnScope.SavedAccountList( + model: HarvestCircleUiModel, + actions: HarvestCircleUiActions, +) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .testTag("saved-account-list"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(model.accounts, key = AccountUiModel::publicKeyHex) { account -> + Column( + modifier = + Modifier + .fillMaxWidth() + .semantics { selected = account.selected } + .testTag("account-row:${account.publicKeyHex}") + .background(InputBackgroundColor) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + BasicText(account.label) + BasicText(account.npub) + BasicText("Key: ${account.keyAvailability}") + if (account.selected) BasicText("Selected") + if (account.active) BasicText("Active") + TextAction( + text = if (account.selected) "Selected account" else "Select", + testTag = "select-account:${account.publicKeyHex}", + contentDescription = "Select ${account.label}", + enabled = !model.busy && !account.selected, + onClick = { actions.selectAccount(account.publicKeyHex) }, + ) + TextAction( + text = if (account.active) "Active account" else "Activate", + testTag = "activate-account:${account.publicKeyHex}", + contentDescription = "Activate ${account.label}", + enabled = !model.busy && !account.active, + onClick = { actions.activateAccount(account.publicKeyHex) }, + ) + TextAction( + text = "Remove", + testTag = "remove-account:${account.publicKeyHex}", + contentDescription = "Remove ${account.label}", + enabled = !model.busy, + onClick = { actions.requestAccountRemoval(account.publicKeyHex) }, + ) + if (model.pendingRemovalPublicKeyHex == account.publicKeyHex) { + BasicText("Remove this saved account?") + if (model.removalImpact?.deletesLocalCredential == true) { + BasicText("Its local credential will be deleted from the operating-system keyring.") + } + if (model.removalImpact?.signsOut == true) { + BasicText("The active session will be signed out before removal.") + } + TextAction( + text = "Cancel", + testTag = "remove-cancel", + contentDescription = "Cancel account removal", + onClick = actions.cancelAccountRemoval, + ) + TextAction( + text = "Confirm removal", + testTag = "remove-confirm", + contentDescription = "Confirm account removal", + enabled = !model.busy, + onClick = actions.confirmAccountRemoval, + ) + } + } + } + } +} + +@Composable +private fun GeneratedKeyRecoveryScreen( + backup: GeneratedKeyBackupUiModel, + actions: HarvestCircleUiActions, +) { + Column( + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("generated-key-backup"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + BasicText("Save this key") + BasicText("Losing this secret key means losing access to the account.") + BasicText(backup.npub) + BasicText(backup.nsec, Modifier.testTag("generated-nsec")) + TextAction( + text = "Copy", + testTag = "copy-generated-key", + contentDescription = "Copy generated Nostr secret key", + onClick = { actions.copyText(backup.nsec) }, + ) + TextAction( + text = "Cancel", + testTag = "cancel-generated-key", + contentDescription = "Cancel generated account", + onClick = actions.cancelGeneratedKeyBackup, + ) + TextAction( + text = "I have saved this key", + testTag = "acknowledge-key-backup", + contentDescription = "Confirm generated key backup", + onClick = actions.acknowledgeGeneratedKeyBackup, + ) + } +} + +@Composable +internal fun TextAction( + text: String, + testTag: String, + contentDescription: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + BasicText( + text = text, + modifier = + Modifier + .semantics { + role = Role.Button + this.contentDescription = contentDescription + if (!enabled) disabled() + }.testTag(testTag) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .background(ButtonBackgroundColor) + .padding(8.dp), + ) +} diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/GeneratedKeyBackup.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/GeneratedKeyBackup.kt @@ -0,0 +1,19 @@ +package org.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/harvestcircle/application/HarvestCircleAppStore.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleAppStore.kt @@ -0,0 +1,508 @@ +package org.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.harvestcircle.ffi.AppLifecycleDto +import org.harvestcircle.ffi.AppSnapshotDto +import org.harvestcircle.ffi.HarvestCircleException +import org.harvestcircle.ffi.WireErrorCode +import org.harvestcircle.ffi.WireRecoveryAction + +enum class HarvestCircleRoute { + OPENING, + CHECKING_COMPATIBILITY, + ACQUIRING_OWNERSHIP, + MIGRATING, + RECOVERING, + ACCOUNTS, + ACTIVE_ACCOUNT, + DEGRADED, + BLOCKED, + SHUTTING_DOWN, + FATAL, + CLOSED, +} + +enum class CommandStatus { + IDLE, + RUNNING, + ACCEPTED, + REJECTED_BUSY, + REJECTED_CLOSED, + FAILED_RETRYABLE, + FAILED_TERMINAL, +} + +enum class AccountEntryMode { + CHOICE, + CREATE, + IMPORT, +} + +enum class RemovalStatus { + NONE, + AWAITING_CONFIRMATION, + CONFIRMING, + COMPLETED, + FAILED, +} + +data class RemovalImpactState( + val publicKeyHex: String, + val deletesLocalCredential: Boolean, + val signsOut: Boolean, + val expiresAtSeconds: Long, +) + +data class HarvestCircleStoreState( + val snapshot: AppSnapshotDto, + val route: HarvestCircleRoute = snapshot.toHarvestCircleRoute(), + val importDraft: String = "", + val generatedKeyBackup: GeneratedKeyBackup? = null, + val pendingRemovalPublicKeyHex: String? = null, + val removalImpact: RemovalImpactState? = null, + val removalStatus: RemovalStatus = RemovalStatus.NONE, + val lastRemovedPublicKeyHex: String? = null, + val accountChooserVisible: Boolean = false, + val accountEntryMode: AccountEntryMode = AccountEntryMode.CHOICE, + val busy: Boolean = false, + val commandStatus: CommandStatus = CommandStatus.IDLE, + val lastCommandRequestId: String? = null, + val lastFailureCode: WireErrorCode? = null, + val recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, + val problem: String? = null, +) + +const val MAX_IMPORT_SECRET_CHARS: Int = 128 + +class HarvestCircleAppStore( + private val gateway: HarvestCircleCoreGateway, + private val scope: CoroutineScope, +) : AutoCloseable { + private val mutableState = mutableStateOf(HarvestCircleStoreState(snapshot = gateway.snapshot())) + private var closed = false + private var subscription: AutoCloseable? = null + private var pendingRemoval: RemovalTicket? = null + private var pendingGeneratedRecovery: PendingGeneratedRecovery? = null + private var command: Job? = null + private var retryableCommand: HarvestCircleCommand? = null + + val state: State<HarvestCircleStoreState> + get() = mutableState + + init { + launchCommand { + val registered = + gateway.subscribeChanges { change -> + scope.launch { + if (!closed) acceptSnapshot(change.snapshot) + } + } + if (closed) { + registered.close() + return@launchCommand + } + subscription = registered + acceptSnapshot(gateway.bootstrap()) + } + } + + fun editImportDraft(value: String) { + mutableState.value = + mutableState.value.copy( + importDraft = value.take(MAX_IMPORT_SECRET_CHARS), + lastFailureCode = null, + recoveryAction = WireRecoveryAction.NONE, + problem = null, + ) + } + + fun chooseCreateAccount() { + mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.CREATE, problem = null) + } + + fun chooseImportAccount() { + mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.IMPORT, problem = null) + } + + fun cancelAccountEntry() { + mutableState.value = + mutableState.value.copy( + accountEntryMode = AccountEntryMode.CHOICE, + importDraft = "", + problem = null, + ) + } + + fun generateAccount() { + launchCommand { + val recovery = gateway.beginGeneratedAccount() + var installed = false + try { + val backup = GeneratedKeyBackup(recovery.account.npub, recovery.takeRecoveryNsec()) + pendingGeneratedRecovery = PendingGeneratedRecovery(recovery, backup) + mutableState.value = mutableState.value.copy(generatedKeyBackup = backup) + installed = true + } finally { + if (!installed) { + withContext(NonCancellable) { + runCatching { recovery.cancel() } + recovery.close() + } + } + } + } + } + + fun acknowledgeGeneratedKeyBackup() { + val recovery = + pendingGeneratedRecovery ?: run { + rejectUnavailableIntent("Generated-key recovery is not available.") + return + } + runSnapshotCommand { + try { + recovery.ticket.acknowledge() + } finally { + releaseGeneratedRecovery(recovery) + } + } + } + + fun cancelGeneratedKeyBackup() { + val recovery = + pendingGeneratedRecovery ?: run { + rejectUnavailableIntent("Generated-key recovery is not available.") + return + } + launchCommand { + try { + if (!recovery.ticket.cancel()) { + throw HarvestCircleGatewayException( + HarvestCircleCommandFailure( + code = WireErrorCode.INVALID_APPLICATION_STATE, + category = org.harvestcircle.ffi.WireErrorCategory.LIFECYCLE, + retryable = false, + recoveryAction = WireRecoveryAction.NONE, + correlationId = recovery.ticket.requestId, + safeMessage = "The generated-key recovery step was already closed.", + ), + ) + } + } finally { + releaseGeneratedRecovery(recovery) + } + } + } + + fun importSecretKey() { + if (rejectIfUnavailable()) return + val input = mutableState.value.importDraft.encodeToByteArray() + mutableState.value = mutableState.value.copy(importDraft = "") + runTypedCommand(HarvestCircleCommand.ImportAccount(input)) + } + + fun selectAccount(publicKeyHex: String) { + runTypedCommand(HarvestCircleCommand.SelectAccount(publicKeyHex)) + } + + fun activateAccount(publicKeyHex: String) { + runTypedCommand(HarvestCircleCommand.ActivateAccount(publicKeyHex), hideChooser = true) + } + + fun signOut() { + runTypedCommand(HarvestCircleCommand.SignOut, hideChooser = true) + } + + fun showAccountChooser() { + mutableState.value = mutableState.value.copy(accountChooserVisible = true, problem = null) + } + + fun hideAccountChooser() { + mutableState.value = mutableState.value.copy(accountChooserVisible = false) + } + + fun refreshActiveProfile() { + runTypedCommand(HarvestCircleCommand.RefreshProfile) + } + + fun retryLastCommand() { + val retry = + retryableCommand ?: run { + rejectUnavailableIntent("This action cannot be retried safely.") + return + } + runTypedCommand(retry) + } + + fun requestAccountRemoval(publicKeyHex: String) { + launchCommand { + runCatching { + pendingRemoval?.close() + pendingRemoval = null + val ticket = gateway.requestAccountRemoval(publicKeyHex) + if (closed) { + ticket.close() + return@runCatching + } + pendingRemoval = ticket + mutableState.value = + mutableState.value.copy( + pendingRemovalPublicKeyHex = publicKeyHex, + removalImpact = + RemovalImpactState( + ticket.publicKeyHex, + ticket.deletesLocalCredential, + ticket.signsOut, + ticket.expiresAtSeconds, + ), + removalStatus = RemovalStatus.AWAITING_CONFIRMATION, + ) + }.getOrThrow() + } + } + + fun cancelAccountRemoval() { + pendingRemoval?.close() + pendingRemoval = null + mutableState.value = + mutableState.value.copy( + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.NONE, + ) + } + + fun confirmAccountRemoval() { + val ticket = + pendingRemoval ?: run { + rejectUnavailableIntent("Account removal confirmation is not available.") + return + } + pendingRemoval = null + mutableState.value = mutableState.value.copy(removalStatus = RemovalStatus.CONFIRMING) + runSnapshotCommand { + try { + gateway.confirmAccountRemoval(ticket).also { + mutableState.value = + mutableState.value.copy( + pendingRemovalPublicKeyHex = null, + lastRemovedPublicKeyHex = ticket.publicKeyHex, + removalImpact = null, + removalStatus = RemovalStatus.COMPLETED, + ) + } + } finally { + ticket.close() + if (mutableState.value.removalStatus != RemovalStatus.COMPLETED) { + mutableState.value = + mutableState.value.copy( + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.FAILED, + ) + } + } + } + } + + fun dismissProblem() { + mutableState.value = mutableState.value.copy(problem = null) + } + + private fun runSnapshotCommand(operation: suspend () -> AppSnapshotDto) { + launchCommand { acceptSnapshot(operation()) } + } + + private fun runTypedCommand( + command: HarvestCircleCommand, + hideChooser: Boolean = false, + ) { + launchCommand { + when (val result = gateway.execute(command)) { + is HarvestCircleCommandResult.Accepted -> { + retryableCommand = null + acceptSnapshot(result.receipt.snapshot) + mutableState.value = + mutableState.value.copy( + commandStatus = CommandStatus.ACCEPTED, + lastCommandRequestId = result.receipt.requestId, + lastFailureCode = null, + recoveryAction = WireRecoveryAction.NONE, + ) + if (hideChooser) { + mutableState.value = mutableState.value.copy(accountChooserVisible = false) + } + } + is HarvestCircleCommandResult.Rejected -> { + retryableCommand = + command.takeIf { + result.failure.retryable && it !is HarvestCircleCommand.ImportAccount + } + mutableState.value = + mutableState.value.copy( + commandStatus = + if (result.failure.retryable) { + CommandStatus.FAILED_RETRYABLE + } else { + CommandStatus.FAILED_TERMINAL + }, + lastCommandRequestId = result.failure.correlationId, + lastFailureCode = result.failure.code, + recoveryAction = result.failure.recoveryAction, + problem = result.failure.safeMessage, + ) + } + } + } + } + + private fun launchCommand(operation: suspend () -> Unit) { + if (rejectIfUnavailable()) return + mutableState.value = + mutableState.value.copy( + busy = true, + commandStatus = CommandStatus.RUNNING, + problem = null, + ) + command = + scope.launch { + try { + operation() + if (mutableState.value.commandStatus == CommandStatus.RUNNING) { + mutableState.value = mutableState.value.copy(commandStatus = CommandStatus.ACCEPTED) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + acceptFailure(error) + } finally { + mutableState.value = mutableState.value.copy(busy = false) + } + } + } + + private fun rejectIfUnavailable(): Boolean { + if (closed) { + mutableState.value = + mutableState.value.copy( + commandStatus = CommandStatus.REJECTED_CLOSED, + problem = "The application runtime is closed.", + ) + return true + } + if (command?.isActive == true) { + mutableState.value = + mutableState.value.copy( + commandStatus = CommandStatus.REJECTED_BUSY, + problem = "The application is busy. Try again.", + ) + return true + } + if (mutableState.value.route !in setOf(HarvestCircleRoute.ACCOUNTS, HarvestCircleRoute.ACTIVE_ACCOUNT)) { + mutableState.value = + mutableState.value.copy( + commandStatus = CommandStatus.FAILED_TERMINAL, + problem = "The application runtime is not ready for this action.", + ) + return true + } + return false + } + + private fun rejectUnavailableIntent(message: String) { + mutableState.value = + mutableState.value.copy( + commandStatus = if (closed) CommandStatus.REJECTED_CLOSED else CommandStatus.FAILED_TERMINAL, + problem = message, + ) + } + + private fun acceptSnapshot(snapshot: AppSnapshotDto) { + if (snapshot.revision >= mutableState.value.snapshot.revision) { + mutableState.value = + mutableState.value.copy( + snapshot = snapshot, + route = snapshot.toHarvestCircleRoute(), + ) + } + } + + private fun acceptFailure(error: Throwable) { + val native = error as? HarvestCircleException.Failure + val gatewayFailure = (error as? HarvestCircleGatewayException)?.failure + mutableState.value = + mutableState.value.copy( + busy = false, + commandStatus = + if (native?.retryable == true || gatewayFailure?.retryable == true) { + CommandStatus.FAILED_RETRYABLE + } else { + CommandStatus.FAILED_TERMINAL + }, + lastCommandRequestId = gatewayFailure?.correlationId ?: native?.correlationId, + lastFailureCode = gatewayFailure?.code ?: native?.code, + recoveryAction = + gatewayFailure?.recoveryAction + ?: native?.recoveryAction + ?: WireRecoveryAction.NONE, + problem = gatewayFailure?.safeMessage ?: native?.safeMessage ?: "The application command failed.", + ) + } + + private fun releaseGeneratedRecovery(recovery: PendingGeneratedRecovery) { + if (pendingGeneratedRecovery === recovery) { + pendingGeneratedRecovery = null + } + recovery.backup.clear() + recovery.ticket.close() + mutableState.value = mutableState.value.copy(generatedKeyBackup = null) + } + + override fun close() { + if (closed) return + closed = true + command?.cancel() + pendingRemoval?.close() + pendingGeneratedRecovery?.let(::releaseGeneratedRecovery) + subscription?.close() + runCatching { gateway.shutdown() } + .onSuccess { receipt -> + mutableState.value = + mutableState.value.copy( + route = if (receipt.closed) HarvestCircleRoute.CLOSED else HarvestCircleRoute.FATAL, + busy = false, + problem = if (receipt.closed) null else "The application could not shut down safely.", + ) + }.onFailure { error -> + acceptFailure(error) + mutableState.value = mutableState.value.copy(route = HarvestCircleRoute.FATAL, busy = false) + } + } +} + +private data class PendingGeneratedRecovery( + val ticket: GeneratedRecoveryTicket, + val backup: GeneratedKeyBackup, +) + +internal fun AppSnapshotDto.toHarvestCircleRoute(): HarvestCircleRoute = + when (lifecycle) { + AppLifecycleDto.OPENING -> HarvestCircleRoute.OPENING + AppLifecycleDto.COMPATIBILITY_CHECKING -> HarvestCircleRoute.CHECKING_COMPATIBILITY + AppLifecycleDto.ACQUIRING_OWNERSHIP -> HarvestCircleRoute.ACQUIRING_OWNERSHIP + AppLifecycleDto.MIGRATING -> HarvestCircleRoute.MIGRATING + AppLifecycleDto.RECOVERING -> HarvestCircleRoute.RECOVERING + AppLifecycleDto.READY -> if (activeAccount != null) HarvestCircleRoute.ACTIVE_ACCOUNT else HarvestCircleRoute.ACCOUNTS + AppLifecycleDto.DEGRADED -> HarvestCircleRoute.DEGRADED + AppLifecycleDto.BLOCKED -> HarvestCircleRoute.BLOCKED + AppLifecycleDto.SHUTTING_DOWN -> HarvestCircleRoute.SHUTTING_DOWN + AppLifecycleDto.CLOSED -> HarvestCircleRoute.CLOSED + AppLifecycleDto.FATAL -> HarvestCircleRoute.FATAL + } diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt @@ -0,0 +1,76 @@ +package org.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.harvestcircle.accounts.ui.HarvestCircleScreen +import org.harvestcircle.accounts.ui.HarvestCircleUiActions +import org.harvestcircle.accounts.ui.StartupFailureScreen +import org.harvestcircle.accounts.ui.toUiModel +import org.harvestcircle.ffi.HarvestCircleAppCore +import org.harvestcircle.ffi.HarvestCircleException +import org.harvestcircle.ffi.compatibilityDescriptor + +internal typealias HarvestCircleStoreFactory = (CoroutineScope) -> HarvestCircleAppStore + +@Composable +fun HarvestCircleApplication(storeFactory: HarvestCircleStoreFactory = ::createHarvestCircleAppStore) { + val scope = rememberCoroutineScope() + val storeResult = remember { runCatching { storeFactory(scope) } } + val store = storeResult.getOrNull() + if (store == null) { + val error = storeResult.exceptionOrNull() + val message = + (error as? HarvestCircleException.Failure)?.safeMessage + ?: "The application could not start." + StartupFailureScreen(message) + return + } + val clipboard = remember { SecretClipboardController(scope) } + + DisposableEffect(store, clipboard) { + onDispose { + clipboard.close() + store.close() + } + } + + HarvestCircleScreen( + model = store.state.value.toUiModel(), + actions = + HarvestCircleUiActions( + chooseCreateAccount = store::chooseCreateAccount, + chooseImportAccount = store::chooseImportAccount, + cancelAccountEntry = store::cancelAccountEntry, + editImportDraft = store::editImportDraft, + generateAccount = store::generateAccount, + importSecretKey = store::importSecretKey, + copyText = { value -> clipboard.copy(value) }, + acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, + cancelGeneratedKeyBackup = store::cancelGeneratedKeyBackup, + selectAccount = store::selectAccount, + activateAccount = store::activateAccount, + requestAccountRemoval = store::requestAccountRemoval, + cancelAccountRemoval = store::cancelAccountRemoval, + confirmAccountRemoval = store::confirmAccountRemoval, + refreshActiveProfile = store::refreshActiveProfile, + retryLastCommand = store::retryLastCommand, + signOut = store::signOut, + showAccountChooser = store::showAccountChooser, + hideAccountChooser = store::hideAccountChooser, + ), + ) +} + +internal fun createHarvestCircleAppStore(scope: CoroutineScope): HarvestCircleAppStore { + val developmentMode = java.lang.Boolean.getBoolean("harvestcircle.development") + val descriptor = compatibilityDescriptor() + val core = + HarvestCircleAppCore.openCompatible( + expectation = verifyNativeCompatibility(descriptor), + developmentMode = developmentMode, + ) + return HarvestCircleAppStore(NativeHarvestCircleCoreGateway(core), scope) +} diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleCoreGateway.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleCoreGateway.kt @@ -0,0 +1,307 @@ +package org.harvestcircle.application + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.harvestcircle.ffi.AccountDto +import org.harvestcircle.ffi.AppSnapshotDto +import org.harvestcircle.ffi.GeneratedRecoveryRequest +import org.harvestcircle.ffi.HarvestCircleAppCore +import org.harvestcircle.ffi.HarvestCircleChangeObserver +import org.harvestcircle.ffi.HarvestCircleException +import org.harvestcircle.ffi.ObserverSubscription +import org.harvestcircle.ffi.RemovalRequest +import org.harvestcircle.ffi.RequestContextDto +import org.harvestcircle.ffi.SnapshotChangeDto +import org.harvestcircle.ffi.WireErrorCategory +import org.harvestcircle.ffi.WireErrorCode +import org.harvestcircle.ffi.WireRecoveryAction +import java.util.concurrent.atomic.AtomicLong + +interface RemovalTicket : AutoCloseable { + val publicKeyHex: String + val deletesLocalCredential: Boolean + val signsOut: Boolean + val expiresAtSeconds: Long +} + +interface GeneratedRecoveryTicket : AutoCloseable { + val requestId: String + val account: AccountDto + + fun takeRecoveryNsec(): String + + suspend fun acknowledge(): AppSnapshotDto + + suspend fun cancel(): Boolean +} + +data class HarvestCircleChange( + val snapshot: AppSnapshotDto, + val previousRevision: ULong?, +) + +sealed interface HarvestCircleCommand { + data class ImportAccount( + val bytes: ByteArray, + ) : HarvestCircleCommand + + data class SelectAccount( + val publicKeyHex: String, + ) : HarvestCircleCommand + + data class ActivateAccount( + val publicKeyHex: String, + ) : HarvestCircleCommand + + data object SignOut : HarvestCircleCommand + + data object RefreshProfile : HarvestCircleCommand +} + +data class HarvestCircleCommandReceipt( + val requestId: String, + val committedRevision: ULong, + val snapshot: AppSnapshotDto, +) + +data class HarvestCircleCommandFailure( + val code: WireErrorCode, + val category: WireErrorCategory, + val retryable: Boolean, + val recoveryAction: WireRecoveryAction, + val correlationId: String?, + val safeMessage: String, +) + +data class HarvestCircleShutdownReceipt( + val finalRevision: ULong, + val closed: Boolean, +) + +sealed interface HarvestCircleCommandResult { + data class Accepted( + val receipt: HarvestCircleCommandReceipt, + ) : HarvestCircleCommandResult + + data class Rejected( + val failure: HarvestCircleCommandFailure, + ) : HarvestCircleCommandResult +} + +interface HarvestCircleCoreGateway : AutoCloseable { + fun snapshot(): AppSnapshotDto + + suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit): AutoCloseable + + suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult + + suspend fun bootstrap(): AppSnapshotDto + + suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket + + suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket + + suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto + + fun shutdown(): HarvestCircleShutdownReceipt +} + +class NativeHarvestCircleCoreGateway( + private val core: HarvestCircleAppCore, +) : HarvestCircleCoreGateway { + private val nextRequest = AtomicLong(1) + private val shutdownLock = Any() + private var shutdownReceipt: HarvestCircleShutdownReceipt? = null + + override fun snapshot(): AppSnapshotDto = core.snapshot() + + override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit): AutoCloseable { + val subscription = + core.subscribeChangesV2( + object : HarvestCircleChangeObserver { + override fun onChange(change: SnapshotChangeDto) { + onChange(HarvestCircleChange(change.snapshot, change.previousRevision)) + } + }, + ) + return NativeSubscription(subscription) + } + + override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult { + val context = requestContext() + return try { + val snapshot = + when (command) { + is HarvestCircleCommand.ImportAccount -> + try { + core.importAccountV2(context, command.bytes).snapshot + } finally { + command.bytes.fill(0) + } + is HarvestCircleCommand.SelectAccount -> core.selectAccount(command.publicKeyHex) + is HarvestCircleCommand.ActivateAccount -> core.activateAccount(command.publicKeyHex) + HarvestCircleCommand.SignOut -> core.signOut() + HarvestCircleCommand.RefreshProfile -> core.refreshActiveProfile() + } + HarvestCircleCommandResult.Accepted( + HarvestCircleCommandReceipt(context.requestId, snapshot.revision, snapshot), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + HarvestCircleCommandResult.Rejected(error.toHarvestCircleCommandFailure(context.requestId)) + } + } + + override suspend fun bootstrap(): AppSnapshotDto = core.bootstrap() + + override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket { + val requestId = nextRequestId() + return try { + val request = core.beginGeneratedAccountV2() + try { + NativeGeneratedRecoveryTicket(core, request, ::requestContext, requestId, request.account()) + } catch (error: Exception) { + request.close() + throw error + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + throw HarvestCircleGatewayException( + error.toHarvestCircleCommandFailure( + requestId, + "The generated key could not be prepared.", + ), + ) + } + } + + override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = + NativeRemovalTicket(core.requestAccountRemoval(publicKeyHex)) + + override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { + require(ticket is NativeRemovalTicket) { "Removal ticket does not belong to native core" } + return core.confirmAccountRemoval(requestContext(), ticket.request) + } + + override fun shutdown(): HarvestCircleShutdownReceipt = + synchronized(shutdownLock) { + shutdownReceipt ?: run { + val receipt = runBlocking { core.shutdownV2() } + HarvestCircleShutdownReceipt(receipt.finalRevision, receipt.closed).also { + check(it.closed) { "Native runtime returned an incomplete shutdown receipt" } + shutdownReceipt = it + core.close() + } + } + } + + override fun close() { + shutdown() + } + + private fun requestContext(): RequestContextDto = + RequestContextDto( + requestId = nextRequestId(), + expectedRevision = core.snapshot().revision, + deadlineMillis = 30_000UL, + ) + + private fun nextRequestId(): String = "kotlin:${nextRequest.getAndIncrement()}" +} + +internal class HarvestCircleGatewayException( + val failure: HarvestCircleCommandFailure, +) : Exception(failure.safeMessage) + +internal fun Throwable.toHarvestCircleCommandFailure( + fallbackCorrelationId: String, + fallbackSafeMessage: String = "The application command failed.", +): HarvestCircleCommandFailure { + val native = this as? HarvestCircleException.Failure + return HarvestCircleCommandFailure( + code = native?.code ?: WireErrorCode.INTERNAL, + category = native?.category ?: WireErrorCategory.INTERNAL, + retryable = native?.retryable ?: false, + recoveryAction = native?.recoveryAction ?: WireRecoveryAction.NONE, + correlationId = native?.correlationId ?: fallbackCorrelationId, + safeMessage = native?.safeMessage ?: fallbackSafeMessage, + ) +} + +private class NativeSubscription( + private val subscription: ObserverSubscription, +) : AutoCloseable { + override fun close() { + try { + runBlocking { subscription.unsubscribe() } + } finally { + subscription.close() + } + } +} + +private class NativeRemovalTicket( + val request: RemovalRequest, +) : RemovalTicket { + override val publicKeyHex: String = request.publicKeyHex() + override val deletesLocalCredential: Boolean = request.deletesLocalCredential() + override val signsOut: Boolean = request.signsOut() + override val expiresAtSeconds: Long = request.expiresAtSeconds() + + override fun close() { + request.close() + } +} + +private class NativeGeneratedRecoveryTicket( + private val core: HarvestCircleAppCore, + private val request: GeneratedRecoveryRequest, + private val requestContext: () -> RequestContextDto, + override val requestId: String, + override val account: AccountDto, +) : GeneratedRecoveryTicket { + override fun takeRecoveryNsec(): String = + try { + request.takeRecoveryNsec() + } catch (error: Exception) { + throw HarvestCircleGatewayException( + error.toHarvestCircleCommandFailure( + requestId, + "The generated recovery key could not be read.", + ), + ) + } + + override suspend fun acknowledge(): AppSnapshotDto { + val context = requestContext() + return call("The generated account could not be saved.", context.requestId) { + core.acknowledgeGeneratedAccountV2(context, request) + } + } + + override suspend fun cancel(): Boolean = + call("The generated key could not be cancelled safely.") { + core.cancelGeneratedAccountV2(request) + } + + override fun close() { + request.close() + } + + private suspend fun <T> call( + fallbackSafeMessage: String, + correlationId: String = requestId, + operation: suspend () -> T, + ): T = + try { + operation() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + throw HarvestCircleGatewayException( + error.toHarvestCircleCommandFailure(correlationId, fallbackSafeMessage), + ) + } +} diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeCompatibility.kt @@ -0,0 +1,36 @@ +package org.harvestcircle.application + +import org.harvestcircle.ffi.CompatibilityDescriptor +import org.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 = "d4e298f0abeaa65aa68e70d7a6e8f69f8182f12f93c12b2dd056d3ed5d83e9c0" +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/harvestcircle/application/SecretClipboardController.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/SecretClipboardController.kt @@ -0,0 +1,71 @@ +package org.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/harvestcircle/desktop/Main.kt b/app/desktop/src/main/kotlin/org/harvestcircle/desktop/Main.kt @@ -0,0 +1,81 @@ +package org.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.harvestcircle.accounts.ui.StartupFailureScreen +import org.harvestcircle.application.HarvestCircleApplication +import java.awt.Dimension +import java.awt.Taskbar +import javax.imageio.ImageIO + +private const val APPLICATION_NAME = "HarvestCircle" +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) { + HarvestCircleApplication() + } 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/harvestcircle.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/harvestcircle/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/AccountsUiModel.kt @@ -1,168 +0,0 @@ -package org.radroots.harvestcircle.accounts.ui - -import org.radroots.harvestcircle.application.AccountEntryMode -import org.radroots.harvestcircle.application.HarvestCircleRoute -import org.radroots.harvestcircle.application.HarvestCircleStoreState -import org.radroots.harvestcircle.application.RemovalImpactState -import org.radroots.harvestcircle.application.RemovalStatus -import org.radroots.harvestcircle.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 HarvestCircleUiModel( - val route: HarvestCircleRoute, - 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 HarvestCircleStoreState.toUiModel(): HarvestCircleUiModel { - 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 HarvestCircleUiModel( - 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/HarvestCircleScreen.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/accounts/ui/HarvestCircleScreen.kt @@ -1,495 +0,0 @@ -package org.radroots.harvestcircle.accounts.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.disabled -import androidx.compose.ui.semantics.password -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import org.radroots.harvestcircle.application.AccountEntryMode -import org.radroots.harvestcircle.application.HarvestCircleRoute - -private val WindowBackgroundColor = Color(0xFFF5F5F2) -private val ButtonBackgroundColor = Color(0xFFE7E7E2) -private val InputBackgroundColor = Color(0xFFFEFDF8) - -data class HarvestCircleUiActions( - val chooseCreateAccount: () -> Unit = {}, - val chooseImportAccount: () -> Unit = {}, - val cancelAccountEntry: () -> Unit = {}, - val editImportDraft: (String) -> Unit = {}, - val generateAccount: () -> Unit = {}, - val importSecretKey: () -> Unit = {}, - val copyText: (String) -> Unit = {}, - val acknowledgeGeneratedKeyBackup: () -> Unit = {}, - val cancelGeneratedKeyBackup: () -> Unit = {}, - val selectAccount: (String) -> Unit = {}, - val activateAccount: (String) -> Unit = {}, - val requestAccountRemoval: (String) -> Unit = {}, - val cancelAccountRemoval: () -> Unit = {}, - val confirmAccountRemoval: () -> Unit = {}, - val refreshActiveProfile: () -> Unit = {}, - val retryLastCommand: () -> Unit = {}, - val signOut: () -> Unit = {}, - val showAccountChooser: () -> Unit = {}, - val hideAccountChooser: () -> Unit = {}, -) - -@Composable -fun StartupFailureScreen(problem: String) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("startup-failure"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText(problem, Modifier.testTag("startup-problem")) - } -} - -@Composable -fun HarvestCircleScreen( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - model.generatedKeyBackup?.let { backup -> - GeneratedKeyRecoveryScreen(backup, actions) - return - } - when (model.route) { - HarvestCircleRoute.OPENING -> LifecycleScreen("Opening local account store", "lifecycle-opening") - HarvestCircleRoute.CHECKING_COMPATIBILITY -> - LifecycleScreen( - "Checking native compatibility", - "lifecycle-compatibility", - ) - HarvestCircleRoute.ACQUIRING_OWNERSHIP -> - LifecycleScreen( - "Acquiring local account store", - "lifecycle-ownership", - ) - HarvestCircleRoute.MIGRATING -> - LifecycleScreen( - "Updating local account store", - "lifecycle-migrating", - ) - HarvestCircleRoute.RECOVERING -> - LifecycleScreen( - "Recovering local account state", - "lifecycle-recovering", - ) - HarvestCircleRoute.SHUTTING_DOWN -> LifecycleScreen("Shutting down", "lifecycle-shutting-down") - HarvestCircleRoute.CLOSED -> LifecycleScreen("Closed", "lifecycle-closed") - HarvestCircleRoute.BLOCKED -> - LifecycleScreen( - model.problem ?: "Local account access is blocked.", - "lifecycle-blocked", - ) - HarvestCircleRoute.FATAL -> - LifecycleScreen( - model.problem ?: "The application could not continue.", - "lifecycle-fatal", - ) - HarvestCircleRoute.DEGRADED -> InactiveAccountsScreen(model, actions, degraded = true) - HarvestCircleRoute.ACTIVE_ACCOUNT -> { - if (model.activeAccount != null && !model.accountChooserVisible) { - ActiveAccountHome(model, model.activeAccount, actions) - } else { - InactiveAccountsScreen(model, actions) - } - } - HarvestCircleRoute.ACCOUNTS -> InactiveAccountsScreen(model, actions) - } -} - -@Composable -private fun LifecycleScreen( - message: String, - testTag: String, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag(testTag), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText(message) - } -} - -@Composable -private fun ActiveAccountHome( - model: HarvestCircleUiModel, - active: ActiveAccountUiModel, - actions: HarvestCircleUiActions, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("home-screen"), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - BasicText("HarvestCircle") - BasicText(active.heading) - BasicText(active.account.npub, Modifier.testTag("active-npub")) - BasicText(active.account.publicKeyHex, Modifier.testTag("active-pubkey-hex")) - BasicText("Name: ${active.profile.name}", Modifier.testTag("active-profile-name")) - BasicText("Display name: ${active.profile.displayName}") - BasicText("NIP-05 (unverified): ${active.profile.nip05}") - BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) - BasicText("Picture: ${active.profile.picture}") - BasicText("Relay: ${active.relayState}", Modifier.testTag("relay-state")) - BasicText("Profile: ${active.profileState}", Modifier.testTag("profile-state")) - BasicText("Configured relays") - if (model.configuredRelays.isEmpty()) { - BasicText("None") - } else { - model.configuredRelays.forEach { relay -> BasicText(relay) } - } - TextAction( - text = "Switch account", - testTag = "switch-account", - contentDescription = "Choose another saved account", - enabled = !model.busy, - onClick = actions.showAccountChooser, - ) - TextAction( - text = "Refresh metadata", - testTag = "refresh-profile", - contentDescription = "Refresh active Nostr profile metadata", - enabled = !model.busy, - onClick = actions.refreshActiveProfile, - ) - TextAction( - text = "Sign out", - testTag = "sign-out", - contentDescription = "Sign out of the active account", - enabled = !model.busy, - onClick = actions.signOut, - ) - model.problem?.let { BasicText(it, Modifier.testTag("home-problem")) } - RecoveryAction(model, actions) - } -} - -@Composable -private fun InactiveAccountsScreen( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, - degraded: Boolean = false, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag("accounts-screen"), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - BasicText("HarvestCircle") - BasicText("Accounts") - if (degraded) { - BasicText(model.problem ?: "Nostr relay access is unavailable. Local accounts remain available.") - } - - if (model.activeAccount != null) { - BasicText("Choose an account to activate. The current account remains active until replacement succeeds.") - TextAction( - text = "Back to active account", - testTag = "return-home", - contentDescription = "Return to the active account", - onClick = actions.hideAccountChooser, - ) - } - - AccountEntry(model, actions) - - model.problem?.let { - BasicText(it, Modifier.testTag("accounts-problem")) - } - RecoveryAction(model, actions) - - if (model.accounts.isEmpty()) { - BasicText("No saved accounts.", Modifier.testTag("accounts-empty")) - } else { - SavedAccountList(model, actions) - } - } -} - -@Composable -private fun RecoveryAction( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - if (model.recoveryAction == org.radroots.harvestcircle.ffi.WireRecoveryAction.RETRY) { - TextAction( - text = "Retry", - testTag = "retry-last-command", - contentDescription = "Retry the last failed action", - enabled = !model.busy, - onClick = actions.retryLastCommand, - ) - } -} - -@Composable -private fun AccountEntry( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - when (model.accountEntryMode) { - AccountEntryMode.CHOICE -> { - TextAction( - text = "Create account", - testTag = "choose-create-account", - contentDescription = "Create a new Nostr account", - enabled = !model.busy, - onClick = actions.chooseCreateAccount, - ) - TextAction( - text = "Import key", - testTag = "choose-import-account", - contentDescription = "Import an existing Nostr secret key", - enabled = !model.busy, - onClick = actions.chooseImportAccount, - ) - } - AccountEntryMode.CREATE -> { - TextAction( - text = "Back", - testTag = "cancel-account-entry", - contentDescription = "Return to account choices", - enabled = !model.busy, - onClick = actions.cancelAccountEntry, - ) - TextAction( - text = "Generate new key", - testTag = "generate-key", - contentDescription = "Generate a new Nostr key", - enabled = !model.busy && model.generatedKeyBackup == null, - onClick = actions.generateAccount, - ) - } - AccountEntryMode.IMPORT -> { - val importFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { importFocusRequester.requestFocus() } - TextAction( - text = "Back", - testTag = "cancel-account-entry", - contentDescription = "Return to account choices", - enabled = !model.busy, - onClick = actions.cancelAccountEntry, - ) - BasicTextField( - value = model.importDraft, - onValueChange = actions.editImportDraft, - enabled = !model.busy, - visualTransformation = PasswordVisualTransformation(), - modifier = - Modifier - .fillMaxWidth() - .semantics { - contentDescription = "Nostr secret key" - password() - }.focusRequester(importFocusRequester) - .testTag("import-nsec-input") - .background(InputBackgroundColor) - .padding(8.dp), - decorationBox = { innerTextField -> - if (model.importDraft.isEmpty()) BasicText("nsec or secret-key hex") - innerTextField() - }, - ) - model.importGuidance?.let { guidance -> - BasicText(guidance, Modifier.testTag("import-guidance")) - } - TextAction( - text = "Add existing key", - testTag = "import-key", - contentDescription = "Import an existing Nostr secret key", - enabled = !model.busy && model.importDraft.isNotBlank(), - onClick = actions.importSecretKey, - ) - } - } -} - -@Composable -private fun ColumnScope.SavedAccountList( - model: HarvestCircleUiModel, - actions: HarvestCircleUiActions, -) { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .testTag("saved-account-list"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(model.accounts, key = AccountUiModel::publicKeyHex) { account -> - Column( - modifier = - Modifier - .fillMaxWidth() - .semantics { selected = account.selected } - .testTag("account-row:${account.publicKeyHex}") - .background(InputBackgroundColor) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - BasicText(account.label) - BasicText(account.npub) - BasicText("Key: ${account.keyAvailability}") - if (account.selected) BasicText("Selected") - if (account.active) BasicText("Active") - TextAction( - text = if (account.selected) "Selected account" else "Select", - testTag = "select-account:${account.publicKeyHex}", - contentDescription = "Select ${account.label}", - enabled = !model.busy && !account.selected, - onClick = { actions.selectAccount(account.publicKeyHex) }, - ) - TextAction( - text = if (account.active) "Active account" else "Activate", - testTag = "activate-account:${account.publicKeyHex}", - contentDescription = "Activate ${account.label}", - enabled = !model.busy && !account.active, - onClick = { actions.activateAccount(account.publicKeyHex) }, - ) - TextAction( - text = "Remove", - testTag = "remove-account:${account.publicKeyHex}", - contentDescription = "Remove ${account.label}", - enabled = !model.busy, - onClick = { actions.requestAccountRemoval(account.publicKeyHex) }, - ) - if (model.pendingRemovalPublicKeyHex == account.publicKeyHex) { - BasicText("Remove this saved account?") - if (model.removalImpact?.deletesLocalCredential == true) { - BasicText("Its local credential will be deleted from the operating-system keyring.") - } - if (model.removalImpact?.signsOut == true) { - BasicText("The active session will be signed out before removal.") - } - TextAction( - text = "Cancel", - testTag = "remove-cancel", - contentDescription = "Cancel account removal", - onClick = actions.cancelAccountRemoval, - ) - TextAction( - text = "Confirm removal", - testTag = "remove-confirm", - contentDescription = "Confirm account removal", - enabled = !model.busy, - onClick = actions.confirmAccountRemoval, - ) - } - } - } - } -} - -@Composable -private fun GeneratedKeyRecoveryScreen( - backup: GeneratedKeyBackupUiModel, - actions: HarvestCircleUiActions, -) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("generated-key-backup"), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - BasicText("Save this key") - BasicText("Losing this secret key means losing access to the account.") - BasicText(backup.npub) - BasicText(backup.nsec, Modifier.testTag("generated-nsec")) - TextAction( - text = "Copy", - testTag = "copy-generated-key", - contentDescription = "Copy generated Nostr secret key", - onClick = { actions.copyText(backup.nsec) }, - ) - TextAction( - text = "Cancel", - testTag = "cancel-generated-key", - contentDescription = "Cancel generated account", - onClick = actions.cancelGeneratedKeyBackup, - ) - TextAction( - text = "I have saved this key", - testTag = "acknowledge-key-backup", - contentDescription = "Confirm generated key backup", - onClick = actions.acknowledgeGeneratedKeyBackup, - ) - } -} - -@Composable -internal fun TextAction( - text: String, - testTag: String, - contentDescription: String, - enabled: Boolean = true, - onClick: () -> Unit, -) { - BasicText( - text = text, - modifier = - Modifier - .semantics { - role = Role.Button - this.contentDescription = contentDescription - if (!enabled) disabled() - }.testTag(testTag) - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .background(ButtonBackgroundColor) - .padding(8.dp), - ) -} diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/GeneratedKeyBackup.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/GeneratedKeyBackup.kt @@ -1,19 +0,0 @@ -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/HarvestCircleAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleAppStore.kt @@ -1,508 +0,0 @@ -package org.radroots.harvestcircle.application - -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.radroots.harvestcircle.ffi.AppLifecycleDto -import org.radroots.harvestcircle.ffi.AppSnapshotDto -import org.radroots.harvestcircle.ffi.HarvestCircleException -import org.radroots.harvestcircle.ffi.WireErrorCode -import org.radroots.harvestcircle.ffi.WireRecoveryAction - -enum class HarvestCircleRoute { - OPENING, - CHECKING_COMPATIBILITY, - ACQUIRING_OWNERSHIP, - MIGRATING, - RECOVERING, - ACCOUNTS, - ACTIVE_ACCOUNT, - DEGRADED, - BLOCKED, - SHUTTING_DOWN, - FATAL, - CLOSED, -} - -enum class CommandStatus { - IDLE, - RUNNING, - ACCEPTED, - REJECTED_BUSY, - REJECTED_CLOSED, - FAILED_RETRYABLE, - FAILED_TERMINAL, -} - -enum class AccountEntryMode { - CHOICE, - CREATE, - IMPORT, -} - -enum class RemovalStatus { - NONE, - AWAITING_CONFIRMATION, - CONFIRMING, - COMPLETED, - FAILED, -} - -data class RemovalImpactState( - val publicKeyHex: String, - val deletesLocalCredential: Boolean, - val signsOut: Boolean, - val expiresAtSeconds: Long, -) - -data class HarvestCircleStoreState( - val snapshot: AppSnapshotDto, - val route: HarvestCircleRoute = snapshot.toHarvestCircleRoute(), - val importDraft: String = "", - val generatedKeyBackup: GeneratedKeyBackup? = null, - val pendingRemovalPublicKeyHex: String? = null, - val removalImpact: RemovalImpactState? = null, - val removalStatus: RemovalStatus = RemovalStatus.NONE, - val lastRemovedPublicKeyHex: String? = null, - val accountChooserVisible: Boolean = false, - val accountEntryMode: AccountEntryMode = AccountEntryMode.CHOICE, - val busy: Boolean = false, - val commandStatus: CommandStatus = CommandStatus.IDLE, - val lastCommandRequestId: String? = null, - val lastFailureCode: WireErrorCode? = null, - val recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, - val problem: String? = null, -) - -const val MAX_IMPORT_SECRET_CHARS: Int = 128 - -class HarvestCircleAppStore( - private val gateway: HarvestCircleCoreGateway, - private val scope: CoroutineScope, -) : AutoCloseable { - private val mutableState = mutableStateOf(HarvestCircleStoreState(snapshot = gateway.snapshot())) - private var closed = false - private var subscription: AutoCloseable? = null - private var pendingRemoval: RemovalTicket? = null - private var pendingGeneratedRecovery: PendingGeneratedRecovery? = null - private var command: Job? = null - private var retryableCommand: HarvestCircleCommand? = null - - val state: State<HarvestCircleStoreState> - get() = mutableState - - init { - launchCommand { - val registered = - gateway.subscribeChanges { change -> - scope.launch { - if (!closed) acceptSnapshot(change.snapshot) - } - } - if (closed) { - registered.close() - return@launchCommand - } - subscription = registered - acceptSnapshot(gateway.bootstrap()) - } - } - - fun editImportDraft(value: String) { - mutableState.value = - mutableState.value.copy( - importDraft = value.take(MAX_IMPORT_SECRET_CHARS), - lastFailureCode = null, - recoveryAction = WireRecoveryAction.NONE, - problem = null, - ) - } - - fun chooseCreateAccount() { - mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.CREATE, problem = null) - } - - fun chooseImportAccount() { - mutableState.value = mutableState.value.copy(accountEntryMode = AccountEntryMode.IMPORT, problem = null) - } - - fun cancelAccountEntry() { - mutableState.value = - mutableState.value.copy( - accountEntryMode = AccountEntryMode.CHOICE, - importDraft = "", - problem = null, - ) - } - - fun generateAccount() { - launchCommand { - val recovery = gateway.beginGeneratedAccount() - var installed = false - try { - val backup = GeneratedKeyBackup(recovery.account.npub, recovery.takeRecoveryNsec()) - pendingGeneratedRecovery = PendingGeneratedRecovery(recovery, backup) - mutableState.value = mutableState.value.copy(generatedKeyBackup = backup) - installed = true - } finally { - if (!installed) { - withContext(NonCancellable) { - runCatching { recovery.cancel() } - recovery.close() - } - } - } - } - } - - fun acknowledgeGeneratedKeyBackup() { - val recovery = - pendingGeneratedRecovery ?: run { - rejectUnavailableIntent("Generated-key recovery is not available.") - return - } - runSnapshotCommand { - try { - recovery.ticket.acknowledge() - } finally { - releaseGeneratedRecovery(recovery) - } - } - } - - fun cancelGeneratedKeyBackup() { - val recovery = - pendingGeneratedRecovery ?: run { - rejectUnavailableIntent("Generated-key recovery is not available.") - return - } - launchCommand { - try { - if (!recovery.ticket.cancel()) { - throw HarvestCircleGatewayException( - HarvestCircleCommandFailure( - code = WireErrorCode.INVALID_APPLICATION_STATE, - category = org.radroots.harvestcircle.ffi.WireErrorCategory.LIFECYCLE, - retryable = false, - recoveryAction = WireRecoveryAction.NONE, - correlationId = recovery.ticket.requestId, - safeMessage = "The generated-key recovery step was already closed.", - ), - ) - } - } finally { - releaseGeneratedRecovery(recovery) - } - } - } - - fun importSecretKey() { - if (rejectIfUnavailable()) return - val input = mutableState.value.importDraft.encodeToByteArray() - mutableState.value = mutableState.value.copy(importDraft = "") - runTypedCommand(HarvestCircleCommand.ImportAccount(input)) - } - - fun selectAccount(publicKeyHex: String) { - runTypedCommand(HarvestCircleCommand.SelectAccount(publicKeyHex)) - } - - fun activateAccount(publicKeyHex: String) { - runTypedCommand(HarvestCircleCommand.ActivateAccount(publicKeyHex), hideChooser = true) - } - - fun signOut() { - runTypedCommand(HarvestCircleCommand.SignOut, hideChooser = true) - } - - fun showAccountChooser() { - mutableState.value = mutableState.value.copy(accountChooserVisible = true, problem = null) - } - - fun hideAccountChooser() { - mutableState.value = mutableState.value.copy(accountChooserVisible = false) - } - - fun refreshActiveProfile() { - runTypedCommand(HarvestCircleCommand.RefreshProfile) - } - - fun retryLastCommand() { - val retry = - retryableCommand ?: run { - rejectUnavailableIntent("This action cannot be retried safely.") - return - } - runTypedCommand(retry) - } - - fun requestAccountRemoval(publicKeyHex: String) { - launchCommand { - runCatching { - pendingRemoval?.close() - pendingRemoval = null - val ticket = gateway.requestAccountRemoval(publicKeyHex) - if (closed) { - ticket.close() - return@runCatching - } - pendingRemoval = ticket - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = publicKeyHex, - removalImpact = - RemovalImpactState( - ticket.publicKeyHex, - ticket.deletesLocalCredential, - ticket.signsOut, - ticket.expiresAtSeconds, - ), - removalStatus = RemovalStatus.AWAITING_CONFIRMATION, - ) - }.getOrThrow() - } - } - - fun cancelAccountRemoval() { - pendingRemoval?.close() - pendingRemoval = null - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = null, - removalImpact = null, - removalStatus = RemovalStatus.NONE, - ) - } - - fun confirmAccountRemoval() { - val ticket = - pendingRemoval ?: run { - rejectUnavailableIntent("Account removal confirmation is not available.") - return - } - pendingRemoval = null - mutableState.value = mutableState.value.copy(removalStatus = RemovalStatus.CONFIRMING) - runSnapshotCommand { - try { - gateway.confirmAccountRemoval(ticket).also { - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = null, - lastRemovedPublicKeyHex = ticket.publicKeyHex, - removalImpact = null, - removalStatus = RemovalStatus.COMPLETED, - ) - } - } finally { - ticket.close() - if (mutableState.value.removalStatus != RemovalStatus.COMPLETED) { - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = null, - removalImpact = null, - removalStatus = RemovalStatus.FAILED, - ) - } - } - } - } - - fun dismissProblem() { - mutableState.value = mutableState.value.copy(problem = null) - } - - private fun runSnapshotCommand(operation: suspend () -> AppSnapshotDto) { - launchCommand { acceptSnapshot(operation()) } - } - - private fun runTypedCommand( - command: HarvestCircleCommand, - hideChooser: Boolean = false, - ) { - launchCommand { - when (val result = gateway.execute(command)) { - is HarvestCircleCommandResult.Accepted -> { - retryableCommand = null - acceptSnapshot(result.receipt.snapshot) - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.ACCEPTED, - lastCommandRequestId = result.receipt.requestId, - lastFailureCode = null, - recoveryAction = WireRecoveryAction.NONE, - ) - if (hideChooser) { - mutableState.value = mutableState.value.copy(accountChooserVisible = false) - } - } - is HarvestCircleCommandResult.Rejected -> { - retryableCommand = - command.takeIf { - result.failure.retryable && it !is HarvestCircleCommand.ImportAccount - } - mutableState.value = - mutableState.value.copy( - commandStatus = - if (result.failure.retryable) { - CommandStatus.FAILED_RETRYABLE - } else { - CommandStatus.FAILED_TERMINAL - }, - lastCommandRequestId = result.failure.correlationId, - lastFailureCode = result.failure.code, - recoveryAction = result.failure.recoveryAction, - problem = result.failure.safeMessage, - ) - } - } - } - } - - private fun launchCommand(operation: suspend () -> Unit) { - if (rejectIfUnavailable()) return - mutableState.value = - mutableState.value.copy( - busy = true, - commandStatus = CommandStatus.RUNNING, - problem = null, - ) - command = - scope.launch { - try { - operation() - if (mutableState.value.commandStatus == CommandStatus.RUNNING) { - mutableState.value = mutableState.value.copy(commandStatus = CommandStatus.ACCEPTED) - } - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - acceptFailure(error) - } finally { - mutableState.value = mutableState.value.copy(busy = false) - } - } - } - - private fun rejectIfUnavailable(): Boolean { - if (closed) { - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.REJECTED_CLOSED, - problem = "The application runtime is closed.", - ) - return true - } - if (command?.isActive == true) { - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.REJECTED_BUSY, - problem = "The application is busy. Try again.", - ) - return true - } - if (mutableState.value.route !in setOf(HarvestCircleRoute.ACCOUNTS, HarvestCircleRoute.ACTIVE_ACCOUNT)) { - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.FAILED_TERMINAL, - problem = "The application runtime is not ready for this action.", - ) - return true - } - return false - } - - private fun rejectUnavailableIntent(message: String) { - mutableState.value = - mutableState.value.copy( - commandStatus = if (closed) CommandStatus.REJECTED_CLOSED else CommandStatus.FAILED_TERMINAL, - problem = message, - ) - } - - private fun acceptSnapshot(snapshot: AppSnapshotDto) { - if (snapshot.revision >= mutableState.value.snapshot.revision) { - mutableState.value = - mutableState.value.copy( - snapshot = snapshot, - route = snapshot.toHarvestCircleRoute(), - ) - } - } - - private fun acceptFailure(error: Throwable) { - val native = error as? HarvestCircleException.Failure - val gatewayFailure = (error as? HarvestCircleGatewayException)?.failure - mutableState.value = - mutableState.value.copy( - busy = false, - commandStatus = - if (native?.retryable == true || gatewayFailure?.retryable == true) { - CommandStatus.FAILED_RETRYABLE - } else { - CommandStatus.FAILED_TERMINAL - }, - lastCommandRequestId = gatewayFailure?.correlationId ?: native?.correlationId, - lastFailureCode = gatewayFailure?.code ?: native?.code, - recoveryAction = - gatewayFailure?.recoveryAction - ?: native?.recoveryAction - ?: WireRecoveryAction.NONE, - problem = gatewayFailure?.safeMessage ?: native?.safeMessage ?: "The application command failed.", - ) - } - - private fun releaseGeneratedRecovery(recovery: PendingGeneratedRecovery) { - if (pendingGeneratedRecovery === recovery) { - pendingGeneratedRecovery = null - } - recovery.backup.clear() - recovery.ticket.close() - mutableState.value = mutableState.value.copy(generatedKeyBackup = null) - } - - override fun close() { - if (closed) return - closed = true - command?.cancel() - pendingRemoval?.close() - pendingGeneratedRecovery?.let(::releaseGeneratedRecovery) - subscription?.close() - runCatching { gateway.shutdown() } - .onSuccess { receipt -> - mutableState.value = - mutableState.value.copy( - route = if (receipt.closed) HarvestCircleRoute.CLOSED else HarvestCircleRoute.FATAL, - busy = false, - problem = if (receipt.closed) null else "The application could not shut down safely.", - ) - }.onFailure { error -> - acceptFailure(error) - mutableState.value = mutableState.value.copy(route = HarvestCircleRoute.FATAL, busy = false) - } - } -} - -private data class PendingGeneratedRecovery( - val ticket: GeneratedRecoveryTicket, - val backup: GeneratedKeyBackup, -) - -internal fun AppSnapshotDto.toHarvestCircleRoute(): HarvestCircleRoute = - when (lifecycle) { - AppLifecycleDto.OPENING -> HarvestCircleRoute.OPENING - AppLifecycleDto.COMPATIBILITY_CHECKING -> HarvestCircleRoute.CHECKING_COMPATIBILITY - AppLifecycleDto.ACQUIRING_OWNERSHIP -> HarvestCircleRoute.ACQUIRING_OWNERSHIP - AppLifecycleDto.MIGRATING -> HarvestCircleRoute.MIGRATING - AppLifecycleDto.RECOVERING -> HarvestCircleRoute.RECOVERING - AppLifecycleDto.READY -> if (activeAccount != null) HarvestCircleRoute.ACTIVE_ACCOUNT else HarvestCircleRoute.ACCOUNTS - AppLifecycleDto.DEGRADED -> HarvestCircleRoute.DEGRADED - AppLifecycleDto.BLOCKED -> HarvestCircleRoute.BLOCKED - AppLifecycleDto.SHUTTING_DOWN -> HarvestCircleRoute.SHUTTING_DOWN - AppLifecycleDto.CLOSED -> HarvestCircleRoute.CLOSED - AppLifecycleDto.FATAL -> HarvestCircleRoute.FATAL - } diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleApplication.kt @@ -1,76 +0,0 @@ -package org.radroots.harvestcircle.application - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import kotlinx.coroutines.CoroutineScope -import org.radroots.harvestcircle.accounts.ui.HarvestCircleScreen -import org.radroots.harvestcircle.accounts.ui.HarvestCircleUiActions -import org.radroots.harvestcircle.accounts.ui.StartupFailureScreen -import org.radroots.harvestcircle.accounts.ui.toUiModel -import org.radroots.harvestcircle.ffi.HarvestCircleAppCore -import org.radroots.harvestcircle.ffi.HarvestCircleException -import org.radroots.harvestcircle.ffi.compatibilityDescriptor - -internal typealias HarvestCircleStoreFactory = (CoroutineScope) -> HarvestCircleAppStore - -@Composable -fun HarvestCircleApplication(storeFactory: HarvestCircleStoreFactory = ::createHarvestCircleAppStore) { - val scope = rememberCoroutineScope() - val storeResult = remember { runCatching { storeFactory(scope) } } - val store = storeResult.getOrNull() - if (store == null) { - val error = storeResult.exceptionOrNull() - val message = - (error as? HarvestCircleException.Failure)?.safeMessage - ?: "The application could not start." - StartupFailureScreen(message) - return - } - val clipboard = remember { SecretClipboardController(scope) } - - DisposableEffect(store, clipboard) { - onDispose { - clipboard.close() - store.close() - } - } - - HarvestCircleScreen( - model = store.state.value.toUiModel(), - actions = - HarvestCircleUiActions( - chooseCreateAccount = store::chooseCreateAccount, - chooseImportAccount = store::chooseImportAccount, - cancelAccountEntry = store::cancelAccountEntry, - editImportDraft = store::editImportDraft, - generateAccount = store::generateAccount, - importSecretKey = store::importSecretKey, - copyText = { value -> clipboard.copy(value) }, - acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, - cancelGeneratedKeyBackup = store::cancelGeneratedKeyBackup, - selectAccount = store::selectAccount, - activateAccount = store::activateAccount, - requestAccountRemoval = store::requestAccountRemoval, - cancelAccountRemoval = store::cancelAccountRemoval, - confirmAccountRemoval = store::confirmAccountRemoval, - refreshActiveProfile = store::refreshActiveProfile, - retryLastCommand = store::retryLastCommand, - signOut = store::signOut, - showAccountChooser = store::showAccountChooser, - hideAccountChooser = store::hideAccountChooser, - ), - ) -} - -internal fun createHarvestCircleAppStore(scope: CoroutineScope): HarvestCircleAppStore { - val developmentMode = java.lang.Boolean.getBoolean("harvestcircle.development") - val descriptor = compatibilityDescriptor() - val core = - HarvestCircleAppCore.openCompatible( - expectation = verifyNativeCompatibility(descriptor), - developmentMode = developmentMode, - ) - return HarvestCircleAppStore(NativeHarvestCircleCoreGateway(core), scope) -} diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/HarvestCircleCoreGateway.kt @@ -1,307 +0,0 @@ -package org.radroots.harvestcircle.application - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.runBlocking -import org.radroots.harvestcircle.ffi.AccountDto -import org.radroots.harvestcircle.ffi.AppSnapshotDto -import org.radroots.harvestcircle.ffi.GeneratedRecoveryRequest -import org.radroots.harvestcircle.ffi.HarvestCircleAppCore -import org.radroots.harvestcircle.ffi.HarvestCircleChangeObserver -import org.radroots.harvestcircle.ffi.HarvestCircleException -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.WireErrorCategory -import org.radroots.harvestcircle.ffi.WireErrorCode -import org.radroots.harvestcircle.ffi.WireRecoveryAction -import java.util.concurrent.atomic.AtomicLong - -interface RemovalTicket : AutoCloseable { - val publicKeyHex: String - val deletesLocalCredential: Boolean - val signsOut: Boolean - val expiresAtSeconds: Long -} - -interface GeneratedRecoveryTicket : AutoCloseable { - val requestId: String - val account: AccountDto - - fun takeRecoveryNsec(): String - - suspend fun acknowledge(): AppSnapshotDto - - suspend fun cancel(): Boolean -} - -data class HarvestCircleChange( - val snapshot: AppSnapshotDto, - val previousRevision: ULong?, -) - -sealed interface HarvestCircleCommand { - data class ImportAccount( - val bytes: ByteArray, - ) : HarvestCircleCommand - - data class SelectAccount( - val publicKeyHex: String, - ) : HarvestCircleCommand - - data class ActivateAccount( - val publicKeyHex: String, - ) : HarvestCircleCommand - - data object SignOut : HarvestCircleCommand - - data object RefreshProfile : HarvestCircleCommand -} - -data class HarvestCircleCommandReceipt( - val requestId: String, - val committedRevision: ULong, - val snapshot: AppSnapshotDto, -) - -data class HarvestCircleCommandFailure( - val code: WireErrorCode, - val category: WireErrorCategory, - val retryable: Boolean, - val recoveryAction: WireRecoveryAction, - val correlationId: String?, - val safeMessage: String, -) - -data class HarvestCircleShutdownReceipt( - val finalRevision: ULong, - val closed: Boolean, -) - -sealed interface HarvestCircleCommandResult { - data class Accepted( - val receipt: HarvestCircleCommandReceipt, - ) : HarvestCircleCommandResult - - data class Rejected( - val failure: HarvestCircleCommandFailure, - ) : HarvestCircleCommandResult -} - -interface HarvestCircleCoreGateway : AutoCloseable { - fun snapshot(): AppSnapshotDto - - suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit): AutoCloseable - - suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult - - suspend fun bootstrap(): AppSnapshotDto - - suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket - - suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket - - suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto - - fun shutdown(): HarvestCircleShutdownReceipt -} - -class NativeHarvestCircleCoreGateway( - private val core: HarvestCircleAppCore, -) : HarvestCircleCoreGateway { - private val nextRequest = AtomicLong(1) - private val shutdownLock = Any() - private var shutdownReceipt: HarvestCircleShutdownReceipt? = null - - override fun snapshot(): AppSnapshotDto = core.snapshot() - - override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit): AutoCloseable { - val subscription = - core.subscribeChangesV2( - object : HarvestCircleChangeObserver { - override fun onChange(change: SnapshotChangeDto) { - onChange(HarvestCircleChange(change.snapshot, change.previousRevision)) - } - }, - ) - return NativeSubscription(subscription) - } - - override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult { - val context = requestContext() - return try { - val snapshot = - when (command) { - is HarvestCircleCommand.ImportAccount -> - try { - core.importAccountV2(context, command.bytes).snapshot - } finally { - command.bytes.fill(0) - } - is HarvestCircleCommand.SelectAccount -> core.selectAccount(command.publicKeyHex) - is HarvestCircleCommand.ActivateAccount -> core.activateAccount(command.publicKeyHex) - HarvestCircleCommand.SignOut -> core.signOut() - HarvestCircleCommand.RefreshProfile -> core.refreshActiveProfile() - } - HarvestCircleCommandResult.Accepted( - HarvestCircleCommandReceipt(context.requestId, snapshot.revision, snapshot), - ) - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - HarvestCircleCommandResult.Rejected(error.toHarvestCircleCommandFailure(context.requestId)) - } - } - - override suspend fun bootstrap(): AppSnapshotDto = core.bootstrap() - - override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket { - val requestId = nextRequestId() - return try { - val request = core.beginGeneratedAccountV2() - try { - NativeGeneratedRecoveryTicket(core, request, ::requestContext, requestId, request.account()) - } catch (error: Exception) { - request.close() - throw error - } - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - throw HarvestCircleGatewayException( - error.toHarvestCircleCommandFailure( - requestId, - "The generated key could not be prepared.", - ), - ) - } - } - - override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = - NativeRemovalTicket(core.requestAccountRemoval(publicKeyHex)) - - override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { - require(ticket is NativeRemovalTicket) { "Removal ticket does not belong to native core" } - return core.confirmAccountRemoval(requestContext(), ticket.request) - } - - override fun shutdown(): HarvestCircleShutdownReceipt = - synchronized(shutdownLock) { - shutdownReceipt ?: run { - val receipt = runBlocking { core.shutdownV2() } - HarvestCircleShutdownReceipt(receipt.finalRevision, receipt.closed).also { - check(it.closed) { "Native runtime returned an incomplete shutdown receipt" } - shutdownReceipt = it - core.close() - } - } - } - - override fun close() { - shutdown() - } - - private fun requestContext(): RequestContextDto = - RequestContextDto( - requestId = nextRequestId(), - expectedRevision = core.snapshot().revision, - deadlineMillis = 30_000UL, - ) - - private fun nextRequestId(): String = "kotlin:${nextRequest.getAndIncrement()}" -} - -internal class HarvestCircleGatewayException( - val failure: HarvestCircleCommandFailure, -) : Exception(failure.safeMessage) - -internal fun Throwable.toHarvestCircleCommandFailure( - fallbackCorrelationId: String, - fallbackSafeMessage: String = "The application command failed.", -): HarvestCircleCommandFailure { - val native = this as? HarvestCircleException.Failure - return HarvestCircleCommandFailure( - code = native?.code ?: WireErrorCode.INTERNAL, - category = native?.category ?: WireErrorCategory.INTERNAL, - retryable = native?.retryable ?: false, - recoveryAction = native?.recoveryAction ?: WireRecoveryAction.NONE, - correlationId = native?.correlationId ?: fallbackCorrelationId, - safeMessage = native?.safeMessage ?: fallbackSafeMessage, - ) -} - -private class NativeSubscription( - private val subscription: ObserverSubscription, -) : AutoCloseable { - override fun close() { - try { - runBlocking { subscription.unsubscribe() } - } finally { - subscription.close() - } - } -} - -private class NativeRemovalTicket( - val request: RemovalRequest, -) : RemovalTicket { - override val publicKeyHex: String = request.publicKeyHex() - override val deletesLocalCredential: Boolean = request.deletesLocalCredential() - override val signsOut: Boolean = request.signsOut() - override val expiresAtSeconds: Long = request.expiresAtSeconds() - - override fun close() { - request.close() - } -} - -private class NativeGeneratedRecoveryTicket( - private val core: HarvestCircleAppCore, - private val request: GeneratedRecoveryRequest, - private val requestContext: () -> RequestContextDto, - override val requestId: String, - override val account: AccountDto, -) : GeneratedRecoveryTicket { - override fun takeRecoveryNsec(): String = - try { - request.takeRecoveryNsec() - } catch (error: Exception) { - throw HarvestCircleGatewayException( - error.toHarvestCircleCommandFailure( - requestId, - "The generated recovery key could not be read.", - ), - ) - } - - override suspend fun acknowledge(): AppSnapshotDto { - val context = requestContext() - return call("The generated account could not be saved.", context.requestId) { - core.acknowledgeGeneratedAccountV2(context, request) - } - } - - override suspend fun cancel(): Boolean = - call("The generated key could not be cancelled safely.") { - core.cancelGeneratedAccountV2(request) - } - - override fun close() { - request.close() - } - - private suspend fun <T> call( - fallbackSafeMessage: String, - correlationId: String = requestId, - operation: suspend () -> T, - ): T = - try { - operation() - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - throw HarvestCircleGatewayException( - error.toHarvestCircleCommandFailure(correlationId, fallbackSafeMessage), - ) - } -} diff --git a/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/NativeCompatibility.kt @@ -1,36 +0,0 @@ -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 = "d4e298f0abeaa65aa68e70d7a6e8f69f8182f12f93c12b2dd056d3ed5d83e9c0" -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/SecretClipboardController.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/application/SecretClipboardController.kt @@ -1,71 +0,0 @@ -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/desktop/Main.kt b/app/desktop/src/main/kotlin/org/radroots/harvestcircle/desktop/Main.kt @@ -1,81 +0,0 @@ -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.HarvestCircleApplication -import java.awt.Dimension -import java.awt.Taskbar -import javax.imageio.ImageIO - -private const val APPLICATION_NAME = "HarvestCircle" -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) { - HarvestCircleApplication() - } 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/harvestcircle.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/harvestcircle/accounts/ui/AccountsUiModelTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/accounts/ui/AccountsUiModelTest.kt @@ -0,0 +1,127 @@ +package org.harvestcircle.accounts.ui + +import org.harvestcircle.application.GeneratedKeyBackup +import org.harvestcircle.application.HarvestCircleStoreState +import org.harvestcircle.ffi.AccountDto +import org.harvestcircle.ffi.ActiveAccountDto +import org.harvestcircle.ffi.AppLifecycleDto +import org.harvestcircle.ffi.AppSnapshotDto +import org.harvestcircle.ffi.KeyAvailabilityDto +import org.harvestcircle.ffi.ProfileDto +import org.harvestcircle.ffi.ProfileLoadStateDto +import org.harvestcircle.ffi.RelayConnectionStateDto +import org.harvestcircle.ffi.SessionStateDto +import org.harvestcircle.ffi.SignerKindDto +import org.harvestcircle.ffi.WireErrorCode +import org.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 = HarvestCircleStoreState(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 = + HarvestCircleStoreState( + 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 = + HarvestCircleStoreState( + snapshot = snapshot(), + lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, + ).toUiModel() + val repair = + HarvestCircleStoreState( + 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/harvestcircle/accounts/ui/HarvestCircleScreenTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/accounts/ui/HarvestCircleScreenTest.kt @@ -0,0 +1,384 @@ +package org.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.harvestcircle.application.AccountEntryMode +import org.harvestcircle.application.HarvestCircleRoute +import org.harvestcircle.application.RemovalImpactState +import org.harvestcircle.application.RemovalStatus +import org.harvestcircle.ffi.SessionStateDto +import org.harvestcircle.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class HarvestCircleScreenTest { + @Test + fun rendersEveryNonReadyLifecycleRouteWithoutAccountControls() = + runComposeUiTest { + var model by mutableStateOf(emptyUiModel().copy(route = HarvestCircleRoute.OPENING)) + setContent { HarvestCircleScreen(model, HarvestCircleUiActions()) } + + val routes = + listOf( + HarvestCircleRoute.OPENING to "lifecycle-opening", + HarvestCircleRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", + HarvestCircleRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", + HarvestCircleRoute.MIGRATING to "lifecycle-migrating", + HarvestCircleRoute.RECOVERING to "lifecycle-recovering", + HarvestCircleRoute.BLOCKED to "lifecycle-blocked", + HarvestCircleRoute.SHUTTING_DOWN to "lifecycle-shutting-down", + HarvestCircleRoute.FATAL to "lifecycle-fatal", + HarvestCircleRoute.CLOSED to "lifecycle-closed", + ) + routes.forEach { (route, tag) -> + model = emptyUiModel(problem = "Safe lifecycle problem").copy(route = route) + waitForIdle() + onNodeWithTag(tag).assertIsDisplayed() + onAllNodesWithTag("generate-key").assertCountEquals(0) + } + + model = emptyUiModel(problem = "Relay access is unavailable.").copy(route = HarvestCircleRoute.DEGRADED) + waitForIdle() + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithTag("accounts-problem").assertIsDisplayed() + } + + @Test + fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = + runComposeUiTest { + var importDraft by mutableStateOf("") + var accountEntryMode by mutableStateOf(AccountEntryMode.CHOICE) + var generateCalls = 0 + var importCalls = 0 + setContent { + HarvestCircleScreen( + model = emptyUiModel(importDraft = importDraft).copy(accountEntryMode = accountEntryMode), + actions = + HarvestCircleUiActions( + chooseCreateAccount = { accountEntryMode = AccountEntryMode.CREATE }, + chooseImportAccount = { accountEntryMode = AccountEntryMode.IMPORT }, + cancelAccountEntry = { accountEntryMode = AccountEntryMode.CHOICE }, + editImportDraft = { importDraft = it }, + generateAccount = { generateCalls += 1 }, + importSecretKey = { importCalls += 1 }, + ), + ) + } + + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithText("HarvestCircle").assertIsDisplayed() + onNodeWithTag("choose-create-account").performClick() + onNodeWithTag("generate-key").performClick() + onNodeWithTag("cancel-account-entry").performClick() + onNodeWithTag("choose-import-account").performClick() + onNodeWithTag("import-nsec-input").assertIsFocused() + onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") + onNodeWithTag("import-key").performClick() + + assertEquals(1, generateCalls) + assertEquals(1, importCalls) + assertEquals("nsec1secret", importDraft) + assertTrue( + onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { + it.key.name == "Password" && it.value == Unit + }, + ) + } + + @Test + fun inactiveScreenShowsSafeFailureAndNoGenericFields() = + runComposeUiTest { + setContent { + HarvestCircleScreen( + model = emptyUiModel(problem = "The secret key is invalid."), + actions = HarvestCircleUiActions(), + ) + } + + onNodeWithText("The secret key is invalid.").assertIsDisplayed() + onNodeWithTag("accounts-empty").assertIsDisplayed() + } + + @Test + fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), + ) + var copied: String? = null + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = + HarvestCircleUiActions( + copyText = { copied = it }, + acknowledgeGeneratedKeyBackup = { backup = null }, + ), + ) + } + + onNodeWithTag("generated-key-backup").assertIsDisplayed() + onAllNodesWithTag("accounts-screen").assertCountEquals(0) + onAllNodesWithTag("generate-key").assertCountEquals(0) + onNodeWithTag("generated-nsec").assertIsDisplayed() + onNodeWithTag("copy-generated-key").performClick() + assertEquals("nsec1generated", copied) + + onNodeWithTag("acknowledge-key-backup").performClick() + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + onAllNodesWithTag("generated-nsec").assertCountEquals(0) + } + + @Test + fun generatedKeyRecoveryCanBeCancelledWithoutExposingAccountControls() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), + ) + var cancelled = 0 + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = + HarvestCircleUiActions( + cancelGeneratedKeyBackup = { + cancelled += 1 + backup = null + }, + ), + ) + } + + onNodeWithTag("cancel-generated-key").performClick() + assertEquals(1, cancelled) + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + } + + @Test + fun savedAccountsSelectActivateAndRequireRemovalConfirmation() = + runComposeUiTest { + val first = accountUi("11".repeat(32), selected = true) + val second = accountUi("22".repeat(32), selected = false) + var pendingRemoval: String? by mutableStateOf(null) + val selected = mutableListOf<String>() + val activated = mutableListOf<String>() + var confirmations = 0 + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + accounts = listOf(first, second), + pendingRemovalPublicKeyHex = pendingRemoval, + removalImpact = + pendingRemoval?.let { + RemovalImpactState(it, deletesLocalCredential = true, signsOut = true, expiresAtSeconds = 60) + }, + ), + actions = + HarvestCircleUiActions( + selectAccount = selected::add, + activateAccount = activated::add, + requestAccountRemoval = { pendingRemoval = it }, + cancelAccountRemoval = { pendingRemoval = null }, + confirmAccountRemoval = { confirmations += 1 }, + ), + ) + } + + onNodeWithTag("saved-account-list").assertIsDisplayed() + onNodeWithTag("account-row:${first.publicKeyHex}").assertIsSelected() + onNodeWithTag("select-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(listOf(second.publicKeyHex), selected) + assertEquals(listOf(second.publicKeyHex), activated) + + onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithText("Its local credential will be deleted from the operating-system keyring.").assertIsDisplayed() + onNodeWithText("The active session will be signed out before removal.").assertIsDisplayed() + onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() + assertEquals(null, pendingRemoval) + onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() + assertEquals(1, confirmations) + } + + @Test + fun savedAccountListRemainsReachableForLargeRegistries() = + runComposeUiTest { + val accounts = + (0 until 100).map { index -> + accountUi(index.toString(16).padStart(64, '0'), selected = index == 0) + } + setContent { + HarvestCircleScreen( + model = emptyUiModel().copy(accounts = accounts), + actions = HarvestCircleUiActions(), + ) + } + + val lastTag = "account-row:${accounts.last().publicKeyHex}" + onNodeWithTag("saved-account-list").performScrollToNode(hasTestTag(lastTag)) + onNodeWithTag(lastTag).assertIsDisplayed() + } + + @Test + fun activeHomeShowsIdentityProfileRelayAndCommands() = + runComposeUiTest { + var refreshCalls = 0 + var signOutCalls = 0 + val account = accountUi("33".repeat(32), selected = true) + val active = + ActiveAccountUiModel( + account = account, + heading = "Alice", + relayState = "connected", + profileState = "fresh", + profile = + ProfileUiModel( + name = "alice", + displayName = "Alice", + nip05 = "alice@example.com", + about = "Local grower", + picture = "https://example.com/alice.png", + ), + ) + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + route = HarvestCircleRoute.ACTIVE_ACCOUNT, + accounts = listOf(account), + activeAccount = active, + configuredRelays = listOf("ws://localhost:8080"), + session = SessionStateDto.ACTIVE, + ), + actions = + HarvestCircleUiActions( + refreshActiveProfile = { refreshCalls += 1 }, + signOut = { signOutCalls += 1 }, + ), + ) + } + + onNodeWithTag("home-screen").assertIsDisplayed() + onNodeWithTag("active-npub").assertIsDisplayed() + onNodeWithTag("active-pubkey-hex").assertIsDisplayed() + onNodeWithTag("active-profile-name").assertIsDisplayed() + onNodeWithTag("active-profile-about").assertIsDisplayed() + onNodeWithTag("relay-state").assertIsDisplayed() + onNodeWithTag("profile-state").assertIsDisplayed() + onNodeWithText("ws://localhost:8080").assertIsDisplayed() + onNodeWithTag("refresh-profile").performClick() + onNodeWithTag("sign-out").performClick() + assertEquals(1, refreshCalls) + assertEquals(1, signOutCalls) + } + + @Test + fun activeAccountCanOpenChooserWithoutDroppingCurrentSession() = + runComposeUiTest { + val first = accountUi("44".repeat(32), selected = true, active = true) + val second = accountUi("55".repeat(32), selected = false) + val active = + ActiveAccountUiModel( + account = first, + heading = first.label, + relayState = "connected", + profileState = "cached", + profile = ProfileUiModel("", "", "", "", ""), + ) + var chooserVisible by mutableStateOf(false) + var activated: String? = null + setContent { + HarvestCircleScreen( + model = + emptyUiModel().copy( + route = HarvestCircleRoute.ACTIVE_ACCOUNT, + accounts = listOf(first, second), + activeAccount = active, + session = SessionStateDto.ACTIVE, + accountChooserVisible = chooserVisible, + ), + actions = + HarvestCircleUiActions( + showAccountChooser = { chooserVisible = true }, + hideAccountChooser = { chooserVisible = false }, + activateAccount = { activated = it }, + ), + ) + } + + onNodeWithTag("switch-account").performClick() + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithTag("activate-account:${first.publicKeyHex}", useUnmergedTree = true).assertIsNotEnabled() + onNodeWithText("Active").assertIsDisplayed() + onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(second.publicKeyHex, activated) + assertEquals( + SessionStateDto.ACTIVE, + emptyUiModel() + .copy( + activeAccount = active, + session = SessionStateDto.ACTIVE, + ).session, + ) + onNodeWithTag("return-home").performClick() + onNodeWithTag("home-screen").assertIsDisplayed() + } +} + +private fun emptyUiModel( + importDraft: String = "", + problem: String? = null, + importGuidance: String? = null, + recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, +) = HarvestCircleUiModel( + route = HarvestCircleRoute.ACCOUNTS, + accounts = emptyList(), + activeAccount = null, + configuredRelays = emptyList(), + importDraft = importDraft, + generatedKeyBackup = null, + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.NONE, + lastRemovedPublicKeyHex = null, + accountChooserVisible = false, + accountEntryMode = AccountEntryMode.CHOICE, + session = SessionStateDto.SIGNED_OUT, + busy = false, + problem = problem, + importGuidance = importGuidance, + recoveryAction = recoveryAction, +) + +private fun accountUi( + publicKeyHex: String, + selected: Boolean, + active: Boolean = false, +) = AccountUiModel( + publicKeyHex = publicKeyHex, + npub = "npub1${publicKeyHex.take(12)}", + shortNpub = "npub1${publicKeyHex.take(12)}", + label = "Account ${publicKeyHex.take(2)}", + keyAvailability = "available", + selected = selected, + active = active, +) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/GeneratedKeyBackupTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/GeneratedKeyBackupTest.kt @@ -0,0 +1,18 @@ +package org.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/harvestcircle/application/HarvestCircleAppStoreTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleAppStoreTest.kt @@ -0,0 +1,481 @@ +package org.harvestcircle.application + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.harvestcircle.ffi.AccountDto +import org.harvestcircle.ffi.AppLifecycleDto +import org.harvestcircle.ffi.AppSnapshotDto +import org.harvestcircle.ffi.KeyAvailabilityDto +import org.harvestcircle.ffi.SessionStateDto +import org.harvestcircle.ffi.SignerKindDto +import org.harvestcircle.ffi.WireErrorCategory +import org.harvestcircle.ffi.WireErrorCode +import org.harvestcircle.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HarvestCircleAppStoreTest { + @Test + fun `bootstraps and ignores stale observer snapshots`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + + advanceUntilIdle() + gateway.emit(snapshot(1UL)) + gateway.emit(snapshot(0UL)) + advanceUntilIdle() + + assertEquals(1UL, store.state.value.snapshot.revision) + assertFalse(store.state.value.busy) + store.close() + assertTrue(gateway.closed) + assertTrue(gateway.shutdownCompleted) + assertTrue(gateway.subscriptionClosed) + assertEquals(HarvestCircleRoute.CLOSED, store.state.value.route) + } + + @Test + fun `holds generated secret only until explicit acknowledgement`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + + store.generateAccount() + advanceUntilIdle() + + assertEquals( + "nsec1secret", + store.state.value.generatedKeyBackup + ?.revealNsec(), + ) + assertEquals( + "npub1account", + store.state.value.generatedKeyBackup + ?.npub, + ) + store.acknowledgeGeneratedKeyBackup() + advanceUntilIdle() + assertNull(store.state.value.generatedKeyBackup) + store.close() + } + + @Test + fun `cancels staged generated account without committing it`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + val revisionBeforeGeneration = store.state.value.snapshot.revision + store.generateAccount() + advanceUntilIdle() + + store.cancelGeneratedKeyBackup() + advanceUntilIdle() + + assertNull(store.state.value.generatedKeyBackup) + assertEquals(revisionBeforeGeneration, store.state.value.snapshot.revision) + store.close() + } + + @Test + fun `partial generated recovery acquisition cancels and closes its native ticket`() = + runTest { + val gateway = + FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { + failGeneratedRecoveryRead = true + } + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + + store.generateAccount() + advanceUntilIdle() + + assertNull(store.state.value.generatedKeyBackup) + assertEquals(1, gateway.lastGeneratedRecoveryTicket?.cancelCalls) + assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) + store.close() + } + + @Test + fun `failed generated acknowledgement releases one-shot recovery ownership`() = + runTest { + val gateway = + FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { + failGeneratedAcknowledgement = true + } + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + store.generateAccount() + advanceUntilIdle() + + store.acknowledgeGeneratedKeyBackup() + advanceUntilIdle() + + assertNull(store.state.value.generatedKeyBackup) + assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) + assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) + assertEquals( + "The generated account could not be saved. Import the recovery key you saved to try again.", + store.state.value.problem, + ) + store.close() + } + + @Test + fun `already resolved cancellation clears recovery and reports the state mismatch`() = + runTest { + val gateway = + FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { + generatedCancellationResult = false + } + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + store.generateAccount() + advanceUntilIdle() + + store.cancelGeneratedKeyBackup() + advanceUntilIdle() + + assertNull(store.state.value.generatedKeyBackup) + assertEquals(WireErrorCode.INVALID_APPLICATION_STATE, store.state.value.lastFailureCode) + assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) + assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) + store.close() + } + + @Test + fun `ignores observer delivery after close`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + val revisionAtClose = store.state.value.snapshot.revision + + store.close() + gateway.emit(snapshot(revisionAtClose + 1UL)) + advanceUntilIdle() + + assertEquals(revisionAtClose, store.state.value.snapshot.revision) + } + + @Test + fun `failed removal confirmation clears consumed presentation state`() = + runTest { + val gateway = + FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { + failRemovalConfirmation = true + } + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + + store.requestAccountRemoval("00".repeat(32)) + advanceUntilIdle() + assertEquals("00".repeat(32), store.state.value.pendingRemovalPublicKeyHex) + store.confirmAccountRemoval() + advanceUntilIdle() + + assertNull(store.state.value.pendingRemovalPublicKeyHex) + assertTrue(gateway.lastRemovalTicket?.closed == true) + assertEquals(RemovalStatus.FAILED, store.state.value.removalStatus) + assertEquals("The application command failed.", store.state.value.problem) + store.close() + } + + @Test + fun `serializes commands while one is active`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + + store.signOut() + assertEquals(CommandStatus.REJECTED_BUSY, store.state.value.commandStatus) + advanceUntilIdle() + + assertEquals(0, gateway.signOutCalls) + store.signOut() + advanceUntilIdle() + assertEquals(1, gateway.signOutCalls) + store.close() + } + + @Test + fun `projects retryable command rejection without dropping intent`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + gateway.nextCommandResult = + HarvestCircleCommandResult.Rejected( + HarvestCircleCommandFailure( + WireErrorCode.STORAGE_UNAVAILABLE, + WireErrorCategory.STORAGE, + retryable = true, + WireRecoveryAction.RETRY, + "request-retry", + "Storage is temporarily unavailable.", + ), + ) + + store.signOut() + advanceUntilIdle() + + assertEquals(CommandStatus.FAILED_RETRYABLE, store.state.value.commandStatus) + assertEquals("request-retry", store.state.value.lastCommandRequestId) + assertEquals("Storage is temporarily unavailable.", store.state.value.problem) + store.retryLastCommand() + advanceUntilIdle() + assertEquals(1, gateway.signOutCalls) + assertEquals(CommandStatus.ACCEPTED, store.state.value.commandStatus) + store.close() + } + + @Test + fun `clears imported secret draft as soon as command is accepted`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + store.editImportDraft("nsec1secret") + + store.importSecretKey() + + assertEquals("", store.state.value.importDraft) + assertEquals(emptyList(), gateway.importedSecrets) + advanceUntilIdle() + assertEquals(listOf("nsec1secret"), gateway.importedSecrets) + assertEquals(true, gateway.lastImportBuffer?.all { it == 0.toByte() }) + store.close() + } + + @Test + fun `bounds imported secret presentation input before transport`() = + runTest { + val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + + store.editImportDraft("x".repeat(MAX_IMPORT_SECRET_CHARS + 50)) + + assertEquals(MAX_IMPORT_SECRET_CHARS, store.state.value.importDraft.length) + store.close() + } + + @Test + fun `projects boot fatal and terminal lifecycle failures`() = + runTest { + val booting = snapshot(0UL, AppLifecycleDto.OPENING) + val bootGateway = FakeHarvestCircleCoreGateway(booting, booting) + val bootStore = HarvestCircleAppStore(bootGateway, this) + advanceUntilIdle() + assertEquals(HarvestCircleRoute.OPENING, bootStore.state.value.route) + bootStore.close() + + val fatal = snapshot(1UL, AppLifecycleDto.FATAL) + val gateway = FakeHarvestCircleCoreGateway(fatal, fatal) + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + assertEquals(HarvestCircleRoute.FATAL, store.state.value.route) + store.signOut() + advanceUntilIdle() + assertEquals(CommandStatus.FAILED_TERMINAL, store.state.value.commandStatus) + assertEquals(0, gateway.signOutCalls) + + store.close() + store.signOut() + assertEquals(CommandStatus.REJECTED_CLOSED, store.state.value.commandStatus) + } + + @Test + fun `disposal waits for native shutdown and fails closed on an incomplete receipt`() = + runTest { + val gateway = + FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { + shutdownReceipt = HarvestCircleShutdownReceipt(1UL, closed = false) + } + val store = HarvestCircleAppStore(gateway, this) + advanceUntilIdle() + + store.close() + + assertTrue(gateway.shutdownCompleted) + assertEquals(HarvestCircleRoute.FATAL, store.state.value.route) + assertEquals("The application could not shut down safely.", store.state.value.problem) + } +} + +private class FakeHarvestCircleCoreGateway( + private var current: AppSnapshotDto, + private val bootstrapSnapshot: AppSnapshotDto = snapshot(1UL), +) : HarvestCircleCoreGateway { + private var observer: ((AppSnapshotDto) -> Unit)? = null + var closed = false + var shutdownCompleted = false + var shutdownReceipt = HarvestCircleShutdownReceipt(current.revision, closed = true) + var subscriptionClosed = false + var signOutCalls = 0 + val importedSecrets = mutableListOf<String>() + var lastImportBuffer: ByteArray? = null + var failRemovalConfirmation = false + var lastRemovalTicket: FakeRemovalTicket? = null + var nextCommandResult: HarvestCircleCommandResult? = null + var failGeneratedRecoveryRead = false + var failGeneratedAcknowledgement = false + var generatedCancellationResult = true + var lastGeneratedRecoveryTicket: FakeGeneratedRecoveryTicket? = null + + override fun snapshot(): AppSnapshotDto = current + + override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit): AutoCloseable { + observer = { snapshot -> onChange(HarvestCircleChange(snapshot, null)) } + return AutoCloseable { subscriptionClosed = true } + } + + override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult { + nextCommandResult?.let { + nextCommandResult = null + return it + } + when (command) { + is HarvestCircleCommand.ImportAccount -> { + lastImportBuffer = command.bytes + importedSecrets += command.bytes.decodeToString() + command.bytes.fill(0) + } + HarvestCircleCommand.SignOut -> signOutCalls += 1 + else -> Unit + } + return HarvestCircleCommandResult.Accepted( + HarvestCircleCommandReceipt("fake-request", current.revision, current), + ) + } + + fun emit(snapshot: AppSnapshotDto) { + current = snapshot + observer?.invoke(snapshot) + } + + override suspend fun bootstrap(): AppSnapshotDto = bootstrapSnapshot.also(::emit) + + override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = + FakeGeneratedRecoveryTicket( + account = account(), + failRecoveryRead = failGeneratedRecoveryRead, + failAcknowledgement = failGeneratedAcknowledgement, + cancellationResult = generatedCancellationResult, + ) { committed -> + current = snapshot(current.revision + 1UL) + emit(current) + committed(current) + }.also { lastGeneratedRecoveryTicket = it } + + override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = FakeRemovalTicket().also { lastRemovalTicket = it } + + override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { + if (failRemovalConfirmation) error("injected confirmation failure") + return current + } + + override fun shutdown(): HarvestCircleShutdownReceipt { + shutdownCompleted = true + closed = true + return shutdownReceipt + } + + override fun close() { + shutdown() + } +} + +private class FakeGeneratedRecoveryTicket( + override val account: AccountDto, + private val failRecoveryRead: Boolean, + private val failAcknowledgement: Boolean, + private val cancellationResult: Boolean, + private val commit: (((AppSnapshotDto) -> Unit) -> Unit), +) : GeneratedRecoveryTicket { + override val requestId: String = "fake-generated-request" + private var available = true + var cancelCalls = 0 + var closed = false + + override fun takeRecoveryNsec(): String { + if (failRecoveryRead) error("injected recovery read failure") + return "nsec1secret" + } + + override suspend fun acknowledge(): AppSnapshotDto { + if (failAcknowledgement) { + available = false + throw HarvestCircleGatewayException( + HarvestCircleCommandFailure( + WireErrorCode.KEYRING_UNAVAILABLE, + WireErrorCategory.CREDENTIAL, + retryable = false, + WireRecoveryAction.NONE, + requestId, + "The generated account could not be saved. Import the recovery key you saved to try again.", + ), + ) + } + lateinit var snapshot: AppSnapshotDto + commit { snapshot = it } + available = false + return snapshot + } + + override suspend fun cancel(): Boolean { + cancelCalls += 1 + return (available && cancellationResult).also { available = false } + } + + override fun close() { + closed = true + } +} + +private class FakeRemovalTicket : RemovalTicket { + override val publicKeyHex: String = "00".repeat(32) + override val deletesLocalCredential: Boolean = true + override val signsOut: Boolean = false + override val expiresAtSeconds: Long = 60 + var closed = false + + override fun close() { + closed = true + } +} + +private fun snapshot( + revision: ULong, + lifecycle: AppLifecycleDto = AppLifecycleDto.READY, +) = AppSnapshotDto( + revision = revision, + lifecycle = lifecycle, + lifecycleError = null, + configuredRelays = emptyList(), + accounts = emptyList(), + selectedPublicKeyHex = null, + session = SessionStateDto.SIGNED_OUT, + sessionSubjectPublicKeyHex = null, + sessionError = null, + activeAccount = null, + recoverableProblem = null, +) + +private fun account() = + AccountDto( + publicKeyHex = "00".repeat(32), + npub = "npub1account", + displayLabel = "Account", + signerKind = SignerKindDto.LOCAL_SECRET, + keyAvailability = KeyAvailabilityDto.AVAILABLE, + createdAtSeconds = 0, + lastUsedAtSeconds = null, + ) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt @@ -0,0 +1,115 @@ +package org.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.harvestcircle.ffi.AppLifecycleDto +import org.harvestcircle.ffi.AppSnapshotDto +import org.harvestcircle.ffi.SessionStateDto +import kotlin.test.Test +import kotlin.test.assertEquals + +class HarvestCircleApplicationTest { + @OptIn(ExperimentalTestApi::class) + @Test + fun applicationCreatesOneStoreAcrossRecompositionAndClosesItOnDisposal() = + runComposeUiTest { + var applicationVisible by mutableStateOf(true) + var factoryCalls = 0 + var gateway: ApplicationGateway? = null + + setContent { + if (applicationVisible) { + HarvestCircleApplication { scope -> + factoryCalls += 1 + val createdGateway = ApplicationGateway() + gateway = createdGateway + HarvestCircleAppStore(createdGateway, scope) + } + } + BasicText( + text = "Toggle", + modifier = + Modifier + .testTag("toggle-application") + .clickable { applicationVisible = !applicationVisible }, + ) + } + + onNodeWithText("HarvestCircle").assertIsDisplayed() + onNodeWithTag("toggle-application").performClick() + waitForIdle() + + assertEquals(1, factoryCalls) + assertEquals(true, gateway?.closed) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun applicationRendersSafeStartupFailureWithoutLeakingInternalMessage() = + runComposeUiTest { + setContent { + HarvestCircleApplication { + error("sensitive internal startup detail") + } + } + + onNodeWithTag("startup-failure").assertIsDisplayed() + onNodeWithText("The application could not start.").assertIsDisplayed() + onAllNodesWithText("sensitive internal startup detail").assertCountEquals(0) + } +} + +private class ApplicationGateway : HarvestCircleCoreGateway { + var closed = false + + override fun snapshot() = applicationSnapshot(0UL) + + override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit) = AutoCloseable {} + + override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult = error("unused") + + override suspend fun bootstrap() = applicationSnapshot(1UL) + + override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = error("unused") + + override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = error("unused") + + override suspend fun confirmAccountRemoval(ticket: RemovalTicket) = error("unused") + + override fun shutdown(): HarvestCircleShutdownReceipt { + closed = true + return HarvestCircleShutdownReceipt(1UL, closed = true) + } + + override fun close() { + shutdown() + } +} + +private fun applicationSnapshot(revision: ULong) = + AppSnapshotDto( + revision = revision, + lifecycle = AppLifecycleDto.READY, + lifecycleError = null, + configuredRelays = emptyList(), + accounts = emptyList(), + selectedPublicKeyHex = null, + session = SessionStateDto.SIGNED_OUT, + sessionSubjectPublicKeyHex = null, + sessionError = null, + activeAccount = null, + recoverableProblem = null, + ) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleCoreGatewayTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleCoreGatewayTest.kt @@ -0,0 +1,24 @@ +package org.harvestcircle.application + +import org.harvestcircle.ffi.WireErrorCategory +import org.harvestcircle.ffi.WireErrorCode +import org.harvestcircle.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class HarvestCircleCoreGatewayTest { + @Test + fun unknownFailuresBecomeSanitizedTypedRejections() { + val failure = + IllegalStateException("sensitive detail") + .toHarvestCircleCommandFailure("request-7") + + assertEquals(WireErrorCode.INTERNAL, failure.code) + assertEquals(WireErrorCategory.INTERNAL, failure.category) + assertEquals(WireRecoveryAction.NONE, failure.recoveryAction) + assertEquals("request-7", failure.correlationId) + assertEquals("The application command failed.", failure.safeMessage) + assertFalse(failure.toString().contains("sensitive detail")) + } +} diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeCompatibilityTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeCompatibilityTest.kt @@ -0,0 +1,40 @@ +package org.harvestcircle.application + +import org.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/harvestcircle/application/NativeGeneratedRecoveryTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeGeneratedRecoveryTest.kt @@ -0,0 +1,53 @@ +package org.harvestcircle.application + +import kotlinx.coroutines.test.runTest +import org.harvestcircle.ffi.HarvestCircleAppCore +import org.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.getProperty("harvestcircle.development.data.dir"))) + + @BeforeTest + fun prepareDataDirectory() { + dataDirectory.toFile().deleteRecursively() + Files.createDirectories(dataDirectory) + } + + @AfterTest + fun removeDataDirectory() { + dataDirectory.toFile().deleteRecursively() + } + + @Test + fun generatedRecoveryCrossesTheNativeBoundaryAndCancelsWithoutPersistence() = + runTest { + val core = + HarvestCircleAppCore.openCompatible( + expectation = verifyNativeCompatibility(compatibilityDescriptor()), + developmentMode = true, + ) + val gateway = NativeHarvestCircleCoreGateway(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/harvestcircle/application/SecretClipboardControllerTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/SecretClipboardControllerTest.kt @@ -0,0 +1,118 @@ +package org.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/harvestcircle/architecture/MachineProvenanceTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/MachineProvenanceTest.kt @@ -0,0 +1,42 @@ +package org.harvestcircle.architecture + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MachineProvenanceTest { + @Test + fun sourceImportProvenanceUsesVerifiedImmutableCoordinates() { + val root = findProvenanceRepositoryRoot() + val legacyProduct = "stu" + "dio" + val provenance = root.resolve("core/provenance/$legacyProduct-import-v1.toml").readText() + + assertTrue(provenance.contains("schema = \"harvestcircle.source_provenance.v1\"")) + assertTrue( + provenance.contains( + "foundation_baseline = \"a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb\"", + ), + ) + assertTrue( + provenance.contains( + "canonical_radroots_revision = \"09065a610d95e57acdc895a14c07580fa099e7c3\"", + ), + ) + assertEquals(8, Regex("(?m)^\\[\\[import]]$").findAll(provenance).count()) + assertEquals(8, Regex("(?m)^commit = \"[0-9a-f]{40}\"$").findAll(provenance).count()) + assertEquals( + 1, + Regex( + "(?m)^source_repository = \"https://github.com/radrootslabs/${legacyProduct}_app\"$", + ).findAll(provenance) + .count(), + ) + } +} + +private fun findProvenanceRepositoryRoot(): Path = + generateSequence(Path.of("").toAbsolutePath()) { it.parent } + .first { Files.isRegularFile(it.resolve("core/Cargo.toml")) && Files.isDirectory(it.resolve("app/desktop")) } diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/architecture/NostrOnlySourceGuardTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/NostrOnlySourceGuardTest.kt @@ -0,0 +1,46 @@ +package org.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/harvestcircle/architecture/ProductCoordinateConsumerTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductCoordinateConsumerTest.kt @@ -0,0 +1,43 @@ +package org.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.assertFalse +import kotlin.test.assertTrue + +class ProductCoordinateConsumerTest { + @Test + fun finalManifestDrivesBuildNativeStorageAndKeyringCoordinates() { + val root = findRepositoryRoot() + val coordinates = + Properties().apply { + root.resolve("config/product/harvestcircle-v1.properties").inputStream().use(::load) + } + + assertEquals("org.harvestcircle", coordinates.getProperty("kotlin.root_namespace")) + assertEquals("org.harvestcircle.desktop", coordinates.getProperty("desktop.application_id")) + assertEquals("org.harvestcircle.desktop", coordinates.getProperty("desktop.bundle_id")) + assertEquals("org.harvestcircle.desktop.MainKt", coordinates.getProperty("desktop.main_class")) + assertEquals("org.harvestcircle.ffi", coordinates.getProperty("ffi.kotlin_package")) + assertEquals("harvestcircle", coordinates.getProperty("database.organization")) + assertEquals("desktop", coordinates.getProperty("database.application")) + assertEquals("harvestcircle.sqlite3", coordinates.getProperty("database.filename")) + assertEquals("org.harvestcircle.desktop.nostr", coordinates.getProperty("keyring.service")) + + val build = root.resolve("app/desktop/build.gradle.kts").readText() + assertTrue(build.contains("ProductCoordinates.parse")) + assertTrue(build.contains("mainClass = desktopMainClass")) + assertTrue(build.contains("bundleID = bundleId")) + assertTrue(build.contains("expectedPackage.set(ffiKotlinPackage)")) + assertFalse(Files.exists(root.resolve("core/compatibility/v5-baseline.properties"))) + } +} + +private fun findRepositoryRoot(): Path = + generateSequence(Path.of("").toAbsolutePath()) { it.parent } + .first { Files.isRegularFile(it.resolve("config/product/harvestcircle-v1.properties")) } diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/architecture/ProductNamespaceGuardTest.kt @@ -0,0 +1,91 @@ +package org.harvestcircle.architecture + +import java.nio.charset.StandardCharsets +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 +import kotlin.test.assertTrue + +class ProductNamespaceGuardTest { + @Test + fun trackedSourcesUseTheHarvestCircleNamingContract() { + val root = findRepositoryRoot() + val contract = root.resolve("AGENTS.md").readText() + assertTrue(contract.contains("`harvestcircle_*`")) + assertTrue(contract.contains("`org.harvestcircle`")) + assertTrue(contract.contains("`HarvestCircle*`")) + assertTrue(contract.contains("`HARVESTCIRCLE_*`")) + + val legacyProduct = "stu" + "dio" + val temporaryNamespace = listOf("org", "radroots", "harvestcircle").joinToString(".") + val temporaryPath = temporaryNamespace.replace('.', '/') + val repositoryUrlException = "https://github.com/radrootslabs/" + legacyProduct + "_app" + val provenanceException = "core/provenance/" + legacyProduct + "-import-v1.toml" + val textExtensions = + setOf( + "gradle", + "json", + "kt", + "kts", + "lock", + "md", + "plist", + "properties", + "rs", + "sql", + "toml", + "xml", + "yaml", + "yml", + ) + val textNames = setOf("Makefile", ".gitignore", "gradlew", "gradlew.bat") + val findings = + trackedFiles(root).flatMap { relative -> + buildList { + val normalizedRelative = relative.lowercase() + if (relative != provenanceException && normalizedRelative.contains(legacyProduct)) { + add("$relative: legacy product name in tracked path") + } + if (normalizedRelative.contains(temporaryPath)) { + add("$relative: temporary product namespace in tracked path") + } + + val path = root.resolve(relative) + if (relative != provenanceException && (path.extension in textExtensions || path.name in textNames)) { + val inspected = path.readText().replace(repositoryUrlException, "") + if (inspected.lowercase().contains(legacyProduct)) { + add("$relative: legacy product name in tracked text") + } + if (inspected.contains(temporaryNamespace) || inspected.contains(temporaryPath)) { + add("$relative: temporary product namespace in tracked text") + } + } + } + } + + assertEquals(emptyList(), findings.sorted()) + } +} + +private fun trackedFiles(root: Path): List<String> { + val process = + ProcessBuilder("git", "-C", root.toString(), "ls-files", "-z") + .redirectErrorStream(true) + .start() + val output = process.inputStream.readAllBytes() + check(process.waitFor() == 0) { + "Unable to enumerate tracked HarvestCircle sources: ${output.toString(StandardCharsets.UTF_8)}" + } + return output + .toString(StandardCharsets.UTF_8) + .split('\u0000') + .filter(String::isNotEmpty) +} + +private fun findRepositoryRoot(): Path = + generateSequence(Path.of("").toAbsolutePath()) { it.parent } + .first { Files.isRegularFile(it.resolve("core/Cargo.toml")) && Files.isDirectory(it.resolve("app/desktop")) } diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/desktop/MainTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/desktop/MainTest.kt @@ -0,0 +1,13 @@ +package org.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/harvestcircle/ffi/NativeLoaderTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/ffi/NativeLoaderTest.kt @@ -0,0 +1,11 @@ +package org.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/harvestcircle/accounts/ui/AccountsUiModelTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/AccountsUiModelTest.kt @@ -1,127 +0,0 @@ -package org.radroots.harvestcircle.accounts.ui - -import org.radroots.harvestcircle.application.GeneratedKeyBackup -import org.radroots.harvestcircle.application.HarvestCircleStoreState -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 = HarvestCircleStoreState(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 = - HarvestCircleStoreState( - 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 = - HarvestCircleStoreState( - snapshot = snapshot(), - lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, - ).toUiModel() - val repair = - HarvestCircleStoreState( - 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/HarvestCircleScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/accounts/ui/HarvestCircleScreenTest.kt @@ -1,384 +0,0 @@ -package org.radroots.harvestcircle.accounts.ui - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollToNode -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.v2.runComposeUiTest -import org.radroots.harvestcircle.application.AccountEntryMode -import org.radroots.harvestcircle.application.HarvestCircleRoute -import org.radroots.harvestcircle.application.RemovalImpactState -import org.radroots.harvestcircle.application.RemovalStatus -import org.radroots.harvestcircle.ffi.SessionStateDto -import org.radroots.harvestcircle.ffi.WireRecoveryAction -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@OptIn(ExperimentalTestApi::class) -class HarvestCircleScreenTest { - @Test - fun rendersEveryNonReadyLifecycleRouteWithoutAccountControls() = - runComposeUiTest { - var model by mutableStateOf(emptyUiModel().copy(route = HarvestCircleRoute.OPENING)) - setContent { HarvestCircleScreen(model, HarvestCircleUiActions()) } - - val routes = - listOf( - HarvestCircleRoute.OPENING to "lifecycle-opening", - HarvestCircleRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", - HarvestCircleRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", - HarvestCircleRoute.MIGRATING to "lifecycle-migrating", - HarvestCircleRoute.RECOVERING to "lifecycle-recovering", - HarvestCircleRoute.BLOCKED to "lifecycle-blocked", - HarvestCircleRoute.SHUTTING_DOWN to "lifecycle-shutting-down", - HarvestCircleRoute.FATAL to "lifecycle-fatal", - HarvestCircleRoute.CLOSED to "lifecycle-closed", - ) - routes.forEach { (route, tag) -> - model = emptyUiModel(problem = "Safe lifecycle problem").copy(route = route) - waitForIdle() - onNodeWithTag(tag).assertIsDisplayed() - onAllNodesWithTag("generate-key").assertCountEquals(0) - } - - model = emptyUiModel(problem = "Relay access is unavailable.").copy(route = HarvestCircleRoute.DEGRADED) - waitForIdle() - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithTag("accounts-problem").assertIsDisplayed() - } - - @Test - fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = - runComposeUiTest { - var importDraft by mutableStateOf("") - var accountEntryMode by mutableStateOf(AccountEntryMode.CHOICE) - var generateCalls = 0 - var importCalls = 0 - setContent { - HarvestCircleScreen( - model = emptyUiModel(importDraft = importDraft).copy(accountEntryMode = accountEntryMode), - actions = - HarvestCircleUiActions( - chooseCreateAccount = { accountEntryMode = AccountEntryMode.CREATE }, - chooseImportAccount = { accountEntryMode = AccountEntryMode.IMPORT }, - cancelAccountEntry = { accountEntryMode = AccountEntryMode.CHOICE }, - editImportDraft = { importDraft = it }, - generateAccount = { generateCalls += 1 }, - importSecretKey = { importCalls += 1 }, - ), - ) - } - - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithText("HarvestCircle").assertIsDisplayed() - onNodeWithTag("choose-create-account").performClick() - onNodeWithTag("generate-key").performClick() - onNodeWithTag("cancel-account-entry").performClick() - onNodeWithTag("choose-import-account").performClick() - onNodeWithTag("import-nsec-input").assertIsFocused() - onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") - onNodeWithTag("import-key").performClick() - - assertEquals(1, generateCalls) - assertEquals(1, importCalls) - assertEquals("nsec1secret", importDraft) - assertTrue( - onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { - it.key.name == "Password" && it.value == Unit - }, - ) - } - - @Test - fun inactiveScreenShowsSafeFailureAndNoGenericFields() = - runComposeUiTest { - setContent { - HarvestCircleScreen( - model = emptyUiModel(problem = "The secret key is invalid."), - actions = HarvestCircleUiActions(), - ) - } - - onNodeWithText("The secret key is invalid.").assertIsDisplayed() - onNodeWithTag("accounts-empty").assertIsDisplayed() - } - - @Test - fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = - runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var copied: String? = null - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = - HarvestCircleUiActions( - copyText = { copied = it }, - acknowledgeGeneratedKeyBackup = { backup = null }, - ), - ) - } - - onNodeWithTag("generated-key-backup").assertIsDisplayed() - onAllNodesWithTag("accounts-screen").assertCountEquals(0) - onAllNodesWithTag("generate-key").assertCountEquals(0) - onNodeWithTag("generated-nsec").assertIsDisplayed() - onNodeWithTag("copy-generated-key").performClick() - assertEquals("nsec1generated", copied) - - onNodeWithTag("acknowledge-key-backup").performClick() - onAllNodesWithTag("generated-key-backup").assertCountEquals(0) - onAllNodesWithTag("generated-nsec").assertCountEquals(0) - } - - @Test - fun generatedKeyRecoveryCanBeCancelledWithoutExposingAccountControls() = - runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var cancelled = 0 - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = - HarvestCircleUiActions( - cancelGeneratedKeyBackup = { - cancelled += 1 - backup = null - }, - ), - ) - } - - onNodeWithTag("cancel-generated-key").performClick() - assertEquals(1, cancelled) - onAllNodesWithTag("generated-key-backup").assertCountEquals(0) - } - - @Test - fun savedAccountsSelectActivateAndRequireRemovalConfirmation() = - runComposeUiTest { - val first = accountUi("11".repeat(32), selected = true) - val second = accountUi("22".repeat(32), selected = false) - var pendingRemoval: String? by mutableStateOf(null) - val selected = mutableListOf<String>() - val activated = mutableListOf<String>() - var confirmations = 0 - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - accounts = listOf(first, second), - pendingRemovalPublicKeyHex = pendingRemoval, - removalImpact = - pendingRemoval?.let { - RemovalImpactState(it, deletesLocalCredential = true, signsOut = true, expiresAtSeconds = 60) - }, - ), - actions = - HarvestCircleUiActions( - selectAccount = selected::add, - activateAccount = activated::add, - requestAccountRemoval = { pendingRemoval = it }, - cancelAccountRemoval = { pendingRemoval = null }, - confirmAccountRemoval = { confirmations += 1 }, - ), - ) - } - - onNodeWithTag("saved-account-list").assertIsDisplayed() - onNodeWithTag("account-row:${first.publicKeyHex}").assertIsSelected() - onNodeWithTag("select-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(listOf(second.publicKeyHex), selected) - assertEquals(listOf(second.publicKeyHex), activated) - - onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithText("Its local credential will be deleted from the operating-system keyring.").assertIsDisplayed() - onNodeWithText("The active session will be signed out before removal.").assertIsDisplayed() - onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() - assertEquals(null, pendingRemoval) - onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() - assertEquals(1, confirmations) - } - - @Test - fun savedAccountListRemainsReachableForLargeRegistries() = - runComposeUiTest { - val accounts = - (0 until 100).map { index -> - accountUi(index.toString(16).padStart(64, '0'), selected = index == 0) - } - setContent { - HarvestCircleScreen( - model = emptyUiModel().copy(accounts = accounts), - actions = HarvestCircleUiActions(), - ) - } - - val lastTag = "account-row:${accounts.last().publicKeyHex}" - onNodeWithTag("saved-account-list").performScrollToNode(hasTestTag(lastTag)) - onNodeWithTag(lastTag).assertIsDisplayed() - } - - @Test - fun activeHomeShowsIdentityProfileRelayAndCommands() = - runComposeUiTest { - var refreshCalls = 0 - var signOutCalls = 0 - val account = accountUi("33".repeat(32), selected = true) - val active = - ActiveAccountUiModel( - account = account, - heading = "Alice", - relayState = "connected", - profileState = "fresh", - profile = - ProfileUiModel( - name = "alice", - displayName = "Alice", - nip05 = "alice@example.com", - about = "Local grower", - picture = "https://example.com/alice.png", - ), - ) - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - route = HarvestCircleRoute.ACTIVE_ACCOUNT, - accounts = listOf(account), - activeAccount = active, - configuredRelays = listOf("ws://localhost:8080"), - session = SessionStateDto.ACTIVE, - ), - actions = - HarvestCircleUiActions( - refreshActiveProfile = { refreshCalls += 1 }, - signOut = { signOutCalls += 1 }, - ), - ) - } - - onNodeWithTag("home-screen").assertIsDisplayed() - onNodeWithTag("active-npub").assertIsDisplayed() - onNodeWithTag("active-pubkey-hex").assertIsDisplayed() - onNodeWithTag("active-profile-name").assertIsDisplayed() - onNodeWithTag("active-profile-about").assertIsDisplayed() - onNodeWithTag("relay-state").assertIsDisplayed() - onNodeWithTag("profile-state").assertIsDisplayed() - onNodeWithText("ws://localhost:8080").assertIsDisplayed() - onNodeWithTag("refresh-profile").performClick() - onNodeWithTag("sign-out").performClick() - assertEquals(1, refreshCalls) - assertEquals(1, signOutCalls) - } - - @Test - fun activeAccountCanOpenChooserWithoutDroppingCurrentSession() = - runComposeUiTest { - val first = accountUi("44".repeat(32), selected = true, active = true) - val second = accountUi("55".repeat(32), selected = false) - val active = - ActiveAccountUiModel( - account = first, - heading = first.label, - relayState = "connected", - profileState = "cached", - profile = ProfileUiModel("", "", "", "", ""), - ) - var chooserVisible by mutableStateOf(false) - var activated: String? = null - setContent { - HarvestCircleScreen( - model = - emptyUiModel().copy( - route = HarvestCircleRoute.ACTIVE_ACCOUNT, - accounts = listOf(first, second), - activeAccount = active, - session = SessionStateDto.ACTIVE, - accountChooserVisible = chooserVisible, - ), - actions = - HarvestCircleUiActions( - showAccountChooser = { chooserVisible = true }, - hideAccountChooser = { chooserVisible = false }, - activateAccount = { activated = it }, - ), - ) - } - - onNodeWithTag("switch-account").performClick() - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithTag("activate-account:${first.publicKeyHex}", useUnmergedTree = true).assertIsNotEnabled() - onNodeWithText("Active").assertIsDisplayed() - onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(second.publicKeyHex, activated) - assertEquals( - SessionStateDto.ACTIVE, - emptyUiModel() - .copy( - activeAccount = active, - session = SessionStateDto.ACTIVE, - ).session, - ) - onNodeWithTag("return-home").performClick() - onNodeWithTag("home-screen").assertIsDisplayed() - } -} - -private fun emptyUiModel( - importDraft: String = "", - problem: String? = null, - importGuidance: String? = null, - recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, -) = HarvestCircleUiModel( - route = HarvestCircleRoute.ACCOUNTS, - accounts = emptyList(), - activeAccount = null, - configuredRelays = emptyList(), - importDraft = importDraft, - generatedKeyBackup = null, - pendingRemovalPublicKeyHex = null, - removalImpact = null, - removalStatus = RemovalStatus.NONE, - lastRemovedPublicKeyHex = null, - accountChooserVisible = false, - accountEntryMode = AccountEntryMode.CHOICE, - session = SessionStateDto.SIGNED_OUT, - busy = false, - problem = problem, - importGuidance = importGuidance, - recoveryAction = recoveryAction, -) - -private fun accountUi( - publicKeyHex: String, - selected: Boolean, - active: Boolean = false, -) = AccountUiModel( - publicKeyHex = publicKeyHex, - npub = "npub1${publicKeyHex.take(12)}", - shortNpub = "npub1${publicKeyHex.take(12)}", - label = "Account ${publicKeyHex.take(2)}", - keyAvailability = "available", - selected = selected, - active = active, -) diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/GeneratedKeyBackupTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/GeneratedKeyBackupTest.kt @@ -1,18 +0,0 @@ -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/HarvestCircleAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleAppStoreTest.kt @@ -1,481 +0,0 @@ -package org.radroots.harvestcircle.application - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.radroots.harvestcircle.ffi.AccountDto -import org.radroots.harvestcircle.ffi.AppLifecycleDto -import org.radroots.harvestcircle.ffi.AppSnapshotDto -import org.radroots.harvestcircle.ffi.KeyAvailabilityDto -import org.radroots.harvestcircle.ffi.SessionStateDto -import org.radroots.harvestcircle.ffi.SignerKindDto -import org.radroots.harvestcircle.ffi.WireErrorCategory -import org.radroots.harvestcircle.ffi.WireErrorCode -import org.radroots.harvestcircle.ffi.WireRecoveryAction -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -class HarvestCircleAppStoreTest { - @Test - fun `bootstraps and ignores stale observer snapshots`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - - advanceUntilIdle() - gateway.emit(snapshot(1UL)) - gateway.emit(snapshot(0UL)) - advanceUntilIdle() - - assertEquals(1UL, store.state.value.snapshot.revision) - assertFalse(store.state.value.busy) - store.close() - assertTrue(gateway.closed) - assertTrue(gateway.shutdownCompleted) - assertTrue(gateway.subscriptionClosed) - assertEquals(HarvestCircleRoute.CLOSED, store.state.value.route) - } - - @Test - fun `holds generated secret only until explicit acknowledgement`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - - store.generateAccount() - advanceUntilIdle() - - assertEquals( - "nsec1secret", - store.state.value.generatedKeyBackup - ?.revealNsec(), - ) - assertEquals( - "npub1account", - store.state.value.generatedKeyBackup - ?.npub, - ) - store.acknowledgeGeneratedKeyBackup() - advanceUntilIdle() - assertNull(store.state.value.generatedKeyBackup) - store.close() - } - - @Test - fun `cancels staged generated account without committing it`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - val revisionBeforeGeneration = store.state.value.snapshot.revision - store.generateAccount() - advanceUntilIdle() - - store.cancelGeneratedKeyBackup() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertEquals(revisionBeforeGeneration, store.state.value.snapshot.revision) - store.close() - } - - @Test - fun `partial generated recovery acquisition cancels and closes its native ticket`() = - runTest { - val gateway = - FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { - failGeneratedRecoveryRead = true - } - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - - store.generateAccount() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertEquals(1, gateway.lastGeneratedRecoveryTicket?.cancelCalls) - assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) - store.close() - } - - @Test - fun `failed generated acknowledgement releases one-shot recovery ownership`() = - runTest { - val gateway = - FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { - failGeneratedAcknowledgement = true - } - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - store.generateAccount() - advanceUntilIdle() - - store.acknowledgeGeneratedKeyBackup() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) - assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) - assertEquals( - "The generated account could not be saved. Import the recovery key you saved to try again.", - store.state.value.problem, - ) - store.close() - } - - @Test - fun `already resolved cancellation clears recovery and reports the state mismatch`() = - runTest { - val gateway = - FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { - generatedCancellationResult = false - } - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - store.generateAccount() - advanceUntilIdle() - - store.cancelGeneratedKeyBackup() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertEquals(WireErrorCode.INVALID_APPLICATION_STATE, store.state.value.lastFailureCode) - assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) - assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) - store.close() - } - - @Test - fun `ignores observer delivery after close`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - val revisionAtClose = store.state.value.snapshot.revision - - store.close() - gateway.emit(snapshot(revisionAtClose + 1UL)) - advanceUntilIdle() - - assertEquals(revisionAtClose, store.state.value.snapshot.revision) - } - - @Test - fun `failed removal confirmation clears consumed presentation state`() = - runTest { - val gateway = - FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { - failRemovalConfirmation = true - } - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - - store.requestAccountRemoval("00".repeat(32)) - advanceUntilIdle() - assertEquals("00".repeat(32), store.state.value.pendingRemovalPublicKeyHex) - store.confirmAccountRemoval() - advanceUntilIdle() - - assertNull(store.state.value.pendingRemovalPublicKeyHex) - assertTrue(gateway.lastRemovalTicket?.closed == true) - assertEquals(RemovalStatus.FAILED, store.state.value.removalStatus) - assertEquals("The application command failed.", store.state.value.problem) - store.close() - } - - @Test - fun `serializes commands while one is active`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - - store.signOut() - assertEquals(CommandStatus.REJECTED_BUSY, store.state.value.commandStatus) - advanceUntilIdle() - - assertEquals(0, gateway.signOutCalls) - store.signOut() - advanceUntilIdle() - assertEquals(1, gateway.signOutCalls) - store.close() - } - - @Test - fun `projects retryable command rejection without dropping intent`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - gateway.nextCommandResult = - HarvestCircleCommandResult.Rejected( - HarvestCircleCommandFailure( - WireErrorCode.STORAGE_UNAVAILABLE, - WireErrorCategory.STORAGE, - retryable = true, - WireRecoveryAction.RETRY, - "request-retry", - "Storage is temporarily unavailable.", - ), - ) - - store.signOut() - advanceUntilIdle() - - assertEquals(CommandStatus.FAILED_RETRYABLE, store.state.value.commandStatus) - assertEquals("request-retry", store.state.value.lastCommandRequestId) - assertEquals("Storage is temporarily unavailable.", store.state.value.problem) - store.retryLastCommand() - advanceUntilIdle() - assertEquals(1, gateway.signOutCalls) - assertEquals(CommandStatus.ACCEPTED, store.state.value.commandStatus) - store.close() - } - - @Test - fun `clears imported secret draft as soon as command is accepted`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - store.editImportDraft("nsec1secret") - - store.importSecretKey() - - assertEquals("", store.state.value.importDraft) - assertEquals(emptyList(), gateway.importedSecrets) - advanceUntilIdle() - assertEquals(listOf("nsec1secret"), gateway.importedSecrets) - assertEquals(true, gateway.lastImportBuffer?.all { it == 0.toByte() }) - store.close() - } - - @Test - fun `bounds imported secret presentation input before transport`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - - store.editImportDraft("x".repeat(MAX_IMPORT_SECRET_CHARS + 50)) - - assertEquals(MAX_IMPORT_SECRET_CHARS, store.state.value.importDraft.length) - store.close() - } - - @Test - fun `projects boot fatal and terminal lifecycle failures`() = - runTest { - val booting = snapshot(0UL, AppLifecycleDto.OPENING) - val bootGateway = FakeHarvestCircleCoreGateway(booting, booting) - val bootStore = HarvestCircleAppStore(bootGateway, this) - advanceUntilIdle() - assertEquals(HarvestCircleRoute.OPENING, bootStore.state.value.route) - bootStore.close() - - val fatal = snapshot(1UL, AppLifecycleDto.FATAL) - val gateway = FakeHarvestCircleCoreGateway(fatal, fatal) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - assertEquals(HarvestCircleRoute.FATAL, store.state.value.route) - store.signOut() - advanceUntilIdle() - assertEquals(CommandStatus.FAILED_TERMINAL, store.state.value.commandStatus) - assertEquals(0, gateway.signOutCalls) - - store.close() - store.signOut() - assertEquals(CommandStatus.REJECTED_CLOSED, store.state.value.commandStatus) - } - - @Test - fun `disposal waits for native shutdown and fails closed on an incomplete receipt`() = - runTest { - val gateway = - FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { - shutdownReceipt = HarvestCircleShutdownReceipt(1UL, closed = false) - } - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - - store.close() - - assertTrue(gateway.shutdownCompleted) - assertEquals(HarvestCircleRoute.FATAL, store.state.value.route) - assertEquals("The application could not shut down safely.", store.state.value.problem) - } -} - -private class FakeHarvestCircleCoreGateway( - private var current: AppSnapshotDto, - private val bootstrapSnapshot: AppSnapshotDto = snapshot(1UL), -) : HarvestCircleCoreGateway { - private var observer: ((AppSnapshotDto) -> Unit)? = null - var closed = false - var shutdownCompleted = false - var shutdownReceipt = HarvestCircleShutdownReceipt(current.revision, closed = true) - var subscriptionClosed = false - var signOutCalls = 0 - val importedSecrets = mutableListOf<String>() - var lastImportBuffer: ByteArray? = null - var failRemovalConfirmation = false - var lastRemovalTicket: FakeRemovalTicket? = null - var nextCommandResult: HarvestCircleCommandResult? = null - var failGeneratedRecoveryRead = false - var failGeneratedAcknowledgement = false - var generatedCancellationResult = true - var lastGeneratedRecoveryTicket: FakeGeneratedRecoveryTicket? = null - - override fun snapshot(): AppSnapshotDto = current - - override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit): AutoCloseable { - observer = { snapshot -> onChange(HarvestCircleChange(snapshot, null)) } - return AutoCloseable { subscriptionClosed = true } - } - - override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult { - nextCommandResult?.let { - nextCommandResult = null - return it - } - when (command) { - is HarvestCircleCommand.ImportAccount -> { - lastImportBuffer = command.bytes - importedSecrets += command.bytes.decodeToString() - command.bytes.fill(0) - } - HarvestCircleCommand.SignOut -> signOutCalls += 1 - else -> Unit - } - return HarvestCircleCommandResult.Accepted( - HarvestCircleCommandReceipt("fake-request", current.revision, current), - ) - } - - fun emit(snapshot: AppSnapshotDto) { - current = snapshot - observer?.invoke(snapshot) - } - - override suspend fun bootstrap(): AppSnapshotDto = bootstrapSnapshot.also(::emit) - - override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = - FakeGeneratedRecoveryTicket( - account = account(), - failRecoveryRead = failGeneratedRecoveryRead, - failAcknowledgement = failGeneratedAcknowledgement, - cancellationResult = generatedCancellationResult, - ) { committed -> - current = snapshot(current.revision + 1UL) - emit(current) - committed(current) - }.also { lastGeneratedRecoveryTicket = it } - - override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = FakeRemovalTicket().also { lastRemovalTicket = it } - - override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { - if (failRemovalConfirmation) error("injected confirmation failure") - return current - } - - override fun shutdown(): HarvestCircleShutdownReceipt { - shutdownCompleted = true - closed = true - return shutdownReceipt - } - - override fun close() { - shutdown() - } -} - -private class FakeGeneratedRecoveryTicket( - override val account: AccountDto, - private val failRecoveryRead: Boolean, - private val failAcknowledgement: Boolean, - private val cancellationResult: Boolean, - private val commit: (((AppSnapshotDto) -> Unit) -> Unit), -) : GeneratedRecoveryTicket { - override val requestId: String = "fake-generated-request" - private var available = true - var cancelCalls = 0 - var closed = false - - override fun takeRecoveryNsec(): String { - if (failRecoveryRead) error("injected recovery read failure") - return "nsec1secret" - } - - override suspend fun acknowledge(): AppSnapshotDto { - if (failAcknowledgement) { - available = false - throw HarvestCircleGatewayException( - HarvestCircleCommandFailure( - WireErrorCode.KEYRING_UNAVAILABLE, - WireErrorCategory.CREDENTIAL, - retryable = false, - WireRecoveryAction.NONE, - requestId, - "The generated account could not be saved. Import the recovery key you saved to try again.", - ), - ) - } - lateinit var snapshot: AppSnapshotDto - commit { snapshot = it } - available = false - return snapshot - } - - override suspend fun cancel(): Boolean { - cancelCalls += 1 - return (available && cancellationResult).also { available = false } - } - - override fun close() { - closed = true - } -} - -private class FakeRemovalTicket : RemovalTicket { - override val publicKeyHex: String = "00".repeat(32) - override val deletesLocalCredential: Boolean = true - override val signsOut: Boolean = false - override val expiresAtSeconds: Long = 60 - var closed = false - - override fun close() { - closed = true - } -} - -private fun snapshot( - revision: ULong, - lifecycle: AppLifecycleDto = AppLifecycleDto.READY, -) = AppSnapshotDto( - revision = revision, - lifecycle = lifecycle, - lifecycleError = null, - configuredRelays = emptyList(), - accounts = emptyList(), - selectedPublicKeyHex = null, - session = SessionStateDto.SIGNED_OUT, - sessionSubjectPublicKeyHex = null, - sessionError = null, - activeAccount = null, - recoverableProblem = null, -) - -private fun account() = - AccountDto( - publicKeyHex = "00".repeat(32), - npub = "npub1account", - displayLabel = "Account", - signerKind = SignerKindDto.LOCAL_SECRET, - keyAvailability = KeyAvailabilityDto.AVAILABLE, - createdAtSeconds = 0, - lastUsedAtSeconds = null, - ) diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleApplicationTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleApplicationTest.kt @@ -1,115 +0,0 @@ -package org.radroots.harvestcircle.application - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.v2.runComposeUiTest -import org.radroots.harvestcircle.ffi.AppLifecycleDto -import org.radroots.harvestcircle.ffi.AppSnapshotDto -import org.radroots.harvestcircle.ffi.SessionStateDto -import kotlin.test.Test -import kotlin.test.assertEquals - -class HarvestCircleApplicationTest { - @OptIn(ExperimentalTestApi::class) - @Test - fun applicationCreatesOneStoreAcrossRecompositionAndClosesItOnDisposal() = - runComposeUiTest { - var applicationVisible by mutableStateOf(true) - var factoryCalls = 0 - var gateway: ApplicationGateway? = null - - setContent { - if (applicationVisible) { - HarvestCircleApplication { scope -> - factoryCalls += 1 - val createdGateway = ApplicationGateway() - gateway = createdGateway - HarvestCircleAppStore(createdGateway, scope) - } - } - BasicText( - text = "Toggle", - modifier = - Modifier - .testTag("toggle-application") - .clickable { applicationVisible = !applicationVisible }, - ) - } - - onNodeWithText("HarvestCircle").assertIsDisplayed() - onNodeWithTag("toggle-application").performClick() - waitForIdle() - - assertEquals(1, factoryCalls) - assertEquals(true, gateway?.closed) - } - - @OptIn(ExperimentalTestApi::class) - @Test - fun applicationRendersSafeStartupFailureWithoutLeakingInternalMessage() = - runComposeUiTest { - setContent { - HarvestCircleApplication { - error("sensitive internal startup detail") - } - } - - onNodeWithTag("startup-failure").assertIsDisplayed() - onNodeWithText("The application could not start.").assertIsDisplayed() - onAllNodesWithText("sensitive internal startup detail").assertCountEquals(0) - } -} - -private class ApplicationGateway : HarvestCircleCoreGateway { - var closed = false - - override fun snapshot() = applicationSnapshot(0UL) - - override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit) = AutoCloseable {} - - override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult = error("unused") - - override suspend fun bootstrap() = applicationSnapshot(1UL) - - override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = error("unused") - - override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = error("unused") - - override suspend fun confirmAccountRemoval(ticket: RemovalTicket) = error("unused") - - override fun shutdown(): HarvestCircleShutdownReceipt { - closed = true - return HarvestCircleShutdownReceipt(1UL, closed = true) - } - - override fun close() { - shutdown() - } -} - -private fun applicationSnapshot(revision: ULong) = - AppSnapshotDto( - revision = revision, - lifecycle = AppLifecycleDto.READY, - lifecycleError = null, - configuredRelays = emptyList(), - accounts = emptyList(), - selectedPublicKeyHex = null, - session = SessionStateDto.SIGNED_OUT, - sessionSubjectPublicKeyHex = null, - sessionError = null, - activeAccount = null, - recoverableProblem = null, - ) diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleCoreGatewayTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/HarvestCircleCoreGatewayTest.kt @@ -1,24 +0,0 @@ -package org.radroots.harvestcircle.application - -import org.radroots.harvestcircle.ffi.WireErrorCategory -import org.radroots.harvestcircle.ffi.WireErrorCode -import org.radroots.harvestcircle.ffi.WireRecoveryAction -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse - -class HarvestCircleCoreGatewayTest { - @Test - fun unknownFailuresBecomeSanitizedTypedRejections() { - val failure = - IllegalStateException("sensitive detail") - .toHarvestCircleCommandFailure("request-7") - - assertEquals(WireErrorCode.INTERNAL, failure.code) - assertEquals(WireErrorCategory.INTERNAL, failure.category) - assertEquals(WireRecoveryAction.NONE, failure.recoveryAction) - assertEquals("request-7", failure.correlationId) - assertEquals("The application command failed.", failure.safeMessage) - assertFalse(failure.toString().contains("sensitive detail")) - } -} diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/NativeCompatibilityTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/NativeCompatibilityTest.kt @@ -1,40 +0,0 @@ -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 @@ -1,53 +0,0 @@ -package org.radroots.harvestcircle.application - -import kotlinx.coroutines.test.runTest -import org.radroots.harvestcircle.ffi.HarvestCircleAppCore -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("HARVESTCIRCLE_DEVELOPMENT_DATA_DIR"))) - - @BeforeTest - fun prepareDataDirectory() { - dataDirectory.toFile().deleteRecursively() - Files.createDirectories(dataDirectory) - } - - @AfterTest - fun removeDataDirectory() { - dataDirectory.toFile().deleteRecursively() - } - - @Test - fun generatedRecoveryCrossesTheNativeBoundaryAndCancelsWithoutPersistence() = - runTest { - val core = - HarvestCircleAppCore.openCompatible( - expectation = verifyNativeCompatibility(compatibilityDescriptor()), - developmentMode = true, - ) - val gateway = NativeHarvestCircleCoreGateway(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/SecretClipboardControllerTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/application/SecretClipboardControllerTest.kt @@ -1,118 +0,0 @@ -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/architecture/LegacyProductIdentityGuardTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/architecture/LegacyProductIdentityGuardTest.kt @@ -1,66 +0,0 @@ -package org.radroots.harvestcircle.architecture - -import java.nio.charset.StandardCharsets -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 -import kotlin.test.assertTrue - -class LegacyProductIdentityGuardTest { - @Test - fun trackedSourcesUseTheHarvestCircleNamingContract() { - val root = findRepositoryRoot() - val contract = root.resolve("AGENTS.md").readText() - assertTrue(contract.contains("`harvestcircle_*`")) - assertTrue(contract.contains("`org.radroots.harvestcircle`")) - assertTrue(contract.contains("`HarvestCircle*`")) - assertTrue(contract.contains("`HARVESTCIRCLE_*`")) - - val legacyProduct = "stu" + "dio" - val repositoryUrlException = "https://github.com/radrootslabs/" + legacyProduct + "_app" - val provenanceException = "core/provenance/" + legacyProduct + "-import-v1.toml" - val textExtensions = setOf("kt", "kts", "rs", "toml", "properties", "yml", "yaml", "md") - val textNames = setOf("Makefile", ".gitignore", "gradlew", "gradlew.bat") - val findings = - trackedFiles(root).flatMap { relative -> - buildList { - if (relative != provenanceException && relative.lowercase().contains(legacyProduct)) { - add("$relative: legacy product name in tracked path") - } - - val path = root.resolve(relative) - if (relative != provenanceException && (path.extension in textExtensions || path.name in textNames)) { - val inspected = path.readText().replace(repositoryUrlException, "") - if (inspected.lowercase().contains(legacyProduct)) { - add("$relative: legacy product name in tracked text") - } - } - } - } - - assertEquals(emptyList(), findings.sorted()) - } -} - -private fun trackedFiles(root: Path): List<String> { - val process = - ProcessBuilder("git", "-C", root.toString(), "ls-files", "-z") - .redirectErrorStream(true) - .start() - val output = process.inputStream.readAllBytes() - check(process.waitFor() == 0) { - "Unable to enumerate tracked HarvestCircle sources: ${output.toString(StandardCharsets.UTF_8)}" - } - return output - .toString(StandardCharsets.UTF_8) - .split('\u0000') - .filter(String::isNotEmpty) -} - -private fun findRepositoryRoot(): Path = - generateSequence(Path.of("").toAbsolutePath()) { it.parent } - .first { Files.isRegularFile(it.resolve("core/Cargo.toml")) && Files.isDirectory(it.resolve("app/desktop")) } diff --git a/app/desktop/src/test/kotlin/org/radroots/harvestcircle/architecture/MachineProvenanceTest.kt b/app/desktop/src/test/kotlin/org/radroots/harvestcircle/architecture/MachineProvenanceTest.kt @@ -1,42 +0,0 @@ -package org.radroots.harvestcircle.architecture - -import java.nio.file.Files -import java.nio.file.Path -import kotlin.io.path.readText -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class MachineProvenanceTest { - @Test - fun sourceImportProvenanceUsesVerifiedImmutableCoordinates() { - val root = findProvenanceRepositoryRoot() - val legacyProduct = "stu" + "dio" - val provenance = root.resolve("core/provenance/$legacyProduct-import-v1.toml").readText() - - assertTrue(provenance.contains("schema = \"harvestcircle.source_provenance.v1\"")) - assertTrue( - provenance.contains( - "foundation_baseline = \"a2038b3e25b9e34f0b8fd001f26a8ed10b5772cb\"", - ), - ) - assertTrue( - provenance.contains( - "canonical_radroots_revision = \"09065a610d95e57acdc895a14c07580fa099e7c3\"", - ), - ) - assertEquals(8, Regex("(?m)^\\[\\[import]]$").findAll(provenance).count()) - assertEquals(8, Regex("(?m)^commit = \"[0-9a-f]{40}\"$").findAll(provenance).count()) - assertEquals( - 1, - Regex( - "(?m)^source_repository = \"https://github.com/radrootslabs/${legacyProduct}_app\"$", - ).findAll(provenance) - .count(), - ) - } -} - -private fun findProvenanceRepositoryRoot(): Path = - generateSequence(Path.of("").toAbsolutePath()) { it.parent } - .first { Files.isRegularFile(it.resolve("core/Cargo.toml")) && Files.isDirectory(it.resolve("app/desktop")) } 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 @@ -1,46 +0,0 @@ -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 @@ -1,51 +0,0 @@ -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("harvestcircle-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.harvestcircle", baseline.getProperty("package.namespace")) - assertEquals("org.radroots.harvestcircle", baseline.getProperty("package.application_id")) - assertEquals("HarvestCircle", baseline.getProperty("package.name")) - assertEquals("org.radroots.harvestcircle", baseline.getProperty("package.bundle_id")) - assertEquals("1.0.0", baseline.getProperty("package.version")) - assertEquals("org.radroots.harvestcircle.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 @@ -1,13 +0,0 @@ -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 @@ -1,11 +0,0 @@ -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/build.gradle.kts b/build.gradle.kts @@ -12,6 +12,9 @@ val verifyProductCoordinates by tasks.registering(VerifyProductCoordinates::clas group = "verification" description = "Validates the canonical HarvestCircle product-coordinate authority." manifestFile.set(productCoordinatesFile) + uniFfiConfigFile.set( + layout.projectDirectory.file("core/crates/harvestcircle_ffi/uniffi.toml"), + ) } providers.environmentVariable("EXT_BUILD_GRADLE_BUILD_DIR").orNull?.let { extBuildGradleRoot -> diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/ProductCoordinates.kt @@ -77,6 +77,10 @@ abstract class VerifyProductCoordinates : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) abstract val manifestFile: RegularFileProperty + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val uniFfiConfigFile: RegularFileProperty + @TaskAction fun verify() { val source = manifestFile.get().asFile.readText() @@ -94,5 +98,17 @@ abstract class VerifyProductCoordinates : DefaultTask() { ) }.isFailure, ) + + val uniFfiConfig = uniFfiConfigFile.get().asFile.readText() + check( + uniFfiConfig.contains( + "package_name = \"${coordinates["ffi.kotlin_package"]}\"", + ), + ) + check( + uniFfiConfig.contains( + "cdylib_name = \"${coordinates["ffi.cdylib_name"]}\"", + ), + ) } } diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -1090,6 +1090,7 @@ dependencies = [ "harvestcircle_application", "harvestcircle_domain", "harvestcircle_nostr", + "harvestcircle_product", "harvestcircle_runtime", "harvestcircle_storage", "nostr 0.44.1", @@ -1156,6 +1157,7 @@ dependencies = [ "getrandom 0.2.17", "harvestcircle_application", "harvestcircle_domain", + "harvestcircle_product", "hmac", "keyring", "refinery", diff --git a/core/compatibility/v5-baseline.properties b/core/compatibility/v5-baseline.properties @@ -1,17 +0,0 @@ -baseline.id=harvestcircle-runtime-v5 -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.harvestcircle -package.application_id=org.radroots.harvestcircle -package.name=HarvestCircle -package.bundle_id=org.radroots.harvestcircle -package.version=1.0.0 -database.qualifier=org -database.organization=radroots -database.application=harvestcircle -database.filename=harvestcircle.sqlite3 -keyring.service=org.radroots.harvestcircle.nostr -keyring.account=canonical-lowercase-public-key-hex diff --git a/core/crates/harvestcircle_ffi/Cargo.toml b/core/crates/harvestcircle_ffi/Cargo.toml @@ -20,6 +20,7 @@ directories = "=6.0.0" harvestcircle_application.workspace = true harvestcircle_domain.workspace = true harvestcircle_nostr.workspace = true +harvestcircle_product.workspace = true harvestcircle_runtime.workspace = true harvestcircle_storage.workspace = true tokio = { version = "=1.47.1", features = ["macros", "rt-multi-thread", "sync", "time"] } diff --git a/core/crates/harvestcircle_ffi/src/commands.rs b/core/crates/harvestcircle_ffi/src/commands.rs @@ -13,6 +13,10 @@ use harvestcircle_application::{ }; use harvestcircle_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; use harvestcircle_nostr::SdkNostrClient; +use harvestcircle_product::{ + DATABASE_APPLICATION, DATABASE_FILENAME, DATABASE_ORGANIZATION, DATABASE_QUALIFIER, + DEVELOPMENT_DATA_DIR_ENVIRONMENT, +}; use harvestcircle_runtime::{ RuntimeActorHandle, RuntimeDependencies, UuidInstallationIdentitySource, }; @@ -27,11 +31,6 @@ use crate::{ dto::error_policy, }; -const DATABASE_QUALIFIER: &str = "org"; -const DATABASE_ORGANIZATION: &str = "radroots"; -const DATABASE_APPLICATION: &str = "harvestcircle"; -const DATABASE_FILENAME: &str = "harvestcircle.sqlite3"; -const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = "HARVESTCIRCLE_DEVELOPMENT_DATA_DIR"; pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64; const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000; @@ -722,9 +721,9 @@ mod tests { use super::{ ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME, DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, - FFI_CONTRACT_MINOR, HarvestCircleAppCore, HarvestCircleError, RequestContextDto, - RuntimeCore, SystemClock, WireErrorCategory, WireErrorCode, WireRecoveryAction, - actor_mailbox_capacity, compatibility_descriptor, confirmation_expired, + FFI_CONTRACT_MINOR, HarvestCircleAppCore, HarvestCircleError, ProjectDirs, + RequestContextDto, RuntimeCore, SystemClock, WireErrorCategory, WireErrorCode, + WireRecoveryAction, actor_mailbox_capacity, compatibility_descriptor, confirmation_expired, generated_commit_failed, local_first_relay_configuration, path_unavailable, runtime, runtime_unavailable, verify_compatibility, }; @@ -1060,34 +1059,23 @@ mod tests { } #[test] - fn v5_compatibility_fixture_preserves_external_coordinates() { - let fixture = include_str!("../../../compatibility/v5-baseline.properties"); - let property = |key: &str| { - fixture.lines().find_map(|line| { - line.split_once('=') - .filter(|(candidate, _)| *candidate == key) - .map(|(_, value)| value) - }) - }; - - assert_eq!(property("baseline.id"), Some("harvestcircle-runtime-v5")); - assert_eq!(property("schema.version"), Some("5")); + fn final_product_coordinates_do_not_adopt_the_temporary_namespace() { + assert_eq!(DATABASE_QUALIFIER, "org"); + assert_eq!(DATABASE_ORGANIZATION, "harvestcircle"); + assert_eq!(DATABASE_APPLICATION, "desktop"); + assert_eq!(DATABASE_FILENAME, "harvestcircle.sqlite3"); + assert_eq!(CREDENTIAL_SERVICE, "org.harvestcircle.desktop.nostr"); + + let current = ProjectDirs::from( + DATABASE_QUALIFIER, + DATABASE_ORGANIZATION, + DATABASE_APPLICATION, + ) + .expect("current product coordinates"); + let temporary = + ProjectDirs::from("org", "radroots", "harvestcircle").expect("temporary coordinates"); + assert_ne!(current.data_dir(), temporary.data_dir()); assert_eq!(CURRENT_SCHEMA_VERSION, 10); - assert_eq!(property("ffi.contract"), Some("legacy-unversioned-v1")); - assert_eq!(property("ffi.snapshot.schema"), Some("1")); - assert_eq!(property("ffi.runtime.version"), Some("0.1.0-alpha")); - assert_eq!(property("database.qualifier"), Some(DATABASE_QUALIFIER)); - assert_eq!( - property("database.organization"), - Some(DATABASE_ORGANIZATION) - ); - assert_eq!(property("database.application"), Some(DATABASE_APPLICATION)); - assert_eq!(property("database.filename"), Some(DATABASE_FILENAME)); - assert_eq!(property("keyring.service"), Some(CREDENTIAL_SERVICE)); - assert_eq!( - property("keyring.account"), - Some("canonical-lowercase-public-key-hex") - ); } #[test] diff --git a/core/crates/harvestcircle_ffi/uniffi.toml b/core/crates/harvestcircle_ffi/uniffi.toml @@ -1,3 +1,3 @@ [crates.harvestcircle_ffi.bindings.kotlin] -package_name = "org.radroots.harvestcircle.ffi" +package_name = "org.harvestcircle.ffi" cdylib_name = "harvestcircle_ffi" diff --git a/core/crates/harvestcircle_product/build.rs b/core/crates/harvestcircle_product/build.rs @@ -34,6 +34,17 @@ fn main() { "pub const PRODUCT_COORDINATE_DIGEST: &str = {digest:?};" ) .expect("write coordinate digest"); + writeln!( + generated, + "pub const DEVELOPMENT_DATA_DIR_ENVIRONMENT: &str = {:?};", + format!( + "{}DEVELOPMENT_DATA_DIR", + coordinates + .get("environment.prefix") + .expect("environment prefix") + ) + ) + .expect("write derived development environment coordinate"); fs::write(out_file("product_coordinates.rs"), generated) .expect("write generated product coordinates"); diff --git a/core/crates/harvestcircle_product/src/lib.rs b/core/crates/harvestcircle_product/src/lib.rs @@ -9,8 +9,8 @@ mod parser; mod tests { use super::parser::{REQUIRED, parse}; use super::{ - DESKTOP_APPLICATION_ID, FFI_CDYLIB_NAME, KOTLIN_ROOT_NAMESPACE, PRODUCT_COORDINATE_DIGEST, - PRODUCT_NAME, + DESKTOP_APPLICATION_ID, DEVELOPMENT_DATA_DIR_ENVIRONMENT, FFI_CDYLIB_NAME, + KOTLIN_ROOT_NAMESPACE, PRODUCT_COORDINATE_DIGEST, PRODUCT_NAME, }; #[test] @@ -19,6 +19,10 @@ mod tests { assert_eq!(KOTLIN_ROOT_NAMESPACE, "org.harvestcircle"); assert_eq!(DESKTOP_APPLICATION_ID, "org.harvestcircle.desktop"); assert_eq!(FFI_CDYLIB_NAME, "harvestcircle_ffi"); + assert_eq!( + DEVELOPMENT_DATA_DIR_ENVIRONMENT, + "HARVESTCIRCLE_DEVELOPMENT_DATA_DIR" + ); assert_eq!(PRODUCT_COORDINATE_DIGEST.len(), 64); assert!( PRODUCT_COORDINATE_DIGEST diff --git a/core/crates/harvestcircle_storage/Cargo.toml b/core/crates/harvestcircle_storage/Cargo.toml @@ -16,6 +16,7 @@ fs2 = "=0.4.3" keyring = "=4.1.6" harvestcircle_application.workspace = true harvestcircle_domain.workspace = true +harvestcircle_product.workspace = true refinery = { version = "=0.9.2", default-features = false, features = ["rusqlite"] } getrandom.workspace = true hmac.workspace = true diff --git a/core/crates/harvestcircle_storage/src/os_keyring.rs b/core/crates/harvestcircle_storage/src/os_keyring.rs @@ -2,10 +2,11 @@ use std::sync::{Mutex, MutexGuard}; use harvestcircle_application::SecretStore; use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput}; +use harvestcircle_product::KEYRING_SERVICE; use keyring::{Entry, Error as KeyringError}; use zeroize::Zeroizing; -pub const CREDENTIAL_SERVICE: &str = "org.radroots.harvestcircle.nostr"; +pub const CREDENTIAL_SERVICE: &str = KEYRING_SERVICE; #[derive(Default)] pub struct OsKeyringSecretStore { @@ -109,7 +110,7 @@ mod tests { let public_key = PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") .expect("valid public key"); - assert_eq!(CREDENTIAL_SERVICE, "org.radroots.harvestcircle.nostr"); + assert_eq!(CREDENTIAL_SERVICE, "org.harvestcircle.desktop.nostr"); assert_eq!( public_key.to_hex(), "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" diff --git a/core/crates/harvestcircle_storage/src/repair.rs b/core/crates/harvestcircle_storage/src/repair.rs @@ -3,6 +3,7 @@ use std::io::Read; use std::path::{Path, PathBuf}; use harvestcircle_domain::{SafeError, SafeErrorCode, SafeMessage}; +use harvestcircle_product::DATABASE_FILENAME; use hmac::{Hmac, Mac}; use rusqlite::{Connection, MAIN_DB, OpenFlags}; use sha2::{Digest, Sha256}; @@ -142,7 +143,7 @@ pub(crate) fn install_candidate( return Err(storage_error()); } copy_secure(&candidate.path, &replacement)?; - let retained = parent.join("harvestcircle.sqlite3.quarantined-evidence"); + let retained = parent.join(format!("{DATABASE_FILENAME}.quarantined-evidence")); if retained.try_exists().map_err(|_| storage_error())? { let _ = fs::remove_file(&replacement); return Err(storage_error()); diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml @@ -1637,6 +1637,14 @@ <sha256 value="f1557c630526feb21dcc612414c5a980c30e9f7d4f35a71be7c052ace63cf576" origin="Generated by Gradle"/> </artifact> </component> + <component group="org.jetbrains.compose" name="gradle-plugin-internal-jdk-version-probe" version="1.11.1"> + <artifact name="gradle-plugin-internal-jdk-version-probe-1.11.1.jar"> + <sha256 value="c0d2d33d16f175f7c95efe56ee054c7a77ff8a2d9da5714bbe894399b648729a" origin="Generated by Gradle"/> + </artifact> + <artifact name="gradle-plugin-internal-jdk-version-probe-1.11.1.module"> + <sha256 value="fb4cc805477abaa08c0b639c20c765746559e075c6acd9eee17e805d14e48e77" origin="Generated by Gradle"/> + </artifact> + </component> <component group="org.jetbrains.compose" name="org.jetbrains.compose.gradle.plugin" version="1.11.1"> <artifact name="org.jetbrains.compose.gradle.plugin-1.11.1.pom"> <sha256 value="0431e75ce5eb64c162a2dae56e2c374b7acd54565a52c1f494d119d90234213f" origin="Generated by Gradle"/>