app

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

commit 5ed105f04deff637c9f57cb0bcbc82565d7a0590
parent 54ff27a33a8526c10e74058493483a105575155d
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 00:30:38 +0000

build: govern the complete local lifecycle

- route Rust and Kotlin formatting, analysis, tests, and builds through Make
- pin Compose-aware ktlint and Detekt quality gates for authored sources
- verify bindings and release libraries while excluding generated code from lint
- clean both build systems and preserve coroutine cancellation semantics

Diffstat:
MMakefile | 17++++++++++++-----
Mapp/desktop/build.gradle.kts | 103++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt | 109++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 196++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedRecoveryController.kt | 12++++++++----
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/NativeCompatibility.kt | 22+++++++++++-----------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 63++++++++++++++++++++++++++++++++-------------------------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/SecretClipboardController.kt | 26++++++++++++++------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 338+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt | 103++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/desktop/Main.kt | 39+++++++++++++++++++++------------------
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt | 85+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt | 548++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/NativeCompatibilityTest.kt | 15++++++++-------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt | 46+++++++++++++++++++++++++++-------------------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/SecretClipboardControllerTest.kt | 125+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 394++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioCoreGatewayTest.kt | 5+++--
Mapp/desktop/src/test/kotlin/org/radroots/studio/architecture/NostrOnlySourceGuardTest.kt | 51+++++++++++++++++++++++++++------------------------
Mapp/desktop/src/test/kotlin/org/radroots/studio/architecture/V5CompatibilityBaselineTest.kt | 7++++---
Aconfig/detekt/detekt.yml | 26++++++++++++++++++++++++++
Mgradle/libs.versions.toml | 4++++
22 files changed, 1299 insertions(+), 1035 deletions(-)

