commit 5548b0e6eb65c3c438c09dfc5063cf5e9e6873de
parent 5d73568ffb852dea9749c23a75816d5adb932831
Author: triesap <tyson@radroots.org>
Date: Mon, 10 Aug 2026 01:52:05 +0000
application: define platform-neutral runtime contracts
- add validated identity, operation, revision, lifecycle, and error models
- define shared commands, results, changes, recovery, and shutdown contracts
- expose a coroutine Flow-based runtime interface without native types
- enforce prohibited imports and exhaustive common-model tests in both lanes
Diffstat:
7 files changed, 697 insertions(+), 1 deletion(-)
diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts
@@ -1,3 +1,4 @@
+import org.harvestcircle.gradle.VerifySharedBoundary
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
@@ -19,6 +20,9 @@ kotlin {
}
sourceSets {
+ commonMain.dependencies {
+ implementation(libs.kotlinx.coroutines.core)
+ }
commonTest.dependencies {
implementation(kotlin("test"))
}
@@ -34,3 +38,15 @@ kotlin {
"HarvestCircle shared must declare exactly one KMP platform target named desktop"
}
}
+
+val verifySharedBoundary by tasks.registering(VerifySharedBoundary::class) {
+ commonSources.from(
+ fileTree("src/commonMain/kotlin") {
+ include("**/*.kt")
+ },
+ )
+}
+
+tasks.named("check") {
+ dependsOn(verifySharedBoundary)
+}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ApplicationModels.kt
@@ -0,0 +1,231 @@
+package org.harvestcircle.application
+
+enum class ApplicationLifecycle {
+ Opening,
+ CompatibilityChecking,
+ AcquiringOwnership,
+ Migrating,
+ Recovering,
+ Ready,
+ Degraded,
+ Blocked,
+ ShuttingDown,
+ Closed,
+ Fatal,
+}
+
+enum class SessionLifecycle {
+ SignedOut,
+ Activating,
+ Active,
+ SigningOut,
+ Failed,
+}
+
+enum class SignerAvailability {
+ Available,
+ CredentialMissing,
+ StoreUnavailable,
+ NotRequired,
+}
+
+sealed interface SignerBindingKind {
+ data object LocalKeyring : SignerBindingKind
+
+ data class Unsupported(
+ val protocol: String,
+ ) : SignerBindingKind {
+ init {
+ requireSafeText(protocol, "Signer protocol", 64)
+ }
+ }
+}
+
+data class SignerBindingSummary(
+ val kind: SignerBindingKind,
+ val availability: SignerAvailability,
+)
+
+data class IdentitySummary(
+ val id: IdentityId,
+ val npub: String,
+ val displayLabel: String,
+ val signer: SignerBindingSummary,
+ val createdAt: UnixSeconds,
+ val lastUsedAt: UnixSeconds?,
+) {
+ init {
+ requireSafeText(npub, "Nostr public identity", 128)
+ requireSafeText(displayLabel, "Identity display label", 128)
+ require(lastUsedAt == null || lastUsedAt.value >= createdAt.value) {
+ "Identity last-used time precedes creation"
+ }
+ }
+}
+
+enum class RelayConnectionState {
+ Disconnected,
+ Connecting,
+ Connected,
+ Degraded,
+ Error,
+}
+
+data class RelaySummary(
+ val destinations: List<String>,
+ val state: RelayConnectionState,
+) {
+ init {
+ require(destinations.distinct() == destinations) { "Relay destinations must be unique" }
+ destinations.forEach { requireSafeText(it, "Relay destination", 2048) }
+ }
+}
+
+enum class ProfileLoadState {
+ Empty,
+ Loading,
+ Cached,
+ Fresh,
+ Error,
+}
+
+data class ProfileSummary(
+ val name: String?,
+ val displayName: String?,
+ val nip05: String?,
+ val about: String?,
+ val picture: String?,
+) {
+ init {
+ validateOptional(name, "Profile name", 256)
+ validateOptional(displayName, "Profile display name", 256)
+ validateOptional(nip05, "Profile NIP-05 identifier", 320)
+ validateOptional(about, "Profile about text", 4096)
+ validateOptional(picture, "Profile picture URL", 2048)
+ }
+}
+
+data class ActiveIdentity(
+ val identity: IdentitySummary,
+ val relays: RelaySummary,
+ val profileState: ProfileLoadState,
+ val profile: ProfileSummary?,
+)
+
+enum class ApplicationErrorCode {
+ InvalidPublicKey,
+ InvalidSecretKey,
+ InvalidIdentityMetadata,
+ InvalidProfileMetadata,
+ InvalidApplicationState,
+ IdentityAlreadyExists,
+ IdentityNotFound,
+ KeyringUnavailable,
+ CredentialMissing,
+ StorageUnavailable,
+ StorageCorrupt,
+ StorageQuarantined,
+ StorageBackupInvalid,
+ UnsupportedSchemaVersion,
+ RepairUnauthorized,
+ PendingOperationRecoveryRequired,
+ InvalidRelayConfiguration,
+ RelayConnectionFailed,
+ ProfileRefreshFailed,
+ ObserverRegistrationFailed,
+ NativeLibraryLoadFailed,
+ CompatibilityMismatch,
+ Internal,
+}
+
+enum class ApplicationErrorCategory {
+ Input,
+ Conflict,
+ Credential,
+ Storage,
+ Network,
+ Lifecycle,
+ Compatibility,
+ Internal,
+}
+
+enum class RecoveryAction {
+ None,
+ Retry,
+ RepairCredential,
+ Authenticate,
+ RepairStorage,
+ RestoreBackup,
+ CheckConfiguration,
+ RestartApplication,
+ UpdateApplication,
+}
+
+data class ApplicationProblem(
+ val code: ApplicationErrorCode,
+ val category: ApplicationErrorCategory,
+ val retryable: Boolean,
+ val recoveryAction: RecoveryAction,
+ val operationId: OperationId?,
+ val safeMessage: String,
+) {
+ init {
+ requireSafeText(safeMessage, "Safe error message", 512)
+ }
+}
+
+data class ApplicationSnapshot(
+ val revision: SnapshotRevision,
+ val lifecycle: ApplicationLifecycle,
+ val lifecycleProblem: ApplicationProblem?,
+ val configuredRelays: List<String>,
+ val identities: List<IdentitySummary>,
+ val selectedIdentityId: IdentityId?,
+ val session: SessionLifecycle,
+ val sessionSubjectIdentityId: IdentityId?,
+ val sessionProblem: ApplicationProblem?,
+ val activeIdentity: ActiveIdentity?,
+ val recoverableProblem: ApplicationProblem?,
+) {
+ init {
+ require(configuredRelays.distinct() == configuredRelays) { "Configured relays must be unique" }
+ configuredRelays.forEach { requireSafeText(it, "Configured relay", 2048) }
+ require(identities.map(IdentitySummary::id).distinct().size == identities.size) {
+ "Snapshot identities must be unique"
+ }
+ val ids = identities.map(IdentitySummary::id).toSet()
+ require(selectedIdentityId == null || selectedIdentityId in ids) {
+ "Selected identity is not present in the snapshot"
+ }
+ require(sessionSubjectIdentityId == null || sessionSubjectIdentityId in ids) {
+ "Session subject is not present in the snapshot"
+ }
+ require(activeIdentity == null || activeIdentity.identity in identities) {
+ "Active identity does not match an identity in the snapshot"
+ }
+ require((session == SessionLifecycle.Active) == (activeIdentity != null)) {
+ "Active session and active identity must agree"
+ }
+ require(activeIdentity == null || activeIdentity.relays.destinations == configuredRelays) {
+ "Active identity relays do not match configured relays"
+ }
+ }
+}
+
+private fun validateOptional(
+ value: String?,
+ label: String,
+ maximumLength: Int,
+) {
+ if (value != null) requireSafeText(value, label, maximumLength)
+}
+
+private fun requireSafeText(
+ value: String,
+ label: String,
+ maximumLength: Int,
+) {
+ require(value.isNotBlank() && value.length <= maximumLength && value.none(Char::isISOControl)) {
+ "$label is empty, oversized, or contains a control character"
+ }
+}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeContracts.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeContracts.kt
@@ -0,0 +1,103 @@
+package org.harvestcircle.application
+
+import kotlinx.coroutines.flow.Flow
+
+sealed interface ApplicationCommand {
+ data class AcknowledgeGeneratedIdentity(
+ val requestId: RecoveryRequestId,
+ val context: RequestContext,
+ ) : ApplicationCommand
+
+ data class CancelGeneratedIdentity(
+ val requestId: RecoveryRequestId,
+ ) : ApplicationCommand
+
+ data class ImportLocalIdentity(
+ val secretKey: SecretKeyInput,
+ val context: RequestContext,
+ ) : ApplicationCommand
+
+ data class SelectIdentity(
+ val identityId: IdentityId,
+ ) : ApplicationCommand
+
+ data class ActivateIdentity(
+ val identityId: IdentityId,
+ ) : ApplicationCommand
+
+ data object SignOut : ApplicationCommand
+
+ data object RefreshActiveProfile : ApplicationCommand
+
+ data class ConfirmIdentityRemoval(
+ val requestId: RemovalRequestId,
+ val context: RequestContext,
+ ) : ApplicationCommand
+}
+
+sealed interface ApplicationCommandResult {
+ val snapshot: ApplicationSnapshot
+
+ data class Committed(
+ val operationId: OperationId,
+ val committedRevision: SnapshotRevision,
+ override val snapshot: ApplicationSnapshot,
+ ) : ApplicationCommandResult {
+ init {
+ require(committedRevision == snapshot.revision) {
+ "Committed revision does not match the returned snapshot"
+ }
+ }
+ }
+
+ data class Updated(
+ override val snapshot: ApplicationSnapshot,
+ ) : ApplicationCommandResult
+}
+
+data class GeneratedIdentityRecovery(
+ val requestId: RecoveryRequestId,
+ val identity: IdentitySummary,
+ val expiresAt: UnixSeconds,
+ val backup: GeneratedKeyBackup,
+)
+
+data class IdentityRemovalRequest(
+ val requestId: RemovalRequestId,
+ val identityId: IdentityId,
+ val deletesLocalCredential: Boolean,
+ val signsOut: Boolean,
+ val expiresAt: UnixSeconds,
+)
+
+data class ApplicationChange(
+ val snapshot: ApplicationSnapshot,
+ val previousRevision: SnapshotRevision?,
+) {
+ init {
+ require(previousRevision == null || previousRevision.value < snapshot.revision.value) {
+ "Application change revisions are not monotonic"
+ }
+ }
+}
+
+data class ShutdownReceipt(
+ val finalRevision: SnapshotRevision,
+ val closed: Boolean,
+)
+
+interface HarvestCircleRuntime {
+ suspend fun bootstrap(): ApplicationSnapshot
+
+ fun currentSnapshot(): ApplicationSnapshot
+
+ fun changes(): Flow<ApplicationChange>
+
+ suspend fun execute(command: ApplicationCommand): ApplicationCommandResult
+
+ suspend fun prepareLocalIdentity(): GeneratedIdentityRecovery
+
+ suspend fun requestIdentityRemoval(identityId: IdentityId): IdentityRemovalRequest
+
+ suspend fun shutdown(): ShutdownReceipt
+}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeIdentifiers.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/RuntimeIdentifiers.kt
@@ -0,0 +1,93 @@
+package org.harvestcircle.application
+
+private val lowercaseHex = Regex("[0-9a-f]{64}")
+private val opaqueIdentifier = Regex("[A-Za-z0-9][A-Za-z0-9._:-]{0,127}")
+
+@JvmInline
+value class IdentityId private constructor(
+ val value: String,
+) {
+ companion object {
+ fun fromPublicKeyHex(value: String): IdentityId {
+ require(lowercaseHex.matches(value)) { "Identity ID must be canonical lowercase public-key hex" }
+ return IdentityId(value)
+ }
+ }
+}
+
+@JvmInline
+value class OperationId private constructor(
+ val value: String,
+) {
+ companion object {
+ fun from(value: String): OperationId {
+ require(opaqueIdentifier.matches(value)) { "Operation ID is malformed" }
+ return OperationId(value)
+ }
+ }
+}
+
+@JvmInline
+value class RecoveryRequestId private constructor(
+ val value: String,
+) {
+ companion object {
+ fun from(value: String): RecoveryRequestId {
+ require(opaqueIdentifier.matches(value)) { "Recovery request ID is malformed" }
+ return RecoveryRequestId(value)
+ }
+ }
+}
+
+@JvmInline
+value class RemovalRequestId private constructor(
+ val value: String,
+) {
+ companion object {
+ fun from(value: String): RemovalRequestId {
+ require(opaqueIdentifier.matches(value)) { "Removal request ID is malformed" }
+ return RemovalRequestId(value)
+ }
+ }
+}
+
+@JvmInline
+value class SnapshotRevision(
+ val value: ULong,
+)
+
+@JvmInline
+value class UnixSeconds(
+ val value: Long,
+)
+
+data class RequestContext(
+ val operationId: OperationId,
+ val expectedRevision: SnapshotRevision,
+ val deadlineMillis: ULong,
+) {
+ init {
+ require(deadlineMillis in 1UL..30_000UL) { "Command deadline is outside the supported window" }
+ }
+}
+
+class SecretKeyInput private constructor(
+ private var secret: String?,
+) {
+ fun take(): String = checkNotNull(secret).also { secret = null }
+
+ fun clear() {
+ secret = null
+ }
+
+ override fun toString(): String = "SecretKeyInput([REDACTED])"
+
+ companion object {
+ fun from(value: String): SecretKeyInput {
+ require(value.isNotBlank() && value.length <= 256 && value.none(Char::isISOControl)) {
+ "Secret-key input is malformed"
+ }
+ return SecretKeyInput(value)
+ }
+ }
+}
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/RuntimeContractsTest.kt
@@ -0,0 +1,217 @@
+package org.harvestcircle.application
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class RuntimeContractsTest {
+ @Test
+ fun identifiersAndCommandInputsValidateAndRedact() {
+ val identityId = IdentityId.fromPublicKeyHex("01".repeat(32))
+ assertEquals("01".repeat(32), identityId.value)
+ assertFailsWith<IllegalArgumentException> { IdentityId.fromPublicKeyHex("AB".repeat(32)) }
+ assertFailsWith<IllegalArgumentException> { OperationId.from("contains space") }
+ assertFailsWith<IllegalArgumentException> {
+ RequestContext(OperationId.from("operation-1"), SnapshotRevision(0UL), 0UL)
+ }
+
+ val secret = SecretKeyInput.from("nsec1boundedsecret")
+ assertFalse(secret.toString().contains("nsec1boundedsecret"))
+ assertEquals("nsec1boundedsecret", secret.take())
+ assertFailsWith<IllegalStateException> { secret.take() }
+ }
+
+ @Test
+ fun snapshotsRejectBrokenIdentityAndRevisionRelationships() {
+ val snapshot = snapshot(revision = 2UL)
+ assertEquals(snapshot.identities.single().id, snapshot.selectedIdentityId)
+ assertFailsWith<IllegalArgumentException> {
+ snapshot.copy(selectedIdentityId = IdentityId.fromPublicKeyHex("02".repeat(32)))
+ }
+ assertFailsWith<IllegalArgumentException> {
+ ApplicationChange(snapshot, SnapshotRevision(2UL))
+ }
+ assertFailsWith<IllegalArgumentException> {
+ ApplicationCommandResult.Committed(
+ operationId = OperationId.from("operation-1"),
+ committedRevision = SnapshotRevision(1UL),
+ snapshot = snapshot,
+ )
+ }
+ }
+
+ @Test
+ fun lifecycleErrorAndRecoveryModelsAreExhaustive() {
+ assertEquals(
+ 11,
+ ApplicationLifecycle.entries
+ .map(::lifecycleName)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 5,
+ SessionLifecycle.entries
+ .map(::sessionName)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 4,
+ SignerAvailability.entries
+ .map(::availabilityName)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 5,
+ RelayConnectionState.entries
+ .map(::relayName)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 5,
+ ProfileLoadState.entries
+ .map(::profileStateName)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 23,
+ ApplicationErrorCode.entries
+ .map(::errorCodeName)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 8,
+ ApplicationErrorCategory.entries
+ .map(::errorCategoryName)
+ .distinct()
+ .size,
+ )
+ assertEquals(
+ 9,
+ RecoveryAction.entries
+ .map(::recoveryName)
+ .distinct()
+ .size,
+ )
+ }
+
+ @Test
+ fun everyFoundationCommandHasAnExplicitRuntimeKind() {
+ val id = IdentityId.fromPublicKeyHex("01".repeat(32))
+ val context = RequestContext(OperationId.from("operation-1"), SnapshotRevision(1UL), 1_000UL)
+ val commands =
+ listOf(
+ ApplicationCommand.AcknowledgeGeneratedIdentity(RecoveryRequestId.from("recovery-1"), context),
+ ApplicationCommand.CancelGeneratedIdentity(RecoveryRequestId.from("recovery-1")),
+ ApplicationCommand.ImportLocalIdentity(SecretKeyInput.from("nsec1boundedsecret"), context),
+ ApplicationCommand.SelectIdentity(id),
+ ApplicationCommand.ActivateIdentity(id),
+ ApplicationCommand.SignOut,
+ ApplicationCommand.RefreshActiveProfile,
+ ApplicationCommand.ConfirmIdentityRemoval(RemovalRequestId.from("removal-1"), context),
+ )
+
+ assertEquals(8, commands.map(::commandName).distinct().size)
+ assertTrue(commands.none { it.toString().contains("nsec1boundedsecret") })
+ }
+}
+
+private fun snapshot(revision: ULong): ApplicationSnapshot {
+ val identity =
+ IdentitySummary(
+ id = IdentityId.fromPublicKeyHex("01".repeat(32)),
+ npub = "npub1identity",
+ displayLabel = "Identity",
+ signer = SignerBindingSummary(SignerBindingKind.LocalKeyring, SignerAvailability.Available),
+ createdAt = UnixSeconds(1),
+ lastUsedAt = null,
+ )
+ return ApplicationSnapshot(
+ revision = SnapshotRevision(revision),
+ lifecycle = ApplicationLifecycle.Ready,
+ lifecycleProblem = null,
+ configuredRelays = listOf("wss://relay.example"),
+ identities = listOf(identity),
+ selectedIdentityId = identity.id,
+ session = SessionLifecycle.SignedOut,
+ sessionSubjectIdentityId = null,
+ sessionProblem = null,
+ activeIdentity = null,
+ recoverableProblem = null,
+ )
+}
+
+private fun lifecycleName(value: ApplicationLifecycle): String =
+ when (value) {
+ ApplicationLifecycle.Opening -> "opening"
+ ApplicationLifecycle.CompatibilityChecking -> "compatibility"
+ ApplicationLifecycle.AcquiringOwnership -> "ownership"
+ ApplicationLifecycle.Migrating -> "migrating"
+ ApplicationLifecycle.Recovering -> "recovering"
+ ApplicationLifecycle.Ready -> "ready"
+ ApplicationLifecycle.Degraded -> "degraded"
+ ApplicationLifecycle.Blocked -> "blocked"
+ ApplicationLifecycle.ShuttingDown -> "shutting-down"
+ ApplicationLifecycle.Closed -> "closed"
+ ApplicationLifecycle.Fatal -> "fatal"
+ }
+
+private fun sessionName(value: SessionLifecycle): String =
+ when (value) {
+ SessionLifecycle.SignedOut -> "signed-out"
+ SessionLifecycle.Activating -> "activating"
+ SessionLifecycle.Active -> "active"
+ SessionLifecycle.SigningOut -> "signing-out"
+ SessionLifecycle.Failed -> "failed"
+ }
+
+private fun availabilityName(value: SignerAvailability): String =
+ when (value) {
+ SignerAvailability.Available -> "available"
+ SignerAvailability.CredentialMissing -> "credential-missing"
+ SignerAvailability.StoreUnavailable -> "store-unavailable"
+ SignerAvailability.NotRequired -> "not-required"
+ }
+
+private fun relayName(value: RelayConnectionState): String =
+ when (value) {
+ RelayConnectionState.Disconnected -> "disconnected"
+ RelayConnectionState.Connecting -> "connecting"
+ RelayConnectionState.Connected -> "connected"
+ RelayConnectionState.Degraded -> "degraded"
+ RelayConnectionState.Error -> "error"
+ }
+
+private fun profileStateName(value: ProfileLoadState): String =
+ when (value) {
+ ProfileLoadState.Empty -> "empty"
+ ProfileLoadState.Loading -> "loading"
+ ProfileLoadState.Cached -> "cached"
+ ProfileLoadState.Fresh -> "fresh"
+ ProfileLoadState.Error -> "error"
+ }
+
+private fun errorCodeName(value: ApplicationErrorCode): String = value.name
+
+private fun errorCategoryName(value: ApplicationErrorCategory): String = value.name
+
+private fun recoveryName(value: RecoveryAction): String = value.name
+
+private fun commandName(value: ApplicationCommand): String =
+ when (value) {
+ is ApplicationCommand.AcknowledgeGeneratedIdentity -> "acknowledge-generated"
+ is ApplicationCommand.CancelGeneratedIdentity -> "cancel-generated"
+ is ApplicationCommand.ImportLocalIdentity -> "import"
+ is ApplicationCommand.SelectIdentity -> "select"
+ is ApplicationCommand.ActivateIdentity -> "activate"
+ ApplicationCommand.SignOut -> "sign-out"
+ ApplicationCommand.RefreshActiveProfile -> "refresh-profile"
+ is ApplicationCommand.ConfirmIdentityRemoval -> "confirm-removal"
+ }
diff --git a/buildSrc/src/main/kotlin/org/harvestcircle/gradle/VerifySharedBoundary.kt b/buildSrc/src/main/kotlin/org/harvestcircle/gradle/VerifySharedBoundary.kt
@@ -0,0 +1,36 @@
+package org.harvestcircle.gradle
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+
+abstract class VerifySharedBoundary : DefaultTask() {
+ @get:InputFiles
+ @get:PathSensitive(PathSensitivity.RELATIVE)
+ abstract val commonSources: ConfigurableFileCollection
+
+ @TaskAction
+ fun verify() {
+ val forbidden =
+ listOf(
+ "org.harvestcircle." + "ffi",
+ "com.sun." + "jna",
+ "java." + "awt",
+ "javax." + "swing",
+ "java." + "io",
+ "java." + "nio",
+ )
+ val findings =
+ commonSources.files
+ .filter { it.isFile && it.extension == "kt" }
+ .flatMap { file ->
+ forbidden
+ .filter(file.readText()::contains)
+ .map { token -> "${file.name}: prohibited common-source dependency $token" }
+ }
+ check(findings.isEmpty()) { findings.sorted().joinToString("\n") }
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
@@ -12,7 +12,7 @@ owasp-dependency-check = "12.2.2"
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" }
compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "compose" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm", version.ref = "coroutines" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
[plugins]