app

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

commit de2218bc2aa103a506362cc94c496c6245bce8d1
parent 3f71e232e50b1a91a3708790b53b5b32dfd64cb3
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:54:11 +0000

runtime(desktop): bootstrap AppCore at application root

- construct one native core and Kotlin store per application composition
- keep the desktop window and macOS integration boundary unchanged
- dispose subscriptions coroutines and native ownership with the root
- verify recomposition stability and deterministic lifecycle cleanup

Diffstat:
Mapp/desktop/build.gradle.kts | 1+
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt | 41+++++++++++++++++++++--------------------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
4 files changed, 96 insertions(+), 45 deletions(-)

diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts @@ -164,6 +164,7 @@ tasks.withType<Test>().configureEach { } tasks.withType<JavaExec>().configureEach { dependsOn(buildRustCore) + systemProperty("radroots.studio.development", "true") systemProperty( "jna.library.path", rustDebugLibrary.asFile.parentFile.absolutePath, diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt b/app/desktop/src/main/kotlin/org/radroots/studio/application/RadrootsApplication.kt @@ -1,30 +1,31 @@ package org.radroots.studio.application +import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember -import java.util.UUID -import org.radroots.studio.accounts.model.AccountId -import org.radroots.studio.accounts.model.AccountsState -import org.radroots.studio.accounts.state.AccountIdFactory -import org.radroots.studio.accounts.state.AccountsReducer -import org.radroots.studio.accounts.state.AccountsStore -import org.radroots.studio.accounts.ui.AccountsScreen +import androidx.compose.runtime.rememberCoroutineScope +import kotlinx.coroutines.CoroutineScope +import org.radroots.studio.ffi.StudioAppCore + +internal typealias StudioStoreFactory = (CoroutineScope) -> StudioAppStore @Composable fun RadrootsApplication( - store: AccountsStore = remember { createAccountsStore() }, + storeFactory: StudioStoreFactory = ::createStudioAppStore, ) { - AccountsScreen( - state = store.state.value, - onAction = store::dispatch, - ) + val scope = rememberCoroutineScope() + val store = remember { storeFactory(scope) } + + DisposableEffect(store) { + onDispose(store::close) + } + + BasicText("radroots") } -internal fun createAccountsStore( - accountIdFactory: AccountIdFactory = AccountIdFactory { - AccountId(UUID.randomUUID().toString()) - }, -) = AccountsStore( - initialState = AccountsState(), - reducer = AccountsReducer(accountIdFactory), -) +internal fun createStudioAppStore(scope: CoroutineScope): StudioAppStore { + val developmentMode = java.lang.Boolean.getBoolean("radroots.studio.development") + val core = StudioAppCore.open(developmentMode = developmentMode) + return StudioAppStore(NativeStudioCoreGateway(core), scope) +} diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt @@ -1,42 +1,91 @@ package org.radroots.studio.application +import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed +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 kotlin.test.assertNotEquals -import org.radroots.studio.accounts.model.AccountsAction +import org.radroots.studio.ffi.AppLifecycleDto +import org.radroots.studio.ffi.AppSnapshotDto +import org.radroots.studio.ffi.SessionStateDto class RadrootsApplicationTest { @OptIn(ExperimentalTestApi::class) @Test - fun composeUiTestRuntimeRendersContent() = runComposeUiTest { - setContent { - RadrootsApplication() - } + fun applicationCreatesOneStoreAcrossRecompositionAndClosesItOnDisposal() = + runComposeUiTest { + var applicationVisible by mutableStateOf(true) + var factoryCalls = 0 + var gateway: ApplicationGateway? = null - onNodeWithText("radroots").assertIsDisplayed() - } + setContent { + if (applicationVisible) { + RadrootsApplication { scope -> + factoryCalls += 1 + val createdGateway = ApplicationGateway() + gateway = createdGateway + StudioAppStore(createdGateway, scope) + } + } + BasicText( + text = "Toggle", + modifier = Modifier + .testTag("toggle-application") + .clickable { applicationVisible = !applicationVisible }, + ) + } - @Test - fun applicationStoresStartEmptyAndGenerateIndependentIds() { - val first = createAccountsStore() - val second = createAccountsStore() - - listOf(first, second).forEach { store -> - assertEquals(emptyList(), store.state.value.accounts) - store.dispatch(AccountsAction.EditAddDisplayName("Farm Account")) - store.dispatch( - AccountsAction.EditAddServerUrl("https://farm.example.test"), - ) - store.dispatch(AccountsAction.SubmitAddAccount) + onNodeWithText("radroots").assertIsDisplayed() + onNodeWithTag("toggle-application").performClick() + waitForIdle() + + assertEquals(1, factoryCalls) + assertEquals(true, gateway?.closed) } +} + +private class ApplicationGateway : StudioCoreGateway { + var closed = false - assertNotEquals( - first.state.value.accounts.single().id, - second.state.value.accounts.single().id, - ) + override fun snapshot() = applicationSnapshot(0UL) + override fun subscribe(onSnapshot: (org.radroots.studio.ffi.AppSnapshotDto) -> Unit) = + AutoCloseable {} + override suspend fun bootstrap() = applicationSnapshot(1UL) + override suspend fun generateAccount(): org.radroots.studio.ffi.GeneratedAccountDto = + error("unused") + override suspend fun importSecretKey(secretKey: String) = error("unused") + override suspend fun selectAccount(publicKeyHex: String) = error("unused") + override suspend fun activateAccount(publicKeyHex: String) = error("unused") + override suspend fun signOut() = error("unused") + override suspend fun refreshActiveProfile() = error("unused") + override suspend fun requestAccountRemoval(publicKeyHex: String): RemovalTicket = error("unused") + override suspend fun confirmAccountRemoval(ticket: RemovalTicket) = error("unused") + + override fun close() { + closed = true } } + +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/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -532,7 +532,7 @@ handoff commit sequence. ### RCLD-10 - [x] 48. Add Kotlin StudioAppStore thin adapter. -- [ ] 49. Bootstrap AppCore once at application root. +- [x] 49. Bootstrap AppCore once at application root. - [ ] 50. Create UI model mapping helpers without server fields. - [ ] 51. Implement inactive account screen generate/import controls. - [ ] 52. Implement generated-key backup panel.