commit a9fd63975b42e7774283e180abdd32a89af8b4d8
parent 5548b0e6eb65c3c438c09dfc5063cf5e9e6873de
Author: triesap <tyson@radroots.org>
Date: Mon, 10 Aug 2026 02:04:54 +0000
desktop: adapt the native runtime to shared contracts
- Map every generated snapshot, receipt, enum, error, and change into shared models
- Keep UniFFI objects, handles, compatibility checks, and native loading desktop-owned
- Clear imported secret material and close one-use recovery and removal handles deterministically
- Cover exhaustive mappings, revision delivery, redaction, lifecycle, and idempotent shutdown
Diffstat:
3 files changed, 1041 insertions(+), 0 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt
@@ -0,0 +1,421 @@
+package org.harvestcircle.application
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.harvestcircle.ffi.AccountCommandReceiptDto
+import org.harvestcircle.ffi.AccountDto
+import org.harvestcircle.ffi.AppSnapshotDto
+import org.harvestcircle.ffi.GeneratedRecoveryRequest
+import org.harvestcircle.ffi.HarvestCircleAppCore
+import org.harvestcircle.ffi.HarvestCircleChangeObserver
+import org.harvestcircle.ffi.ObserverSubscription
+import org.harvestcircle.ffi.RemovalRequest
+import org.harvestcircle.ffi.RequestContextDto
+import org.harvestcircle.ffi.ShutdownReceiptDto
+import org.harvestcircle.ffi.SnapshotChangeDto
+import org.harvestcircle.ffi.compatibilityDescriptor
+import java.util.concurrent.atomic.AtomicLong
+
+class HarvestCircleRuntimeException(
+ val problem: ApplicationProblem,
+) : Exception(problem.safeMessage)
+
+class NativeHarvestCircleRuntime internal constructor(
+ private val native: NativeCorePort,
+ private val handleIds: NativeHandleIdSource = AtomicNativeHandleIdSource(),
+) : HarvestCircleRuntime {
+ private val recoveryMutex = Mutex()
+ private val recoveryHandles = mutableMapOf<RecoveryRequestId, NativeGeneratedRecoveryHandle>()
+ private val removalMutex = Mutex()
+ private val removalHandles = mutableMapOf<RemovalRequestId, NativeRemovalHandle>()
+ private val shutdownMutex = Mutex()
+ private var shutdownReceipt: ShutdownReceipt? = null
+
+ override suspend fun bootstrap(): ApplicationSnapshot = callNative { native.bootstrap().toApplicationSnapshot() }
+
+ override fun currentSnapshot(): ApplicationSnapshot =
+ try {
+ native.snapshot().toApplicationSnapshot()
+ } catch (error: HarvestCircleRuntimeException) {
+ throw error
+ } catch (error: Exception) {
+ throw HarvestCircleRuntimeException(error.toApplicationProblem())
+ }
+
+ override fun changes(): Flow<ApplicationChange> =
+ callbackFlow {
+ val subscription =
+ callNative {
+ native.subscribe { change ->
+ trySend(change.toApplicationChange())
+ }
+ }
+ awaitClose(subscription::close)
+ }
+
+ override suspend fun execute(command: ApplicationCommand): ApplicationCommandResult =
+ when (command) {
+ is ApplicationCommand.AcknowledgeGeneratedIdentity -> acknowledgeGeneratedIdentity(command)
+ is ApplicationCommand.CancelGeneratedIdentity -> cancelGeneratedIdentity(command)
+ is ApplicationCommand.ImportLocalIdentity -> importLocalIdentity(command)
+ is ApplicationCommand.SelectIdentity -> updated { native.selectIdentity(command.identityId.value) }
+ is ApplicationCommand.ActivateIdentity -> updated { native.activateIdentity(command.identityId.value) }
+ ApplicationCommand.SignOut -> updated { native.signOut() }
+ ApplicationCommand.RefreshActiveProfile -> updated { native.refreshActiveProfile() }
+ is ApplicationCommand.ConfirmIdentityRemoval -> confirmIdentityRemoval(command)
+ }
+
+ override suspend fun prepareLocalIdentity(): GeneratedIdentityRecovery {
+ val handle = callNative { native.beginGeneratedIdentity() }
+ val requestId = RecoveryRequestId.from(handleIds.next("recovery"))
+ return try {
+ val identity = handle.account().toIdentitySummary()
+ val backup = GeneratedKeyBackup(identity.npub, handle.takeRecoverySecret())
+ recoveryMutex.withLock { recoveryHandles[requestId] = handle }
+ GeneratedIdentityRecovery(
+ requestId = requestId,
+ identity = identity,
+ expiresAt = UnixSeconds(handle.expiresAtSeconds()),
+ backup = backup,
+ )
+ } catch (error: CancellationException) {
+ handle.close()
+ throw error
+ } catch (error: HarvestCircleRuntimeException) {
+ handle.close()
+ throw error
+ } catch (error: Exception) {
+ handle.close()
+ throw HarvestCircleRuntimeException(error.toApplicationProblem())
+ }
+ }
+
+ override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest {
+ val handle = callNative { native.requestIdentityRemoval(identityId.value) }
+ val requestId = RemovalRequestId.from(handleIds.next("removal"))
+ return try {
+ val requestedIdentity = IdentityId.fromPublicKeyHex(handle.publicKeyHex())
+ check(requestedIdentity == identityId) { "Native removal identity does not match the request" }
+ removalMutex.withLock { removalHandles[requestId] = handle }
+ IdentityRemovalRequest(
+ requestId = requestId,
+ identityId = requestedIdentity,
+ deletesLocalCredential = handle.deletesLocalCredential(),
+ signsOut = handle.signsOut(),
+ expiresAt = UnixSeconds(handle.expiresAtSeconds()),
+ )
+ } catch (error: CancellationException) {
+ handle.close()
+ throw error
+ } catch (error: HarvestCircleRuntimeException) {
+ handle.close()
+ throw error
+ } catch (error: Exception) {
+ handle.close()
+ throw HarvestCircleRuntimeException(error.toApplicationProblem())
+ }
+ }
+
+ override suspend fun shutdown(): ShutdownReceipt =
+ shutdownMutex.withLock {
+ shutdownReceipt?.let { return@withLock it }
+ closeOutstandingHandles()
+ callNative { native.shutdown().toShutdownReceipt() }.also { receipt ->
+ if (!receipt.closed) throw incompleteShutdown()
+ native.close()
+ shutdownReceipt = receipt
+ }
+ }
+
+ private suspend fun acknowledgeGeneratedIdentity(command: ApplicationCommand.AcknowledgeGeneratedIdentity): ApplicationCommandResult {
+ val handle =
+ recoveryMutex.withLock { recoveryHandles.remove(command.requestId) }
+ ?: throw missingHandle("generated identity recovery", command.context.operationId)
+ return try {
+ updated(command.context.operationId) {
+ native.acknowledgeGeneratedIdentity(command.context.toNative(), handle)
+ }
+ } finally {
+ handle.close()
+ }
+ }
+
+ private suspend fun cancelGeneratedIdentity(command: ApplicationCommand.CancelGeneratedIdentity): ApplicationCommandResult {
+ val handle =
+ recoveryMutex.withLock { recoveryHandles.remove(command.requestId) }
+ ?: throw missingHandle("generated identity recovery")
+ return try {
+ callNative { check(native.cancelGeneratedIdentity(handle)) { "Native recovery cancellation was incomplete" } }
+ ApplicationCommandResult.Updated(currentSnapshot())
+ } finally {
+ handle.close()
+ }
+ }
+
+ private suspend fun importLocalIdentity(command: ApplicationCommand.ImportLocalIdentity): ApplicationCommandResult {
+ val bytes = command.secretKey.take().encodeToByteArray()
+ return try {
+ callNative(command.context.operationId) {
+ native.importIdentity(command.context.toNative(), bytes).toApplicationResult()
+ }
+ } finally {
+ bytes.fill(0)
+ command.secretKey.clear()
+ }
+ }
+
+ private suspend fun confirmIdentityRemoval(command: ApplicationCommand.ConfirmIdentityRemoval): ApplicationCommandResult {
+ val handle =
+ removalMutex.withLock { removalHandles.remove(command.requestId) }
+ ?: throw missingHandle("identity removal", command.context.operationId)
+ return try {
+ updated(command.context.operationId) {
+ native.confirmIdentityRemoval(command.context.toNative(), handle)
+ }
+ } finally {
+ handle.close()
+ }
+ }
+
+ private suspend fun updated(
+ operationId: OperationId? = null,
+ operation: suspend () -> AppSnapshotDto,
+ ): ApplicationCommandResult = callNative(operationId) { ApplicationCommandResult.Updated(operation().toApplicationSnapshot()) }
+
+ private suspend fun closeOutstandingHandles() {
+ recoveryMutex.withLock {
+ recoveryHandles.values.forEach(NativeGeneratedRecoveryHandle::close)
+ recoveryHandles.clear()
+ }
+ removalMutex.withLock {
+ removalHandles.values.forEach(NativeRemovalHandle::close)
+ removalHandles.clear()
+ }
+ }
+
+ private fun missingHandle(
+ kind: String,
+ operationId: OperationId? = null,
+ ): HarvestCircleRuntimeException =
+ HarvestCircleRuntimeException(
+ ApplicationProblem(
+ code = ApplicationErrorCode.InvalidApplicationState,
+ category = ApplicationErrorCategory.Lifecycle,
+ retryable = false,
+ recoveryAction = RecoveryAction.None,
+ operationId = operationId,
+ safeMessage = "The $kind request is no longer available.",
+ ),
+ )
+
+ private fun incompleteShutdown(): HarvestCircleRuntimeException =
+ HarvestCircleRuntimeException(
+ ApplicationProblem(
+ code = ApplicationErrorCode.InvalidApplicationState,
+ category = ApplicationErrorCategory.Lifecycle,
+ retryable = false,
+ recoveryAction = RecoveryAction.None,
+ operationId = null,
+ safeMessage = "The native runtime did not complete shutdown.",
+ ),
+ )
+
+ private suspend fun <T> callNative(
+ fallbackOperationId: OperationId? = null,
+ operation: suspend () -> T,
+ ): T =
+ try {
+ operation()
+ } catch (error: CancellationException) {
+ throw error
+ } catch (error: HarvestCircleRuntimeException) {
+ throw error
+ } catch (error: Exception) {
+ throw HarvestCircleRuntimeException(error.toApplicationProblem(fallbackOperationId))
+ }
+
+ companion object {
+ fun open(developmentMode: Boolean): NativeHarvestCircleRuntime {
+ val expectation = verifyNativeCompatibility(compatibilityDescriptor())
+ return NativeHarvestCircleRuntime(
+ UniFfiNativeCorePort(HarvestCircleAppCore.openCompatible(expectation, developmentMode)),
+ )
+ }
+ }
+}
+
+internal fun interface NativeHandleIdSource {
+ fun next(kind: String): String
+}
+
+private class AtomicNativeHandleIdSource : NativeHandleIdSource {
+ private val next = AtomicLong(1)
+
+ override fun next(kind: String): String = "native-$kind:${next.getAndIncrement()}"
+}
+
+internal interface NativeCorePort : AutoCloseable {
+ fun snapshot(): AppSnapshotDto
+
+ suspend fun bootstrap(): AppSnapshotDto
+
+ suspend fun subscribe(onChange: (SnapshotChangeDto) -> Unit): NativeSubscriptionHandle
+
+ suspend fun beginGeneratedIdentity(): NativeGeneratedRecoveryHandle
+
+ suspend fun acknowledgeGeneratedIdentity(
+ context: RequestContextDto,
+ request: NativeGeneratedRecoveryHandle,
+ ): AppSnapshotDto
+
+ suspend fun cancelGeneratedIdentity(request: NativeGeneratedRecoveryHandle): Boolean
+
+ suspend fun importIdentity(
+ context: RequestContextDto,
+ secretKey: ByteArray,
+ ): AccountCommandReceiptDto
+
+ suspend fun selectIdentity(publicKeyHex: String): AppSnapshotDto
+
+ suspend fun activateIdentity(publicKeyHex: String): AppSnapshotDto
+
+ suspend fun signOut(): AppSnapshotDto
+
+ suspend fun refreshActiveProfile(): AppSnapshotDto
+
+ suspend fun requestIdentityRemoval(publicKeyHex: String): NativeRemovalHandle
+
+ suspend fun confirmIdentityRemoval(
+ context: RequestContextDto,
+ request: NativeRemovalHandle,
+ ): AppSnapshotDto
+
+ suspend fun shutdown(): ShutdownReceiptDto
+}
+
+internal interface NativeGeneratedRecoveryHandle : AutoCloseable {
+ fun account(): AccountDto
+
+ fun expiresAtSeconds(): Long
+
+ fun takeRecoverySecret(): String
+}
+
+internal interface NativeRemovalHandle : AutoCloseable {
+ fun publicKeyHex(): String
+
+ fun deletesLocalCredential(): Boolean
+
+ fun signsOut(): Boolean
+
+ fun expiresAtSeconds(): Long
+}
+
+internal fun interface NativeSubscriptionHandle : AutoCloseable {
+ override fun close()
+}
+
+private class UniFfiNativeCorePort(
+ private val core: HarvestCircleAppCore,
+) : NativeCorePort {
+ override fun snapshot(): AppSnapshotDto = core.snapshot()
+
+ override suspend fun bootstrap(): AppSnapshotDto = core.bootstrap()
+
+ override suspend fun subscribe(onChange: (SnapshotChangeDto) -> Unit): NativeSubscriptionHandle {
+ val subscription =
+ core.subscribeChangesV2(
+ object : HarvestCircleChangeObserver {
+ override fun onChange(change: SnapshotChangeDto) = onChange(change)
+ },
+ )
+ return UniFfiNativeSubscriptionHandle(subscription)
+ }
+
+ override suspend fun beginGeneratedIdentity(): NativeGeneratedRecoveryHandle =
+ UniFfiGeneratedRecoveryHandle(core.beginGeneratedAccountV2())
+
+ override suspend fun acknowledgeGeneratedIdentity(
+ context: RequestContextDto,
+ request: NativeGeneratedRecoveryHandle,
+ ): AppSnapshotDto = core.acknowledgeGeneratedAccountV2(context, request.generated())
+
+ override suspend fun cancelGeneratedIdentity(request: NativeGeneratedRecoveryHandle): Boolean =
+ core.cancelGeneratedAccountV2(request.generated())
+
+ override suspend fun importIdentity(
+ context: RequestContextDto,
+ secretKey: ByteArray,
+ ): AccountCommandReceiptDto = core.importAccountV2(context, secretKey)
+
+ override suspend fun selectIdentity(publicKeyHex: String): AppSnapshotDto = core.selectAccount(publicKeyHex)
+
+ override suspend fun activateIdentity(publicKeyHex: String): AppSnapshotDto = core.activateAccount(publicKeyHex)
+
+ override suspend fun signOut(): AppSnapshotDto = core.signOut()
+
+ override suspend fun refreshActiveProfile(): AppSnapshotDto = core.refreshActiveProfile()
+
+ override suspend fun requestIdentityRemoval(publicKeyHex: String): NativeRemovalHandle =
+ UniFfiRemovalHandle(core.requestAccountRemoval(publicKeyHex))
+
+ override suspend fun confirmIdentityRemoval(
+ context: RequestContextDto,
+ request: NativeRemovalHandle,
+ ): AppSnapshotDto = core.confirmAccountRemoval(context, request.generated())
+
+ override suspend fun shutdown(): ShutdownReceiptDto = core.shutdownV2()
+
+ override fun close() = core.close()
+
+ private fun NativeGeneratedRecoveryHandle.generated(): GeneratedRecoveryRequest =
+ (this as? UniFfiGeneratedRecoveryHandle)?.request
+ ?: error("Generated recovery handle does not belong to this native runtime")
+
+ private fun NativeRemovalHandle.generated(): RemovalRequest =
+ (this as? UniFfiRemovalHandle)?.request
+ ?: error("Removal handle does not belong to this native runtime")
+}
+
+private class UniFfiGeneratedRecoveryHandle(
+ val request: GeneratedRecoveryRequest,
+) : NativeGeneratedRecoveryHandle {
+ override fun account(): AccountDto = request.account()
+
+ override fun expiresAtSeconds(): Long = request.expiresAtSeconds()
+
+ override fun takeRecoverySecret(): String = request.takeRecoveryNsec()
+
+ override fun close() = request.close()
+}
+
+private class UniFfiRemovalHandle(
+ val request: RemovalRequest,
+) : NativeRemovalHandle {
+ override fun publicKeyHex(): String = request.publicKeyHex()
+
+ override fun deletesLocalCredential(): Boolean = request.deletesLocalCredential()
+
+ override fun signsOut(): Boolean = request.signsOut()
+
+ override fun expiresAtSeconds(): Long = request.expiresAtSeconds()
+
+ override fun close() = request.close()
+}
+
+private class UniFfiNativeSubscriptionHandle(
+ private val subscription: ObserverSubscription,
+) : NativeSubscriptionHandle {
+ override fun close() {
+ try {
+ runBlocking { subscription.unsubscribe() }
+ } finally {
+ subscription.close()
+ }
+ }
+}
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt
@@ -0,0 +1,220 @@
+package org.harvestcircle.application
+
+import org.harvestcircle.ffi.AccountCommandReceiptDto
+import org.harvestcircle.ffi.AccountDto
+import org.harvestcircle.ffi.ActiveAccountDto
+import org.harvestcircle.ffi.AppLifecycleDto
+import org.harvestcircle.ffi.AppSnapshotDto
+import org.harvestcircle.ffi.HarvestCircleException
+import org.harvestcircle.ffi.KeyAvailabilityDto
+import org.harvestcircle.ffi.ProfileDto
+import org.harvestcircle.ffi.ProfileLoadStateDto
+import org.harvestcircle.ffi.RelayConnectionStateDto
+import org.harvestcircle.ffi.RequestContextDto
+import org.harvestcircle.ffi.SafeErrorDto
+import org.harvestcircle.ffi.SessionStateDto
+import org.harvestcircle.ffi.ShutdownReceiptDto
+import org.harvestcircle.ffi.SignerKindDto
+import org.harvestcircle.ffi.SnapshotChangeDto
+import org.harvestcircle.ffi.WireErrorCategory
+import org.harvestcircle.ffi.WireErrorCode
+import org.harvestcircle.ffi.WireRecoveryAction
+
+internal fun RequestContext.toNative(): RequestContextDto =
+ RequestContextDto(
+ requestId = operationId.value,
+ expectedRevision = expectedRevision.value,
+ deadlineMillis = deadlineMillis,
+ )
+
+internal fun AccountCommandReceiptDto.toApplicationResult(): ApplicationCommandResult.Committed =
+ ApplicationCommandResult.Committed(
+ operationId = OperationId.from(requestId),
+ committedRevision = SnapshotRevision(committedRevision),
+ snapshot = snapshot.toApplicationSnapshot(),
+ )
+
+internal fun AccountDto.toIdentitySummary(): IdentitySummary =
+ IdentitySummary(
+ id = IdentityId.fromPublicKeyHex(publicKeyHex),
+ npub = npub,
+ displayLabel = displayLabel,
+ signer = SignerBindingSummary(signerKind.toSignerBindingKind(), keyAvailability.toSignerAvailability()),
+ createdAt = UnixSeconds(createdAtSeconds),
+ lastUsedAt = lastUsedAtSeconds?.let(::UnixSeconds),
+ )
+
+internal fun ActiveAccountDto.toActiveIdentity(configuredRelays: List<String>): ActiveIdentity =
+ ActiveIdentity(
+ identity = account.toIdentitySummary(),
+ relays = RelaySummary(configuredRelays, relayState.toRelayConnectionState()),
+ profileState = profileState.toProfileLoadState(),
+ profile = profile?.toProfileSummary(),
+ )
+
+internal fun AppSnapshotDto.toApplicationSnapshot(): ApplicationSnapshot =
+ ApplicationSnapshot(
+ revision = SnapshotRevision(revision),
+ lifecycle = lifecycle.toApplicationLifecycle(),
+ lifecycleProblem = lifecycleError?.toApplicationProblem(),
+ configuredRelays = configuredRelays,
+ identities = accounts.map(AccountDto::toIdentitySummary),
+ selectedIdentityId = selectedPublicKeyHex?.let(IdentityId::fromPublicKeyHex),
+ session = session.toSessionLifecycle(),
+ sessionSubjectIdentityId = sessionSubjectPublicKeyHex?.let(IdentityId::fromPublicKeyHex),
+ sessionProblem = sessionError?.toApplicationProblem(),
+ activeIdentity = activeAccount?.toActiveIdentity(configuredRelays),
+ recoverableProblem = recoverableProblem?.toApplicationProblem(),
+ )
+
+internal fun ProfileDto.toProfileSummary(): ProfileSummary =
+ ProfileSummary(
+ name = name,
+ displayName = displayName,
+ nip05 = nip05,
+ about = about,
+ picture = picture,
+ )
+
+internal fun SafeErrorDto.toApplicationProblem(): ApplicationProblem =
+ ApplicationProblem(
+ code = code.toApplicationErrorCode(),
+ category = category.toApplicationErrorCategory(),
+ retryable = retryable,
+ recoveryAction = recoveryAction.toRecoveryAction(),
+ operationId = null,
+ safeMessage = message,
+ )
+
+internal fun SnapshotChangeDto.toApplicationChange(): ApplicationChange =
+ ApplicationChange(
+ snapshot = snapshot.toApplicationSnapshot(),
+ previousRevision = previousRevision?.let(::SnapshotRevision),
+ )
+
+internal fun ShutdownReceiptDto.toShutdownReceipt(): ShutdownReceipt =
+ ShutdownReceipt(
+ finalRevision = SnapshotRevision(finalRevision),
+ closed = closed,
+ )
+
+internal fun Throwable.toApplicationProblem(fallbackOperationId: OperationId? = null): ApplicationProblem {
+ val native = this as? HarvestCircleException.Failure
+ return ApplicationProblem(
+ code = native?.code?.toApplicationErrorCode() ?: ApplicationErrorCode.Internal,
+ category = native?.category?.toApplicationErrorCategory() ?: ApplicationErrorCategory.Internal,
+ retryable = native?.retryable ?: false,
+ recoveryAction = native?.recoveryAction?.toRecoveryAction() ?: RecoveryAction.None,
+ operationId = native?.correlationId?.let { runCatching { OperationId.from(it) }.getOrNull() } ?: fallbackOperationId,
+ safeMessage = native?.safeMessage ?: "The application command failed.",
+ )
+}
+
+internal fun AppLifecycleDto.toApplicationLifecycle(): ApplicationLifecycle =
+ when (this) {
+ AppLifecycleDto.OPENING -> ApplicationLifecycle.Opening
+ AppLifecycleDto.COMPATIBILITY_CHECKING -> ApplicationLifecycle.CompatibilityChecking
+ AppLifecycleDto.ACQUIRING_OWNERSHIP -> ApplicationLifecycle.AcquiringOwnership
+ AppLifecycleDto.MIGRATING -> ApplicationLifecycle.Migrating
+ AppLifecycleDto.RECOVERING -> ApplicationLifecycle.Recovering
+ AppLifecycleDto.READY -> ApplicationLifecycle.Ready
+ AppLifecycleDto.DEGRADED -> ApplicationLifecycle.Degraded
+ AppLifecycleDto.BLOCKED -> ApplicationLifecycle.Blocked
+ AppLifecycleDto.SHUTTING_DOWN -> ApplicationLifecycle.ShuttingDown
+ AppLifecycleDto.CLOSED -> ApplicationLifecycle.Closed
+ AppLifecycleDto.FATAL -> ApplicationLifecycle.Fatal
+ }
+
+internal fun SessionStateDto.toSessionLifecycle(): SessionLifecycle =
+ when (this) {
+ SessionStateDto.SIGNED_OUT -> SessionLifecycle.SignedOut
+ SessionStateDto.ACTIVATING -> SessionLifecycle.Activating
+ SessionStateDto.ACTIVE -> SessionLifecycle.Active
+ SessionStateDto.SIGNING_OUT -> SessionLifecycle.SigningOut
+ SessionStateDto.FAILED -> SessionLifecycle.Failed
+ }
+
+internal fun SignerKindDto.toSignerBindingKind(): SignerBindingKind =
+ when (this) {
+ SignerKindDto.LOCAL_SECRET -> SignerBindingKind.LocalKeyring
+ SignerKindDto.WATCH_ONLY -> SignerBindingKind.Unsupported("read-only")
+ SignerKindDto.REMOTE_NIP46 -> SignerBindingKind.Unsupported("remote-nip46")
+ }
+
+internal fun KeyAvailabilityDto.toSignerAvailability(): SignerAvailability =
+ when (this) {
+ KeyAvailabilityDto.AVAILABLE -> SignerAvailability.Available
+ KeyAvailabilityDto.CREDENTIAL_MISSING -> SignerAvailability.CredentialMissing
+ KeyAvailabilityDto.STORE_UNAVAILABLE -> SignerAvailability.StoreUnavailable
+ KeyAvailabilityDto.NOT_REQUIRED -> SignerAvailability.NotRequired
+ }
+
+internal fun RelayConnectionStateDto.toRelayConnectionState(): RelayConnectionState =
+ when (this) {
+ RelayConnectionStateDto.DISCONNECTED -> RelayConnectionState.Disconnected
+ RelayConnectionStateDto.CONNECTING -> RelayConnectionState.Connecting
+ RelayConnectionStateDto.CONNECTED -> RelayConnectionState.Connected
+ RelayConnectionStateDto.DEGRADED -> RelayConnectionState.Degraded
+ RelayConnectionStateDto.ERROR -> RelayConnectionState.Error
+ }
+
+internal fun ProfileLoadStateDto.toProfileLoadState(): ProfileLoadState =
+ when (this) {
+ ProfileLoadStateDto.EMPTY -> ProfileLoadState.Empty
+ ProfileLoadStateDto.LOADING -> ProfileLoadState.Loading
+ ProfileLoadStateDto.CACHED -> ProfileLoadState.Cached
+ ProfileLoadStateDto.FRESH -> ProfileLoadState.Fresh
+ ProfileLoadStateDto.ERROR -> ProfileLoadState.Error
+ }
+
+internal fun WireErrorCode.toApplicationErrorCode(): ApplicationErrorCode =
+ when (this) {
+ WireErrorCode.INVALID_PUBLIC_KEY -> ApplicationErrorCode.InvalidPublicKey
+ WireErrorCode.INVALID_SECRET_KEY -> ApplicationErrorCode.InvalidSecretKey
+ WireErrorCode.INVALID_ACCOUNT_METADATA -> ApplicationErrorCode.InvalidIdentityMetadata
+ WireErrorCode.INVALID_PROFILE_METADATA -> ApplicationErrorCode.InvalidProfileMetadata
+ WireErrorCode.INVALID_APPLICATION_STATE -> ApplicationErrorCode.InvalidApplicationState
+ WireErrorCode.ACCOUNT_ALREADY_EXISTS -> ApplicationErrorCode.IdentityAlreadyExists
+ WireErrorCode.ACCOUNT_NOT_FOUND -> ApplicationErrorCode.IdentityNotFound
+ WireErrorCode.KEYRING_UNAVAILABLE -> ApplicationErrorCode.KeyringUnavailable
+ WireErrorCode.CREDENTIAL_MISSING -> ApplicationErrorCode.CredentialMissing
+ WireErrorCode.STORAGE_UNAVAILABLE -> ApplicationErrorCode.StorageUnavailable
+ WireErrorCode.STORAGE_CORRUPT -> ApplicationErrorCode.StorageCorrupt
+ WireErrorCode.STORAGE_QUARANTINED -> ApplicationErrorCode.StorageQuarantined
+ WireErrorCode.STORAGE_BACKUP_INVALID -> ApplicationErrorCode.StorageBackupInvalid
+ WireErrorCode.UNSUPPORTED_SCHEMA_VERSION -> ApplicationErrorCode.UnsupportedSchemaVersion
+ WireErrorCode.REPAIR_UNAUTHORIZED -> ApplicationErrorCode.RepairUnauthorized
+ WireErrorCode.PENDING_OPERATION_RECOVERY_REQUIRED -> ApplicationErrorCode.PendingOperationRecoveryRequired
+ WireErrorCode.INVALID_RELAY_CONFIGURATION -> ApplicationErrorCode.InvalidRelayConfiguration
+ WireErrorCode.RELAY_CONNECTION_FAILED -> ApplicationErrorCode.RelayConnectionFailed
+ WireErrorCode.PROFILE_REFRESH_FAILED -> ApplicationErrorCode.ProfileRefreshFailed
+ WireErrorCode.OBSERVER_REGISTRATION_FAILED -> ApplicationErrorCode.ObserverRegistrationFailed
+ WireErrorCode.NATIVE_LIBRARY_LOAD_FAILED -> ApplicationErrorCode.NativeLibraryLoadFailed
+ WireErrorCode.COMPATIBILITY_MISMATCH -> ApplicationErrorCode.CompatibilityMismatch
+ WireErrorCode.INTERNAL -> ApplicationErrorCode.Internal
+ }
+
+internal fun WireErrorCategory.toApplicationErrorCategory(): ApplicationErrorCategory =
+ when (this) {
+ WireErrorCategory.INPUT -> ApplicationErrorCategory.Input
+ WireErrorCategory.CONFLICT -> ApplicationErrorCategory.Conflict
+ WireErrorCategory.CREDENTIAL -> ApplicationErrorCategory.Credential
+ WireErrorCategory.STORAGE -> ApplicationErrorCategory.Storage
+ WireErrorCategory.NETWORK -> ApplicationErrorCategory.Network
+ WireErrorCategory.LIFECYCLE -> ApplicationErrorCategory.Lifecycle
+ WireErrorCategory.COMPATIBILITY -> ApplicationErrorCategory.Compatibility
+ WireErrorCategory.INTERNAL -> ApplicationErrorCategory.Internal
+ }
+
+internal fun WireRecoveryAction.toRecoveryAction(): RecoveryAction =
+ when (this) {
+ WireRecoveryAction.NONE -> RecoveryAction.None
+ WireRecoveryAction.RETRY -> RecoveryAction.Retry
+ WireRecoveryAction.REPAIR_CREDENTIAL -> RecoveryAction.RepairCredential
+ WireRecoveryAction.AUTHENTICATE -> RecoveryAction.Authenticate
+ WireRecoveryAction.REPAIR_STORAGE -> RecoveryAction.RepairStorage
+ WireRecoveryAction.RESTORE_BACKUP -> RecoveryAction.RestoreBackup
+ WireRecoveryAction.CHECK_CONFIGURATION -> RecoveryAction.CheckConfiguration
+ WireRecoveryAction.RESTART_APPLICATION -> RecoveryAction.RestartApplication
+ WireRecoveryAction.UPDATE_APPLICATION -> RecoveryAction.UpdateApplication
+ }
diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt
@@ -0,0 +1,400 @@
+package org.harvestcircle.application
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.harvestcircle.ffi.AccountCommandReceiptDto
+import org.harvestcircle.ffi.AccountDto
+import org.harvestcircle.ffi.ActiveAccountDto
+import org.harvestcircle.ffi.AppLifecycleDto
+import org.harvestcircle.ffi.AppSnapshotDto
+import org.harvestcircle.ffi.HarvestCircleException
+import org.harvestcircle.ffi.KeyAvailabilityDto
+import org.harvestcircle.ffi.ProfileDto
+import org.harvestcircle.ffi.ProfileLoadStateDto
+import org.harvestcircle.ffi.RelayConnectionStateDto
+import org.harvestcircle.ffi.RequestContextDto
+import org.harvestcircle.ffi.SafeErrorDto
+import org.harvestcircle.ffi.SessionStateDto
+import org.harvestcircle.ffi.ShutdownReceiptDto
+import org.harvestcircle.ffi.SignerKindDto
+import org.harvestcircle.ffi.SnapshotChangeDto
+import org.harvestcircle.ffi.WireErrorCategory
+import org.harvestcircle.ffi.WireErrorCode
+import org.harvestcircle.ffi.WireRecoveryAction
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class NativeRuntimeMappingsTest {
+ @Test
+ fun everyGeneratedEnumMapsExhaustively() {
+ assertEquals(
+ 11,
+ AppLifecycleDto.entries
+ .map(AppLifecycleDto::toApplicationLifecycle)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 5,
+ SessionStateDto.entries
+ .map(SessionStateDto::toSessionLifecycle)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 3,
+ SignerKindDto.entries
+ .map(SignerKindDto::toSignerBindingKind)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 4,
+ KeyAvailabilityDto.entries
+ .map(KeyAvailabilityDto::toSignerAvailability)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 5,
+ RelayConnectionStateDto.entries
+ .map(RelayConnectionStateDto::toRelayConnectionState)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 5,
+ ProfileLoadStateDto.entries
+ .map(ProfileLoadStateDto::toProfileLoadState)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 23,
+ WireErrorCode.entries
+ .map(WireErrorCode::toApplicationErrorCode)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 8,
+ WireErrorCategory.entries
+ .map(WireErrorCategory::toApplicationErrorCategory)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 9,
+ WireRecoveryAction.entries
+ .map(WireRecoveryAction::toRecoveryAction)
+ .distinct()
+ .size,
+ )
+ }
+
+ @Test
+ fun snapshotMapperPreservesEveryRecordAndOptionalField() {
+ val native = populatedSnapshot(revision = 2UL)
+ val mapped = native.toApplicationSnapshot()
+
+ assertEquals(SnapshotRevision(2UL), mapped.revision)
+ assertEquals(ApplicationLifecycle.Degraded, mapped.lifecycle)
+ assertEquals(ApplicationErrorCode.RelayConnectionFailed, mapped.lifecycleProblem?.code)
+ assertEquals(native.configuredRelays, mapped.configuredRelays)
+ assertEquals(
+ native.accounts.single().publicKeyHex,
+ mapped.identities
+ .single()
+ .id.value,
+ )
+ assertEquals(UnixSeconds(2), mapped.identities.single().lastUsedAt)
+ assertEquals(mapped.identities.single().id, mapped.selectedIdentityId)
+ assertEquals(mapped.identities.single().id, mapped.sessionSubjectIdentityId)
+ assertEquals(ProfileLoadState.Fresh, mapped.activeIdentity?.profileState)
+ assertEquals("display", mapped.activeIdentity?.profile?.displayName)
+ assertEquals(ApplicationErrorCode.CredentialMissing, mapped.sessionProblem?.code)
+ assertEquals(ApplicationErrorCode.StorageCorrupt, mapped.recoverableProblem?.code)
+
+ val empty = emptySnapshot().toApplicationSnapshot()
+ assertNull(empty.lifecycleProblem)
+ assertNull(empty.selectedIdentityId)
+ assertNull(empty.sessionSubjectIdentityId)
+ assertNull(empty.sessionProblem)
+ assertNull(empty.activeIdentity)
+ assertNull(empty.recoverableProblem)
+ }
+
+ @Test
+ fun receiptChangeContextAndShutdownMappingsPreserveRevisions() {
+ val snapshot = populatedSnapshot(revision = 2UL)
+ val result =
+ AccountCommandReceiptDto("operation-7", 2UL, snapshot)
+ .toApplicationResult()
+ assertEquals(OperationId.from("operation-7"), result.operationId)
+ assertEquals(SnapshotRevision(2UL), result.committedRevision)
+
+ val change = SnapshotChangeDto(snapshot, 1UL).toApplicationChange()
+ assertEquals(SnapshotRevision(1UL), change.previousRevision)
+ assertEquals(SnapshotRevision(2UL), change.snapshot.revision)
+
+ val context = RequestContext(OperationId.from("operation-7"), SnapshotRevision(1UL), 5_000UL).toNative()
+ assertEquals("operation-7", context.requestId)
+ assertEquals(1UL, context.expectedRevision)
+ assertEquals(5_000UL, context.deadlineMillis)
+
+ assertEquals(
+ ShutdownReceipt(SnapshotRevision(2UL), true),
+ ShutdownReceiptDto(2UL, true).toShutdownReceipt(),
+ )
+ }
+
+ @Test
+ fun nativeAndUnknownErrorsBecomeStructuredAndSecretSafe() {
+ val native =
+ HarvestCircleException
+ .Failure(
+ code = WireErrorCode.CREDENTIAL_MISSING,
+ category = WireErrorCategory.CREDENTIAL,
+ retryable = false,
+ recoveryAction = WireRecoveryAction.REPAIR_CREDENTIAL,
+ correlationId = "operation-7",
+ safeMessage = "The local credential is unavailable.",
+ ).toApplicationProblem()
+ assertEquals(ApplicationErrorCode.CredentialMissing, native.code)
+ assertEquals(OperationId.from("operation-7"), native.operationId)
+ assertEquals(RecoveryAction.RepairCredential, native.recoveryAction)
+
+ val unknown = IllegalStateException("sensitive detail").toApplicationProblem(OperationId.from("fallback-1"))
+ assertEquals(ApplicationErrorCode.Internal, unknown.code)
+ assertEquals("The application command failed.", unknown.safeMessage)
+ assertEquals(OperationId.from("fallback-1"), unknown.operationId)
+ assertFalse(unknown.toString().contains("sensitive detail"))
+ }
+
+ @Test
+ fun signerVariantsRemainExplicitWithoutClaimingFutureSupport() {
+ assertEquals(SignerBindingKind.LocalKeyring, SignerKindDto.LOCAL_SECRET.toSignerBindingKind())
+ assertIs<SignerBindingKind.Unsupported>(SignerKindDto.WATCH_ONLY.toSignerBindingKind())
+ assertIs<SignerBindingKind.Unsupported>(SignerKindDto.REMOTE_NIP46.toSignerBindingKind())
+ }
+}
+
+class NativeHarvestCircleRuntimeTest {
+ @Test
+ fun adapterOwnsCommandsHandlesAndIdempotentShutdown() =
+ runTest {
+ val port = FakeNativeCorePort()
+ val runtime = NativeHarvestCircleRuntime(port, NativeHandleIdSource { kind -> "$kind-1" })
+ val context = RequestContext(OperationId.from("operation-7"), SnapshotRevision(2UL), 5_000UL)
+
+ val secret = SecretKeyInput.from("nsec1boundedsecret")
+ val imported =
+ runtime.execute(ApplicationCommand.ImportLocalIdentity(secret, context))
+ assertIs<ApplicationCommandResult.Committed>(imported)
+ assertEquals("nsec1boundedsecret", port.importedSecret?.decodeToString())
+ assertFailsWith<IllegalStateException> { secret.take() }
+
+ val recovery = runtime.prepareLocalIdentity()
+ assertEquals("recovery-1", recovery.requestId.value)
+ assertEquals("nsec1generated", recovery.backup.revealNsec())
+ runtime.execute(ApplicationCommand.AcknowledgeGeneratedIdentity(recovery.requestId, context))
+ assertTrue(port.generated.closed)
+
+ val identityId = IdentityId.fromPublicKeyHex(nativeAccount().publicKeyHex)
+ val removal = runtime.requestIdentityRemoval(identityId)
+ assertEquals("removal-1", removal.requestId.value)
+ runtime.execute(ApplicationCommand.ConfirmIdentityRemoval(removal.requestId, context))
+ assertTrue(port.removal.closed)
+
+ val first = runtime.shutdown()
+ val repeated = runtime.shutdown()
+ assertEquals(first, repeated)
+ assertEquals(1, port.shutdownCalls)
+ assertTrue(port.closed)
+ }
+
+ @Test
+ fun observerChangesPreservePredecessorRevisionAndCloseSubscription() =
+ runTest {
+ val port = FakeNativeCorePort()
+ val runtime = NativeHarvestCircleRuntime(port)
+ val pending = async { runtime.changes().first() }
+ runCurrent()
+
+ port.emit(SnapshotChangeDto(populatedSnapshot(2UL), 1UL))
+ val change = pending.await()
+
+ assertEquals(SnapshotRevision(1UL), change.previousRevision)
+ assertEquals(SnapshotRevision(2UL), change.snapshot.revision)
+ runCurrent()
+ assertTrue(port.subscriptionClosed)
+ }
+}
+
+private class FakeNativeCorePort : NativeCorePort {
+ val generated = FakeGeneratedRecoveryHandle()
+ val removal = FakeRemovalHandle()
+ var importedSecret: ByteArray? = null
+ var shutdownCalls = 0
+ var closed = false
+ var subscriptionClosed = false
+ private var observer: ((SnapshotChangeDto) -> Unit)? = null
+ private val snapshot = populatedSnapshot(2UL)
+
+ override fun snapshot(): AppSnapshotDto = snapshot
+
+ override suspend fun bootstrap(): AppSnapshotDto = snapshot
+
+ override suspend fun subscribe(onChange: (SnapshotChangeDto) -> Unit): NativeSubscriptionHandle {
+ observer = onChange
+ return NativeSubscriptionHandle { subscriptionClosed = true }
+ }
+
+ fun emit(change: SnapshotChangeDto) {
+ checkNotNull(observer)(change)
+ }
+
+ override suspend fun beginGeneratedIdentity(): NativeGeneratedRecoveryHandle = generated
+
+ override suspend fun acknowledgeGeneratedIdentity(
+ context: RequestContextDto,
+ request: NativeGeneratedRecoveryHandle,
+ ): AppSnapshotDto {
+ assertEquals("operation-7", context.requestId)
+ assertEquals(generated, request)
+ return snapshot
+ }
+
+ override suspend fun cancelGeneratedIdentity(request: NativeGeneratedRecoveryHandle): Boolean = true
+
+ override suspend fun importIdentity(
+ context: RequestContextDto,
+ secretKey: ByteArray,
+ ): AccountCommandReceiptDto {
+ importedSecret = secretKey.copyOf()
+ return AccountCommandReceiptDto(context.requestId, snapshot.revision, snapshot)
+ }
+
+ override suspend fun selectIdentity(publicKeyHex: String): AppSnapshotDto = snapshot
+
+ override suspend fun activateIdentity(publicKeyHex: String): AppSnapshotDto = snapshot
+
+ override suspend fun signOut(): AppSnapshotDto = emptySnapshot()
+
+ override suspend fun refreshActiveProfile(): AppSnapshotDto = snapshot
+
+ override suspend fun requestIdentityRemoval(publicKeyHex: String): NativeRemovalHandle = removal
+
+ override suspend fun confirmIdentityRemoval(
+ context: RequestContextDto,
+ request: NativeRemovalHandle,
+ ): AppSnapshotDto {
+ assertEquals("operation-7", context.requestId)
+ assertEquals(removal, request)
+ return snapshot
+ }
+
+ override suspend fun shutdown(): ShutdownReceiptDto {
+ shutdownCalls += 1
+ return ShutdownReceiptDto(snapshot.revision, true)
+ }
+
+ override fun close() {
+ closed = true
+ }
+}
+
+private class FakeGeneratedRecoveryHandle : NativeGeneratedRecoveryHandle {
+ var closed = false
+
+ override fun account(): AccountDto = nativeAccount()
+
+ override fun expiresAtSeconds(): Long = 100
+
+ override fun takeRecoverySecret(): String = "nsec1generated"
+
+ override fun close() {
+ closed = true
+ }
+}
+
+private class FakeRemovalHandle : NativeRemovalHandle {
+ var closed = false
+
+ override fun publicKeyHex(): String = nativeAccount().publicKeyHex
+
+ override fun deletesLocalCredential(): Boolean = true
+
+ override fun signsOut(): Boolean = true
+
+ override fun expiresAtSeconds(): Long = 100
+
+ override fun close() {
+ closed = true
+ }
+}
+
+private fun populatedSnapshot(revision: ULong): AppSnapshotDto {
+ val account = nativeAccount()
+ return AppSnapshotDto(
+ revision = revision,
+ lifecycle = AppLifecycleDto.DEGRADED,
+ lifecycleError = safeError(WireErrorCode.RELAY_CONNECTION_FAILED),
+ configuredRelays = listOf("wss://relay.example"),
+ accounts = listOf(account),
+ selectedPublicKeyHex = account.publicKeyHex,
+ session = SessionStateDto.ACTIVE,
+ sessionSubjectPublicKeyHex = account.publicKeyHex,
+ sessionError = safeError(WireErrorCode.CREDENTIAL_MISSING),
+ activeAccount =
+ ActiveAccountDto(
+ account = account,
+ relayState = RelayConnectionStateDto.CONNECTED,
+ profileState = ProfileLoadStateDto.FRESH,
+ profile = ProfileDto("name", "display", "name@example.com", "about", "https://example.com/p.png"),
+ ),
+ recoverableProblem = safeError(WireErrorCode.STORAGE_CORRUPT),
+ )
+}
+
+private fun emptySnapshot(): AppSnapshotDto =
+ AppSnapshotDto(
+ revision = 0UL,
+ lifecycle = AppLifecycleDto.READY,
+ lifecycleError = null,
+ configuredRelays = emptyList(),
+ accounts = emptyList(),
+ selectedPublicKeyHex = null,
+ session = SessionStateDto.SIGNED_OUT,
+ sessionSubjectPublicKeyHex = null,
+ sessionError = null,
+ activeAccount = null,
+ recoverableProblem = null,
+ )
+
+private fun nativeAccount(): AccountDto =
+ AccountDto(
+ publicKeyHex = "01".repeat(32),
+ npub = "npub1identity",
+ displayLabel = "Identity",
+ signerKind = SignerKindDto.LOCAL_SECRET,
+ keyAvailability = KeyAvailabilityDto.AVAILABLE,
+ createdAtSeconds = 1,
+ lastUsedAtSeconds = 2,
+ )
+
+private fun safeError(code: WireErrorCode): SafeErrorDto =
+ SafeErrorDto(
+ code = code,
+ category = WireErrorCategory.STORAGE,
+ retryable = false,
+ recoveryAction = WireRecoveryAction.RETRY,
+ message = "A safe problem occurred.",
+ )