app

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

commit 3f71e232e50b1a91a3708790b53b5b32dfd64cb3
parent 562472657ea5ab525d9771df5e3a9d86ccbc606a
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:52:04 +0000

runtime(desktop): add the thin application store

- wrap generated UniFFI objects behind a Kotlin gateway
- observe revisioned Rust snapshots as read-only Compose state
- forward explicit asynchronous account and session commands
- close subscriptions removal handles and native core ownership

Diffstat:
Mapp/desktop/build.gradle.kts | 2++
Aapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
Mgradle/libs.versions.toml | 3+++
6 files changed, 392 insertions(+), 1 deletion(-)

diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -98,9 +98,11 @@ dependencies { implementation(compose.desktop.currentOs) implementation(libs.compose.foundation) implementation(libs.jna) + implementation(libs.kotlinx.coroutines.core) testImplementation(kotlin("test-junit")) testImplementation(libs.compose.ui.test.junit4) + testImplementation(libs.kotlinx.coroutines.test) } val testInventoryRoot = providers 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 @@ -0,0 +1,152 @@ +package org.radroots.studio.application + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.radroots.studio.ffi.AppSnapshotDto +import org.radroots.studio.ffi.StudioException + +data class GeneratedKeyBackup( + val npub: String, + val nsec: String, +) + +data class StudioStoreState( + val snapshot: AppSnapshotDto, + val importDraft: String = "", + val generatedKeyBackup: GeneratedKeyBackup? = null, + val pendingRemovalPublicKeyHex: String? = null, + val busy: Boolean = false, + val problem: String? = null, +) + +class StudioAppStore( + private val gateway: StudioCoreGateway, + private val scope: CoroutineScope, +) : AutoCloseable { + private val mutableState = mutableStateOf(StudioStoreState(snapshot = gateway.snapshot())) + private val subscription = gateway.subscribe(::acceptSnapshot) + private var pendingRemoval: RemovalTicket? = null + private var command: Job? = null + + val state: State<StudioStoreState> + get() = mutableState + + init { + runCommand { gateway.bootstrap() } + } + + fun editImportDraft(value: String) { + mutableState.value = mutableState.value.copy(importDraft = value, problem = null) + } + + fun generateAccount() { + runCommand { + val receipt = gateway.generateAccount() + mutableState.value = mutableState.value.copy( + generatedKeyBackup = GeneratedKeyBackup(receipt.account.npub, receipt.nsec), + ) + receipt.snapshot + } + } + + fun acknowledgeGeneratedKeyBackup() { + mutableState.value = mutableState.value.copy(generatedKeyBackup = null) + } + + fun importSecretKey() { + val input = mutableState.value.importDraft + runCommand { + gateway.importSecretKey(input).also { + mutableState.value = mutableState.value.copy(importDraft = "") + } + } + } + + fun selectAccount(publicKeyHex: String) { + runCommand { gateway.selectAccount(publicKeyHex) } + } + + fun activateAccount(publicKeyHex: String) { + runCommand { gateway.activateAccount(publicKeyHex) } + } + + fun signOut() { + runCommand { gateway.signOut() } + } + + fun refreshActiveProfile() { + runCommand { gateway.refreshActiveProfile() } + } + + fun requestAccountRemoval(publicKeyHex: String) { + launchCommand { + runCatching { + pendingRemoval?.close() + pendingRemoval = gateway.requestAccountRemoval(publicKeyHex) + mutableState.value = mutableState.value.copy( + pendingRemovalPublicKeyHex = publicKeyHex, + ) + }.getOrThrow() + } + } + + fun cancelAccountRemoval() { + pendingRemoval?.close() + pendingRemoval = null + mutableState.value = mutableState.value.copy(pendingRemovalPublicKeyHex = null) + } + + fun confirmAccountRemoval() { + val ticket = pendingRemoval ?: return + pendingRemoval = null + runCommand { + try { + gateway.confirmAccountRemoval(ticket).also { + mutableState.value = mutableState.value.copy(pendingRemovalPublicKeyHex = null) + } + } finally { + ticket.close() + } + } + } + + fun dismissProblem() { + mutableState.value = mutableState.value.copy(problem = null) + } + + private fun runCommand(operation: suspend () -> AppSnapshotDto) { + launchCommand { acceptSnapshot(operation()) } + } + + private fun launchCommand(operation: suspend () -> Unit) { + if (command?.isActive == true) return + mutableState.value = mutableState.value.copy(busy = true, problem = null) + command = scope.launch { + runCatching { operation() } + .onFailure(::acceptFailure) + mutableState.value = mutableState.value.copy(busy = false) + } + } + + private fun acceptSnapshot(snapshot: AppSnapshotDto) { + if (snapshot.revision >= mutableState.value.snapshot.revision) { + mutableState.value = mutableState.value.copy(snapshot = snapshot) + } + } + + private fun acceptFailure(error: Throwable) { + val message = (error as? StudioException.Failure)?.safeMessage + ?: "The application command failed." + mutableState.value = mutableState.value.copy(busy = false, problem = message) + } + + override fun close() { + command?.cancel() + pendingRemoval?.close() + subscription.close() + gateway.close() + } +} 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 @@ -0,0 +1,98 @@ +package org.radroots.studio.application + +import org.radroots.studio.ffi.AppSnapshotDto +import org.radroots.studio.ffi.GeneratedAccountDto +import org.radroots.studio.ffi.ObserverSubscription +import org.radroots.studio.ffi.RemovalRequest +import org.radroots.studio.ffi.StudioAppCore +import org.radroots.studio.ffi.StudioObserver + +interface RemovalTicket : AutoCloseable + +interface StudioCoreGateway : AutoCloseable { + fun snapshot(): AppSnapshotDto + + fun subscribe(onSnapshot: (AppSnapshotDto) -> Unit): AutoCloseable + + suspend fun bootstrap(): AppSnapshotDto + + suspend fun generateAccount(): GeneratedAccountDto + + suspend fun importSecretKey(secretKey: String): AppSnapshotDto + + suspend fun selectAccount(publicKeyHex: String): AppSnapshotDto + + suspend fun activateAccount(publicKeyHex: String): AppSnapshotDto + + suspend fun signOut(): AppSnapshotDto + + suspend fun refreshActiveProfile(): AppSnapshotDto + + suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket + + suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto +} + +class NativeStudioCoreGateway( + private val core: StudioAppCore, +) : StudioCoreGateway { + override fun snapshot(): AppSnapshotDto = core.snapshot() + + override fun subscribe(onSnapshot: (AppSnapshotDto) -> Unit): AutoCloseable { + val subscription = core.subscribe( + object : StudioObserver { + override fun onSnapshotChanged(snapshot: AppSnapshotDto) { + onSnapshot(snapshot) + } + }, + ) + return NativeSubscription(subscription) + } + + override suspend fun bootstrap(): AppSnapshotDto = core.bootstrap() + + override suspend fun generateAccount(): GeneratedAccountDto = core.generateAccount() + + override suspend fun importSecretKey(secretKey: String): AppSnapshotDto = + core.importSecretKey(secretKey) + + override suspend fun selectAccount(publicKeyHex: String): AppSnapshotDto = + core.selectAccount(publicKeyHex) + + override suspend fun activateAccount(publicKeyHex: String): AppSnapshotDto = + core.activateAccount(publicKeyHex) + + override suspend fun signOut(): AppSnapshotDto = core.signOut() + + override suspend fun refreshActiveProfile(): AppSnapshotDto = core.refreshActiveProfile() + + override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = + NativeRemovalTicket(core.requestAccountRemoval(publicKeyHex)) + + override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto { + require(ticket is NativeRemovalTicket) { "Removal ticket does not belong to native core" } + return core.confirmAccountRemoval(ticket.request) + } + + override fun close() { + core.shutdown() + core.close() + } +} + +private class NativeSubscription( + private val subscription: ObserverSubscription, +) : AutoCloseable { + override fun close() { + subscription.unsubscribe() + subscription.close() + } +} + +private class NativeRemovalTicket( + val request: RemovalRequest, +) : RemovalTicket { + override fun close() { + request.close() + } +} 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 @@ -0,0 +1,136 @@ +package org.radroots.studio.application + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.radroots.studio.ffi.AccountDto +import org.radroots.studio.ffi.AppLifecycleDto +import org.radroots.studio.ffi.AppSnapshotDto +import org.radroots.studio.ffi.GeneratedAccountDto +import org.radroots.studio.ffi.KeyAvailabilityDto +import org.radroots.studio.ffi.SessionStateDto +import org.radroots.studio.ffi.SignerKindDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class StudioAppStoreTest { + @Test + fun `bootstraps and ignores stale observer snapshots`() = runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + + advanceUntilIdle() + gateway.emit(snapshot(1UL)) + gateway.emit(snapshot(0UL)) + + assertEquals(1UL, store.state.value.snapshot.revision) + assertFalse(store.state.value.busy) + store.close() + assertTrue(gateway.closed) + assertTrue(gateway.subscriptionClosed) + } + + @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?.nsec) + assertEquals("npub1account", store.state.value.generatedKeyBackup?.npub) + store.acknowledgeGeneratedKeyBackup() + assertNull(store.state.value.generatedKeyBackup) + store.close() + } + + @Test + fun `serializes commands while one is active`() = runTest { + val gateway = FakeStudioCoreGateway(snapshot(0UL)) + val store = StudioAppStore(gateway, this) + + store.signOut() + advanceUntilIdle() + + assertEquals(0, gateway.signOutCalls) + store.signOut() + advanceUntilIdle() + assertEquals(1, gateway.signOutCalls) + store.close() + } +} + +private class FakeStudioCoreGateway( + private var current: AppSnapshotDto, +) : StudioCoreGateway { + private var observer: ((AppSnapshotDto) -> Unit)? = null + var closed = false + var subscriptionClosed = false + var signOutCalls = 0 + + override fun snapshot(): AppSnapshotDto = current + + override fun subscribe(onSnapshot: (AppSnapshotDto) -> Unit): AutoCloseable { + observer = onSnapshot + return AutoCloseable { subscriptionClosed = true } + } + + fun emit(snapshot: AppSnapshotDto) { + current = snapshot + observer?.invoke(snapshot) + } + + override suspend fun bootstrap(): AppSnapshotDto = snapshot(1UL).also(::emit) + + override suspend fun generateAccount(): GeneratedAccountDto { + val next = snapshot(current.revision + 1UL) + emit(next) + return GeneratedAccountDto(account(), next, "nsec1secret") + } + + override suspend fun importSecretKey(secretKey: String): AppSnapshotDto = current + override suspend fun selectAccount(publicKeyHex: String): AppSnapshotDto = current + override suspend fun activateAccount(publicKeyHex: String): AppSnapshotDto = current + override suspend fun signOut(): AppSnapshotDto = current.also { signOutCalls += 1 } + override suspend fun refreshActiveProfile(): AppSnapshotDto = current + override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = + object : RemovalTicket { + override fun close() = Unit + } + + override suspend fun confirmAccountRemoval(ticket: RemovalTicket): AppSnapshotDto = current + + override fun close() { + closed = true + } +} + +private fun snapshot(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 account() = AccountDto( + publicKeyHex = "00".repeat(32), + npub = "npub1account", + displayLabel = "Account", + signerKind = SignerKindDto.LOCAL_SECRET, + keyAvailability = KeyAvailabilityDto.AVAILABLE, + createdAtSeconds = 0, + lastUsedAtSeconds = null, +) diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -531,7 +531,7 @@ handoff commit sequence. ### RCLD-10 -- [ ] 48. Add Kotlin StudioAppStore thin adapter. +- [x] 48. Add Kotlin StudioAppStore thin adapter. - [ ] 49. Bootstrap AppCore once at application root. - [ ] 50. Create UI model mapping helpers without server fields. - [ ] 51. Implement inactive account screen generate/import controls. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml @@ -2,11 +2,14 @@ kotlin = "2.4.10" compose = "1.11.1" jna = "5.17.0" +coroutines = "1.9.0" [libraries] compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" } compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "compose" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version.ref = "coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }