commit 7541fc20eab2ad41ccd43654911533388d677422 parent b6b9795b18522710de9cfdada4524023a832c2d8 Author: triesap <tyson@radroots.org> Date: Mon, 10 Aug 2026 02:36:33 +0000 application: move identity orchestration to shared StateFlow - replace the desktop mutable store with a shared StateFlow presenter - preserve monotonic snapshots, admission, retries, and transient secret cleanup - inject application time and operation identity sources through shared contracts - cover presenter cancellation, removal, retry, and desktop disposal behavior Diffstat:
15 files changed, 1109 insertions(+), 1166 deletions(-)
diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleAppStore.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleAppStore.kt @@ -1,508 +0,0 @@ -package org.harvestcircle.application - -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.harvestcircle.ffi.AppLifecycleDto -import org.harvestcircle.ffi.AppSnapshotDto -import org.harvestcircle.ffi.HarvestCircleException -import org.harvestcircle.ffi.WireErrorCode -import org.harvestcircle.ffi.WireRecoveryAction - -enum class HarvestCircleRoute { - OPENING, - CHECKING_COMPATIBILITY, - ACQUIRING_OWNERSHIP, - MIGRATING, - RECOVERING, - IDENTITYS, - ACTIVE_IDENTITY, - DEGRADED, - BLOCKED, - SHUTTING_DOWN, - FATAL, - CLOSED, -} - -enum class CommandStatus { - IDLE, - RUNNING, - ACCEPTED, - REJECTED_BUSY, - REJECTED_CLOSED, - FAILED_RETRYABLE, - FAILED_TERMINAL, -} - -enum class IdentityEntryMode { - CHOICE, - CREATE, - IMPORT, -} - -enum class RemovalStatus { - NONE, - AWAITING_CONFIRMATION, - CONFIRMING, - COMPLETED, - FAILED, -} - -data class RemovalImpactState( - val publicKeyHex: String, - val deletesLocalCredential: Boolean, - val signsOut: Boolean, - val expiresAtSeconds: Long, -) - -data class HarvestCircleStoreState( - val snapshot: AppSnapshotDto, - val route: HarvestCircleRoute = snapshot.toHarvestCircleRoute(), - val importDraft: String = "", - val generatedKeyBackup: GeneratedKeyBackup? = null, - val pendingRemovalPublicKeyHex: String? = null, - val removalImpact: RemovalImpactState? = null, - val removalStatus: RemovalStatus = RemovalStatus.NONE, - val lastRemovedPublicKeyHex: String? = null, - val identityChooserVisible: Boolean = false, - val identityEntryMode: IdentityEntryMode = IdentityEntryMode.CHOICE, - val busy: Boolean = false, - val commandStatus: CommandStatus = CommandStatus.IDLE, - val lastCommandRequestId: String? = null, - val lastFailureCode: WireErrorCode? = null, - val recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, - val problem: String? = null, -) - -const val MAX_IMPORT_SECRET_CHARS: Int = 128 - -class HarvestCircleAppStore( - private val gateway: HarvestCircleCoreGateway, - private val scope: CoroutineScope, -) : AutoCloseable { - private val mutableState = mutableStateOf(HarvestCircleStoreState(snapshot = gateway.snapshot())) - private var closed = false - private var subscription: AutoCloseable? = null - private var pendingRemoval: RemovalTicket? = null - private var pendingGeneratedRecovery: PendingGeneratedRecovery? = null - private var command: Job? = null - private var retryableCommand: HarvestCircleCommand? = null - - val state: State<HarvestCircleStoreState> - get() = mutableState - - init { - launchCommand { - val registered = - gateway.subscribeChanges { change -> - scope.launch { - if (!closed) acceptSnapshot(change.snapshot) - } - } - if (closed) { - registered.close() - return@launchCommand - } - subscription = registered - acceptSnapshot(gateway.bootstrap()) - } - } - - fun editImportDraft(value: String) { - mutableState.value = - mutableState.value.copy( - importDraft = value.take(MAX_IMPORT_SECRET_CHARS), - lastFailureCode = null, - recoveryAction = WireRecoveryAction.NONE, - problem = null, - ) - } - - fun chooseCreateIdentity() { - mutableState.value = mutableState.value.copy(identityEntryMode = IdentityEntryMode.CREATE, problem = null) - } - - fun chooseImportIdentity() { - mutableState.value = mutableState.value.copy(identityEntryMode = IdentityEntryMode.IMPORT, problem = null) - } - - fun cancelIdentityEntry() { - mutableState.value = - mutableState.value.copy( - identityEntryMode = IdentityEntryMode.CHOICE, - importDraft = "", - problem = null, - ) - } - - fun generateIdentity() { - launchCommand { - val recovery = gateway.beginGeneratedIdentity() - var installed = false - try { - val backup = GeneratedKeyBackup(recovery.identity.npub, recovery.takeRecoveryNsec()) - pendingGeneratedRecovery = PendingGeneratedRecovery(recovery, backup) - mutableState.value = mutableState.value.copy(generatedKeyBackup = backup) - installed = true - } finally { - if (!installed) { - withContext(NonCancellable) { - runCatching { recovery.cancel() } - recovery.close() - } - } - } - } - } - - fun acknowledgeGeneratedKeyBackup() { - val recovery = - pendingGeneratedRecovery ?: run { - rejectUnavailableIntent("Generated-key recovery is not available.") - return - } - runSnapshotCommand { - try { - recovery.ticket.acknowledge() - } finally { - releaseGeneratedRecovery(recovery) - } - } - } - - fun cancelGeneratedKeyBackup() { - val recovery = - pendingGeneratedRecovery ?: run { - rejectUnavailableIntent("Generated-key recovery is not available.") - return - } - launchCommand { - try { - if (!recovery.ticket.cancel()) { - throw HarvestCircleGatewayException( - HarvestCircleCommandFailure( - code = WireErrorCode.INVALID_APPLICATION_STATE, - category = org.harvestcircle.ffi.WireErrorCategory.LIFECYCLE, - retryable = false, - recoveryAction = WireRecoveryAction.NONE, - correlationId = recovery.ticket.requestId, - safeMessage = "The generated-key recovery step was already closed.", - ), - ) - } - } finally { - releaseGeneratedRecovery(recovery) - } - } - } - - fun importSecretKey() { - if (rejectIfUnavailable()) return - val input = mutableState.value.importDraft.encodeToByteArray() - mutableState.value = mutableState.value.copy(importDraft = "") - runTypedCommand(HarvestCircleCommand.ImportIdentity(input)) - } - - fun selectIdentity(publicKeyHex: String) { - runTypedCommand(HarvestCircleCommand.SelectIdentity(publicKeyHex)) - } - - fun activateIdentity(publicKeyHex: String) { - runTypedCommand(HarvestCircleCommand.ActivateIdentity(publicKeyHex), hideChooser = true) - } - - fun signOut() { - runTypedCommand(HarvestCircleCommand.SignOut, hideChooser = true) - } - - fun showIdentityChooser() { - mutableState.value = mutableState.value.copy(identityChooserVisible = true, problem = null) - } - - fun hideIdentityChooser() { - mutableState.value = mutableState.value.copy(identityChooserVisible = false) - } - - fun refreshActiveProfile() { - runTypedCommand(HarvestCircleCommand.RefreshProfile) - } - - fun retryLastCommand() { - val retry = - retryableCommand ?: run { - rejectUnavailableIntent("This action cannot be retried safely.") - return - } - runTypedCommand(retry) - } - - fun requestIdentityRemoval(publicKeyHex: String) { - launchCommand { - runCatching { - pendingRemoval?.close() - pendingRemoval = null - val ticket = gateway.requestIdentityRemoval(publicKeyHex) - if (closed) { - ticket.close() - return@runCatching - } - pendingRemoval = ticket - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = publicKeyHex, - removalImpact = - RemovalImpactState( - ticket.publicKeyHex, - ticket.deletesLocalCredential, - ticket.signsOut, - ticket.expiresAtSeconds, - ), - removalStatus = RemovalStatus.AWAITING_CONFIRMATION, - ) - }.getOrThrow() - } - } - - fun cancelIdentityRemoval() { - pendingRemoval?.close() - pendingRemoval = null - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = null, - removalImpact = null, - removalStatus = RemovalStatus.NONE, - ) - } - - fun confirmIdentityRemoval() { - val ticket = - pendingRemoval ?: run { - rejectUnavailableIntent("Identity removal confirmation is not available.") - return - } - pendingRemoval = null - mutableState.value = mutableState.value.copy(removalStatus = RemovalStatus.CONFIRMING) - runSnapshotCommand { - try { - gateway.confirmIdentityRemoval(ticket).also { - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = null, - lastRemovedPublicKeyHex = ticket.publicKeyHex, - removalImpact = null, - removalStatus = RemovalStatus.COMPLETED, - ) - } - } finally { - ticket.close() - if (mutableState.value.removalStatus != RemovalStatus.COMPLETED) { - mutableState.value = - mutableState.value.copy( - pendingRemovalPublicKeyHex = null, - removalImpact = null, - removalStatus = RemovalStatus.FAILED, - ) - } - } - } - } - - fun dismissProblem() { - mutableState.value = mutableState.value.copy(problem = null) - } - - private fun runSnapshotCommand(operation: suspend () -> AppSnapshotDto) { - launchCommand { acceptSnapshot(operation()) } - } - - private fun runTypedCommand( - command: HarvestCircleCommand, - hideChooser: Boolean = false, - ) { - launchCommand { - when (val result = gateway.execute(command)) { - is HarvestCircleCommandResult.Accepted -> { - retryableCommand = null - acceptSnapshot(result.receipt.snapshot) - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.ACCEPTED, - lastCommandRequestId = result.receipt.requestId, - lastFailureCode = null, - recoveryAction = WireRecoveryAction.NONE, - ) - if (hideChooser) { - mutableState.value = mutableState.value.copy(identityChooserVisible = false) - } - } - is HarvestCircleCommandResult.Rejected -> { - retryableCommand = - command.takeIf { - result.failure.retryable && it !is HarvestCircleCommand.ImportIdentity - } - mutableState.value = - mutableState.value.copy( - commandStatus = - if (result.failure.retryable) { - CommandStatus.FAILED_RETRYABLE - } else { - CommandStatus.FAILED_TERMINAL - }, - lastCommandRequestId = result.failure.correlationId, - lastFailureCode = result.failure.code, - recoveryAction = result.failure.recoveryAction, - problem = result.failure.safeMessage, - ) - } - } - } - } - - private fun launchCommand(operation: suspend () -> Unit) { - if (rejectIfUnavailable()) return - mutableState.value = - mutableState.value.copy( - busy = true, - commandStatus = CommandStatus.RUNNING, - problem = null, - ) - command = - scope.launch { - try { - operation() - if (mutableState.value.commandStatus == CommandStatus.RUNNING) { - mutableState.value = mutableState.value.copy(commandStatus = CommandStatus.ACCEPTED) - } - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - acceptFailure(error) - } finally { - mutableState.value = mutableState.value.copy(busy = false) - } - } - } - - private fun rejectIfUnavailable(): Boolean { - if (closed) { - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.REJECTED_CLOSED, - problem = "The application runtime is closed.", - ) - return true - } - if (command?.isActive == true) { - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.REJECTED_BUSY, - problem = "The application is busy. Try again.", - ) - return true - } - if (mutableState.value.route !in setOf(HarvestCircleRoute.IDENTITYS, HarvestCircleRoute.ACTIVE_IDENTITY)) { - mutableState.value = - mutableState.value.copy( - commandStatus = CommandStatus.FAILED_TERMINAL, - problem = "The application runtime is not ready for this action.", - ) - return true - } - return false - } - - private fun rejectUnavailableIntent(message: String) { - mutableState.value = - mutableState.value.copy( - commandStatus = if (closed) CommandStatus.REJECTED_CLOSED else CommandStatus.FAILED_TERMINAL, - problem = message, - ) - } - - private fun acceptSnapshot(snapshot: AppSnapshotDto) { - if (snapshot.revision >= mutableState.value.snapshot.revision) { - mutableState.value = - mutableState.value.copy( - snapshot = snapshot, - route = snapshot.toHarvestCircleRoute(), - ) - } - } - - private fun acceptFailure(error: Throwable) { - val native = error as? HarvestCircleException.Failure - val gatewayFailure = (error as? HarvestCircleGatewayException)?.failure - mutableState.value = - mutableState.value.copy( - busy = false, - commandStatus = - if (native?.retryable == true || gatewayFailure?.retryable == true) { - CommandStatus.FAILED_RETRYABLE - } else { - CommandStatus.FAILED_TERMINAL - }, - lastCommandRequestId = gatewayFailure?.correlationId ?: native?.correlationId, - lastFailureCode = gatewayFailure?.code ?: native?.code, - recoveryAction = - gatewayFailure?.recoveryAction - ?: native?.recoveryAction - ?: WireRecoveryAction.NONE, - problem = gatewayFailure?.safeMessage ?: native?.safeMessage ?: "The application command failed.", - ) - } - - private fun releaseGeneratedRecovery(recovery: PendingGeneratedRecovery) { - if (pendingGeneratedRecovery === recovery) { - pendingGeneratedRecovery = null - } - recovery.backup.clear() - recovery.ticket.close() - mutableState.value = mutableState.value.copy(generatedKeyBackup = null) - } - - override fun close() { - if (closed) return - closed = true - command?.cancel() - pendingRemoval?.close() - pendingGeneratedRecovery?.let(::releaseGeneratedRecovery) - subscription?.close() - runCatching { gateway.shutdown() } - .onSuccess { receipt -> - mutableState.value = - mutableState.value.copy( - route = if (receipt.closed) HarvestCircleRoute.CLOSED else HarvestCircleRoute.FATAL, - busy = false, - problem = if (receipt.closed) null else "The application could not shut down safely.", - ) - }.onFailure { error -> - acceptFailure(error) - mutableState.value = mutableState.value.copy(route = HarvestCircleRoute.FATAL, busy = false) - } - } -} - -private data class PendingGeneratedRecovery( - val ticket: GeneratedRecoveryTicket, - val backup: GeneratedKeyBackup, -) - -internal fun AppSnapshotDto.toHarvestCircleRoute(): HarvestCircleRoute = - when (lifecycle) { - AppLifecycleDto.OPENING -> HarvestCircleRoute.OPENING - AppLifecycleDto.COMPATIBILITY_CHECKING -> HarvestCircleRoute.CHECKING_COMPATIBILITY - AppLifecycleDto.ACQUIRING_OWNERSHIP -> HarvestCircleRoute.ACQUIRING_OWNERSHIP - AppLifecycleDto.MIGRATING -> HarvestCircleRoute.MIGRATING - AppLifecycleDto.RECOVERING -> HarvestCircleRoute.RECOVERING - AppLifecycleDto.READY -> if (activeIdentity != null) HarvestCircleRoute.ACTIVE_IDENTITY else HarvestCircleRoute.IDENTITYS - AppLifecycleDto.DEGRADED -> HarvestCircleRoute.DEGRADED - AppLifecycleDto.BLOCKED -> HarvestCircleRoute.BLOCKED - AppLifecycleDto.SHUTTING_DOWN -> HarvestCircleRoute.SHUTTING_DOWN - AppLifecycleDto.CLOSED -> HarvestCircleRoute.CLOSED - AppLifecycleDto.FATAL -> HarvestCircleRoute.FATAL - } diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/HarvestCircleApplication.kt @@ -2,75 +2,92 @@ package org.harvestcircle.application import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import kotlinx.coroutines.CoroutineScope -import org.harvestcircle.ffi.HarvestCircleAppCore -import org.harvestcircle.ffi.HarvestCircleException -import org.harvestcircle.ffi.compatibilityDescriptor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import org.harvestcircle.identities.ui.HarvestCircleScreen import org.harvestcircle.identities.ui.HarvestCircleUiActions import org.harvestcircle.identities.ui.StartupFailureScreen import org.harvestcircle.identities.ui.toUiModel +import java.util.concurrent.atomic.AtomicLong -internal typealias HarvestCircleStoreFactory = (CoroutineScope) -> HarvestCircleAppStore +internal typealias HarvestCirclePresenterFactory = (CoroutineScope) -> HarvestCirclePresenter @Composable -fun HarvestCircleApplication(storeFactory: HarvestCircleStoreFactory = ::createHarvestCircleAppStore) { - val scope = rememberCoroutineScope() - val storeResult = remember { runCatching { storeFactory(scope) } } - val store = storeResult.getOrNull() - if (store == null) { - val error = storeResult.exceptionOrNull() +fun HarvestCircleApplication(presenterFactory: HarvestCirclePresenterFactory = ::createHarvestCirclePresenter) { + val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Default) } + val presenterResult = remember { runCatching { presenterFactory(scope) } } + val presenter = presenterResult.getOrNull() + if (presenter == null) { val message = - (error as? HarvestCircleException.Failure)?.safeMessage + (presenterResult.exceptionOrNull() as? ApplicationFailure)?.problem?.safeMessage ?: "The application could not start." StartupFailureScreen(message) return } val clipboard = remember { SecretClipboardController(scope) } + val state by presenter.state.collectAsState() - DisposableEffect(store, clipboard) { + DisposableEffect(presenter, clipboard) { onDispose { clipboard.close() - store.close() + scope.launch { + presenter.close() + scope.cancel() + } } } HarvestCircleScreen( - model = store.state.value.toUiModel(), + model = state.toUiModel(), actions = HarvestCircleUiActions( - chooseCreateIdentity = store::chooseCreateIdentity, - chooseImportIdentity = store::chooseImportIdentity, - cancelIdentityEntry = store::cancelIdentityEntry, - editImportDraft = store::editImportDraft, - generateIdentity = store::generateIdentity, - importSecretKey = store::importSecretKey, - copyText = { value -> clipboard.copy(value) }, - acknowledgeGeneratedKeyBackup = store::acknowledgeGeneratedKeyBackup, - cancelGeneratedKeyBackup = store::cancelGeneratedKeyBackup, - selectIdentity = store::selectIdentity, - activateIdentity = store::activateIdentity, - requestIdentityRemoval = store::requestIdentityRemoval, - cancelIdentityRemoval = store::cancelIdentityRemoval, - confirmIdentityRemoval = store::confirmIdentityRemoval, - refreshActiveProfile = store::refreshActiveProfile, - retryLastCommand = store::retryLastCommand, - signOut = store::signOut, - showIdentityChooser = store::showIdentityChooser, - hideIdentityChooser = store::hideIdentityChooser, + chooseCreateIdentity = { presenter.dispatch(HarvestCircleIntent.ChooseCreateIdentity) }, + chooseImportIdentity = { presenter.dispatch(HarvestCircleIntent.ChooseImportIdentity) }, + cancelIdentityEntry = { presenter.dispatch(HarvestCircleIntent.CancelIdentityEntry) }, + editImportDraft = { presenter.dispatch(HarvestCircleIntent.EditImportDraft(it)) }, + generateIdentity = { presenter.dispatch(HarvestCircleIntent.GenerateIdentity) }, + importSecretKey = { presenter.dispatch(HarvestCircleIntent.ImportIdentity) }, + copyText = clipboard::copy, + acknowledgeGeneratedKeyBackup = { presenter.dispatch(HarvestCircleIntent.AcknowledgeGeneratedRecovery) }, + cancelGeneratedKeyBackup = { presenter.dispatch(HarvestCircleIntent.CancelGeneratedRecovery) }, + selectIdentity = { presenter.dispatch(HarvestCircleIntent.SelectIdentity(IdentityId.fromPublicKeyHex(it))) }, + activateIdentity = { presenter.dispatch(HarvestCircleIntent.ActivateIdentity(IdentityId.fromPublicKeyHex(it))) }, + requestIdentityRemoval = { + presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(IdentityId.fromPublicKeyHex(it))) + }, + cancelIdentityRemoval = { presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval) }, + confirmIdentityRemoval = { presenter.dispatch(HarvestCircleIntent.ConfirmIdentityRemoval) }, + refreshActiveProfile = { presenter.dispatch(HarvestCircleIntent.RefreshActiveProfile) }, + retryLastCommand = { presenter.dispatch(HarvestCircleIntent.RetryLastCommand) }, + signOut = { presenter.dispatch(HarvestCircleIntent.SignOut) }, + showIdentityChooser = { presenter.dispatch(HarvestCircleIntent.ShowIdentityChooser) }, + hideIdentityChooser = { presenter.dispatch(HarvestCircleIntent.HideIdentityChooser) }, ), ) } -internal fun createHarvestCircleAppStore(scope: CoroutineScope): HarvestCircleAppStore { +internal fun createHarvestCirclePresenter(scope: CoroutineScope): HarvestCirclePresenter { val developmentMode = java.lang.Boolean.getBoolean("harvestcircle.development") - val descriptor = compatibilityDescriptor() - val core = - HarvestCircleAppCore.openCompatible( - expectation = verifyNativeCompatibility(descriptor), - developmentMode = developmentMode, - ) - return HarvestCircleAppStore(NativeHarvestCircleCoreGateway(core), scope) + return HarvestCirclePresenter( + runtime = NativeHarvestCircleRuntime.open(developmentMode), + scope = scope, + clock = DesktopApplicationClock, + operationIds = DesktopOperationIdSource, + ) +} + +private object DesktopApplicationClock : ApplicationClock { + override fun now(): UnixSeconds = UnixSeconds(System.currentTimeMillis() / 1_000) +} + +private object DesktopOperationIdSource : OperationIdSource { + private val next = AtomicLong(1) + + override fun next(): OperationId = OperationId.from("desktop-operation:${next.getAndIncrement()}") } diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt b/app/desktop/src/main/kotlin/org/harvestcircle/application/NativeHarvestCircleRuntime.kt @@ -21,10 +21,6 @@ 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(), @@ -41,10 +37,10 @@ class NativeHarvestCircleRuntime internal constructor( override fun currentSnapshot(): ApplicationSnapshot = try { native.snapshot().toApplicationSnapshot() - } catch (error: HarvestCircleRuntimeException) { + } catch (error: ApplicationFailure) { throw error } catch (error: Exception) { - throw HarvestCircleRuntimeException(error.toApplicationProblem()) + throw ApplicationFailure(error.toApplicationProblem()) } override fun changes(): Flow<ApplicationChange> = @@ -86,12 +82,12 @@ class NativeHarvestCircleRuntime internal constructor( } catch (error: CancellationException) { handle.close() throw error - } catch (error: HarvestCircleRuntimeException) { + } catch (error: ApplicationFailure) { handle.close() throw error } catch (error: Exception) { handle.close() - throw HarvestCircleRuntimeException(error.toApplicationProblem()) + throw ApplicationFailure(error.toApplicationProblem()) } } @@ -112,15 +108,21 @@ class NativeHarvestCircleRuntime internal constructor( } catch (error: CancellationException) { handle.close() throw error - } catch (error: HarvestCircleRuntimeException) { + } catch (error: ApplicationFailure) { handle.close() throw error } catch (error: Exception) { handle.close() - throw HarvestCircleRuntimeException(error.toApplicationProblem()) + throw ApplicationFailure(error.toApplicationProblem()) } } + override suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean { + val handle = removalMutex.withLock { removalHandles.remove(requestId) } ?: return false + handle.close() + return true + } + override suspend fun shutdown(): ShutdownReceipt = shutdownMutex.withLock { shutdownReceipt?.let { return@withLock it } @@ -201,8 +203,8 @@ class NativeHarvestCircleRuntime internal constructor( private fun missingHandle( kind: String, operationId: OperationId? = null, - ): HarvestCircleRuntimeException = - HarvestCircleRuntimeException( + ): ApplicationFailure = + ApplicationFailure( ApplicationProblem( code = ApplicationErrorCode.InvalidApplicationState, category = ApplicationErrorCategory.Lifecycle, @@ -213,8 +215,8 @@ class NativeHarvestCircleRuntime internal constructor( ), ) - private fun incompleteShutdown(): HarvestCircleRuntimeException = - HarvestCircleRuntimeException( + private fun incompleteShutdown(): ApplicationFailure = + ApplicationFailure( ApplicationProblem( code = ApplicationErrorCode.InvalidApplicationState, category = ApplicationErrorCategory.Lifecycle, @@ -233,10 +235,10 @@ class NativeHarvestCircleRuntime internal constructor( operation() } catch (error: CancellationException) { throw error - } catch (error: HarvestCircleRuntimeException) { + } catch (error: ApplicationFailure) { throw error } catch (error: Exception) { - throw HarvestCircleRuntimeException(error.toApplicationProblem(fallbackOperationId)) + throw ApplicationFailure(error.toApplicationProblem(fallbackOperationId)) } companion object { diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreen.kt b/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreen.kt @@ -128,7 +128,7 @@ fun HarvestCircleScreen( InactiveIdentitiesScreen(model, actions) } } - HarvestCircleRoute.IDENTITYS -> InactiveIdentitiesScreen(model, actions) + HarvestCircleRoute.IDENTITIES -> InactiveIdentitiesScreen(model, actions) } } @@ -261,7 +261,7 @@ private fun RecoveryAction( model: HarvestCircleUiModel, actions: HarvestCircleUiActions, ) { - if (model.recoveryAction == org.harvestcircle.ffi.WireRecoveryAction.RETRY) { + if (model.recoveryAction == org.harvestcircle.application.RecoveryAction.Retry) { TextAction( text = "Retry", testTag = "retry-last-command", diff --git a/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/IdentityUiModel.kt b/app/desktop/src/main/kotlin/org/harvestcircle/identity/ui/IdentityUiModel.kt @@ -1,17 +1,17 @@ package org.harvestcircle.identities.ui +import org.harvestcircle.application.ActiveIdentity +import org.harvestcircle.application.ApplicationErrorCode +import org.harvestcircle.application.HarvestCirclePresenterState import org.harvestcircle.application.HarvestCircleRoute -import org.harvestcircle.application.HarvestCircleStoreState import org.harvestcircle.application.IdentityEntryMode +import org.harvestcircle.application.IdentitySummary +import org.harvestcircle.application.ProfileLoadState +import org.harvestcircle.application.RecoveryAction +import org.harvestcircle.application.RelayConnectionState import org.harvestcircle.application.RemovalImpactState import org.harvestcircle.application.RemovalStatus -import org.harvestcircle.ffi.ActiveIdentityDto -import org.harvestcircle.ffi.IdentityDto -import org.harvestcircle.ffi.ProfileLoadStateDto -import org.harvestcircle.ffi.RelayConnectionStateDto -import org.harvestcircle.ffi.SessionStateDto -import org.harvestcircle.ffi.WireErrorCode -import org.harvestcircle.ffi.WireRecoveryAction +import org.harvestcircle.application.SessionLifecycle data class IdentityUiModel( val publicKeyHex: String, @@ -59,21 +59,25 @@ data class HarvestCircleUiModel( val lastRemovedPublicKeyHex: String?, val identityChooserVisible: Boolean, val identityEntryMode: IdentityEntryMode, - val session: SessionStateDto, + val session: SessionLifecycle, val busy: Boolean, val problem: String?, val importGuidance: String?, - val recoveryAction: WireRecoveryAction, + val recoveryAction: RecoveryAction, ) -fun HarvestCircleStoreState.toUiModel(): HarvestCircleUiModel { - val selectedPublicKeyHex = snapshot.selectedPublicKeyHex - val activePublicKeyHex = snapshot.activeIdentity?.identity?.publicKeyHex +fun HarvestCirclePresenterState.toUiModel(): HarvestCircleUiModel { + val selectedPublicKeyHex = snapshot.selectedIdentityId?.value + val activePublicKeyHex = + snapshot.activeIdentity + ?.identity + ?.id + ?.value val identities = snapshot.identities.map { it.toUiModel( - selected = it.publicKeyHex == selectedPublicKeyHex, - active = it.publicKeyHex == activePublicKeyHex, + selected = it.id.value == selectedPublicKeyHex, + active = it.id.value == activePublicKeyHex, ) } return HarvestCircleUiModel( @@ -86,32 +90,32 @@ fun HarvestCircleStoreState.toUiModel(): HarvestCircleUiModel { generatedKeyBackup?.let { GeneratedKeyBackupUiModel(npub = it.npub, nsec = it.revealNsec()) }, - pendingRemovalPublicKeyHex = pendingRemovalPublicKeyHex, + pendingRemovalPublicKeyHex = pendingRemovalIdentityId?.value, removalImpact = removalImpact, removalStatus = removalStatus, - lastRemovedPublicKeyHex = lastRemovedPublicKeyHex, + lastRemovedPublicKeyHex = lastRemovedIdentityId?.value, identityChooserVisible = identityChooserVisible, identityEntryMode = identityEntryMode, session = snapshot.session, busy = busy, problem = problem - ?: snapshot.recoverableProblem?.message - ?: snapshot.sessionError?.message - ?: snapshot.lifecycleError?.message, - importGuidance = importGuidance(lastFailureCode, recoveryAction), - recoveryAction = recoveryAction, + ?: snapshot.recoverableProblem?.safeMessage + ?: snapshot.sessionProblem?.safeMessage + ?: snapshot.lifecycleProblem?.safeMessage, + importGuidance = importGuidance(lastProblem?.code, lastProblem?.recoveryAction ?: RecoveryAction.None), + recoveryAction = lastProblem?.recoveryAction ?: RecoveryAction.None, ) } private fun importGuidance( - code: WireErrorCode?, - recoveryAction: WireRecoveryAction, + code: ApplicationErrorCode?, + recoveryAction: RecoveryAction, ): String? = when { - code == WireErrorCode.INVALID_SECRET_KEY -> "Enter a valid nsec or 64-character hexadecimal secret key." - code == WireErrorCode.IDENTITY_ALREADY_EXISTS -> "This Nostr identity is already saved." - code == WireErrorCode.CREDENTIAL_MISSING || recoveryAction == WireRecoveryAction.REPAIR_CREDENTIAL -> + code == ApplicationErrorCode.InvalidSecretKey -> "Enter a valid nsec or 64-character hexadecimal secret key." + code == ApplicationErrorCode.IdentityAlreadyExists -> "This Nostr identity is already saved." + code == ApplicationErrorCode.CredentialMissing || recoveryAction == RecoveryAction.RepairCredential -> "This saved identity is missing its local credential. Re-enter its secret key to repair it." else -> null } @@ -127,31 +131,34 @@ fun shortenNpub(npub: String): String = "${npub.take(SHORT_NPUB_PREFIX_LENGTH)}…${npub.takeLast(SHORT_NPUB_SUFFIX_LENGTH)}" } -private fun IdentityDto.toUiModel( +private fun IdentitySummary.toUiModel( selected: Boolean, active: Boolean = false, ) = IdentityUiModel( - publicKeyHex = publicKeyHex, + publicKeyHex = id.value, npub = npub, shortNpub = shortenNpub(npub), label = displayLabel.ifBlank { shortenNpub(npub) }, - signerAvailability = signerAvailability.name.lowercase().replace('_', ' '), + signerAvailability = + signer.availability.name + .lowercase() + .replace('_', ' '), selected = selected, active = active, ) -private fun ActiveIdentityDto.toUiModel(selectedPublicKeyHex: String?) = +private fun ActiveIdentity.toUiModel(selectedPublicKeyHex: String?) = ActiveIdentityUiModel( identity = identity.toUiModel( - selected = identity.publicKeyHex == selectedPublicKeyHex, + selected = identity.id.value == selectedPublicKeyHex, active = true, ), heading = profile?.displayName?.takeIf(String::isNotBlank) ?: profile?.name?.takeIf(String::isNotBlank) ?: identity.displayLabel.ifBlank { shortenNpub(identity.npub) }, - relayState = relayState.toDisplayText(), + relayState = relays.state.toDisplayText(), profileState = profileState.toDisplayText(), profile = ProfileUiModel( @@ -163,6 +170,6 @@ private fun ActiveIdentityDto.toUiModel(selectedPublicKeyHex: String?) = ), ) -private fun RelayConnectionStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') +private fun RelayConnectionState.toDisplayText(): String = name.lowercase().replace('_', ' ') -private fun ProfileLoadStateDto.toDisplayText(): String = name.lowercase().replace('_', ' ') +private fun ProfileLoadState.toDisplayText(): String = name.lowercase().replace('_', ' ') diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleAppStoreTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleAppStoreTest.kt @@ -1,481 +0,0 @@ -package org.harvestcircle.application - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.harvestcircle.ffi.AppLifecycleDto -import org.harvestcircle.ffi.AppSnapshotDto -import org.harvestcircle.ffi.IdentityDto -import org.harvestcircle.ffi.SessionStateDto -import org.harvestcircle.ffi.SignerAvailabilityDto -import org.harvestcircle.ffi.SignerBindingKindDto -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.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@OptIn(ExperimentalCoroutinesApi::class) -class HarvestCircleAppStoreTest { - @Test - fun `bootstraps and ignores stale observer snapshots`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - - advanceUntilIdle() - gateway.emit(snapshot(1UL)) - gateway.emit(snapshot(0UL)) - advanceUntilIdle() - - assertEquals(1UL, store.state.value.snapshot.revision) - assertFalse(store.state.value.busy) - store.close() - assertTrue(gateway.closed) - assertTrue(gateway.shutdownCompleted) - assertTrue(gateway.subscriptionClosed) - assertEquals(HarvestCircleRoute.CLOSED, store.state.value.route) - } - - @Test - fun `holds generated secret only until explicit acknowledgement`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - - store.generateIdentity() - advanceUntilIdle() - - assertEquals( - "nsec1secret", - store.state.value.generatedKeyBackup - ?.revealNsec(), - ) - assertEquals( - "npub1identity", - store.state.value.generatedKeyBackup - ?.npub, - ) - store.acknowledgeGeneratedKeyBackup() - advanceUntilIdle() - assertNull(store.state.value.generatedKeyBackup) - store.close() - } - - @Test - fun `cancels staged generated identity without committing it`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - val revisionBeforeGeneration = store.state.value.snapshot.revision - store.generateIdentity() - advanceUntilIdle() - - store.cancelGeneratedKeyBackup() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertEquals(revisionBeforeGeneration, store.state.value.snapshot.revision) - store.close() - } - - @Test - fun `partial generated recovery acquisition cancels and closes its native ticket`() = - runTest { - val gateway = - FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { - failGeneratedRecoveryRead = true - } - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - - store.generateIdentity() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertEquals(1, gateway.lastGeneratedRecoveryTicket?.cancelCalls) - assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) - store.close() - } - - @Test - fun `failed generated acknowledgement releases one-shot recovery ownership`() = - runTest { - val gateway = - FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { - failGeneratedAcknowledgement = true - } - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - store.generateIdentity() - advanceUntilIdle() - - store.acknowledgeGeneratedKeyBackup() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) - assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) - assertEquals( - "The generated identity could not be saved. Import the recovery key you saved to try again.", - store.state.value.problem, - ) - store.close() - } - - @Test - fun `already resolved cancellation clears recovery and reports the state mismatch`() = - runTest { - val gateway = - FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { - generatedCancellationResult = false - } - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - store.generateIdentity() - advanceUntilIdle() - - store.cancelGeneratedKeyBackup() - advanceUntilIdle() - - assertNull(store.state.value.generatedKeyBackup) - assertEquals(WireErrorCode.INVALID_APPLICATION_STATE, store.state.value.lastFailureCode) - assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) - assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) - store.close() - } - - @Test - fun `ignores observer delivery after close`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - val revisionAtClose = store.state.value.snapshot.revision - - store.close() - gateway.emit(snapshot(revisionAtClose + 1UL)) - advanceUntilIdle() - - assertEquals(revisionAtClose, store.state.value.snapshot.revision) - } - - @Test - fun `failed removal confirmation clears consumed presentation state`() = - runTest { - val gateway = - FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { - failRemovalConfirmation = true - } - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - - store.requestIdentityRemoval("00".repeat(32)) - advanceUntilIdle() - assertEquals("00".repeat(32), store.state.value.pendingRemovalPublicKeyHex) - store.confirmIdentityRemoval() - advanceUntilIdle() - - assertNull(store.state.value.pendingRemovalPublicKeyHex) - assertTrue(gateway.lastRemovalTicket?.closed == true) - assertEquals(RemovalStatus.FAILED, store.state.value.removalStatus) - assertEquals("The application command failed.", store.state.value.problem) - store.close() - } - - @Test - fun `serializes commands while one is active`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - - store.signOut() - assertEquals(CommandStatus.REJECTED_BUSY, store.state.value.commandStatus) - advanceUntilIdle() - - assertEquals(0, gateway.signOutCalls) - store.signOut() - advanceUntilIdle() - assertEquals(1, gateway.signOutCalls) - store.close() - } - - @Test - fun `projects retryable command rejection without dropping intent`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - gateway.nextCommandResult = - HarvestCircleCommandResult.Rejected( - HarvestCircleCommandFailure( - WireErrorCode.STORAGE_UNAVAILABLE, - WireErrorCategory.STORAGE, - retryable = true, - WireRecoveryAction.RETRY, - "request-retry", - "Storage is temporarily unavailable.", - ), - ) - - store.signOut() - advanceUntilIdle() - - assertEquals(CommandStatus.FAILED_RETRYABLE, store.state.value.commandStatus) - assertEquals("request-retry", store.state.value.lastCommandRequestId) - assertEquals("Storage is temporarily unavailable.", store.state.value.problem) - store.retryLastCommand() - advanceUntilIdle() - assertEquals(1, gateway.signOutCalls) - assertEquals(CommandStatus.ACCEPTED, store.state.value.commandStatus) - store.close() - } - - @Test - fun `clears imported secret draft as soon as command is accepted`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - store.editImportDraft("nsec1secret") - - store.importSecretKey() - - assertEquals("", store.state.value.importDraft) - assertEquals(emptyList(), gateway.importedSecrets) - advanceUntilIdle() - assertEquals(listOf("nsec1secret"), gateway.importedSecrets) - assertEquals(true, gateway.lastImportBuffer?.all { it == 0.toByte() }) - store.close() - } - - @Test - fun `bounds imported secret presentation input before transport`() = - runTest { - val gateway = FakeHarvestCircleCoreGateway(snapshot(0UL)) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - - store.editImportDraft("x".repeat(MAX_IMPORT_SECRET_CHARS + 50)) - - assertEquals(MAX_IMPORT_SECRET_CHARS, store.state.value.importDraft.length) - store.close() - } - - @Test - fun `projects boot fatal and terminal lifecycle failures`() = - runTest { - val booting = snapshot(0UL, AppLifecycleDto.OPENING) - val bootGateway = FakeHarvestCircleCoreGateway(booting, booting) - val bootStore = HarvestCircleAppStore(bootGateway, this) - advanceUntilIdle() - assertEquals(HarvestCircleRoute.OPENING, bootStore.state.value.route) - bootStore.close() - - val fatal = snapshot(1UL, AppLifecycleDto.FATAL) - val gateway = FakeHarvestCircleCoreGateway(fatal, fatal) - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - assertEquals(HarvestCircleRoute.FATAL, store.state.value.route) - store.signOut() - advanceUntilIdle() - assertEquals(CommandStatus.FAILED_TERMINAL, store.state.value.commandStatus) - assertEquals(0, gateway.signOutCalls) - - store.close() - store.signOut() - assertEquals(CommandStatus.REJECTED_CLOSED, store.state.value.commandStatus) - } - - @Test - fun `disposal waits for native shutdown and fails closed on an incomplete receipt`() = - runTest { - val gateway = - FakeHarvestCircleCoreGateway(snapshot(0UL)).apply { - shutdownReceipt = HarvestCircleShutdownReceipt(1UL, closed = false) - } - val store = HarvestCircleAppStore(gateway, this) - advanceUntilIdle() - - store.close() - - assertTrue(gateway.shutdownCompleted) - assertEquals(HarvestCircleRoute.FATAL, store.state.value.route) - assertEquals("The application could not shut down safely.", store.state.value.problem) - } -} - -private class FakeHarvestCircleCoreGateway( - private var current: AppSnapshotDto, - private val bootstrapSnapshot: AppSnapshotDto = snapshot(1UL), -) : HarvestCircleCoreGateway { - private var observer: ((AppSnapshotDto) -> Unit)? = null - var closed = false - var shutdownCompleted = false - var shutdownReceipt = HarvestCircleShutdownReceipt(current.revision, closed = true) - var subscriptionClosed = false - var signOutCalls = 0 - val importedSecrets = mutableListOf<String>() - var lastImportBuffer: ByteArray? = null - var failRemovalConfirmation = false - var lastRemovalTicket: FakeRemovalTicket? = null - var nextCommandResult: HarvestCircleCommandResult? = null - var failGeneratedRecoveryRead = false - var failGeneratedAcknowledgement = false - var generatedCancellationResult = true - var lastGeneratedRecoveryTicket: FakeGeneratedRecoveryTicket? = null - - override fun snapshot(): AppSnapshotDto = current - - override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit): AutoCloseable { - observer = { snapshot -> onChange(HarvestCircleChange(snapshot, null)) } - return AutoCloseable { subscriptionClosed = true } - } - - override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult { - nextCommandResult?.let { - nextCommandResult = null - return it - } - when (command) { - is HarvestCircleCommand.ImportIdentity -> { - lastImportBuffer = command.bytes - importedSecrets += command.bytes.decodeToString() - command.bytes.fill(0) - } - HarvestCircleCommand.SignOut -> signOutCalls += 1 - else -> Unit - } - return HarvestCircleCommandResult.Accepted( - HarvestCircleCommandReceipt("fake-request", current.revision, current), - ) - } - - fun emit(snapshot: AppSnapshotDto) { - current = snapshot - observer?.invoke(snapshot) - } - - override suspend fun bootstrap(): AppSnapshotDto = bootstrapSnapshot.also(::emit) - - override suspend fun beginGeneratedIdentity(): GeneratedRecoveryTicket = - FakeGeneratedRecoveryTicket( - identity = identity(), - failRecoveryRead = failGeneratedRecoveryRead, - failAcknowledgement = failGeneratedAcknowledgement, - cancellationResult = generatedCancellationResult, - ) { committed -> - current = snapshot(current.revision + 1UL) - emit(current) - committed(current) - }.also { lastGeneratedRecoveryTicket = it } - - override suspend fun requestIdentityRemoval(publicKeyHex: String): RemovalTicket = FakeRemovalTicket().also { lastRemovalTicket = it } - - override suspend fun confirmIdentityRemoval(ticket: RemovalTicket): AppSnapshotDto { - if (failRemovalConfirmation) error("injected confirmation failure") - return current - } - - override fun shutdown(): HarvestCircleShutdownReceipt { - shutdownCompleted = true - closed = true - return shutdownReceipt - } - - override fun close() { - shutdown() - } -} - -private class FakeGeneratedRecoveryTicket( - override val identity: IdentityDto, - private val failRecoveryRead: Boolean, - private val failAcknowledgement: Boolean, - private val cancellationResult: Boolean, - private val commit: (((AppSnapshotDto) -> Unit) -> Unit), -) : GeneratedRecoveryTicket { - override val requestId: String = "fake-generated-request" - private var available = true - var cancelCalls = 0 - var closed = false - - override fun takeRecoveryNsec(): String { - if (failRecoveryRead) error("injected recovery read failure") - return "nsec1secret" - } - - override suspend fun acknowledge(): AppSnapshotDto { - if (failAcknowledgement) { - available = false - throw HarvestCircleGatewayException( - HarvestCircleCommandFailure( - WireErrorCode.KEYRING_UNAVAILABLE, - WireErrorCategory.CREDENTIAL, - retryable = false, - WireRecoveryAction.NONE, - requestId, - "The generated identity could not be saved. Import the recovery key you saved to try again.", - ), - ) - } - lateinit var snapshot: AppSnapshotDto - commit { snapshot = it } - available = false - return snapshot - } - - override suspend fun cancel(): Boolean { - cancelCalls += 1 - return (available && cancellationResult).also { available = false } - } - - override fun close() { - closed = true - } -} - -private class FakeRemovalTicket : RemovalTicket { - override val publicKeyHex: String = "00".repeat(32) - override val deletesLocalCredential: Boolean = true - override val signsOut: Boolean = false - override val expiresAtSeconds: Long = 60 - var closed = false - - override fun close() { - closed = true - } -} - -private fun snapshot( - revision: ULong, - lifecycle: AppLifecycleDto = AppLifecycleDto.READY, -) = AppSnapshotDto( - revision = revision, - lifecycle = lifecycle, - lifecycleError = null, - configuredRelays = emptyList(), - identities = emptyList(), - selectedPublicKeyHex = null, - session = SessionStateDto.SIGNED_OUT, - sessionSubjectPublicKeyHex = null, - sessionError = null, - activeIdentity = null, - recoverableProblem = null, -) - -private fun identity() = - IdentityDto( - publicKeyHex = "00".repeat(32), - npub = "npub1identity", - displayLabel = "Identity", - signerBindingKind = SignerBindingKindDto.LOCAL_KEYRING, - signerAvailability = SignerAvailabilityDto.AVAILABLE, - createdAtSeconds = 0, - lastUsedAtSeconds = null, - ) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/application/HarvestCircleApplicationTest.kt @@ -15,28 +15,32 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.v2.runComposeUiTest -import org.harvestcircle.ffi.AppLifecycleDto -import org.harvestcircle.ffi.AppSnapshotDto -import org.harvestcircle.ffi.SessionStateDto +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlin.test.Test import kotlin.test.assertEquals class HarvestCircleApplicationTest { @OptIn(ExperimentalTestApi::class) @Test - fun applicationCreatesOneStoreAcrossRecompositionAndClosesItOnDisposal() = + fun applicationCreatesOnePresenterAcrossRecompositionAndClosesItOnDisposal() = runComposeUiTest { var applicationVisible by mutableStateOf(true) var factoryCalls = 0 - var gateway: ApplicationGateway? = null + var runtime: ApplicationRuntime? = null setContent { if (applicationVisible) { HarvestCircleApplication { scope -> factoryCalls += 1 - val createdGateway = ApplicationGateway() - gateway = createdGateway - HarvestCircleAppStore(createdGateway, scope) + val createdRuntime = ApplicationRuntime() + runtime = createdRuntime + HarvestCirclePresenter( + runtime = createdRuntime, + scope = scope, + clock = ApplicationClock { UnixSeconds(0) }, + operationIds = OperationIdSource { OperationId.from("application-test") }, + ) } } BasicText( @@ -50,10 +54,10 @@ class HarvestCircleApplicationTest { onNodeWithText("HarvestCircle").assertIsDisplayed() onNodeWithTag("toggle-application").performClick() - waitForIdle() + waitUntil { runtime?.closed == true } assertEquals(1, factoryCalls) - assertEquals(true, gateway?.closed) + assertEquals(true, runtime?.closed) } @OptIn(ExperimentalTestApi::class) @@ -72,44 +76,40 @@ class HarvestCircleApplicationTest { } } -private class ApplicationGateway : HarvestCircleCoreGateway { +private class ApplicationRuntime : HarvestCircleRuntime { var closed = false - override fun snapshot() = applicationSnapshot(0UL) + override suspend fun bootstrap(): ApplicationSnapshot = applicationSnapshot(SnapshotRevision(1UL)) - override suspend fun subscribeChanges(onChange: (HarvestCircleChange) -> Unit) = AutoCloseable {} + override fun currentSnapshot(): ApplicationSnapshot = applicationSnapshot(SnapshotRevision(0UL)) - override suspend fun execute(command: HarvestCircleCommand): HarvestCircleCommandResult = error("unused") + override fun changes(): Flow<ApplicationChange> = emptyFlow() - override suspend fun bootstrap() = applicationSnapshot(1UL) + override suspend fun execute(command: ApplicationCommand): ApplicationCommandResult = error("unused") - override suspend fun beginGeneratedIdentity(): GeneratedRecoveryTicket = error("unused") + override suspend fun prepareLocalIdentity(): GeneratedIdentityRecovery = error("unused") - override suspend fun requestIdentityRemoval(publicKeyHex: String): RemovalTicket = error("unused") + override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest = error("unused") - override suspend fun confirmIdentityRemoval(ticket: RemovalTicket) = error("unused") + override suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean = false - override fun shutdown(): HarvestCircleShutdownReceipt { + override suspend fun shutdown(): ShutdownReceipt { closed = true - return HarvestCircleShutdownReceipt(1UL, closed = true) - } - - override fun close() { - shutdown() + return ShutdownReceipt(SnapshotRevision(1UL), closed = true) } } -private fun applicationSnapshot(revision: ULong) = - AppSnapshotDto( +private fun applicationSnapshot(revision: SnapshotRevision) = + ApplicationSnapshot( revision = revision, - lifecycle = AppLifecycleDto.READY, - lifecycleError = null, + lifecycle = ApplicationLifecycle.Ready, + lifecycleProblem = null, configuredRelays = emptyList(), identities = emptyList(), - selectedPublicKeyHex = null, - session = SessionStateDto.SIGNED_OUT, - sessionSubjectPublicKeyHex = null, - sessionError = null, + selectedIdentityId = null, + session = SessionLifecycle.SignedOut, + sessionSubjectIdentityId = null, + sessionProblem = null, activeIdentity = null, recoverableProblem = null, ) diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreenTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/HarvestCircleScreenTest.kt @@ -19,10 +19,12 @@ import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.v2.runComposeUiTest import org.harvestcircle.application.HarvestCircleRoute import org.harvestcircle.application.IdentityEntryMode +import org.harvestcircle.application.IdentityId +import org.harvestcircle.application.RecoveryAction import org.harvestcircle.application.RemovalImpactState import org.harvestcircle.application.RemovalStatus -import org.harvestcircle.ffi.SessionStateDto -import org.harvestcircle.ffi.WireRecoveryAction +import org.harvestcircle.application.SessionLifecycle +import org.harvestcircle.application.UnixSeconds import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -188,7 +190,12 @@ class HarvestCircleScreenTest { pendingRemovalPublicKeyHex = pendingRemoval, removalImpact = pendingRemoval?.let { - RemovalImpactState(it, deletesLocalCredential = true, signsOut = true, expiresAtSeconds = 60) + RemovalImpactState( + IdentityId.fromPublicKeyHex(it), + deletesLocalCredential = true, + signsOut = true, + expiresAt = UnixSeconds(60), + ) }, ), actions = @@ -267,7 +274,7 @@ class HarvestCircleScreenTest { identities = listOf(identity), activeIdentity = active, configuredRelays = listOf("ws://localhost:8080"), - session = SessionStateDto.ACTIVE, + session = SessionLifecycle.Active, ), actions = HarvestCircleUiActions( @@ -313,7 +320,7 @@ class HarvestCircleScreenTest { route = HarvestCircleRoute.ACTIVE_IDENTITY, identities = listOf(first, second), activeIdentity = active, - session = SessionStateDto.ACTIVE, + session = SessionLifecycle.Active, identityChooserVisible = chooserVisible, ), actions = @@ -332,11 +339,11 @@ class HarvestCircleScreenTest { onNodeWithTag("activate-identity:${second.publicKeyHex}", useUnmergedTree = true).performClick() assertEquals(second.publicKeyHex, activated) assertEquals( - SessionStateDto.ACTIVE, + SessionLifecycle.Active, emptyUiModel() .copy( activeIdentity = active, - session = SessionStateDto.ACTIVE, + session = SessionLifecycle.Active, ).session, ) onNodeWithTag("return-home").performClick() @@ -348,9 +355,9 @@ private fun emptyUiModel( importDraft: String = "", problem: String? = null, importGuidance: String? = null, - recoveryAction: WireRecoveryAction = WireRecoveryAction.NONE, + recoveryAction: RecoveryAction = RecoveryAction.None, ) = HarvestCircleUiModel( - route = HarvestCircleRoute.IDENTITYS, + route = HarvestCircleRoute.IDENTITIES, identities = emptyList(), activeIdentity = null, configuredRelays = emptyList(), @@ -362,7 +369,7 @@ private fun emptyUiModel( lastRemovedPublicKeyHex = null, identityChooserVisible = false, identityEntryMode = IdentityEntryMode.CHOICE, - session = SessionStateDto.SIGNED_OUT, + session = SessionLifecycle.SignedOut, busy = false, problem = problem, importGuidance = importGuidance, diff --git a/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/IdentityUiModelTest.kt b/app/desktop/src/test/kotlin/org/harvestcircle/identity/ui/IdentityUiModelTest.kt @@ -1,19 +1,26 @@ package org.harvestcircle.identities.ui +import org.harvestcircle.application.ActiveIdentity +import org.harvestcircle.application.ApplicationErrorCategory +import org.harvestcircle.application.ApplicationErrorCode +import org.harvestcircle.application.ApplicationLifecycle +import org.harvestcircle.application.ApplicationProblem +import org.harvestcircle.application.ApplicationSnapshot import org.harvestcircle.application.GeneratedKeyBackup -import org.harvestcircle.application.HarvestCircleStoreState -import org.harvestcircle.ffi.ActiveIdentityDto -import org.harvestcircle.ffi.AppLifecycleDto -import org.harvestcircle.ffi.AppSnapshotDto -import org.harvestcircle.ffi.IdentityDto -import org.harvestcircle.ffi.ProfileDto -import org.harvestcircle.ffi.ProfileLoadStateDto -import org.harvestcircle.ffi.RelayConnectionStateDto -import org.harvestcircle.ffi.SessionStateDto -import org.harvestcircle.ffi.SignerAvailabilityDto -import org.harvestcircle.ffi.SignerBindingKindDto -import org.harvestcircle.ffi.WireErrorCode -import org.harvestcircle.ffi.WireRecoveryAction +import org.harvestcircle.application.HarvestCirclePresenterState +import org.harvestcircle.application.IdentityId +import org.harvestcircle.application.IdentitySummary +import org.harvestcircle.application.ProfileLoadState +import org.harvestcircle.application.ProfileSummary +import org.harvestcircle.application.RecoveryAction +import org.harvestcircle.application.RelayConnectionState +import org.harvestcircle.application.RelaySummary +import org.harvestcircle.application.SessionLifecycle +import org.harvestcircle.application.SignerAvailability +import org.harvestcircle.application.SignerBindingKind +import org.harvestcircle.application.SignerBindingSummary +import org.harvestcircle.application.SnapshotRevision +import org.harvestcircle.application.UnixSeconds import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -28,15 +35,15 @@ class IdentityUiModelTest { snapshot( identity = identity, active = - ActiveIdentityDto( + ActiveIdentity( identity = identity, - relayState = RelayConnectionStateDto.CONNECTED, - profileState = ProfileLoadStateDto.FRESH, - profile = ProfileDto("alice", "Alice", "alice@example.com", "Farmer", "https://example.com/a.png"), + relays = RelaySummary(listOf("ws://localhost:8080"), RelayConnectionState.Connected), + profileState = ProfileLoadState.Fresh, + profile = ProfileSummary("alice", "Alice", "alice@example.com", "Farmer", "https://example.com/a.png"), ), ) - val model = HarvestCircleStoreState(snapshot).toUiModel() + val model = HarvestCirclePresenterState(snapshot).toUiModel() assertEquals("Alice", model.activeIdentity?.heading) assertEquals("connected", model.activeIdentity?.relayState) @@ -57,7 +64,7 @@ class IdentityUiModelTest { @Test fun mapsSafeProblemAndTransientBackupSeparatelyFromSnapshot() { val state = - HarvestCircleStoreState( + HarvestCirclePresenterState( snapshot = snapshot(), generatedKeyBackup = GeneratedKeyBackup("npub1generated", "nsec1generated"), problem = "Try again.", @@ -79,15 +86,14 @@ class IdentityUiModelTest { @Test fun mapsTypedImportFailuresToSpecificRepairGuidance() { val invalid = - HarvestCircleStoreState( + HarvestCirclePresenterState( snapshot = snapshot(), - lastFailureCode = WireErrorCode.INVALID_SECRET_KEY, + lastProblem = problem(ApplicationErrorCode.InvalidSecretKey), ).toUiModel() val repair = - HarvestCircleStoreState( + HarvestCirclePresenterState( snapshot = snapshot(), - lastFailureCode = WireErrorCode.CREDENTIAL_MISSING, - recoveryAction = WireRecoveryAction.REPAIR_CREDENTIAL, + lastProblem = problem(ApplicationErrorCode.CredentialMissing, RecoveryAction.RepairCredential), ).toUiModel() assertEquals("Enter a valid nsec or 64-character hexadecimal secret key.", invalid.importGuidance) @@ -99,29 +105,40 @@ class IdentityUiModelTest { } private fun snapshot( - identity: IdentityDto? = null, - active: ActiveIdentityDto? = null, -) = AppSnapshotDto( - revision = 1UL, - lifecycle = AppLifecycleDto.READY, - lifecycleError = null, + identity: IdentitySummary? = null, + active: ActiveIdentity? = null, +) = ApplicationSnapshot( + revision = SnapshotRevision(1UL), + lifecycle = ApplicationLifecycle.Ready, + lifecycleProblem = null, configuredRelays = listOf("ws://localhost:8080"), identities = listOfNotNull(identity), - selectedPublicKeyHex = identity?.publicKeyHex, - session = if (active == null) SessionStateDto.SIGNED_OUT else SessionStateDto.ACTIVE, - sessionSubjectPublicKeyHex = active?.identity?.publicKeyHex, - sessionError = null, + selectedIdentityId = identity?.id, + session = if (active == null) SessionLifecycle.SignedOut else SessionLifecycle.Active, + sessionSubjectIdentityId = active?.identity?.id, + sessionProblem = null, activeIdentity = active, recoverableProblem = null, ) private fun identity() = - IdentityDto( - publicKeyHex = "12".repeat(32), + IdentitySummary( + id = IdentityId.fromPublicKeyHex("12".repeat(32)), npub = "npub1abcdefghijklmnopqrstuvwxyz1234567890", displayLabel = "Alice", - signerBindingKind = SignerBindingKindDto.LOCAL_KEYRING, - signerAvailability = SignerAvailabilityDto.AVAILABLE, - createdAtSeconds = 1, - lastUsedAtSeconds = null, + signer = SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available), + createdAt = UnixSeconds(1), + lastUsedAt = null, ) + +private fun problem( + code: ApplicationErrorCode, + recoveryAction: RecoveryAction = RecoveryAction.None, +) = ApplicationProblem( + code = code, + category = ApplicationErrorCategory.Input, + retryable = false, + recoveryAction = recoveryAction, + operationId = null, + safeMessage = "Safe problem.", +) diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts @@ -25,6 +25,7 @@ kotlin { } commonTest.dependencies { implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCirclePresenter.kt @@ -0,0 +1,420 @@ +package org.harvestcircle.application + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class HarvestCirclePresenter( + private val runtime: HarvestCircleRuntime, + private val scope: CoroutineScope, + private val clock: ApplicationClock, + private val operationIds: OperationIdSource, +) { + private val mutableState = MutableStateFlow(HarvestCirclePresenterState(runtime.currentSnapshot())) + private val mutableEffects = MutableSharedFlow<HarvestCircleEffect>(extraBufferCapacity = EFFECT_BUFFER_CAPACITY) + private var subscriptionJob: Job? = null + private var commandJob: Job? = null + private var pendingRecovery: GeneratedIdentityRecovery? = null + private var pendingRemoval: IdentityRemovalRequest? = null + private var pendingRetry: PendingRetry? = null + private var closed = false + + val state: StateFlow<HarvestCirclePresenterState> = mutableState.asStateFlow() + val effects: SharedFlow<HarvestCircleEffect> = mutableEffects.asSharedFlow() + + init { + subscriptionJob = + scope.launch { + try { + runtime.changes().collect { change -> acceptSnapshot(change.snapshot) } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + if (!closed) acceptFailure(error, null) + } + } + launchOperation(requireReady = false) { + acceptSnapshot(runtime.bootstrap()) + } + } + + fun dispatch(intent: HarvestCircleIntent) { + when (intent) { + is HarvestCircleIntent.EditImportDraft -> editImportDraft(intent.value) + HarvestCircleIntent.ChooseCreateIdentity -> updateState { copy(identityEntryMode = IdentityEntryMode.CREATE, problem = null) } + HarvestCircleIntent.ChooseImportIdentity -> updateState { copy(identityEntryMode = IdentityEntryMode.IMPORT, problem = null) } + HarvestCircleIntent.CancelIdentityEntry -> + updateState { + copy(identityEntryMode = IdentityEntryMode.CHOICE, importDraft = "", problem = null) + } + HarvestCircleIntent.GenerateIdentity -> prepareIdentity() + HarvestCircleIntent.AcknowledgeGeneratedRecovery -> acknowledgeRecovery() + HarvestCircleIntent.CancelGeneratedRecovery -> cancelRecovery() + HarvestCircleIntent.ImportIdentity -> importIdentity() + is HarvestCircleIntent.SelectIdentity -> executeIntent(intent) + is HarvestCircleIntent.ActivateIdentity -> executeIntent(intent) + HarvestCircleIntent.SignOut -> executeIntent(intent) + HarvestCircleIntent.ShowIdentityChooser -> updateState { copy(identityChooserVisible = true, problem = null) } + HarvestCircleIntent.HideIdentityChooser -> updateState { copy(identityChooserVisible = false) } + HarvestCircleIntent.RefreshActiveProfile -> executeIntent(intent) + HarvestCircleIntent.RetryLastCommand -> retryLastCommand() + is HarvestCircleIntent.RequestIdentityRemoval -> requestIdentityRemoval(intent) + HarvestCircleIntent.CancelIdentityRemoval -> cancelIdentityRemoval() + HarvestCircleIntent.ConfirmIdentityRemoval -> confirmIdentityRemoval() + HarvestCircleIntent.DismissProblem -> updateState { copy(problem = null) } + } + } + + suspend fun close(): ShutdownReceipt? { + if (closed) return null + closed = true + updateState { copy(route = HarvestCircleRoute.SHUTTING_DOWN, busy = true, problem = null) } + commandJob?.cancelAndJoin() + subscriptionJob?.cancelAndJoin() + releaseRecovery() + pendingRemoval = null + return try { + runtime.shutdown().also { receipt -> + updateState { + copy( + route = if (receipt.closed) HarvestCircleRoute.CLOSED else HarvestCircleRoute.FATAL, + busy = false, + problem = if (receipt.closed) null else "The application could not shut down safely.", + ) + } + } + } catch (error: Exception) { + acceptFailure(error, null) + updateState { copy(route = HarvestCircleRoute.FATAL, busy = false) } + null + } + } + + private fun editImportDraft(value: String) { + updateState { + copy( + importDraft = value.take(MAX_IMPORT_SECRET_CHARS), + lastProblem = null, + problem = null, + ) + } + } + + private fun prepareIdentity() { + launchOperation { + pendingRecovery?.let { previous -> + runtime.execute(ApplicationCommand.CancelGeneratedIdentity(previous.requestId)) + previous.backup.clear() + } + val recovery = runtime.prepareLocalIdentity() + pendingRecovery = recovery + updateState { copy(generatedKeyBackup = recovery.backup) } + } + } + + private fun acknowledgeRecovery() { + val recovery = pendingRecovery ?: return rejectUnavailable("Generated-key recovery is not available.") + val operationId = operationIds.next() + launchOperation(operationId = operationId) { + try { + val result = + runtime.execute( + ApplicationCommand.AcknowledgeGeneratedIdentity( + recovery.requestId, + requestContext(operationId), + ), + ) + acceptResult(result, operationId) + } finally { + releaseRecovery() + } + } + } + + private fun cancelRecovery() { + val recovery = pendingRecovery ?: return rejectUnavailable("Generated-key recovery is not available.") + launchOperation { + try { + runtime.execute(ApplicationCommand.CancelGeneratedIdentity(recovery.requestId)) + } finally { + releaseRecovery() + } + } + } + + private fun importIdentity() { + val draft = state.value.importDraft + val operationId = operationIds.next() + launchOperation( + operationId = operationId, + onAccepted = { updateState { copy(importDraft = "") } }, + ) { + val input = SecretKeyInput.from(draft) + val command = ApplicationCommand.ImportLocalIdentity(input, requestContext(operationId)) + try { + acceptResult(runtime.execute(command), operationId) + } finally { + input.clear() + } + } + } + + private fun executeIntent( + intent: HarvestCircleIntent, + operationId: OperationId = operationIds.next(), + ) { + launchOperation(operationId = operationId) { + val command = intent.toApplicationCommand() + try { + acceptResult(runtime.execute(command), operationId) + pendingRetry = null + if (intent is HarvestCircleIntent.ActivateIdentity || intent == HarvestCircleIntent.SignOut) { + updateState { copy(identityChooserVisible = false) } + } + } catch (error: Exception) { + val problem = error.toProblem(operationId) + pendingRetry = PendingRetry(intent, operationId).takeIf { problem.retryable } + throw ApplicationFailure(problem) + } + } + } + + private fun retryLastCommand() { + val retry = pendingRetry ?: return rejectUnavailable("This action cannot be retried safely.") + executeIntent(retry.intent, retry.operationId) + } + + private fun requestIdentityRemoval(intent: HarvestCircleIntent.RequestIdentityRemoval) { + launchOperation { + pendingRemoval?.let { runtime.cancelIdentityRemoval(it.requestId) } + val request = runtime.requestIdentityRemoval(intent.identityId) + check(request.expiresAt.value > clock.now().value) { "Identity removal request is already expired" } + pendingRemoval = request + updateState { + copy( + pendingRemovalIdentityId = request.identityId, + removalImpact = + RemovalImpactState( + request.identityId, + request.deletesLocalCredential, + request.signsOut, + request.expiresAt, + ), + removalStatus = RemovalStatus.AWAITING_CONFIRMATION, + ) + } + } + } + + private fun cancelIdentityRemoval() { + val request = pendingRemoval ?: return rejectUnavailable("Identity removal confirmation is not available.") + launchOperation { + runtime.cancelIdentityRemoval(request.requestId) + pendingRemoval = null + updateState { + copy( + pendingRemovalIdentityId = null, + removalImpact = null, + removalStatus = RemovalStatus.NONE, + ) + } + } + } + + private fun confirmIdentityRemoval() { + val request = pendingRemoval ?: return rejectUnavailable("Identity removal confirmation is not available.") + val operationId = operationIds.next() + launchOperation( + operationId = operationId, + onAccepted = { + pendingRemoval = null + updateState { copy(removalStatus = RemovalStatus.CONFIRMING) } + }, + ) { + try { + val result = + runtime.execute( + ApplicationCommand.ConfirmIdentityRemoval( + request.requestId, + requestContext(operationId), + ), + ) + acceptResult(result, operationId) + updateState { + copy( + pendingRemovalIdentityId = null, + removalImpact = null, + removalStatus = RemovalStatus.COMPLETED, + lastRemovedIdentityId = request.identityId, + ) + } + mutableEffects.tryEmit(HarvestCircleEffect.IdentityRemoved(request.identityId)) + } finally { + if (state.value.removalStatus != RemovalStatus.COMPLETED) { + runtime.cancelIdentityRemoval(request.requestId) + updateState { + copy( + pendingRemovalIdentityId = null, + removalImpact = null, + removalStatus = RemovalStatus.FAILED, + ) + } + } + } + } + } + + private fun launchOperation( + requireReady: Boolean = true, + operationId: OperationId? = null, + onAccepted: () -> Unit = {}, + operation: suspend () -> Unit, + ) { + if (rejectIfUnavailable(requireReady)) return + updateState { + copy( + busy = true, + commandStatus = CommandStatus.RUNNING, + lastCommandOperationId = operationId ?: lastCommandOperationId, + problem = null, + ) + } + onAccepted() + commandJob = + scope.launch { + try { + operation() + if (state.value.commandStatus == CommandStatus.RUNNING) { + updateState { copy(commandStatus = CommandStatus.ACCEPTED) } + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + acceptFailure(error, operationId) + } finally { + updateState { copy(busy = false) } + } + } + } + + private fun rejectIfUnavailable(requireReady: Boolean): Boolean { + val current = state.value + if (closed) { + updateState { + copy(commandStatus = CommandStatus.REJECTED_CLOSED, problem = "The application runtime is closed.") + } + return true + } + if (commandJob?.isActive == true) { + updateState { + copy(commandStatus = CommandStatus.REJECTED_BUSY, problem = "The application is busy. Try again.") + } + return true + } + if (requireReady && current.route !in READY_ROUTES) { + updateState { + copy(commandStatus = CommandStatus.FAILED_TERMINAL, problem = "The application runtime is not ready for this action.") + } + return true + } + return false + } + + private fun rejectUnavailable(message: String) { + updateState { + copy( + commandStatus = if (closed) CommandStatus.REJECTED_CLOSED else CommandStatus.FAILED_TERMINAL, + problem = message, + ) + } + } + + private fun acceptResult( + result: ApplicationCommandResult, + operationId: OperationId, + ) { + acceptSnapshot(result.snapshot) + updateState { + copy( + commandStatus = CommandStatus.ACCEPTED, + lastCommandOperationId = operationId, + lastProblem = null, + problem = null, + ) + } + } + + private fun acceptSnapshot(snapshot: ApplicationSnapshot) { + if (snapshot.revision.value >= state.value.snapshot.revision.value) { + updateState { copy(snapshot = snapshot, route = snapshot.toHarvestCircleRoute()) } + } + } + + private fun acceptFailure( + error: Throwable, + operationId: OperationId?, + ) { + val problem = error.toProblem(operationId) + updateState { + copy( + commandStatus = if (problem.retryable) CommandStatus.FAILED_RETRYABLE else CommandStatus.FAILED_TERMINAL, + lastCommandOperationId = problem.operationId ?: operationId ?: lastCommandOperationId, + lastProblem = problem, + problem = problem.safeMessage, + ) + } + mutableEffects.tryEmit(HarvestCircleEffect.Problem(problem)) + } + + private fun releaseRecovery() { + pendingRecovery?.backup?.clear() + pendingRecovery = null + updateState { copy(generatedKeyBackup = null) } + } + + private fun requestContext(operationId: OperationId): RequestContext = + RequestContext(operationId, state.value.snapshot.revision, COMMAND_DEADLINE_MILLIS) + + private fun updateState(transform: HarvestCirclePresenterState.() -> HarvestCirclePresenterState) { + mutableState.update(transform) + } +} + +private data class PendingRetry( + val intent: HarvestCircleIntent, + val operationId: OperationId, +) + +private fun HarvestCircleIntent.toApplicationCommand(): ApplicationCommand = + when (this) { + is HarvestCircleIntent.SelectIdentity -> ApplicationCommand.SelectIdentity(identityId) + is HarvestCircleIntent.ActivateIdentity -> ApplicationCommand.ActivateIdentity(identityId) + HarvestCircleIntent.SignOut -> ApplicationCommand.SignOut + HarvestCircleIntent.RefreshActiveProfile -> ApplicationCommand.RefreshActiveProfile + else -> error("Intent is not a direct application command") + } + +private fun Throwable.toProblem(operationId: OperationId?): ApplicationProblem = + (this as? ApplicationFailure)?.problem + ?: ApplicationProblem( + code = ApplicationErrorCode.Internal, + category = ApplicationErrorCategory.Internal, + retryable = false, + recoveryAction = RecoveryAction.None, + operationId = operationId, + safeMessage = "The application command failed.", + ) + +private val READY_ROUTES = setOf(HarvestCircleRoute.IDENTITIES, HarvestCircleRoute.ACTIVE_IDENTITY) +private const val EFFECT_BUFFER_CAPACITY = 8 +private const val COMMAND_DEADLINE_MILLIS = 5_000UL diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/PresentationModels.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/PresentationModels.kt @@ -0,0 +1,140 @@ +package org.harvestcircle.application + +enum class HarvestCircleRoute { + OPENING, + CHECKING_COMPATIBILITY, + ACQUIRING_OWNERSHIP, + MIGRATING, + RECOVERING, + IDENTITIES, + ACTIVE_IDENTITY, + DEGRADED, + BLOCKED, + SHUTTING_DOWN, + FATAL, + CLOSED, +} + +enum class CommandStatus { + IDLE, + RUNNING, + ACCEPTED, + REJECTED_BUSY, + REJECTED_CLOSED, + FAILED_RETRYABLE, + FAILED_TERMINAL, +} + +enum class IdentityEntryMode { + CHOICE, + CREATE, + IMPORT, +} + +enum class RemovalStatus { + NONE, + AWAITING_CONFIRMATION, + CONFIRMING, + COMPLETED, + FAILED, +} + +data class RemovalImpactState( + val identityId: IdentityId, + val deletesLocalCredential: Boolean, + val signsOut: Boolean, + val expiresAt: UnixSeconds, +) + +data class HarvestCirclePresenterState( + val snapshot: ApplicationSnapshot, + val route: HarvestCircleRoute = snapshot.toHarvestCircleRoute(), + val importDraft: String = "", + val generatedKeyBackup: GeneratedKeyBackup? = null, + val pendingRemovalIdentityId: IdentityId? = null, + val removalImpact: RemovalImpactState? = null, + val removalStatus: RemovalStatus = RemovalStatus.NONE, + val lastRemovedIdentityId: IdentityId? = null, + val identityChooserVisible: Boolean = false, + val identityEntryMode: IdentityEntryMode = IdentityEntryMode.CHOICE, + val busy: Boolean = false, + val commandStatus: CommandStatus = CommandStatus.IDLE, + val lastCommandOperationId: OperationId? = null, + val lastProblem: ApplicationProblem? = null, + val problem: String? = null, +) + +sealed interface HarvestCircleIntent { + data class EditImportDraft( + val value: String, + ) : HarvestCircleIntent + + data object ChooseCreateIdentity : HarvestCircleIntent + + data object ChooseImportIdentity : HarvestCircleIntent + + data object CancelIdentityEntry : HarvestCircleIntent + + data object GenerateIdentity : HarvestCircleIntent + + data object AcknowledgeGeneratedRecovery : HarvestCircleIntent + + data object CancelGeneratedRecovery : HarvestCircleIntent + + data object ImportIdentity : HarvestCircleIntent + + data class SelectIdentity( + val identityId: IdentityId, + ) : HarvestCircleIntent + + data class ActivateIdentity( + val identityId: IdentityId, + ) : HarvestCircleIntent + + data object SignOut : HarvestCircleIntent + + data object ShowIdentityChooser : HarvestCircleIntent + + data object HideIdentityChooser : HarvestCircleIntent + + data object RefreshActiveProfile : HarvestCircleIntent + + data object RetryLastCommand : HarvestCircleIntent + + data class RequestIdentityRemoval( + val identityId: IdentityId, + ) : HarvestCircleIntent + + data object CancelIdentityRemoval : HarvestCircleIntent + + data object ConfirmIdentityRemoval : HarvestCircleIntent + + data object DismissProblem : HarvestCircleIntent +} + +sealed interface HarvestCircleEffect { + data class Problem( + val problem: ApplicationProblem, + ) : HarvestCircleEffect + + data class IdentityRemoved( + val identityId: IdentityId, + ) : HarvestCircleEffect +} + +const val MAX_IMPORT_SECRET_CHARS: Int = 128 + +internal fun ApplicationSnapshot.toHarvestCircleRoute(): HarvestCircleRoute = + when (lifecycle) { + ApplicationLifecycle.Opening -> HarvestCircleRoute.OPENING + ApplicationLifecycle.CompatibilityChecking -> HarvestCircleRoute.CHECKING_COMPATIBILITY + ApplicationLifecycle.AcquiringOwnership -> HarvestCircleRoute.ACQUIRING_OWNERSHIP + ApplicationLifecycle.Migrating -> HarvestCircleRoute.MIGRATING + ApplicationLifecycle.Recovering -> HarvestCircleRoute.RECOVERING + ApplicationLifecycle.Ready -> if (activeIdentity == null) HarvestCircleRoute.IDENTITIES else HarvestCircleRoute.ACTIVE_IDENTITY + ApplicationLifecycle.Degraded -> HarvestCircleRoute.DEGRADED + ApplicationLifecycle.Blocked -> HarvestCircleRoute.BLOCKED + ApplicationLifecycle.ShuttingDown -> HarvestCircleRoute.SHUTTING_DOWN + ApplicationLifecycle.Closed -> HarvestCircleRoute.CLOSED + ApplicationLifecycle.Fatal -> HarvestCircleRoute.FATAL + } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeContracts.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeContracts.kt @@ -99,5 +99,11 @@ interface HarvestCircleRuntime { suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest + suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean + suspend fun shutdown(): ShutdownReceipt } + +class ApplicationFailure( + val problem: ApplicationProblem, +) : Exception(problem.safeMessage) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeIdentifiers.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeIdentifiers.kt @@ -61,6 +61,14 @@ value class UnixSeconds( val value: Long, ) +fun interface ApplicationClock { + fun now(): UnixSeconds +} + +fun interface OperationIdSource { + fun next(): OperationId +} + data class RequestContext( val operationId: OperationId, val expectedRevision: SnapshotRevision, diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCirclePresenterTest.kt @@ -0,0 +1,307 @@ +package org.harvestcircle.application + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class HarvestCirclePresenterTest { + @Test + fun bootstrapAndObserverChangesPreserveMonotonicSnapshots() = + runTest { + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + + assertEquals(1UL, presenter.state.value.snapshot.revision.value) + runtime.emit(snapshot(3UL)) + runtime.emit(snapshot(2UL)) + runCurrent() + + assertEquals(3UL, presenter.state.value.snapshot.revision.value) + assertFalse(presenter.state.value.busy) + presenter.close() + } + + @Test + fun busyAdmissionRejectsOverlappingCommands() = + runTest { + val bootstrapGate = CompletableDeferred<Unit>() + val runtime = FakePresenterRuntime(bootstrapGate = bootstrapGate) + val presenter = presenter(runtime) + runCurrent() + + presenter.dispatch(HarvestCircleIntent.SignOut) + + assertEquals(CommandStatus.REJECTED_BUSY, presenter.state.value.commandStatus) + assertEquals(0, runtime.executeCalls) + bootstrapGate.complete(Unit) + advanceUntilIdle() + presenter.close() + } + + @Test + fun retryReusesTheOriginalInjectedOperationIdentity() = + runTest { + val runtime = FakePresenterRuntime() + val ids = DeterministicOperationIds() + val presenter = presenter(runtime, ids) + runCurrent() + runtime.nextFailure = problem(retryable = true, operationId = OperationId.from("operation-1")) + + presenter.dispatch(HarvestCircleIntent.SignOut) + advanceUntilIdle() + + assertEquals(CommandStatus.FAILED_RETRYABLE, presenter.state.value.commandStatus) + assertEquals(OperationId.from("operation-1"), presenter.state.value.lastCommandOperationId) + presenter.dispatch(HarvestCircleIntent.RetryLastCommand) + advanceUntilIdle() + + assertEquals(CommandStatus.ACCEPTED, presenter.state.value.commandStatus) + assertEquals(2, runtime.executeCalls) + assertEquals(1, ids.calls) + presenter.close() + } + + @Test + fun terminalFailureCannotBeRetried() = + runTest { + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + runtime.nextFailure = problem(retryable = false) + + presenter.dispatch(HarvestCircleIntent.RefreshActiveProfile) + advanceUntilIdle() + presenter.dispatch(HarvestCircleIntent.RetryLastCommand) + + assertEquals(CommandStatus.FAILED_TERMINAL, presenter.state.value.commandStatus) + assertEquals("This action cannot be retried safely.", presenter.state.value.problem) + assertEquals(1, runtime.executeCalls) + presenter.close() + } + + @Test + fun closeCancelsAnInFlightCommandBeforeNativeShutdown() = + runTest { + val executeGate = CompletableDeferred<Unit>() + val runtime = FakePresenterRuntime(executeGate = executeGate) + val presenter = presenter(runtime) + runCurrent() + presenter.dispatch(HarvestCircleIntent.SignOut) + runCurrent() + assertTrue(presenter.state.value.busy) + + val receipt = async { presenter.close() } + advanceUntilIdle() + + assertTrue(runtime.executeCancelled) + assertTrue(runtime.shutdownCalled) + assertEquals(HarvestCircleRoute.CLOSED, presenter.state.value.route) + assertTrue(receipt.await()?.closed == true) + } + + @Test + fun generatedRecoveryIsOneUseAndClearedOnCancellation() = + runTest { + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + + presenter.dispatch(HarvestCircleIntent.GenerateIdentity) + advanceUntilIdle() + val backup = presenter.state.value.generatedKeyBackup ?: error("recovery backup") + assertEquals("nsec1presenter-secret", backup.revealNsec()) + + presenter.dispatch(HarvestCircleIntent.CancelGeneratedRecovery) + advanceUntilIdle() + + assertNull(presenter.state.value.generatedKeyBackup) + assertEquals(1, runtime.generatedCancellationCalls) + assertFailsWith<IllegalStateException> { backup.revealNsec() } + presenter.close() + } + + @Test + fun importDraftIsBoundedClearedAndConsumedOnce() = + runTest { + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + + presenter.dispatch(HarvestCircleIntent.EditImportDraft("nsec1" + "x".repeat(200))) + assertEquals(MAX_IMPORT_SECRET_CHARS, presenter.state.value.importDraft.length) + presenter.dispatch(HarvestCircleIntent.ImportIdentity) + assertEquals("", presenter.state.value.importDraft) + advanceUntilIdle() + + assertEquals(1, runtime.importedSecrets.size) + assertEquals(MAX_IMPORT_SECRET_CHARS, runtime.importedSecrets.single().length) + presenter.close() + } + + @Test + fun removalCancellationReleasesTheRuntimeRequest() = + runTest { + val runtime = FakePresenterRuntime() + val presenter = presenter(runtime) + runCurrent() + val identityId = IdentityId.fromPublicKeyHex("01".repeat(32)) + + presenter.dispatch(HarvestCircleIntent.RequestIdentityRemoval(identityId)) + advanceUntilIdle() + assertEquals(identityId, presenter.state.value.pendingRemovalIdentityId) + presenter.dispatch(HarvestCircleIntent.CancelIdentityRemoval) + advanceUntilIdle() + + assertNull(presenter.state.value.pendingRemovalIdentityId) + assertEquals(1, runtime.removalCancellationCalls) + presenter.close() + } + + private fun TestScope.presenter( + runtime: FakePresenterRuntime, + ids: DeterministicOperationIds = DeterministicOperationIds(), + ): HarvestCirclePresenter = + HarvestCirclePresenter( + runtime = runtime, + scope = this, + clock = ApplicationClock { UnixSeconds(10) }, + operationIds = ids, + ) +} + +private class DeterministicOperationIds : OperationIdSource { + var calls = 0 + + override fun next(): OperationId { + calls += 1 + return OperationId.from("operation-$calls") + } +} + +private class FakePresenterRuntime( + private val bootstrapGate: CompletableDeferred<Unit>? = null, + private val executeGate: CompletableDeferred<Unit>? = null, +) : HarvestCircleRuntime { + private val changes = MutableSharedFlow<ApplicationChange>(extraBufferCapacity = 8) + private var current = snapshot(0UL) + var nextFailure: ApplicationProblem? = null + var executeCalls = 0 + var executeCancelled = false + var shutdownCalled = false + var generatedCancellationCalls = 0 + var removalCancellationCalls = 0 + val importedSecrets = mutableListOf<String>() + + override suspend fun bootstrap(): ApplicationSnapshot { + bootstrapGate?.await() + return snapshot(1UL).also { current = it } + } + + override fun currentSnapshot(): ApplicationSnapshot = current + + override fun changes(): Flow<ApplicationChange> = changes + + override suspend fun execute(command: ApplicationCommand): ApplicationCommandResult { + executeCalls += 1 + nextFailure?.let { + nextFailure = null + throw ApplicationFailure(it) + } + try { + executeGate?.await() + } catch (error: CancellationException) { + executeCancelled = true + throw error + } + when (command) { + is ApplicationCommand.ImportLocalIdentity -> importedSecrets += command.secretKey.take() + is ApplicationCommand.CancelGeneratedIdentity -> generatedCancellationCalls += 1 + else -> Unit + } + return ApplicationCommandResult.Updated(current) + } + + override suspend fun prepareLocalIdentity(): GeneratedIdentityRecovery = + GeneratedIdentityRecovery( + requestId = RecoveryRequestId.from("recovery-1"), + identity = identity(), + expiresAt = UnixSeconds(60), + backup = GeneratedKeyBackup("npub1presenter", "nsec1presenter-secret"), + ) + + override suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest = + IdentityRemovalRequest( + requestId = RemovalRequestId.from("removal-1"), + identityId = identityId, + deletesLocalCredential = true, + signsOut = false, + expiresAt = UnixSeconds(60), + ) + + override suspend fun cancelIdentityRemoval(requestId: RemovalRequestId): Boolean { + removalCancellationCalls += 1 + return true + } + + override suspend fun shutdown(): ShutdownReceipt { + shutdownCalled = true + return ShutdownReceipt(current.revision, closed = true) + } + + fun emit(snapshot: ApplicationSnapshot) { + current = snapshot + changes.tryEmit(ApplicationChange(snapshot, null)) + } +} + +private fun problem( + retryable: Boolean, + operationId: OperationId? = null, +) = ApplicationProblem( + code = ApplicationErrorCode.StorageUnavailable, + category = ApplicationErrorCategory.Storage, + retryable = retryable, + recoveryAction = if (retryable) RecoveryAction.Retry else RecoveryAction.None, + operationId = operationId, + safeMessage = "Storage is temporarily unavailable.", +) + +private fun identity() = + IdentitySummary( + id = IdentityId.fromPublicKeyHex("01".repeat(32)), + npub = "npub1presenter", + displayLabel = "Identity", + signer = SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available), + createdAt = UnixSeconds(1), + lastUsedAt = null, + ) + +private fun snapshot(revision: ULong) = + ApplicationSnapshot( + revision = SnapshotRevision(revision), + lifecycle = ApplicationLifecycle.Ready, + lifecycleProblem = null, + configuredRelays = emptyList(), + identities = emptyList(), + selectedIdentityId = null, + session = SessionLifecycle.SignedOut, + sessionSubjectIdentityId = null, + sessionProblem = null, + activeIdentity = null, + recoverableProblem = null, + )