app

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

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:
Aapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt | 421+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/main/kotlin/org/harvestcircle/application/NativeRuntimeMappings.kt | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/desktop/src/test/kotlin/org/harvestcircle/application/NativeRuntimeMappingsTest.kt | 400+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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.", + )