diff --git a/Makefile b/Makefile @@ -4,10 +4,10 @@ GRADLE ?= ./gradlew CARGO ?= cargo CARGO_MANIFEST := core/Cargo.toml -.PHONY: help doctor format lint test check build bindings dev run package clean +.PHONY: help doctor format format-fix lint test check build bindings dev run package clean help: - @printf '%s\n' doctor format lint test check build bindings dev run package clean + @printf '%s\n' doctor format format-fix lint test check build bindings dev run package clean doctor: java -version @@ -16,9 +16,15 @@ doctor: format: doctor $(CARGO) fmt --manifest-path $(CARGO_MANIFEST) --all -- --check + $(GRADLE) --no-daemon :app:desktop:ktlintCheck + +format-fix: doctor + $(CARGO) fmt --manifest-path $(CARGO_MANIFEST) --all + $(GRADLE) --no-daemon :app:desktop:ktlintFormat lint: doctor $(CARGO) clippy --manifest-path $(CARGO_MANIFEST) --workspace --all-targets -- -D warnings + $(GRADLE) --no-daemon :app:desktop:detekt test: doctor $(CARGO) test --manifest-path $(CARGO_MANIFEST) --workspace @@ -32,7 +38,7 @@ build: doctor $(GRADLE) --no-daemon :app:desktop:build bindings: doctor - $(GRADLE) --no-daemon :app:desktop:generateUniFfiKotlin :app:desktop:stageReleaseNativeLibrary + $(GRADLE) --no-daemon :app:desktop:verifyUniFfiBindings :app:desktop:verifyReleaseNativeLibrary dev: doctor $(GRADLE) :app:desktop:hotRun --mainClass org.radroots.studio.desktop.MainKt @@ -40,8 +46,9 @@ dev: doctor run: doctor $(GRADLE) :app:desktop:run -package: doctor +package: check $(GRADLE) --no-daemon :app:desktop:packageDistributionForCurrentOS clean: doctor - $(GRADLE) --no-daemon :app:desktop:clean + $(CARGO) clean --manifest-path $(CARGO_MANIFEST) + $(GRADLE) --no-daemon clean diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -1,21 +1,21 @@ -import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property +import org.gradle.api.tasks.Delete +import org.gradle.api.tasks.Exec import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.Exec -import org.gradle.api.tasks.Delete import org.gradle.api.tasks.PathSensitive 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.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.kotlin.gradle.dsl.JvmTarget version = "0.1.0-alpha" @@ -24,24 +24,49 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.compiler) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) } -val rustManifest = rootProject.layout.projectDirectory.file("core/Cargo.toml") -val rustSources = rootProject.fileTree("core") { - include( - "**/Cargo.toml", - "Cargo.lock", - "rust-toolchain.toml", - "**/*.rs", - "**/*.sql", - "**/uniffi.toml", - "compatibility/**", +configure<org.jlleitschuh.gradle.ktlint.KtlintExtension> { + additionalEditorconfig.set( + mapOf( + "ktlint_function_naming_ignore_when_annotated_with" to "Composable", + ), ) - exclude("target/**") + filter { + exclude { it.file.path.contains("/build/generated/") } + } +} + +tasks.withType<org.jlleitschuh.gradle.ktlint.tasks.BaseKtLintCheckTask>().configureEach { + exclude { it.file.path.contains("/build/generated/") } } -data class NativeTarget(val libraryName: String, val jnaPrefix: String) -fun resolveNativeTarget(osName: String, architecture: String): NativeTarget { +val rustManifest = rootProject.layout.projectDirectory.file("core/Cargo.toml") +val rustSources = + rootProject.fileTree("core") { + include( + "**/Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + "**/*.rs", + "**/*.sql", + "**/uniffi.toml", + "compatibility/**", + ) + exclude("target/**") + } + +data class NativeTarget( + val libraryName: String, + val jnaPrefix: String, +) + +fun resolveNativeTarget( + osName: String, + architecture: String, +): NativeTarget { val os = osName.lowercase() val arch = architecture.lowercase() return when { @@ -61,10 +86,11 @@ fun resolveNativeTarget(osName: String, architecture: String): NativeTarget { } } -val nativeTarget = resolveNativeTarget( - providers.gradleProperty("nativeOs").getOrElse(System.getProperty("os.name")), - providers.gradleProperty("nativeArch").getOrElse(System.getProperty("os.arch")), -) +val nativeTarget = + resolveNativeTarget( + providers.gradleProperty("nativeOs").getOrElse(System.getProperty("os.name")), + providers.gradleProperty("nativeArch").getOrElse(System.getProperty("os.arch")), + ) val rustLibraryName = nativeTarget.libraryName val rustDebugLibrary = rootProject.layout.projectDirectory.file("core/target/debug/$rustLibraryName") val rustReleaseLibrary = rootProject.layout.projectDirectory.file("core/target/release/$rustLibraryName") @@ -133,6 +159,7 @@ val generateUniFfiKotlin by tasks.registering(Exec::class) { inputs.file(rootProject.layout.projectDirectory.file("core/crates/ffi/uniffi.toml")) outputs.dir(generatedUniFfiKotlin) } + abstract class VerifyUniFfiBindings : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) @@ -143,8 +170,9 @@ abstract class VerifyUniFfiBindings : DefaultTask() { @TaskAction fun verify() { - val kotlinFiles = generatedDirectory.asFileTree.files - .filter { it.isFile && it.extension == "kt" } + val kotlinFiles = + generatedDirectory.asFileTree.files + .filter { it.isFile && it.extension == "kt" } if (kotlinFiles.size != 1) { throw GradleException("Expected exactly one generated UniFFI Kotlin source") } @@ -166,6 +194,7 @@ val stageReleaseNativeLibrary by tasks.registering(Copy::class) { from(rustReleaseLibrary) into(generatedReleaseNativeResources.map { it.dir(jnaPlatformPrefix) }) } + abstract class VerifyReleaseNativeLibrary : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.NONE) @@ -215,12 +244,15 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) } -val testInventoryRoot = providers - .gradleProperty("testInventoryRoot") - .orElse("src/test/kotlin") -val expectedTests = fileTree(testInventoryRoot.get()) { - include("**/*Test.kt") -} +val testInventoryRoot = + providers + .gradleProperty("testInventoryRoot") + .orElse("src/test/kotlin") +val expectedTests = + fileTree(testInventoryRoot.get()) { + include("**/*Test.kt") + } + abstract class VerifyTestInventory : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) @@ -261,6 +293,12 @@ kotlin { tasks.named("compileKotlin") { dependsOn(generateUniFfiKotlin) } +tasks.named("runKtlintCheckOverMainSourceSet") { + dependsOn(generateUniFfiKotlin) +} +tasks.named("runKtlintFormatOverMainSourceSet") { + dependsOn(generateUniFfiKotlin) +} tasks.withType<Test>().configureEach { dependsOn(buildRustCoreDebug) systemProperty( @@ -283,10 +321,11 @@ compose.desktop { fromFiles(releaseNativeResourcesJar) mainClass = "org.radroots.studio.desktop.MainKt" - jvmArgs += listOf( - "-Dapple.awt.application.name=Radroots", - "-Dapple.awt.application.appearance=system", - ) + jvmArgs += + listOf( + "-Dapple.awt.application.name=Radroots", + "-Dapple.awt.application.appearance=system", + ) nativeDistributions { targetFormats(TargetFormat.Dmg) diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/AccountsUiModel.kt @@ -1,10 +1,10 @@ package org.radroots.studio.accounts.ui -import org.radroots.studio.application.StudioStoreState -import org.radroots.studio.application.StudioRoute import org.radroots.studio.application.AccountEntryMode import org.radroots.studio.application.RemovalImpactState import org.radroots.studio.application.RemovalStatus +import org.radroots.studio.application.StudioRoute +import org.radroots.studio.application.StudioStoreState import org.radroots.studio.ffi.AccountDto import org.radroots.studio.ffi.ActiveAccountDto import org.radroots.studio.ffi.ProfileLoadStateDto @@ -69,21 +69,23 @@ data class StudioUiModel( fun StudioStoreState.toUiModel(): StudioUiModel { val selectedPublicKeyHex = snapshot.selectedPublicKeyHex val activePublicKeyHex = snapshot.activeAccount?.account?.publicKeyHex - val accounts = snapshot.accounts.map { - it.toUiModel( - selected = it.publicKeyHex == selectedPublicKeyHex, - active = it.publicKeyHex == activePublicKeyHex, - ) - } + val accounts = + snapshot.accounts.map { + it.toUiModel( + selected = it.publicKeyHex == selectedPublicKeyHex, + active = it.publicKeyHex == activePublicKeyHex, + ) + } return StudioUiModel( route = route, accounts = accounts, activeAccount = snapshot.activeAccount?.toUiModel(selectedPublicKeyHex), configuredRelays = snapshot.configuredRelays, importDraft = importDraft, - generatedKeyBackup = generatedKeyBackup?.let { - GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.revealNsec()) - }, + generatedKeyBackup = + generatedKeyBackup?.let { + GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.revealNsec()) + }, pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex, removalImpact = removalImpact, removalStatus = removalStatus, @@ -92,10 +94,11 @@ fun StudioStoreState.toUiModel(): StudioUiModel { accountEntryMode = accountEntryMode, session = snapshot.session, busy = busy, - problem = problem - ?: snapshot.recoverableProblem?.message - ?: snapshot.sessionError?.message - ?: snapshot.lifecycleError?.message, + problem = + problem + ?: snapshot.recoverableProblem?.message + ?: snapshot.sessionError?.message + ?: snapshot.lifecycleError?.message, importGuidance = importGuidance(lastFailureCode, recoveryAction), recoveryAction = recoveryAction, ) @@ -104,18 +107,30 @@ fun StudioStoreState.toUiModel(): StudioUiModel { 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 -} +): 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 <= 24) npub else "${npub.take(14)}…${npub.takeLast(8)}" + 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( +private fun AccountDto.toUiModel( + selected: Boolean, + active: Boolean = false, +) = AccountUiModel( publicKeyHex = publicKeyHex, npub = npub, shortNpub = shortenNpub(npub), @@ -125,27 +140,29 @@ private fun AccountDto.toUiModel(selected: Boolean, active: Boolean = false) = A 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 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 RelayConnectionStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') -private fun ProfileLoadStateDto.toDisplayText(): String = - name.lowercase().replace('_', ' ') +private fun ProfileLoadStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt @@ -11,9 +11,9 @@ 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.verticalScroll 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 @@ -31,8 +31,8 @@ import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp -import org.radroots.studio.application.StudioRoute import org.radroots.studio.application.AccountEntryMode +import org.radroots.studio.application.StudioRoute private val WindowBackgroundColor = Color(0xFFF5F5F2) private val ButtonBackgroundColor = Color(0xFFE7E7E2) @@ -63,12 +63,13 @@ data class StudioUiActions( @Composable fun StartupFailureScreen(problem: String) { Column( - modifier = Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("startup-failure"), + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("startup-failure"), verticalArrangement = Arrangement.spacedBy(16.dp), ) { BasicText("radroots") @@ -87,32 +88,38 @@ fun StudioScreen( } when (model.route) { StudioRoute.OPENING -> LifecycleScreen("Opening local account store", "lifecycle-opening") - StudioRoute.CHECKING_COMPATIBILITY -> LifecycleScreen( - "Checking native compatibility", - "lifecycle-compatibility", - ) - StudioRoute.ACQUIRING_OWNERSHIP -> LifecycleScreen( - "Acquiring local account store", - "lifecycle-ownership", - ) - StudioRoute.MIGRATING -> LifecycleScreen( - "Updating local account store", - "lifecycle-migrating", - ) - StudioRoute.RECOVERING -> LifecycleScreen( - "Recovering local account state", - "lifecycle-recovering", - ) + StudioRoute.CHECKING_COMPATIBILITY -> + LifecycleScreen( + "Checking native compatibility", + "lifecycle-compatibility", + ) + StudioRoute.ACQUIRING_OWNERSHIP -> + LifecycleScreen( + "Acquiring local account store", + "lifecycle-ownership", + ) + StudioRoute.MIGRATING -> + LifecycleScreen( + "Updating local account store", + "lifecycle-migrating", + ) + StudioRoute.RECOVERING -> + LifecycleScreen( + "Recovering local account state", + "lifecycle-recovering", + ) StudioRoute.SHUTTING_DOWN -> LifecycleScreen("Shutting down", "lifecycle-shutting-down") StudioRoute.CLOSED -> LifecycleScreen("Closed", "lifecycle-closed") - StudioRoute.BLOCKED -> LifecycleScreen( - model.problem ?: "Local account access is blocked.", - "lifecycle-blocked", - ) - StudioRoute.FATAL -> LifecycleScreen( - model.problem ?: "The application could not continue.", - "lifecycle-fatal", - ) + StudioRoute.BLOCKED -> + LifecycleScreen( + model.problem ?: "Local account access is blocked.", + "lifecycle-blocked", + ) + StudioRoute.FATAL -> + LifecycleScreen( + model.problem ?: "The application could not continue.", + "lifecycle-fatal", + ) StudioRoute.DEGRADED -> InactiveAccountsScreen(model, actions, degraded = true) StudioRoute.ACTIVE_ACCOUNT -> { if (model.activeAccount != null && !model.accountChooserVisible) { @@ -126,13 +133,17 @@ fun StudioScreen( } @Composable -private fun LifecycleScreen(message: String, testTag: String) { +private fun LifecycleScreen( + message: String, + testTag: String, +) { Column( - modifier = Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag(testTag), + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag(testTag), verticalArrangement = Arrangement.spacedBy(16.dp), ) { BasicText("radroots") @@ -147,12 +158,13 @@ private fun ActiveAccountHome( actions: StudioUiActions, ) { Column( - modifier = Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("home-screen"), + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("home-screen"), verticalArrangement = Arrangement.spacedBy(10.dp), ) { BasicText("radroots") @@ -205,11 +217,12 @@ private fun InactiveAccountsScreen( degraded: Boolean = false, ) { Column( - modifier = Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .testTag("accounts-screen"), + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .testTag("accounts-screen"), verticalArrangement = Arrangement.spacedBy(16.dp), ) { BasicText("radroots") @@ -244,7 +257,10 @@ private fun InactiveAccountsScreen( } @Composable -private fun RecoveryAction(model: StudioUiModel, actions: StudioUiActions) { +private fun RecoveryAction( + model: StudioUiModel, + actions: StudioUiActions, +) { if (model.recoveryAction == org.radroots.studio.ffi.WireRecoveryAction.RETRY) { TextAction( text = "Retry", @@ -257,7 +273,10 @@ private fun RecoveryAction(model: StudioUiModel, actions: StudioUiActions) { } @Composable -private fun AccountEntry(model: StudioUiModel, actions: StudioUiActions) { +private fun AccountEntry( + model: StudioUiModel, + actions: StudioUiActions, +) { when (model.accountEntryMode) { AccountEntryMode.CHOICE -> { TextAction( @@ -306,16 +325,16 @@ private fun AccountEntry(model: StudioUiModel, actions: StudioUiActions) { 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), + 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() @@ -341,20 +360,22 @@ private fun ColumnScope.SavedAccountList( actions: StudioUiActions, ) { LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .testTag("saved-account-list"), + 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), + 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) @@ -416,12 +437,13 @@ private fun GeneratedKeyRecoveryScreen( actions: StudioUiActions, ) { Column( - modifier = Modifier - .fillMaxSize() - .background(WindowBackgroundColor) - .padding(24.dp) - .verticalScroll(rememberScrollState()) - .testTag("generated-key-backup"), + modifier = + Modifier + .fillMaxSize() + .background(WindowBackgroundColor) + .padding(24.dp) + .verticalScroll(rememberScrollState()) + .testTag("generated-key-backup"), verticalArrangement = Arrangement.spacedBy(8.dp), ) { BasicText("Save this key") @@ -459,15 +481,15 @@ internal fun TextAction( ) { 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), + modifier = + Modifier + .semantics { + role = Role.Button + this.contentDescription = contentDescription + if (!enabled) disabled() + }.testTag(testTag) + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .background(ButtonBackgroundColor) + .padding(8.dp), ) } diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedRecoveryController.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/GeneratedRecoveryController.kt @@ -6,9 +6,10 @@ class GeneratedKeyBackup internal constructor( ) { private var recoveryText: String? = nsec - internal fun revealNsec(): String = checkNotNull(recoveryText) { - "Generated recovery material is no longer available" - } + internal fun revealNsec(): String = + checkNotNull(recoveryText) { + "Generated recovery material is no longer available" + } internal fun clear() { recoveryText = null @@ -20,7 +21,10 @@ class GeneratedKeyBackup internal constructor( internal class GeneratedRecoveryController : AutoCloseable { private var active: GeneratedKeyBackup? = null - fun begin(npub: String, nsec: String): GeneratedKeyBackup { + fun begin( + npub: String, + nsec: String, + ): GeneratedKeyBackup { check(active == null) { "Generated-key recovery is already active" } return GeneratedKeyBackup(npub, nsec).also { active = it } } diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/NativeCompatibility.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/NativeCompatibility.kt @@ -9,18 +9,18 @@ internal val MINIMUM_FFI_CONTRACT_MINOR: UShort = 0.toUShort() internal const val MINIMUM_STORAGE_SCHEMA: UInt = 5U internal const val MAXIMUM_STORAGE_SCHEMA: UInt = 9U -internal class NativeCompatibilityException : IllegalStateException( - "The application and native runtime are incompatible.", -) +internal class NativeCompatibilityException : + IllegalStateException( + "The application and native runtime are incompatible.", + ) -internal fun verifyNativeCompatibility( - descriptor: CompatibilityDescriptor, -): CompatibilityExpectation { - val compatible = 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 +internal fun verifyNativeCompatibility(descriptor: CompatibilityDescriptor): CompatibilityExpectation { + val compatible = + 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, diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt @@ -5,9 +5,9 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.CoroutineScope +import org.radroots.studio.accounts.ui.StartupFailureScreen import org.radroots.studio.accounts.ui.StudioScreen import org.radroots.studio.accounts.ui.StudioUiActions -import org.radroots.studio.accounts.ui.StartupFailureScreen import org.radroots.studio.accounts.ui.toUiModel import org.radroots.studio.ffi.StudioAppCore import org.radroots.studio.ffi.StudioException @@ -16,16 +16,15 @@ import org.radroots.studio.ffi.compatibilityDescriptor internal typealias StudioStoreFactory = (CoroutineScope) -> StudioAppStore @Composable -fun RadrootsApplication( - storeFactory: StudioStoreFactory = ::createStudioAppStore, -) { +fun RadrootsApplication(storeFactory: StudioStoreFactory = ::createStudioAppStore) { val scope = rememberCoroutineScope() val storeResult = remember { runCatching { storeFactory(scope) } } val store = storeResult.getOrNull() if (store == null) { val error = storeResult.exceptionOrNull() - val message = (error as? StudioException.Failure)?.safeMessage - ?: "The application could not start." + val message = + (error as? StudioException.Failure)?.safeMessage + ?: "The application could not start." StartupFailureScreen(message) return } @@ -40,36 +39,38 @@ fun RadrootsApplication( StudioScreen( model = store.state.value.toUiModel(), - actions = StudioUiActions( - chooseCreateAccount = store::chooseCreateAccount, - chooseImportAccount = store::chooseImportAccount, - cancelAccountEntry = store::cancelAccountEntry, - editImportDraft = store::editImportDraft, - generateAccount = store::generateAccount, - importSecretKey = store::importSecretKey, - copyText = { value -> clipboard.copy(value) }, - acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, - cancelGeneratedKeyBackup = store::cancelGeneratedKeyBackup, - selectAccount = store::selectAccount, - activateAccount = store::activateAccount, - requestAccountRemoval = store::requestAccountRemoval, - cancelAccountRemoval = store::cancelAccountRemoval, - confirmAccountRemoval = store::confirmAccountRemoval, - refreshActiveProfile = store::refreshActiveProfile, - retryLastCommand = store::retryLastCommand, - signOut = store::signOut, - showAccountChooser = store::showAccountChooser, - hideAccountChooser = store::hideAccountChooser, - ), + actions = + StudioUiActions( + chooseCreateAccount = store::chooseCreateAccount, + chooseImportAccount = store::chooseImportAccount, + cancelAccountEntry = store::cancelAccountEntry, + editImportDraft = store::editImportDraft, + generateAccount = store::generateAccount, + importSecretKey = store::importSecretKey, + copyText = { value -> clipboard.copy(value) }, + acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, + cancelGeneratedKeyBackup = store::cancelGeneratedKeyBackup, + selectAccount = store::selectAccount, + activateAccount = store::activateAccount, + requestAccountRemoval = store::requestAccountRemoval, + cancelAccountRemoval = store::cancelAccountRemoval, + confirmAccountRemoval = store::confirmAccountRemoval, + refreshActiveProfile = store::refreshActiveProfile, + retryLastCommand = store::retryLastCommand, + signOut = store::signOut, + showAccountChooser = store::showAccountChooser, + hideAccountChooser = store::hideAccountChooser, + ), ) } internal fun createStudioAppStore(scope: CoroutineScope): StudioAppStore { val developmentMode = java.lang.Boolean.getBoolean("radroots.studio.development") val descriptor = compatibilityDescriptor() - val core = StudioAppCore.openCompatible( - expectation = verifyNativeCompatibility(descriptor), - developmentMode = developmentMode, - ) + val core = + StudioAppCore.openCompatible( + expectation = verifyNativeCompatibility(descriptor), + developmentMode = developmentMode, + ) return StudioAppStore(NativeStudioCoreGateway(core), scope) } diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/SecretClipboardController.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/SecretClipboardController.kt @@ -1,12 +1,12 @@ package org.radroots.studio.application -import java.awt.Toolkit -import java.awt.datatransfer.DataFlavor -import java.awt.datatransfer.StringSelection 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? @@ -34,13 +34,14 @@ internal class SecretClipboardController( } copiedValue = value clearJob?.cancel() - clearJob = scope.launch { - delay(clearDelayMillis) - runCatching { - if (clipboard.readText() == value) clipboard.writeText("") + clearJob = + scope.launch { + delay(clearDelayMillis) + runCatching { + if (clipboard.readText() == value) clipboard.writeText("") + } + if (copiedValue == value) copiedValue = null } - if (copiedValue == value) copiedValue = null - } return SecretClipboardResult.Copied } @@ -59,9 +60,10 @@ 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 readText(): String? = + runCatching { + clipboard.getData(DataFlavor.stringFlavor) as? String + }.getOrNull() override fun writeText(value: String) { clipboard.setContents(StringSelection(value), null) diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt @@ -2,11 +2,12 @@ package org.radroots.studio.application import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import org.radroots.studio.ffi.AppSnapshotDto import org.radroots.studio.ffi.AppLifecycleDto +import org.radroots.studio.ffi.AppSnapshotDto import org.radroots.studio.ffi.StudioException import org.radroots.studio.ffi.WireErrorCode import org.radroots.studio.ffi.WireRecoveryAction @@ -96,11 +97,12 @@ class StudioAppStore( init { launchCommand { - val registered = gateway.subscribeChanges { change -> - scope.launch { - if (!closed) acceptSnapshot(change.snapshot) + val registered = + gateway.subscribeChanges { change -> + scope.launch { + if (!closed) acceptSnapshot(change.snapshot) + } } - } if (closed) { registered.close() return@launchCommand @@ -111,12 +113,13 @@ class StudioAppStore( } fun editImportDraft(value: String) { - mutableState.value = mutableState.value.copy( - importDraft = value.take(MAX_IMPORT_SECRET_CHARS), - lastFailureCode = null, - recoveryAction = WireRecoveryAction.NONE, - problem = null, - ) + mutableState.value = + mutableState.value.copy( + importDraft = value.take(MAX_IMPORT_SECRET_CHARS), + lastFailureCode = null, + recoveryAction = WireRecoveryAction.NONE, + problem = null, + ) } fun chooseCreateAccount() { @@ -128,31 +131,35 @@ class StudioAppStore( } fun cancelAccountEntry() { - mutableState.value = mutableState.value.copy( - accountEntryMode = AccountEntryMode.CHOICE, - importDraft = "", - problem = null, - ) + mutableState.value = + mutableState.value.copy( + accountEntryMode = AccountEntryMode.CHOICE, + importDraft = "", + problem = null, + ) } fun generateAccount() { launchCommand { val recovery = gateway.beginGeneratedAccount() pendingGeneratedRecovery = recovery - mutableState.value = mutableState.value.copy( - generatedKeyBackup = generatedRecovery.begin( - recovery.account.npub, - recovery.takeRecoveryNsec(), - ), - ) + mutableState.value = + mutableState.value.copy( + generatedKeyBackup = + generatedRecovery.begin( + recovery.account.npub, + recovery.takeRecoveryNsec(), + ), + ) } } fun acknowledgeGeneratedKeyBackup() { - val recovery = pendingGeneratedRecovery ?: run { - rejectUnavailableIntent("Generated-key recovery is not available.") - return - } + val recovery = + pendingGeneratedRecovery ?: run { + rejectUnavailableIntent("Generated-key recovery is not available.") + return + } pendingGeneratedRecovery = null runSnapshotCommand { try { @@ -167,10 +174,11 @@ class StudioAppStore( } fun cancelGeneratedKeyBackup() { - val recovery = pendingGeneratedRecovery ?: run { - rejectUnavailableIntent("Generated-key recovery is not available.") - return - } + val recovery = + pendingGeneratedRecovery ?: run { + rejectUnavailableIntent("Generated-key recovery is not available.") + return + } pendingGeneratedRecovery = null launchCommand { try { @@ -215,10 +223,11 @@ class StudioAppStore( } fun retryLastCommand() { - val retry = retryableCommand ?: run { - rejectUnavailableIntent("This action cannot be retried safely.") - return - } + val retry = + retryableCommand ?: run { + rejectUnavailableIntent("This action cannot be retried safely.") + return + } runTypedCommand(retry) } @@ -233,16 +242,18 @@ class StudioAppStore( 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, - ) + mutableState.value = + mutableState.value.copy( + pendingRemovalPublicKeyHex = publicKeyHex, + removalImpact = + RemovalImpactState( + ticket.publicKeyHex, + ticket.deletesLocalCredential, + ticket.signsOut, + ticket.expiresAtSeconds, + ), + removalStatus = RemovalStatus.AWAITING_CONFIRMATION, + ) }.getOrThrow() } } @@ -250,38 +261,42 @@ class StudioAppStore( fun cancelAccountRemoval() { pendingRemoval?.close() pendingRemoval = null - mutableState.value = mutableState.value.copy( - pendingRemovalPublicKeyHex = null, - removalImpact = null, - removalStatus = RemovalStatus.NONE, - ) + 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 - } + 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, - ) + 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, - ) + mutableState.value = + mutableState.value.copy( + pendingRemovalPublicKeyHex = null, + removalImpact = null, + removalStatus = RemovalStatus.FAILED, + ) } } } @@ -295,37 +310,44 @@ class StudioAppStore( launchCommand { acceptSnapshot(operation()) } } - private fun runTypedCommand(command: StudioCommand, hideChooser: Boolean = false) { + private fun runTypedCommand( + command: StudioCommand, + hideChooser: Boolean = false, + ) { launchCommand { when (val result = gateway.execute(command)) { is StudioCommandResult.Accepted -> { retryableCommand = null acceptSnapshot(result.receipt.snapshot) - mutableState.value = mutableState.value.copy( - commandStatus = CommandStatus.ACCEPTED, - lastCommandRequestId = result.receipt.requestId, - lastFailureCode = null, - recoveryAction = WireRecoveryAction.NONE, - ) + mutableState.value = + mutableState.value.copy( + commandStatus = CommandStatus.ACCEPTED, + lastCommandRequestId = result.receipt.requestId, + lastFailureCode = null, + recoveryAction = WireRecoveryAction.NONE, + ) if (hideChooser) { mutableState.value = mutableState.value.copy(accountChooserVisible = false) } } is StudioCommandResult.Rejected -> { - retryableCommand = command.takeIf { - result.failure.retryable && it !is StudioCommand.ImportAccount - } - mutableState.value = mutableState.value.copy( - commandStatus = if (result.failure.retryable) { - CommandStatus.FAILED_RETRYABLE - } else { - CommandStatus.FAILED_TERMINAL - }, - lastCommandRequestId = result.failure.correlationId, - lastFailureCode = result.failure.code, - recoveryAction = result.failure.recoveryAction, - problem = result.failure.safeMessage, - ) + retryableCommand = + command.takeIf { + result.failure.retryable && it !is StudioCommand.ImportAccount + } + mutableState.value = + mutableState.value.copy( + commandStatus = + if (result.failure.retryable) { + CommandStatus.FAILED_RETRYABLE + } else { + CommandStatus.FAILED_TERMINAL + }, + lastCommandRequestId = result.failure.correlationId, + lastFailureCode = result.failure.code, + recoveryAction = result.failure.recoveryAction, + problem = result.failure.safeMessage, + ) } } } @@ -333,80 +355,91 @@ class StudioAppStore( 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) + 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) } - } catch (error: Throwable) { - 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.", - ) + 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.", - ) + mutableState.value = + mutableState.value.copy( + commandStatus = CommandStatus.REJECTED_BUSY, + problem = "The application is busy. Try again.", + ) return true } if (mutableState.value.route !in setOf(StudioRoute.ACCOUNTS, StudioRoute.ACTIVE_ACCOUNT)) { - mutableState.value = mutableState.value.copy( - commandStatus = CommandStatus.FAILED_TERMINAL, - problem = "The application runtime is not ready for this action.", - ) + 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, - ) + mutableState.value = + mutableState.value.copy( + commandStatus = if (closed) CommandStatus.REJECTED_CLOSED else CommandStatus.FAILED_TERMINAL, + problem = message, + ) } private fun acceptSnapshot(snapshot: AppSnapshotDto) { if (snapshot.revision >= mutableState.value.snapshot.revision) { - mutableState.value = mutableState.value.copy( - snapshot = snapshot, - route = snapshot.toStudioRoute(), - ) + mutableState.value = + mutableState.value.copy( + snapshot = snapshot, + route = snapshot.toStudioRoute(), + ) } } private fun acceptFailure(error: Throwable) { val native = error as? StudioException.Failure - mutableState.value = mutableState.value.copy( - busy = false, - commandStatus = if (native?.retryable == true) { - CommandStatus.FAILED_RETRYABLE - } else { - CommandStatus.FAILED_TERMINAL - }, - lastCommandRequestId = native?.correlationId, - lastFailureCode = native?.code, - recoveryAction = native?.recoveryAction ?: WireRecoveryAction.NONE, - problem = native?.safeMessage ?: "The application command failed.", - ) + mutableState.value = + mutableState.value.copy( + busy = false, + commandStatus = + if (native?.retryable == true) { + CommandStatus.FAILED_RETRYABLE + } else { + CommandStatus.FAILED_TERMINAL + }, + lastCommandRequestId = native?.correlationId, + lastFailureCode = native?.code, + recoveryAction = native?.recoveryAction ?: WireRecoveryAction.NONE, + problem = native?.safeMessage ?: "The application command failed.", + ) } override fun close() { @@ -419,29 +452,30 @@ class StudioAppStore( generatedRecovery.close() runCatching { gateway.shutdown() } .onSuccess { receipt -> - mutableState.value = mutableState.value.copy( - route = if (receipt.closed) StudioRoute.CLOSED else StudioRoute.FATAL, - busy = false, - problem = if (receipt.closed) null else "The application could not shut down safely.", - ) - } - .onFailure { error -> + mutableState.value = + mutableState.value.copy( + route = if (receipt.closed) StudioRoute.CLOSED else StudioRoute.FATAL, + busy = false, + problem = if (receipt.closed) null else "The application could not shut down safely.", + ) + }.onFailure { error -> acceptFailure(error) mutableState.value = mutableState.value.copy(route = StudioRoute.FATAL, busy = false) } } } -internal fun AppSnapshotDto.toStudioRoute(): StudioRoute = when (lifecycle) { - AppLifecycleDto.OPENING -> StudioRoute.OPENING - AppLifecycleDto.COMPATIBILITY_CHECKING -> StudioRoute.CHECKING_COMPATIBILITY - AppLifecycleDto.ACQUIRING_OWNERSHIP -> StudioRoute.ACQUIRING_OWNERSHIP - AppLifecycleDto.MIGRATING -> StudioRoute.MIGRATING - AppLifecycleDto.RECOVERING -> StudioRoute.RECOVERING - AppLifecycleDto.READY -> if (activeAccount != null) StudioRoute.ACTIVE_ACCOUNT else StudioRoute.ACCOUNTS - AppLifecycleDto.DEGRADED -> StudioRoute.DEGRADED - AppLifecycleDto.BLOCKED -> StudioRoute.BLOCKED - AppLifecycleDto.SHUTTING_DOWN -> StudioRoute.SHUTTING_DOWN - AppLifecycleDto.CLOSED -> StudioRoute.CLOSED - AppLifecycleDto.FATAL -> StudioRoute.FATAL -} +internal fun AppSnapshotDto.toStudioRoute(): StudioRoute = + when (lifecycle) { + AppLifecycleDto.OPENING -> StudioRoute.OPENING + AppLifecycleDto.COMPATIBILITY_CHECKING -> StudioRoute.CHECKING_COMPATIBILITY + AppLifecycleDto.ACQUIRING_OWNERSHIP -> StudioRoute.ACQUIRING_OWNERSHIP + AppLifecycleDto.MIGRATING -> StudioRoute.MIGRATING + AppLifecycleDto.RECOVERING -> StudioRoute.RECOVERING + AppLifecycleDto.READY -> if (activeAccount != null) StudioRoute.ACTIVE_ACCOUNT else StudioRoute.ACCOUNTS + AppLifecycleDto.DEGRADED -> StudioRoute.DEGRADED + AppLifecycleDto.BLOCKED -> StudioRoute.BLOCKED + AppLifecycleDto.SHUTTING_DOWN -> StudioRoute.SHUTTING_DOWN + AppLifecycleDto.CLOSED -> StudioRoute.CLOSED + AppLifecycleDto.FATAL -> StudioRoute.FATAL + } diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt @@ -1,9 +1,9 @@ package org.radroots.studio.application -import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking -import org.radroots.studio.ffi.AppSnapshotDto import org.radroots.studio.ffi.AccountDto +import org.radroots.studio.ffi.AppSnapshotDto import org.radroots.studio.ffi.GeneratedRecoveryRequest import org.radroots.studio.ffi.ObserverSubscription import org.radroots.studio.ffi.RemovalRequest @@ -15,6 +15,7 @@ import org.radroots.studio.ffi.StudioException import org.radroots.studio.ffi.WireErrorCategory import org.radroots.studio.ffi.WireErrorCode import org.radroots.studio.ffi.WireRecoveryAction +import java.util.concurrent.atomic.AtomicLong interface RemovalTicket : AutoCloseable { val publicKeyHex: String @@ -39,10 +40,20 @@ data class StudioChange( ) sealed interface StudioCommand { - data class ImportAccount(val bytes: ByteArray) : StudioCommand - data class SelectAccount(val publicKeyHex: String) : StudioCommand - data class ActivateAccount(val publicKeyHex: String) : StudioCommand + data class ImportAccount( + val bytes: ByteArray, + ) : StudioCommand + + data class SelectAccount( + val publicKeyHex: String, + ) : StudioCommand + + data class ActivateAccount( + val publicKeyHex: String, + ) : StudioCommand + data object SignOut : StudioCommand + data object RefreshProfile : StudioCommand } @@ -67,8 +78,13 @@ data class StudioShutdownReceipt( ) sealed interface StudioCommandResult { - data class Accepted(val receipt: StudioCommandReceipt) : StudioCommandResult - data class Rejected(val failure: StudioCommandFailure) : StudioCommandResult + data class Accepted( + val receipt: StudioCommandReceipt, + ) : StudioCommandResult + + data class Rejected( + val failure: StudioCommandFailure, + ) : StudioCommandResult } interface StudioCoreGateway : AutoCloseable { @@ -95,37 +111,43 @@ class NativeStudioCoreGateway( private val nextRequest = AtomicLong(1) private val shutdownLock = Any() private var shutdownReceipt: StudioShutdownReceipt? = null + override fun snapshot(): AppSnapshotDto = core.snapshot() override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable { - val subscription = core.subscribeChangesV2( - object : StudioChangeObserver { - override fun onChange(change: SnapshotChangeDto) { - onChange(StudioChange(change.snapshot, change.previousRevision)) - } - }, - ) + val subscription = + core.subscribeChangesV2( + object : StudioChangeObserver { + override fun onChange(change: SnapshotChangeDto) { + onChange(StudioChange(change.snapshot, change.previousRevision)) + } + }, + ) return NativeSubscription(subscription) } override suspend fun execute(command: StudioCommand): StudioCommandResult { val context = requestContext() return try { - val snapshot = when (command) { - is StudioCommand.ImportAccount -> try { - core.importAccountV2(context, command.bytes).snapshot - } finally { - command.bytes.fill(0) + val snapshot = + when (command) { + is StudioCommand.ImportAccount -> + try { + core.importAccountV2(context, command.bytes).snapshot + } finally { + command.bytes.fill(0) + } + is StudioCommand.SelectAccount -> core.selectAccount(command.publicKeyHex) + is StudioCommand.ActivateAccount -> core.activateAccount(command.publicKeyHex) + StudioCommand.SignOut -> core.signOut() + StudioCommand.RefreshProfile -> core.refreshActiveProfile() } - is StudioCommand.SelectAccount -> core.selectAccount(command.publicKeyHex) - is StudioCommand.ActivateAccount -> core.activateAccount(command.publicKeyHex) - StudioCommand.SignOut -> core.signOut() - StudioCommand.RefreshProfile -> core.refreshActiveProfile() - } StudioCommandResult.Accepted( StudioCommandReceipt(context.requestId, snapshot.revision, snapshot), ) - } catch (error: Throwable) { + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { StudioCommandResult.Rejected(error.toStudioCommandFailure(context.requestId)) } } @@ -143,26 +165,28 @@ class NativeStudioCoreGateway( return core.confirmAccountRemoval(ticket.request) } - override fun shutdown(): StudioShutdownReceipt = synchronized(shutdownLock) { - shutdownReceipt ?: run { - val receipt = runBlocking { core.shutdownV2() } - StudioShutdownReceipt(receipt.finalRevision, receipt.closed).also { - check(it.closed) { "Native runtime returned an incomplete shutdown receipt" } - shutdownReceipt = it - core.close() + override fun shutdown(): StudioShutdownReceipt = + synchronized(shutdownLock) { + shutdownReceipt ?: run { + val receipt = runBlocking { core.shutdownV2() } + StudioShutdownReceipt(receipt.finalRevision, receipt.closed).also { + check(it.closed) { "Native runtime returned an incomplete shutdown receipt" } + shutdownReceipt = it + core.close() + } } } - } override fun close() { shutdown() } - private fun requestContext(): RequestContextDto = RequestContextDto( - requestId = "kotlin:${nextRequest.getAndIncrement()}", - expectedRevision = core.snapshot().revision, - deadlineMillis = 30_000UL, - ) + private fun requestContext(): RequestContextDto = + RequestContextDto( + requestId = "kotlin:${nextRequest.getAndIncrement()}", + expectedRevision = core.snapshot().revision, + deadlineMillis = 30_000UL, + ) } internal fun Throwable.toStudioCommandFailure(fallbackCorrelationId: String): StudioCommandFailure { @@ -207,8 +231,7 @@ private class NativeGeneratedRecoveryTicket( override fun takeRecoveryNsec(): String = request.takeRecoveryNsec() - override suspend fun acknowledge(): AppSnapshotDto = - core.acknowledgeGeneratedAccountV2(request) + override suspend fun acknowledge(): AppSnapshotDto = core.acknowledgeGeneratedAccountV2(request) override suspend fun cancel(): Boolean = core.cancelGeneratedAccountV2(request) diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/desktop/Main.kt b/app/desktop/src/main/kotlin/org/radroots/studio/desktop/Main.kt @@ -5,20 +5,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import org.radroots.studio.accounts.ui.StartupFailureScreen +import org.radroots.studio.application.RadrootsApplication import java.awt.Dimension import java.awt.Taskbar import javax.imageio.ImageIO -import org.radroots.studio.application.RadrootsApplication -import org.radroots.studio.accounts.ui.StartupFailureScreen -private const val ApplicationName = "Radroots" -private const val InitialWindowWidth = 1284 -private const val InitialWindowHeight = 795 -private const val MinimumWindowWidth = 1080 -private const val MinimumWindowHeight = 720 +private const val APPLICATION_NAME = "Radroots" +private const val INITIAL_WINDOW_WIDTH = 1284 +private const val INITIAL_WINDOW_HEIGHT = 795 +private const val MINIMUM_WINDOW_WIDTH = 1080 +private const val MINIMUM_WINDOW_HEIGHT = 720 private val isMacOs: Boolean = - System.getProperty("os.name", "") + System + .getProperty("os.name", "") .startsWith("Mac", ignoreCase = true) fun main() { @@ -27,14 +28,15 @@ fun main() { application { Window( onCloseRequest = ::exitApplication, - title = ApplicationName, - state = rememberWindowState( - width = InitialWindowWidth.dp, - height = InitialWindowHeight.dp, - ), + title = APPLICATION_NAME, + state = + rememberWindowState( + width = INITIAL_WINDOW_WIDTH.dp, + height = INITIAL_WINDOW_HEIGHT.dp, + ), ) { DisposableEffect(window) { - window.minimumSize = Dimension(MinimumWindowWidth, MinimumWindowHeight) + window.minimumSize = Dimension(MINIMUM_WINDOW_WIDTH, MINIMUM_WINDOW_HEIGHT) if (isMacOs) { val rootPane = window.rootPane @@ -56,7 +58,7 @@ fun main() { } private fun configureMacOsApplication(): String? { - System.setProperty("apple.awt.application.name", ApplicationName) + System.setProperty("apple.awt.application.name", APPLICATION_NAME) System.setProperty("apple.awt.application.appearance", "system") if (!Taskbar.isTaskbarSupported()) return null @@ -64,9 +66,10 @@ private fun configureMacOsApplication(): String? { val taskbar = Taskbar.getTaskbar() if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) return null - val icon = loadRuntimeIcon { - Thread.currentThread().contextClassLoader.getResourceAsStream("icons/radroots.png") - } ?: return "The application icon resource is unavailable." + val icon = + loadRuntimeIcon { + Thread.currentThread().contextClassLoader.getResourceAsStream("icons/radroots.png") + } ?: return "The application icon resource is unavailable." return runCatching { taskbar.iconImage = icon } .fold( onSuccess = { null }, diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/AccountsUiModelTest.kt @@ -1,10 +1,5 @@ package org.radroots.studio.accounts.ui -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import kotlin.test.assertNull import org.radroots.studio.application.GeneratedKeyBackup import org.radroots.studio.application.StudioStoreState import org.radroots.studio.ffi.AccountDto @@ -19,20 +14,27 @@ import org.radroots.studio.ffi.SessionStateDto import org.radroots.studio.ffi.SignerKindDto import org.radroots.studio.ffi.WireErrorCode import org.radroots.studio.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue class AccountsUiModelTest { @Test fun mapsPublicNostrIdentityAndProfileState() { val account = account() - val snapshot = snapshot( - account = account, - active = ActiveAccountDto( + val snapshot = + snapshot( account = account, - relayState = RelayConnectionStateDto.CONNECTED, - profileState = ProfileLoadStateDto.FRESH, - profile = ProfileDto("alice", "Alice", "alice@example.com", "Farmer", "https://example.com/a.png"), - ), - ) + active = + ActiveAccountDto( + account = account, + relayState = RelayConnectionStateDto.CONNECTED, + profileState = ProfileLoadStateDto.FRESH, + profile = ProfileDto("alice", "Alice", "alice@example.com", "Farmer", "https://example.com/a.png"), + ), + ) val model = StudioStoreState(snapshot).toUiModel() @@ -42,18 +44,24 @@ class AccountsUiModelTest { 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)) + assertFalse( + model.accounts + .single() + .label + .contains("server", ignoreCase = true), + ) assertTrue(model.accounts.single().selected) assertTrue(model.accounts.single().active) } @Test fun mapsSafeProblemAndTransientBackupSeparatelyFromSnapshot() { - val state = StudioStoreState( - snapshot = snapshot(), - generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated"), - problem = "Try again.", - ) + val state = + StudioStoreState( + snapshot = snapshot(), + generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated"), + problem = "Try again.", + ) val model = state.toUiModel() @@ -70,15 +78,17 @@ class AccountsUiModelTest { @Test fun mapsTypedImportFailuresToSpecificRepairGuidance() { - val invalid = StudioStoreState( - snapshot = snapshot(), - lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, - ).toUiModel() - val repair = StudioStoreState( - snapshot = snapshot(), - lastFailureCode = WireErrorCode.CREDENTIAL_MISSING, - recoveryAction = WireRecoveryAction.REPAIR_CREDENTIAL, - ).toUiModel() + val invalid = + StudioStoreState( + snapshot = snapshot(), + lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, + ).toUiModel() + val repair = + StudioStoreState( + snapshot = snapshot(), + lastFailureCode = WireErrorCode.CREDENTIAL_MISSING, + recoveryAction = WireRecoveryAction.REPAIR_CREDENTIAL, + ).toUiModel() assertEquals("Enter a valid nsec or 64-character hexadecimal secret key.", invalid.importGuidance) assertEquals( @@ -105,12 +115,13 @@ private fun snapshot( 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, -) +private fun account() = + AccountDto( + publicKeyHex = "12".repeat(32), + npub = "npub1abcdefghijklmnopqrstuvwxyz1234567890", + displayLabel = "Alice", + signerKind = SignerKindDto.LOCAL_SECRET, + keyAvailability = KeyAvailabilityDto.AVAILABLE, + createdAtSeconds = 1, + lastUsedAtSeconds = null, + ) diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt @@ -4,314 +4,344 @@ 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.assertIsSelected -import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsFocused -import androidx.compose.ui.test.assertCountEquals +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.performTextInput import androidx.compose.ui.test.performScrollToNode -import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runComposeUiTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import org.radroots.studio.ffi.SessionStateDto -import org.radroots.studio.application.StudioRoute import org.radroots.studio.application.AccountEntryMode import org.radroots.studio.application.RemovalImpactState import org.radroots.studio.application.RemovalStatus +import org.radroots.studio.application.StudioRoute +import org.radroots.studio.ffi.SessionStateDto import org.radroots.studio.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue @OptIn(ExperimentalTestApi::class) class StudioScreenTest { @Test - fun rendersEveryNonReadyLifecycleRouteWithoutAccountControls() = runComposeUiTest { - var model by mutableStateOf(emptyUiModel().copy(route = StudioRoute.OPENING)) - setContent { StudioScreen(model, StudioUiActions()) } + fun rendersEveryNonReadyLifecycleRouteWithoutAccountControls() = + runComposeUiTest { + var model by mutableStateOf(emptyUiModel().copy(route = StudioRoute.OPENING)) + setContent { StudioScreen(model, StudioUiActions()) } + + val routes = + listOf( + StudioRoute.OPENING to "lifecycle-opening", + StudioRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", + StudioRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", + StudioRoute.MIGRATING to "lifecycle-migrating", + StudioRoute.RECOVERING to "lifecycle-recovering", + StudioRoute.BLOCKED to "lifecycle-blocked", + StudioRoute.SHUTTING_DOWN to "lifecycle-shutting-down", + StudioRoute.FATAL to "lifecycle-fatal", + StudioRoute.CLOSED to "lifecycle-closed", + ) + routes.forEach { (route, tag) -> + model = emptyUiModel(problem = "Safe lifecycle problem").copy(route = route) + waitForIdle() + onNodeWithTag(tag).assertIsDisplayed() + onAllNodesWithTag("generate-key").assertCountEquals(0) + } - val routes = listOf( - StudioRoute.OPENING to "lifecycle-opening", - StudioRoute.CHECKING_COMPATIBILITY to "lifecycle-compatibility", - StudioRoute.ACQUIRING_OWNERSHIP to "lifecycle-ownership", - StudioRoute.MIGRATING to "lifecycle-migrating", - StudioRoute.RECOVERING to "lifecycle-recovering", - StudioRoute.BLOCKED to "lifecycle-blocked", - StudioRoute.SHUTTING_DOWN to "lifecycle-shutting-down", - StudioRoute.FATAL to "lifecycle-fatal", - StudioRoute.CLOSED to "lifecycle-closed", - ) - routes.forEach { (route, tag) -> - model = emptyUiModel(problem = "Safe lifecycle problem").copy(route = route) + model = emptyUiModel(problem = "Relay access is unavailable.").copy(route = StudioRoute.DEGRADED) waitForIdle() - onNodeWithTag(tag).assertIsDisplayed() - onAllNodesWithTag("generate-key").assertCountEquals(0) + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithTag("accounts-problem").assertIsDisplayed() } - model = emptyUiModel(problem = "Relay access is unavailable.").copy(route = StudioRoute.DEGRADED) - waitForIdle() - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithTag("accounts-problem").assertIsDisplayed() - } - @Test - fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = runComposeUiTest { - var importDraft by mutableStateOf("") - var accountEntryMode by mutableStateOf(AccountEntryMode.CHOICE) - var generateCalls = 0 - var importCalls = 0 - setContent { - StudioScreen( - model = emptyUiModel(importDraft = importDraft).copy(accountEntryMode = accountEntryMode), - actions = StudioUiActions( - chooseCreateAccount = { accountEntryMode = AccountEntryMode.CREATE }, - chooseImportAccount = { accountEntryMode = AccountEntryMode.IMPORT }, - cancelAccountEntry = { accountEntryMode = AccountEntryMode.CHOICE }, - editImportDraft = { importDraft = it }, - generateAccount = { generateCalls += 1 }, - importSecretKey = { importCalls += 1 }, - ), - ) - } - - onNodeWithTag("accounts-screen").assertIsDisplayed() - onNodeWithText("radroots").assertIsDisplayed() - onNodeWithTag("choose-create-account").performClick() - onNodeWithTag("generate-key").performClick() - onNodeWithTag("cancel-account-entry").performClick() - onNodeWithTag("choose-import-account").performClick() - onNodeWithTag("import-nsec-input").assertIsFocused() - onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") - onNodeWithTag("import-key").performClick() + fun inactiveScreenGeneratesAndImportsMaskedSecretInput() = + runComposeUiTest { + var importDraft by mutableStateOf("") + var accountEntryMode by mutableStateOf(AccountEntryMode.CHOICE) + var generateCalls = 0 + var importCalls = 0 + setContent { + StudioScreen( + model = emptyUiModel(importDraft = importDraft).copy(accountEntryMode = accountEntryMode), + actions = + StudioUiActions( + chooseCreateAccount = { accountEntryMode = AccountEntryMode.CREATE }, + chooseImportAccount = { accountEntryMode = AccountEntryMode.IMPORT }, + cancelAccountEntry = { accountEntryMode = AccountEntryMode.CHOICE }, + editImportDraft = { importDraft = it }, + generateAccount = { generateCalls += 1 }, + importSecretKey = { importCalls += 1 }, + ), + ) + } - assertEquals(1, generateCalls) - assertEquals(1, importCalls) - assertEquals("nsec1secret", importDraft) - assertTrue(onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { - it.key.name == "Password" && it.value == Unit - }) - } + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithText("radroots").assertIsDisplayed() + onNodeWithTag("choose-create-account").performClick() + onNodeWithTag("generate-key").performClick() + onNodeWithTag("cancel-account-entry").performClick() + onNodeWithTag("choose-import-account").performClick() + onNodeWithTag("import-nsec-input").assertIsFocused() + onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") + onNodeWithTag("import-key").performClick() - @Test - fun inactiveScreenShowsSafeFailureAndNoGenericFields() = runComposeUiTest { - setContent { - StudioScreen( - model = emptyUiModel(problem = "The secret key is invalid."), - actions = StudioUiActions(), + assertEquals(1, generateCalls) + assertEquals(1, importCalls) + assertEquals("nsec1secret", importDraft) + assertTrue( + onNodeWithTag("import-nsec-input").fetchSemanticsNode().config.any { + it.key.name == "Password" && it.value == Unit + }, ) } - onNodeWithText("The secret key is invalid.").assertIsDisplayed() - onNodeWithTag("accounts-empty").assertIsDisplayed() - } + @Test + fun inactiveScreenShowsSafeFailureAndNoGenericFields() = + runComposeUiTest { + setContent { + StudioScreen( + model = emptyUiModel(problem = "The secret key is invalid."), + actions = StudioUiActions(), + ) + } + + onNodeWithText("The secret key is invalid.").assertIsDisplayed() + onNodeWithTag("accounts-empty").assertIsDisplayed() + } @Test - fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var copied: String? = null - setContent { - StudioScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = StudioUiActions( - copyText = { copied = it }, - acknowledgeGeneratedKeyBackup = { backup = null }, - ), + fun generatedKeyBackupCopiesAndClearsOnlyAfterAcknowledgement() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), ) - } + var copied: String? = null + setContent { + StudioScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = + StudioUiActions( + copyText = { copied = it }, + acknowledgeGeneratedKeyBackup = { backup = null }, + ), + ) + } - onNodeWithTag("generated-key-backup").assertIsDisplayed() - onAllNodesWithTag("accounts-screen").assertCountEquals(0) - onAllNodesWithTag("generate-key").assertCountEquals(0) - onNodeWithTag("generated-nsec").assertIsDisplayed() - onNodeWithTag("copy-generated-key").performClick() - assertEquals("nsec1generated", copied) + onNodeWithTag("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) - } + onNodeWithTag("acknowledge-key-backup").performClick() + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + onAllNodesWithTag("generated-nsec").assertCountEquals(0) + } @Test - fun generatedKeyRecoveryCanBeCancelledWithoutExposingAccountControls() = runComposeUiTest { - var backup: GeneratedKeyBackupUiModel? by mutableStateOf( - GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), - ) - var cancelled = 0 - setContent { - StudioScreen( - model = emptyUiModel().copy(generatedKeyBackup = backup), - actions = StudioUiActions( - cancelGeneratedKeyBackup = { - cancelled += 1 - backup = null - }, - ), + fun generatedKeyRecoveryCanBeCancelledWithoutExposingAccountControls() = + runComposeUiTest { + var backup: GeneratedKeyBackupUiModel? by mutableStateOf( + GeneratedKeyBackupUiModel("npub1generated", "nsec1generated"), ) - } + var cancelled = 0 + setContent { + StudioScreen( + model = emptyUiModel().copy(generatedKeyBackup = backup), + actions = + StudioUiActions( + cancelGeneratedKeyBackup = { + cancelled += 1 + backup = null + }, + ), + ) + } - onNodeWithTag("cancel-generated-key").performClick() - assertEquals(1, cancelled) - onAllNodesWithTag("generated-key-backup").assertCountEquals(0) - } + onNodeWithTag("cancel-generated-key").performClick() + assertEquals(1, cancelled) + onAllNodesWithTag("generated-key-backup").assertCountEquals(0) + } @Test - fun savedAccountsSelectActivateAndRequireRemovalConfirmation() = runComposeUiTest { - val first = accountUi("11".repeat(32), selected = true) - val second = accountUi("22".repeat(32), selected = false) - var pendingRemoval: String? by mutableStateOf(null) - val selected = mutableListOf<String>() - val activated = mutableListOf<String>() - var confirmations = 0 - setContent { - StudioScreen( - model = emptyUiModel().copy( - accounts = listOf(first, second), - pendingRemovalPublicKeyHex = pendingRemoval, - removalImpact = pendingRemoval?.let { - RemovalImpactState(it, deletesLocalCredential = true, signsOut = true, expiresAtSeconds = 60) - }, - ), - actions = StudioUiActions( - selectAccount = selected::add, - activateAccount = activated::add, - requestAccountRemoval = { pendingRemoval = it }, - cancelAccountRemoval = { pendingRemoval = null }, - confirmAccountRemoval = { confirmations += 1 }, - ), - ) - } + fun savedAccountsSelectActivateAndRequireRemovalConfirmation() = + runComposeUiTest { + val first = accountUi("11".repeat(32), selected = true) + val second = accountUi("22".repeat(32), selected = false) + var pendingRemoval: String? by mutableStateOf(null) + val selected = mutableListOf<String>() + val activated = mutableListOf<String>() + var confirmations = 0 + setContent { + StudioScreen( + model = + emptyUiModel().copy( + accounts = listOf(first, second), + pendingRemovalPublicKeyHex = pendingRemoval, + removalImpact = + pendingRemoval?.let { + RemovalImpactState(it, deletesLocalCredential = true, signsOut = true, expiresAtSeconds = 60) + }, + ), + actions = + StudioUiActions( + selectAccount = selected::add, + activateAccount = activated::add, + requestAccountRemoval = { pendingRemoval = it }, + cancelAccountRemoval = { pendingRemoval = null }, + confirmAccountRemoval = { confirmations += 1 }, + ), + ) + } - onNodeWithTag("saved-account-list").assertIsDisplayed() - onNodeWithTag("account-row:${first.publicKeyHex}").assertIsSelected() - onNodeWithTag("select-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() - assertEquals(listOf(second.publicKeyHex), selected) - assertEquals(listOf(second.publicKeyHex), activated) + onNodeWithTag("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) - } + onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithText("Its local credential will be deleted from the operating-system keyring.").assertIsDisplayed() + onNodeWithText("The active session will be signed out before removal.").assertIsDisplayed() + onNodeWithTag("remove-cancel", useUnmergedTree = true).performClick() + assertEquals(null, pendingRemoval) + onNodeWithTag("remove-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + onNodeWithTag("remove-confirm", useUnmergedTree = true).performClick() + assertEquals(1, confirmations) + } @Test - fun savedAccountListRemainsReachableForLargeRegistries() = runComposeUiTest { - val accounts = (0 until 100).map { index -> - accountUi(index.toString(16).padStart(64, '0'), selected = index == 0) - } - setContent { - StudioScreen( - model = emptyUiModel().copy(accounts = accounts), - actions = StudioUiActions(), - ) - } + fun savedAccountListRemainsReachableForLargeRegistries() = + runComposeUiTest { + val accounts = + (0 until 100).map { index -> + accountUi(index.toString(16).padStart(64, '0'), selected = index == 0) + } + setContent { + StudioScreen( + model = emptyUiModel().copy(accounts = accounts), + actions = StudioUiActions(), + ) + } - val lastTag = "account-row:${accounts.last().publicKeyHex}" - onNodeWithTag("saved-account-list").performScrollToNode(hasTestTag(lastTag)) - onNodeWithTag(lastTag).assertIsDisplayed() - } + val lastTag = "account-row:${accounts.last().publicKeyHex}" + onNodeWithTag("saved-account-list").performScrollToNode(hasTestTag(lastTag)) + onNodeWithTag(lastTag).assertIsDisplayed() + } @Test - fun activeHomeShowsIdentityProfileRelayAndCommands() = runComposeUiTest { - var refreshCalls = 0 - var signOutCalls = 0 - val account = accountUi("33".repeat(32), selected = true) - val active = ActiveAccountUiModel( - account = account, - heading = "Alice", - relayState = "connected", - profileState = "fresh", - profile = ProfileUiModel( - name = "alice", - displayName = "Alice", - nip05 = "alice@example.com", - about = "Local grower", - picture = "https://example.com/alice.png", - ), - ) - setContent { - StudioScreen( - model = emptyUiModel().copy( - route = StudioRoute.ACTIVE_ACCOUNT, - accounts = listOf(account), - activeAccount = active, - configuredRelays = listOf("ws://localhost:8080"), - session = SessionStateDto.ACTIVE, - ), - actions = StudioUiActions( - refreshActiveProfile = { refreshCalls += 1 }, - signOut = { signOutCalls += 1 }, - ), - ) - } + fun activeHomeShowsIdentityProfileRelayAndCommands() = + runComposeUiTest { + var refreshCalls = 0 + var signOutCalls = 0 + val account = accountUi("33".repeat(32), selected = true) + val active = + ActiveAccountUiModel( + account = account, + heading = "Alice", + relayState = "connected", + profileState = "fresh", + profile = + ProfileUiModel( + name = "alice", + displayName = "Alice", + nip05 = "alice@example.com", + about = "Local grower", + picture = "https://example.com/alice.png", + ), + ) + setContent { + StudioScreen( + model = + emptyUiModel().copy( + route = StudioRoute.ACTIVE_ACCOUNT, + accounts = listOf(account), + activeAccount = active, + configuredRelays = listOf("ws://localhost:8080"), + session = SessionStateDto.ACTIVE, + ), + actions = + StudioUiActions( + refreshActiveProfile = { refreshCalls += 1 }, + signOut = { signOutCalls += 1 }, + ), + ) + } - onNodeWithTag("home-screen").assertIsDisplayed() - onNodeWithTag("active-npub").assertIsDisplayed() - onNodeWithTag("active-pubkey-hex").assertIsDisplayed() - onNodeWithTag("active-profile-name").assertIsDisplayed() - onNodeWithTag("active-profile-about").assertIsDisplayed() - onNodeWithTag("relay-state").assertIsDisplayed() - onNodeWithTag("profile-state").assertIsDisplayed() - onNodeWithText("ws://localhost:8080").assertIsDisplayed() - onNodeWithTag("refresh-profile").performClick() - onNodeWithTag("sign-out").performClick() - assertEquals(1, refreshCalls) - assertEquals(1, signOutCalls) - } + onNodeWithTag("home-screen").assertIsDisplayed() + onNodeWithTag("active-npub").assertIsDisplayed() + onNodeWithTag("active-pubkey-hex").assertIsDisplayed() + onNodeWithTag("active-profile-name").assertIsDisplayed() + onNodeWithTag("active-profile-about").assertIsDisplayed() + onNodeWithTag("relay-state").assertIsDisplayed() + onNodeWithTag("profile-state").assertIsDisplayed() + onNodeWithText("ws://localhost:8080").assertIsDisplayed() + onNodeWithTag("refresh-profile").performClick() + onNodeWithTag("sign-out").performClick() + assertEquals(1, refreshCalls) + assertEquals(1, signOutCalls) + } @Test - fun activeAccountCanOpenChooserWithoutDroppingCurrentSession() = runComposeUiTest { - val first = accountUi("44".repeat(32), selected = true, active = true) - val second = accountUi("55".repeat(32), selected = false) - val active = ActiveAccountUiModel( - account = first, - heading = first.label, - relayState = "connected", - profileState = "cached", - profile = ProfileUiModel("", "", "", "", ""), - ) - var chooserVisible by mutableStateOf(false) - var activated: String? = null - setContent { - StudioScreen( - model = emptyUiModel().copy( - route = StudioRoute.ACTIVE_ACCOUNT, - accounts = listOf(first, second), - activeAccount = active, - session = SessionStateDto.ACTIVE, - accountChooserVisible = chooserVisible, - ), - actions = StudioUiActions( - showAccountChooser = { chooserVisible = true }, - hideAccountChooser = { chooserVisible = false }, - activateAccount = { activated = it }, - ), + fun activeAccountCanOpenChooserWithoutDroppingCurrentSession() = + runComposeUiTest { + val first = accountUi("44".repeat(32), selected = true, active = true) + val second = accountUi("55".repeat(32), selected = false) + val active = + ActiveAccountUiModel( + account = first, + heading = first.label, + relayState = "connected", + profileState = "cached", + profile = ProfileUiModel("", "", "", "", ""), + ) + var chooserVisible by mutableStateOf(false) + var activated: String? = null + setContent { + StudioScreen( + model = + emptyUiModel().copy( + route = StudioRoute.ACTIVE_ACCOUNT, + accounts = listOf(first, second), + activeAccount = active, + session = SessionStateDto.ACTIVE, + accountChooserVisible = chooserVisible, + ), + actions = + StudioUiActions( + showAccountChooser = { chooserVisible = true }, + hideAccountChooser = { chooserVisible = false }, + activateAccount = { activated = it }, + ), + ) + } + + onNodeWithTag("switch-account").performClick() + onNodeWithTag("accounts-screen").assertIsDisplayed() + onNodeWithTag("activate-account:${first.publicKeyHex}", useUnmergedTree = true).assertIsNotEnabled() + onNodeWithText("Active").assertIsDisplayed() + onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() + assertEquals(second.publicKeyHex, activated) + assertEquals( + SessionStateDto.ACTIVE, + emptyUiModel() + .copy( + activeAccount = active, + session = SessionStateDto.ACTIVE, + ).session, ) + onNodeWithTag("return-home").performClick() + onNodeWithTag("home-screen").assertIsDisplayed() } - - 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( diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/NativeCompatibilityTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/NativeCompatibilityTest.kt @@ -25,11 +25,12 @@ class NativeCompatibilityTest { } } - private fun compatibleDescriptor() = CompatibilityDescriptor( - contractMajor = EXPECTED_FFI_CONTRACT_MAJOR, - contractMinor = MINIMUM_FFI_CONTRACT_MINOR, - contractHash = EXPECTED_FFI_CONTRACT_HASH, - minimumSchemaVersion = MINIMUM_STORAGE_SCHEMA, - currentSchemaVersion = MAXIMUM_STORAGE_SCHEMA, - ) + private fun compatibleDescriptor() = + CompatibilityDescriptor( + contractMajor = EXPECTED_FFI_CONTRACT_MAJOR, + contractMinor = MINIMUM_FFI_CONTRACT_MINOR, + contractHash = EXPECTED_FFI_CONTRACT_HASH, + minimumSchemaVersion = MINIMUM_STORAGE_SCHEMA, + currentSchemaVersion = MAXIMUM_STORAGE_SCHEMA, + ) } diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt @@ -8,18 +8,18 @@ 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.assertIsDisplayed 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 kotlin.test.Test -import kotlin.test.assertEquals import org.radroots.studio.ffi.AppLifecycleDto import org.radroots.studio.ffi.AppSnapshotDto import org.radroots.studio.ffi.SessionStateDto +import kotlin.test.Test +import kotlin.test.assertEquals class RadrootsApplicationTest { @OptIn(ExperimentalTestApi::class) @@ -41,9 +41,10 @@ class RadrootsApplicationTest { } BasicText( text = "Toggle", - modifier = Modifier - .testTag("toggle-application") - .clickable { applicationVisible = !applicationVisible }, + modifier = + Modifier + .testTag("toggle-application") + .clickable { applicationVisible = !applicationVisible }, ) } @@ -75,11 +76,17 @@ private class ApplicationGateway : StudioCoreGateway { var closed = false override fun snapshot() = applicationSnapshot(0UL) + override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit) = AutoCloseable {} + override suspend fun execute(command: StudioCommand): StudioCommandResult = error("unused") + override suspend fun bootstrap() = applicationSnapshot(1UL) + override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = error("unused") + override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = error("unused") + override suspend fun confirmAccountRemoval(ticket: RemovalTicket) = error("unused") override fun shutdown(): StudioShutdownReceipt { @@ -92,16 +99,17 @@ private class ApplicationGateway : StudioCoreGateway { } } -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, -) +private fun applicationSnapshot(revision: ULong) = + AppSnapshotDto( + revision = revision, + lifecycle = AppLifecycleDto.READY, + lifecycleError = null, + configuredRelays = emptyList(), + accounts = emptyList(), + selectedPublicKeyHex = null, + session = SessionStateDto.SIGNED_OUT, + sessionSubjectPublicKeyHex = null, + sessionError = null, + activeAccount = null, + recoverableProblem = null, + ) diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/SecretClipboardControllerTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/SecretClipboardControllerTest.kt @@ -11,81 +11,86 @@ import kotlin.test.assertIs @OptIn(ExperimentalCoroutinesApi::class) class SecretClipboardControllerTest { @Test - fun clearsCopiedSecretAfterDelayWhenClipboardIsUnchanged() = runTest { - val clipboard = FakeTextClipboard() - val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) + fun clearsCopiedSecretAfterDelayWhenClipboardIsUnchanged() = + runTest { + val clipboard = FakeTextClipboard() + val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) - assertIs<SecretClipboardResult.Copied>(controller.copy("nsec1generated")) - advanceTimeBy(60_000) - runCurrent() + assertIs<SecretClipboardResult.Copied>(controller.copy("nsec1generated")) + advanceTimeBy(60_000) + runCurrent() - assertEquals("", clipboard.value) - controller.close() - } + assertEquals("", clipboard.value) + controller.close() + } @Test - fun preservesClipboardContentReplacedByUserBeforeDelay() = runTest { - val clipboard = FakeTextClipboard() - val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) + fun preservesClipboardContentReplacedByUserBeforeDelay() = + runTest { + val clipboard = FakeTextClipboard() + val controller = SecretClipboardController(this, clipboard, clearDelayMillis = 60_000) - controller.copy("nsec1generated") - clipboard.writeText("replacement") - advanceTimeBy(60_000) - runCurrent() + controller.copy("nsec1generated") + clipboard.writeText("replacement") + advanceTimeBy(60_000) + runCurrent() - assertEquals("replacement", clipboard.value) - controller.close() - } + 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() - } + 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") + fun disposalClearsOnlyClipboardTextOwnedByController() = + runTest { + val clipboard = FakeTextClipboard() + val controller = SecretClipboardController(this, clipboard) + controller.copy("nsec1generated") - controller.close() + controller.close() - assertEquals("", clipboard.value) + assertEquals("", clipboard.value) - val replacedClipboard = FakeTextClipboard() - val replacedController = SecretClipboardController(this, replacedClipboard) - replacedController.copy("nsec1generated") - replacedClipboard.writeText("replacement") - replacedController.close() - assertEquals("replacement", replacedClipboard.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() - } + 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 { diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt @@ -21,205 +21,227 @@ import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class StudioAppStoreTest { @Test - fun `bootstraps and ignores stale observer snapshots`() = runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - - advanceUntilIdle() - gateway.emit(snapshot(1UL)) - gateway.emit(snapshot(0UL)) - advanceUntilIdle() - - assertEquals(1UL, store.state.value.snapshot.revision) - assertFalse(store.state.value.busy) - store.close() - assertTrue(gateway.closed) - assertTrue(gateway.shutdownCompleted) - assertTrue(gateway.subscriptionClosed) - assertEquals(StudioRoute.CLOSED, store.state.value.route) - } + fun `bootstraps and ignores stale observer snapshots`() = + runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + + advanceUntilIdle() + gateway.emit(snapshot(1UL)) + gateway.emit(snapshot(0UL)) + advanceUntilIdle() + + assertEquals(1UL, store.state.value.snapshot.revision) + assertFalse(store.state.value.busy) + store.close() + assertTrue(gateway.closed) + assertTrue(gateway.shutdownCompleted) + assertTrue(gateway.subscriptionClosed) + assertEquals(StudioRoute.CLOSED, store.state.value.route) + } @Test - fun `holds generated secret only until explicit acknowledgement`() = runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - - store.generateAccount() - advanceUntilIdle() - - assertEquals("nsec1secret", store.state.value.generatedKeyBackup?.revealNsec()) - assertEquals("npub1account", store.state.value.generatedKeyBackup?.npub) - store.acknowledgeGeneratedKeyBackup() - advanceUntilIdle() - assertNull(store.state.value.generatedKeyBackup) - store.close() - } + fun `holds generated secret only until explicit acknowledgement`() = + runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + + store.generateAccount() + advanceUntilIdle() + + assertEquals( + "nsec1secret", + store.state.value.generatedKeyBackup + ?.revealNsec(), + ) + assertEquals( + "npub1account", + store.state.value.generatedKeyBackup + ?.npub, + ) + store.acknowledgeGeneratedKeyBackup() + advanceUntilIdle() + assertNull(store.state.value.generatedKeyBackup) + store.close() + } @Test - fun `cancels staged generated account without committing it`() = runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - val revisionBeforeGeneration = store.state.value.snapshot.revision - store.generateAccount() - advanceUntilIdle() - - store.cancelGeneratedKeyBackup() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertEquals(revisionBeforeGeneration, store.state.value.snapshot.revision) - store.close() - } + fun `cancels staged generated account without committing it`() = + runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + val revisionBeforeGeneration = store.state.value.snapshot.revision + store.generateAccount() + advanceUntilIdle() + + store.cancelGeneratedKeyBackup() + advanceUntilIdle() + + assertNull(store.state.value.generatedKeyBackup) + assertEquals(revisionBeforeGeneration, store.state.value.snapshot.revision) + store.close() + } @Test - fun `ignores observer delivery after close`() = runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - val revisionAtClose = store.state.value.snapshot.revision - - store.close() - gateway.emit(snapshot(revisionAtClose + 1UL)) - advanceUntilIdle() - - assertEquals(revisionAtClose, store.state.value.snapshot.revision) - } + fun `ignores observer delivery after close`() = + runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + val revisionAtClose = store.state.value.snapshot.revision + + store.close() + gateway.emit(snapshot(revisionAtClose + 1UL)) + advanceUntilIdle() + + assertEquals(revisionAtClose, store.state.value.snapshot.revision) + } @Test - fun `failed removal confirmation clears consumed presentation state`() = runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)).apply { - failRemovalConfirmation = true + fun `failed removal confirmation clears consumed presentation state`() = + runTest { + val gateway = + FakeStudioCoreGateway(snapshot(0UL)).apply { + failRemovalConfirmation = true + } + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + + store.requestAccountRemoval("00".repeat(32)) + advanceUntilIdle() + assertEquals("00".repeat(32), store.state.value.pendingRemovalPublicKeyHex) + store.confirmAccountRemoval() + advanceUntilIdle() + + assertNull(store.state.value.pendingRemovalPublicKeyHex) + assertTrue(gateway.lastRemovalTicket?.closed == true) + assertEquals(RemovalStatus.FAILED, store.state.value.removalStatus) + assertEquals("The application command failed.", store.state.value.problem) + store.close() } - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - - store.requestAccountRemoval("00".repeat(32)) - advanceUntilIdle() - assertEquals("00".repeat(32), store.state.value.pendingRemovalPublicKeyHex) - store.confirmAccountRemoval() - advanceUntilIdle() - - assertNull(store.state.value.pendingRemovalPublicKeyHex) - assertTrue(gateway.lastRemovalTicket?.closed == true) - assertEquals(RemovalStatus.FAILED, store.state.value.removalStatus) - assertEquals("The application command failed.", store.state.value.problem) - store.close() - } @Test - fun `serializes commands while one is active`() = runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - - store.signOut() - assertEquals(CommandStatus.REJECTED_BUSY, store.state.value.commandStatus) - advanceUntilIdle() - - assertEquals(0, gateway.signOutCalls) - store.signOut() - advanceUntilIdle() - assertEquals(1, gateway.signOutCalls) - store.close() - } + fun `serializes commands while one is active`() = + runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + + store.signOut() + assertEquals(CommandStatus.REJECTED_BUSY, store.state.value.commandStatus) + advanceUntilIdle() + + assertEquals(0, gateway.signOutCalls) + store.signOut() + advanceUntilIdle() + assertEquals(1, gateway.signOutCalls) + store.close() + } @Test - fun `projects retryable command rejection without dropping intent`() = runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - gateway.nextCommandResult = StudioCommandResult.Rejected( - StudioCommandFailure( - WireErrorCode.STORAGE_UNAVAILABLE, - WireErrorCategory.STORAGE, - retryable = true, - WireRecoveryAction.RETRY, - "request-retry", - "Storage is temporarily unavailable.", - ), - ) - - store.signOut() - advanceUntilIdle() - - assertEquals(CommandStatus.FAILED_RETRYABLE, store.state.value.commandStatus) - assertEquals("request-retry", store.state.value.lastCommandRequestId) - assertEquals("Storage is temporarily unavailable.", store.state.value.problem) - store.retryLastCommand() - advanceUntilIdle() - assertEquals(1, gateway.signOutCalls) - assertEquals(CommandStatus.ACCEPTED, store.state.value.commandStatus) - store.close() - } + fun `projects retryable command rejection without dropping intent`() = + runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + gateway.nextCommandResult = + StudioCommandResult.Rejected( + StudioCommandFailure( + WireErrorCode.STORAGE_UNAVAILABLE, + WireErrorCategory.STORAGE, + retryable = true, + WireRecoveryAction.RETRY, + "request-retry", + "Storage is temporarily unavailable.", + ), + ) + + store.signOut() + advanceUntilIdle() + + assertEquals(CommandStatus.FAILED_RETRYABLE, store.state.value.commandStatus) + assertEquals("request-retry", store.state.value.lastCommandRequestId) + assertEquals("Storage is temporarily unavailable.", store.state.value.problem) + store.retryLastCommand() + advanceUntilIdle() + assertEquals(1, gateway.signOutCalls) + assertEquals(CommandStatus.ACCEPTED, store.state.value.commandStatus) + store.close() + } @Test - fun `clears imported secret draft as soon as command is accepted`() = runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - store.editImportDraft("nsec1secret") - - store.importSecretKey() - - assertEquals("", store.state.value.importDraft) - assertEquals(emptyList(), gateway.importedSecrets) - advanceUntilIdle() - assertEquals(listOf("nsec1secret"), gateway.importedSecrets) - assertEquals(true, gateway.lastImportBuffer?.all { it == 0.toByte() }) - store.close() - } + fun `clears imported secret draft as soon as command is accepted`() = + runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + store.editImportDraft("nsec1secret") + + store.importSecretKey() + + assertEquals("", store.state.value.importDraft) + assertEquals(emptyList(), gateway.importedSecrets) + advanceUntilIdle() + assertEquals(listOf("nsec1secret"), gateway.importedSecrets) + assertEquals(true, gateway.lastImportBuffer?.all { it == 0.toByte() }) + store.close() + } @Test - fun `bounds imported secret presentation input before transport`() = runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() + fun `bounds imported secret presentation input before transport`() = + runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + advanceUntilIdle() - store.editImportDraft("x".repeat(MAX_IMPORT_SECRET_CHARS + 50)) + store.editImportDraft("x".repeat(MAX_IMPORT_SECRET_CHARS + 50)) - assertEquals(MAX_IMPORT_SECRET_CHARS, store.state.value.importDraft.length) - store.close() - } + assertEquals(MAX_IMPORT_SECRET_CHARS, store.state.value.importDraft.length) + store.close() + } @Test - fun `projects boot fatal and terminal lifecycle failures`() = runTest { - val booting = snapshot(0UL, AppLifecycleDto.OPENING) - val bootGateway = FakeStudioCoreGateway(booting, booting) - val bootStore = StudioAppStore(bootGateway, this) - advanceUntilIdle() - assertEquals(StudioRoute.OPENING, bootStore.state.value.route) - bootStore.close() - - val fatal = snapshot(1UL, AppLifecycleDto.FATAL) - val gateway = FakeStudioCoreGateway(fatal, fatal) - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - assertEquals(StudioRoute.FATAL, store.state.value.route) - store.signOut() - advanceUntilIdle() - assertEquals(CommandStatus.FAILED_TERMINAL, store.state.value.commandStatus) - assertEquals(0, gateway.signOutCalls) - - store.close() - store.signOut() - assertEquals(CommandStatus.REJECTED_CLOSED, store.state.value.commandStatus) - } + fun `projects boot fatal and terminal lifecycle failures`() = + runTest { + val booting = snapshot(0UL, AppLifecycleDto.OPENING) + val bootGateway = FakeStudioCoreGateway(booting, booting) + val bootStore = StudioAppStore(bootGateway, this) + advanceUntilIdle() + assertEquals(StudioRoute.OPENING, bootStore.state.value.route) + bootStore.close() + + val fatal = snapshot(1UL, AppLifecycleDto.FATAL) + val gateway = FakeStudioCoreGateway(fatal, fatal) + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + assertEquals(StudioRoute.FATAL, store.state.value.route) + store.signOut() + advanceUntilIdle() + assertEquals(CommandStatus.FAILED_TERMINAL, store.state.value.commandStatus) + assertEquals(0, gateway.signOutCalls) + + store.close() + store.signOut() + assertEquals(CommandStatus.REJECTED_CLOSED, store.state.value.commandStatus) + } @Test - fun `disposal waits for native shutdown and fails closed on an incomplete receipt`() = runTest { - val gateway = FakeStudioCoreGateway(snapshot(0UL)).apply { - shutdownReceipt = StudioShutdownReceipt(1UL, closed = false) + fun `disposal waits for native shutdown and fails closed on an incomplete receipt`() = + runTest { + val gateway = + FakeStudioCoreGateway(snapshot(0UL)).apply { + shutdownReceipt = StudioShutdownReceipt(1UL, closed = false) + } + val store = StudioAppStore(gateway, this) + advanceUntilIdle() + + store.close() + + assertTrue(gateway.shutdownCompleted) + assertEquals(StudioRoute.FATAL, store.state.value.route) + assertEquals("The application could not shut down safely.", store.state.value.problem) } - val store = StudioAppStore(gateway, this) - advanceUntilIdle() - - store.close() - - assertTrue(gateway.shutdownCompleted) - assertEquals(StudioRoute.FATAL, store.state.value.route) - assertEquals("The application could not shut down safely.", store.state.value.problem) - } } private class FakeStudioCoreGateway( @@ -278,8 +300,7 @@ private class FakeStudioCoreGateway( committed(current) } - override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = - FakeRemovalTicket().also { lastRemovalTicket = 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") @@ -346,12 +367,13 @@ private fun snapshot( 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, -) +private fun account() = + AccountDto( + publicKeyHex = "00".repeat(32), + npub = "npub1account", + displayLabel = "Account", + signerKind = SignerKindDto.LOCAL_SECRET, + keyAvailability = KeyAvailabilityDto.AVAILABLE, + createdAtSeconds = 0, + lastUsedAtSeconds = null, + ) diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioCoreGatewayTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioCoreGatewayTest.kt @@ -10,8 +10,9 @@ import kotlin.test.assertFalse class StudioCoreGatewayTest { @Test fun unknownFailuresBecomeSanitizedTypedRejections() { - val failure = IllegalStateException("sensitive detail") - .toStudioCommandFailure("request-7") + val failure = + IllegalStateException("sensitive detail") + .toStudioCommandFailure("request-7") assertEquals(WireErrorCode.INTERNAL, failure.code) assertEquals(WireErrorCategory.INTERNAL, failure.category) diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/architecture/NostrOnlySourceGuardTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/architecture/NostrOnlySourceGuardTest.kt @@ -12,32 +12,35 @@ 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() - } + 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) +private fun findSourceRoot(): Path = + generateSequence(Path.of("").toAbsolutePath()) { it.parent } + .map { it.resolve("app/desktop/src") } + .first(Files::isDirectory) diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/architecture/V5CompatibilityBaselineTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/architecture/V5CompatibilityBaselineTest.kt @@ -13,9 +13,10 @@ class V5CompatibilityBaselineTest { @Test fun baselineFreezesPackageAndNativeCoordinates() { val root = findRepositoryRoot() - val baseline = Properties().apply { - root.resolve("core/compatibility/v5-baseline.properties").inputStream().use(::load) - } + val baseline = + Properties().apply { + root.resolve("core/compatibility/v5-baseline.properties").inputStream().use(::load) + } assertEquals("studio-runtime-v5", baseline.getProperty("baseline.id")) assertEquals("5", baseline.getProperty("schema.version")) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml @@ -0,0 +1,26 @@ +config: + validation: true + warningsAsErrors: true + +complexity: + CyclomaticComplexMethod: + allowedComplexity: 20 + LongMethod: + allowedLines: 80 + TooManyFunctions: + allowedFunctionsPerClass: 30 + +exceptions: + TooGenericExceptionCaught: + excludes: + - '**/StudioAppStore.kt' + - '**/StudioCoreGateway.kt' + +naming: + FunctionNaming: + functionPattern: '[a-zA-Z][a-zA-Z0-9]*' + +style: + ReturnCount: + max: 4 + excludeGuardClauses: true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -3,6 +3,8 @@ kotlin = "2.4.10" compose = "1.11.1" jna = "5.17.0" coroutines = "1.9.0" +detekt = "2.0.0-alpha.5" +ktlint = "14.2.0" [libraries] compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" } @@ -15,3 +17,5 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +detekt = { id = "dev.detekt", version.ref = "detekt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }