app

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

commit 8fad1d6ac79bed152cc5b02b32b587558f095081
parent 1b62f86d353263c39507d62d1c7954321c1ecb0f
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:42:13 +0000

application: add typed native gateway contracts

- model account intents as explicit Kotlin command types
- project native receipts into correlated command results
- preserve structured error policy without exception leakage
- expose ordered snapshot changes with predecessor revisions

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/application/StudioCoreGateway.kt | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/RadrootsApplicationTest.kt | 2++
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 6++++++
Aapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioCoreGatewayTest.kt | 23+++++++++++++++++++++++
4 files changed, 111 insertions(+), 1 deletion(-)

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 @@ -11,6 +11,10 @@ import org.radroots.studio.ffi.RequestContextDto import org.radroots.studio.ffi.SnapshotChangeDto import org.radroots.studio.ffi.StudioAppCore import org.radroots.studio.ffi.StudioChangeObserver +import org.radroots.studio.ffi.StudioException +import org.radroots.studio.ffi.WireErrorCategory +import org.radroots.studio.ffi.WireErrorCode +import org.radroots.studio.ffi.WireRecoveryAction interface RemovalTicket : AutoCloseable @@ -24,11 +28,48 @@ interface GeneratedRecoveryTicket : AutoCloseable { suspend fun cancel(): Boolean } +data class StudioChange( + val snapshot: AppSnapshotDto, + val previousRevision: ULong?, +) + +sealed interface StudioCommand { + data class ImportAccount(val bytes: ByteArray) : StudioCommand + data class SelectAccount(val publicKeyHex: String) : StudioCommand + data class ActivateAccount(val publicKeyHex: String) : StudioCommand + data object SignOut : StudioCommand + data object RefreshProfile : StudioCommand +} + +data class StudioCommandReceipt( + val requestId: String, + val committedRevision: ULong, + val snapshot: AppSnapshotDto, +) + +data class StudioCommandFailure( + val code: WireErrorCode, + val category: WireErrorCategory, + val retryable: Boolean, + val recoveryAction: WireRecoveryAction, + val correlationId: String?, + val safeMessage: String, +) + +sealed interface StudioCommandResult { + data class Accepted(val receipt: StudioCommandReceipt) : StudioCommandResult + data class Rejected(val failure: StudioCommandFailure) : 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 + suspend fun bootstrap(): AppSnapshotDto suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket @@ -55,16 +96,42 @@ class NativeStudioCoreGateway( 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 { override fun onChange(change: SnapshotChangeDto) { - onSnapshot(change.snapshot) + onChange(StudioChange(change.snapshot, change.previousRevision)) } }, ) return NativeSubscription(subscription) } + override suspend fun execute(command: StudioCommand): StudioCommandResult { + val context = requestContext() + return try { + val snapshot = when (command) { + is StudioCommand.ImportAccount -> try { + core.importAccountV2(context, command.bytes).snapshot + } finally { + command.bytes.fill(0) + } + is StudioCommand.SelectAccount -> core.selectAccount(command.publicKeyHex) + is StudioCommand.ActivateAccount -> core.activateAccount(command.publicKeyHex) + StudioCommand.SignOut -> core.signOut() + StudioCommand.RefreshProfile -> core.refreshActiveProfile() + } + StudioCommandResult.Accepted( + StudioCommandReceipt(context.requestId, snapshot.revision, snapshot), + ) + } catch (error: Throwable) { + StudioCommandResult.Rejected(error.toStudioCommandFailure(context.requestId)) + } + } + override suspend fun bootstrap(): AppSnapshotDto = core.bootstrap() override suspend fun beginGeneratedAccount(): GeneratedRecoveryTicket = @@ -107,6 +174,18 @@ class NativeStudioCoreGateway( ) } +internal fun Throwable.toStudioCommandFailure(fallbackCorrelationId: String): StudioCommandFailure { + val native = this as? StudioException.Failure + return StudioCommandFailure( + code = native?.code ?: WireErrorCode.INTERNAL, + category = native?.category ?: WireErrorCategory.INTERNAL, + retryable = native?.retryable ?: false, + recoveryAction = native?.recoveryAction ?: WireRecoveryAction.NONE, + correlationId = native?.correlationId ?: fallbackCorrelationId, + safeMessage = native?.safeMessage ?: "The application command failed.", + ) +} + private class NativeSubscription( private val subscription: ObserverSubscription, ) : AutoCloseable { 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 @@ -77,6 +77,8 @@ private class ApplicationGateway : StudioCoreGateway { 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") 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 @@ -137,6 +137,12 @@ private class FakeStudioCoreGateway( 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") + fun emit(snapshot: AppSnapshotDto) { current = snapshot observer?.invoke(snapshot) diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioCoreGatewayTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioCoreGatewayTest.kt @@ -0,0 +1,23 @@ +package org.radroots.studio.application + +import org.radroots.studio.ffi.WireErrorCategory +import org.radroots.studio.ffi.WireErrorCode +import org.radroots.studio.ffi.WireRecoveryAction +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class StudioCoreGatewayTest { + @Test + fun unknownFailuresBecomeSanitizedTypedRejections() { + val failure = IllegalStateException("sensitive detail") + .toStudioCommandFailure("request-7") + + assertEquals(WireErrorCode.INTERNAL, failure.code) + assertEquals(WireErrorCategory.INTERNAL, failure.category) + assertEquals(WireRecoveryAction.NONE, failure.recoveryAction) + assertEquals("request-7", failure.correlationId) + assertEquals("The application command failed.", failure.safeMessage) + assertFalse(failure.toString().contains("sensitive detail")) + } +}