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:
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.