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