app

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

commit cd6b276b28c5d1789674fa9139781583c61abaf7
parent 8fad1d6ac79bed152cc5b02b32b587558f095081
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:43:50 +0000

application: project root lifecycle from native state

- derive explicit root routes from immutable native snapshots
- consume ordered changes without recreating the Rust reducer
- route account intents through typed gateway commands
- keep generated recovery and removal as exclusive native flows

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioAppStore.kt | 73++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt | 33---------------------------------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt | 7-------
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 31++++++++++++++++---------------
4 files changed, 64 insertions(+), 80 deletions(-)

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 @@ -6,10 +6,20 @@ 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.StudioException +enum class StudioRoute { + BOOTING, + ACCOUNTS, + ACTIVE_ACCOUNT, + FATAL, + CLOSED, +} + data class StudioStoreState( val snapshot: AppSnapshotDto, + val route: StudioRoute = snapshot.toStudioRoute(), val importDraft: String = "", val generatedKeyBackup: GeneratedKeyBackup? = null, val pendingRemovalPublicKeyHex: String? = null, @@ -37,9 +47,9 @@ class StudioAppStore( init { launchCommand { - val registered = gateway.subscribe { snapshot -> + val registered = gateway.subscribeChanges { change -> scope.launch { - if (!closed) acceptSnapshot(snapshot) + if (!closed) acceptSnapshot(change.snapshot) } } if (closed) { @@ -74,7 +84,7 @@ class StudioAppStore( fun acknowledgeGeneratedKeyBackup() { val recovery = pendingGeneratedRecovery ?: return pendingGeneratedRecovery = null - runCommand { + runSnapshotCommand { try { recovery.acknowledge().also { generatedRecovery.acknowledge() @@ -90,33 +100,19 @@ class StudioAppStore( if (command?.isActive == true) return val input = mutableState.value.importDraft.encodeToByteArray() mutableState.value = mutableState.value.copy(importDraft = "") - runCommand { - try { - gateway.importSecretKey(input) - } finally { - input.fill(0) - } - } + runTypedCommand(StudioCommand.ImportAccount(input)) } fun selectAccount(publicKeyHex: String) { - runCommand { gateway.selectAccount(publicKeyHex) } + runTypedCommand(StudioCommand.SelectAccount(publicKeyHex)) } fun activateAccount(publicKeyHex: String) { - runCommand { - gateway.activateAccount(publicKeyHex).also { - mutableState.value = mutableState.value.copy(accountChooserVisible = false) - } - } + runTypedCommand(StudioCommand.ActivateAccount(publicKeyHex), hideChooser = true) } fun signOut() { - runCommand { - gateway.signOut().also { - mutableState.value = mutableState.value.copy(accountChooserVisible = false) - } - } + runTypedCommand(StudioCommand.SignOut, hideChooser = true) } fun showAccountChooser() { @@ -128,7 +124,7 @@ class StudioAppStore( } fun refreshActiveProfile() { - runCommand { gateway.refreshActiveProfile() } + runTypedCommand(StudioCommand.RefreshProfile) } fun requestAccountRemoval(publicKeyHex: String) { @@ -159,7 +155,7 @@ class StudioAppStore( fun confirmAccountRemoval() { val ticket = pendingRemoval ?: return pendingRemoval = null - runCommand { + runSnapshotCommand { try { gateway.confirmAccountRemoval(ticket).also { mutableState.value = mutableState.value.copy(pendingRemovalPublicKeyHex = null) @@ -175,10 +171,26 @@ class StudioAppStore( mutableState.value = mutableState.value.copy(problem = null) } - private fun runCommand(operation: suspend () -> AppSnapshotDto) { + private fun runSnapshotCommand(operation: suspend () -> AppSnapshotDto) { launchCommand { acceptSnapshot(operation()) } } + private fun runTypedCommand(command: StudioCommand, hideChooser: Boolean = false) { + launchCommand { + when (val result = gateway.execute(command)) { + is StudioCommandResult.Accepted -> { + acceptSnapshot(result.receipt.snapshot) + if (hideChooser) { + mutableState.value = mutableState.value.copy(accountChooserVisible = false) + } + } + is StudioCommandResult.Rejected -> { + mutableState.value = mutableState.value.copy(problem = result.failure.safeMessage) + } + } + } + } + private fun launchCommand(operation: suspend () -> Unit) { if (closed || command?.isActive == true) return mutableState.value = mutableState.value.copy(busy = true, problem = null) @@ -191,7 +203,10 @@ class StudioAppStore( private fun acceptSnapshot(snapshot: AppSnapshotDto) { if (snapshot.revision >= mutableState.value.snapshot.revision) { - mutableState.value = mutableState.value.copy(snapshot = snapshot) + mutableState.value = mutableState.value.copy( + snapshot = snapshot, + route = snapshot.toStudioRoute(), + ) } } @@ -209,5 +224,13 @@ class StudioAppStore( subscription?.close() generatedRecovery.close() gateway.close() + mutableState.value = mutableState.value.copy(route = StudioRoute.CLOSED, busy = false) } } + +private fun AppSnapshotDto.toStudioRoute(): StudioRoute = when { + lifecycle == AppLifecycleDto.BOOTING -> StudioRoute.BOOTING + lifecycle == AppLifecycleDto.FATAL -> StudioRoute.FATAL + activeAccount != null -> StudioRoute.ACTIVE_ACCOUNT + else -> StudioRoute.ACCOUNTS +} 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 @@ -64,8 +64,6 @@ sealed interface StudioCommandResult { interface StudioCoreGateway : AutoCloseable { fun snapshot(): AppSnapshotDto - suspend fun subscribe(onSnapshot: (AppSnapshotDto) -> Unit): AutoCloseable - suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable suspend fun execute(command: StudioCommand): StudioCommandResult @@ -74,16 +72,6 @@ interface StudioCoreGateway : AutoCloseable { suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket - suspend fun importSecretKey(secretKey: ByteArray): 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 @@ -95,10 +83,6 @@ class NativeStudioCoreGateway( private val nextRequest = AtomicLong(1) override fun snapshot(): AppSnapshotDto = core.snapshot() - override suspend fun subscribe(onSnapshot: (AppSnapshotDto) -> Unit): AutoCloseable { - return subscribeChanges { change -> onSnapshot(change.snapshot) } - } - override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable { val subscription = core.subscribeChangesV2( object : StudioChangeObserver { @@ -137,23 +121,6 @@ class NativeStudioCoreGateway( override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = NativeGeneratedRecoveryTicket(core, core.beginGeneratedAccountV2()) - override suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto = - try { - core.importAccountV2(requestContext(), secretKey).snapshot - } finally { - secretKey.fill(0) - } - - 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)) 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 @@ -75,17 +75,10 @@ private class ApplicationGateway : StudioCoreGateway { var closed = false override fun snapshot() = applicationSnapshot(0UL) - override suspend fun subscribe(onSnapshot: (org.radroots.studio.ffi.AppSnapshotDto) -> Unit) = - AutoCloseable {} 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 importSecretKey(secretKey: ByteArray) = 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") 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 @@ -132,16 +132,25 @@ private class FakeStudioCoreGateway( override fun snapshot(): AppSnapshotDto = current - override suspend fun subscribe(onSnapshot: (AppSnapshotDto) -> Unit): AutoCloseable { - observer = onSnapshot + override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable { + observer = { snapshot -> onChange(StudioChange(snapshot, null)) } return AutoCloseable { subscriptionClosed = true } } - override suspend fun subscribeChanges(onChange: (StudioChange) -> Unit): AutoCloseable = - subscribe { snapshot -> onChange(StudioChange(snapshot, null)) } - - override suspend fun execute(command: StudioCommand): StudioCommandResult = - error("unused") + override suspend fun execute(command: StudioCommand): StudioCommandResult { + when (command) { + is StudioCommand.ImportAccount -> { + lastImportBuffer = command.bytes + importedSecrets += command.bytes.decodeToString() + command.bytes.fill(0) + } + StudioCommand.SignOut -> signOutCalls += 1 + else -> Unit + } + return StudioCommandResult.Accepted( + StudioCommandReceipt("fake-request", current.revision, current), + ) + } fun emit(snapshot: AppSnapshotDto) { current = snapshot @@ -157,14 +166,6 @@ private class FakeStudioCoreGateway( committed(current) } - override suspend fun importSecretKey(secretKey: ByteArray): AppSnapshotDto = current.also { - lastImportBuffer = secretKey - importedSecrets += secretKey.decodeToString() - } - 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 = FakeRemovalTicket().also { lastRemovalTicket = it }