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:
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" }