feat(di): enable Koin compile-time safety (#7127)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-11 11:39:04 +00:00
1 parent 277d94033c
commit 2ea6c2e2fb
24 files changed
+838 -446

No files matched your search

+5 -2
View File
@@ -106,7 +106,7 @@ jobs:
install_jetbrains_jdk: 'true'
- name: Lint, Analysis & KMP Smoke Compile
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug kmpSmokeCompile -Pci=true --continue
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug -Pci=true --continue
# ── Screenshot Test Validation ──────────────────────────────────────
screenshot-check:
@@ -197,7 +197,9 @@ jobs:
# These lists are hand-maintained; pull-request.yml's check-changes job
# guards them against drift (every module in settings.gradle.kts with test
# sources must appear here or be explicitly exempted).
# shard-core: remaining core:* KMP module tests (allTests)
# shard-core: remaining core:* KMP module tests (allTests), plus kmpSmokeCompile,
# which is the only iOS compile :core:di, :core:nfc and :core:resources
# get. It lived in lint-check until a cold build there blew the 30m budget.
# shard-feature: feature:* KMP module tests + :core:service
# shard-app: Pure-Android/JVM tests (androidApp, desktopApp,
# core:barcode, feature:widget)
@@ -230,6 +232,7 @@ jobs:
:core:takserver:allTests
:core:testing:allTests
:core:ui:allTests
kmpSmokeCompile
kover: >-
:core:ble:koverXmlReport
:core:common:koverXmlReport
+4 -2
View File
@@ -12,7 +12,7 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na
4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs.
### Anti-Patterns
- **A1 Module Compile Safety:** Do **not** enable `compileSafety`. It is a single boolean that enables A1 per-module checks — there is no separate A3 full-graph mode. Runtime graph verification is handled by `KoinVerificationTest` and `DesktopKoinTest` instead.
- **Compile Safety Outside An Entry Point:** Do **not** enable `compileSafety` on a library module. Validation is whole-graph and runs at the `@KoinApplication` entry point, so a library validates against a graph it cannot see and reports `KOIN-D003` for definitions its consumers supply. `KoinConventionPlugin` enables it only for the modules in `KOIN_ENTRY_POINTS`.
- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values.
### Koin Startup Pattern (K2 Compiler Plugin)
@@ -31,7 +31,9 @@ startKoin<AndroidKoinApp> {
- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class.
- `startKoin<T>()` (from `org.koin.plugin.module.dsl`) is a compiler plugin stub — if the plugin isn't applied, it throws `NotImplementedError`.
- `stopKoin()` uses the standard runtime API (`org.koin.core.context.stopKoin`).
- `compileSafety` must stay **disabled** — it enables A1 per-module checks that break our inverted-dependency architecture. There is no separate A3 full-graph flag.
- `compileSafety` is **on at the entry points only** (`:androidApp`, `:desktopApp`). Plugin 1.1.0 replaced per-module validation with whole-graph validation, so the flag is only meaningful where the graph is assembled. A new app target must be added to `KOIN_ENTRY_POINTS` or it is never validated.
- A definition two `@Module(includes = ...)` levels below the entry point is invisible to the index. The flavor modules carry `@Configuration` as well as their `includes` for this reason; dropping the `includes` removes them from the **runtime** graph, which `KoinVerificationTest` catches.
- Hand-written DSL `module { }` definitions are not reachable by the assembled graph, which is why `:desktopApp` uses `@Module` classes.
## Navigation 3
@@ -16,6 +16,7 @@
*/
package org.meshtastic.app.di
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.feature.discovery.ai.AlgorithmicSummaryProvider
@@ -29,6 +30,7 @@ import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
/** Provides keyword-only fallback AI assistant for the F-Droid flavor (no on-device model). */
@Module
@Configuration
class FdroidAiModule {
@Single fun aiDocAssistant(fallback: KeywordFallbackAssistant): AIDocAssistant = fallback
@@ -17,6 +17,7 @@
package org.meshtastic.app.di
import android.content.Context
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
@@ -29,6 +30,7 @@ import org.meshtastic.core.repository.AppFunctionsPrefs
/** Provides AppFunctions integration for the Google flavor. */
@Module
@Configuration
class AppFunctionsModule {
@Single
fun meshtasticAppFunctions(provider: AiFunctionProvider): MeshtasticAppFunctions = MeshtasticAppFunctions(provider)
@@ -19,6 +19,7 @@ package org.meshtastic.app.di
import android.content.Context
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.app.ai.GeminiNanoDocAssistant
@@ -41,6 +42,7 @@ import org.meshtastic.feature.messaging.translation.MessageTranslationService
/** Provides the on-device Gemini Nano AI assistant for the Google flavor. */
@Module
@Configuration
class GoogleAiModule {
@Single
fun aiDocAssistant(
@@ -23,11 +23,13 @@ import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
@Module
@Configuration
@ComponentScan("org.meshtastic.app.map")
class GoogleMapsKoinModule {
@@ -44,6 +44,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.workmanager.factory.KoinWorkerFactory
import org.koin.androidx.workmanager.koin.workManagerFactory
import org.koin.plugin.module.dsl.startKoin
import org.meshtastic.app.di.AndroidKoinApp
@@ -247,6 +248,10 @@ open class MeshUtilApplication :
)
}
/**
* Dead unless WorkManager falls back to on-demand init: [workManagerFactory] initializes it eagerly during
* [startKoin]. Constructed rather than resolved because nothing declares a [WorkerFactory] in the graph.
*/
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(get()).build()
get() = Configuration.Builder().setWorkerFactory(KoinWorkerFactory()).build()
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.di
import org.koin.plugin.module.dsl.koinApplication
import org.meshtastic.feature.docs.translation.DocTranslationService
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
import org.meshtastic.feature.messaging.translation.MessageTranslationService
import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
import kotlin.test.Test
import kotlin.test.assertIs
class FdroidBindingWinnerTest {
@Test
fun `flavor bindings win over the shared graph`() {
// The flavor modules are @Configuration, which loads them before the ones listed in @KoinApplication, and
// Koin is last-wins. KoinVerificationTest only checks definitions exist, never which one survives, so a
// core-level default added later would silently take these over. Only the Fdroid no-ops are asserted:
// the Google flavor's MlKitMessageTranslator builds a RemoteModelManager in a field initializer.
val app = koinApplication<AndroidKoinApp>()
try {
val koin = app.koin
assertIs<NoOpMessageTranslator>(koin.get<MessageTranslationService>())
assertIs<NoOpDocTranslator>(koin.get<DocTranslationService>())
} finally {
app.close()
}
}
}
@@ -29,13 +29,10 @@ class KoinConventionPlugin : Plugin<Project> {
// Configure Koin K2 Compiler Plugin (1.1.0+)
extensions.configure(KoinGradleExtension::class.java) {
// 1.1.0 moved validation to the entry points, which suits this graph's shape, but
// its definition index still can't resolve two structural patterns here: modules
// reached through FlavorModule's nested `includes` are invisible to it, and DSL
// declarations (desktopApp's whole root, workManagerFactory()) are never indexed at
// all. Every entry point therefore fails on definitions that exist. Runtime graph
// verification is handled by KoinVerificationTest instead.
compileSafety.set(false)
// Validation is whole-graph and happens at the @KoinApplication entry points, so
// it is only enabled there. A library module validates locally, cannot see the
// assembled graph, and reports KOIN-D003 on definitions its consumers supply.
compileSafety.set(path in KOIN_ENTRY_POINTS)
}
val koinAnnotations = libs.findLibrary("koin-annotations").get()
@@ -80,3 +77,6 @@ class KoinConventionPlugin : Plugin<Project> {
}
}
}
/** Modules declaring a `@KoinApplication`. A new app target must be added here or it is never validated. */
private val KOIN_ENTRY_POINTS = setOf(":androidApp", ":desktopApp")
@@ -39,7 +39,7 @@ import androidx.compose.ui.window.Notification as ComposeNotification
* Native sends run on [Dispatchers.IO] within the suspending [dispatch] call, so the returned Boolean reflects the
* resolved outcome (native success, or acceptance by the tray fallback) rather than an optimistic guess.
*
* Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the
* Registered manually in `DesktopRuntimeModule` -- do **not** add `@Single` to avoid double-registration with the
* `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
*/
class DesktopNotificationManager(
@@ -71,8 +71,9 @@ import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.context.startKoin
import org.koin.core.context.GlobalContext
import org.koin.core.context.stopKoin
import org.koin.plugin.module.dsl.startKoin
import org.maplibre.compose.desktop.ProvideMapPresentationHost
import org.maplibre.compose.desktop.rememberAwtComposeMapPresentationHost
import org.meshtastic.core.common.BuildConfigProvider
@@ -105,11 +106,11 @@ import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.rememberOpenUrl
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.data.DesktopPreferencesDataSource
import org.meshtastic.desktop.di.desktopModule
import org.meshtastic.desktop.di.desktopPlatformModule
import org.meshtastic.desktop.di.DesktopKoinApp
import org.meshtastic.desktop.map.DesktopTracerouteMap
import org.meshtastic.desktop.map.desktopMapViewProvider
import org.meshtastic.desktop.notification.DesktopOS
import org.meshtastic.desktop.notification.NativeNotificationSender
import org.meshtastic.desktop.ui.DesktopMainScreen
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
@@ -146,6 +147,7 @@ private fun svgPainterResource(path: String, density: Density): Painter = rememb
@OptIn(ExperimentalCoilApi::class)
fun main(args: Array<String>) {
installQuitHandler()
// exitProcessOnExit = false is what makes the shutdown block below reachable at all: with the default (true),
// application() calls System.exit(0) itself as soon as the Compose loop ends, and control never returns here.
// Do not "simplify" this back to a bare application {} — that silently disables every teardown that follows.
@@ -156,8 +158,9 @@ fun main(args: Array<String>) {
// Keep console output and also capture into the in-memory buffer the Debug screen views/exports.
Logger.setLogWriters(listOf(platformLogWriter(), InMemoryLogBuffer))
Logger.i { "Meshtastic Desktop — Starting" }
startKoin { modules(desktopPlatformModule(), desktopModule()) }
startKoin<DesktopKoinApp> {}
}
LaunchedEffect(Unit) { publishExitApplication(::exitApplication) }
val systemLocale = remember { Locale.getDefault() }
val uiViewModel = remember { koinApp.koin.get<UIViewModel>() }
val httpClient = remember { koinApp.koin.get<HttpClient>() }
@@ -167,9 +170,11 @@ fun main(args: Array<String>) {
ThemeAndLocaleProvider(uiViewModel)
}
// Runs on the main thread with the UI already gone. Closing the container fires the `onClose` callbacks that
// release native handles — currently libnotify's process-wide state in LinuxNotificationSender. Guarded because
// a teardown failure must not turn a clean quit into a non-zero exit.
// Runs on the main thread with the UI already gone. The native sender must be closed before the container goes,
// because it owns libnotify's process-wide handle on Linux. Both guarded: a teardown failure must not turn a
// clean quit into a non-zero exit.
runCatching { (GlobalContext.get().get<NativeNotificationSender>() as? AutoCloseable)?.close() }
.onFailure { Logger.w(it) { "Closing the native notification sender failed during shutdown" } }
runCatching { stopKoin() }.onFailure { Logger.w(it) { "stopKoin() failed during shutdown" } }
Logger.i { "Meshtastic Desktop — Stopped" }
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop
import java.awt.Desktop
import java.awt.desktop.QuitResponse
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
/** Published by the composition so [installQuitHandler] can end the Compose loop from AppKit's quit thread. */
private val exitApplicationRef = AtomicReference<(() -> Unit)?>(null)
/** A quit that arrived before the composition published its callback, held until it can be honoured. */
private val pendingQuitRef = AtomicReference<QuitResponse?>(null)
/** Hands the running composition's `exitApplication` to [installQuitHandler], honouring a quit that beat it here. */
internal fun publishExitApplication(exitApplication: () -> Unit) {
exitApplicationRef.set(exitApplication)
pendingQuitRef.getAndSet(null)?.let { response ->
exitApplication()
startQuitWatchdog()
response.cancelQuit()
}
}
/**
* Routes macOS's AppKit quit (Cmd+Q and the app menu) into `exitApplication` so the shutdown in `main` runs at all.
* Without it AppKit kills the JVM directly and every teardown is skipped. CMP-6359, still open.
*/
internal fun installQuitHandler() {
if (!Desktop.isDesktopSupported()) return
val desktop = Desktop.getDesktop()
if (!desktop.isSupported(Desktop.Action.APP_QUIT_HANDLER)) return
desktop.setQuitHandler { _, response ->
val exitApplication = exitApplicationRef.get()
if (exitApplication == null) {
// Quitting before the composition published its callback must not skip the shutdown: hold the
// response until publishExitApplication can run it.
pendingQuitRef.set(response)
} else {
// Cancels the native quit because the shutdown this unblocks ends in exitProcess(). The watchdog is
// what stops a Compose loop that never returns from leaving the app un-quittable.
exitApplication()
startQuitWatchdog()
response.cancelQuit()
}
}
}
/** Quitting must not depend on the Compose loop returning: force the exit if the shutdown has not run in time. */
private fun startQuitWatchdog() = thread(isDaemon = true, name = "quit-watchdog") {
Thread.sleep(QUIT_TIMEOUT_MS)
Runtime.getRuntime().halt(0)
}
private const val QUIT_TIMEOUT_MS = 5_000L
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.di
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.feature.discovery.ai.AlgorithmicSummaryProvider
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
import org.meshtastic.feature.docs.ai.AIDocAssistant
import org.meshtastic.feature.docs.ai.KeywordFallbackAssistant
import org.meshtastic.feature.docs.translation.DocTranslationService
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
import org.meshtastic.feature.messaging.translation.MessageTranslationService
import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
/** Keyword-only fallback AI assistant and no-op translators for Desktop (no on-device model). */
@Module
class DesktopAiModule {
@Single fun aiDocAssistant(fallback: KeywordFallbackAssistant): AIDocAssistant = fallback
@Single fun discoverySummaryAiProvider(fallback: AlgorithmicSummaryProvider): DiscoverySummaryAiProvider = fallback
@Single fun docTranslationService(): DocTranslationService = NoOpDocTranslator()
@Single fun messageTranslationService(): MessageTranslationService = NoOpMessageTranslator()
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.di
import org.koin.core.annotation.KoinApplication
/**
* Root Koin bootstrap for Desktop. The K2 compiler plugin uses this to discover the full module graph when
* [org.koin.plugin.module.dsl.startKoin] is called with this type parameter.
*/
@KoinApplication(modules = [DesktopKoinModule::class])
object DesktopKoinApp
@@ -14,258 +14,70 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress(
"ktlint:standard:no-unused-imports",
) // Koin K2 compiler plugin generates aliased module extensions referenced in desktopModule()
package org.meshtastic.desktop.di
// Generated Koin module extensions from core KMP modules
import io.ktor.client.HttpClient
import io.ktor.client.engine.java.Java
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import org.koin.dsl.module
import org.koin.dsl.onClose
import org.meshtastic.core.common.di.ServiceScope
import org.meshtastic.core.data.datasource.BundledAssetReader
import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
import org.meshtastic.core.network.configureDefaultRetry
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.network.service.ApiService
import org.meshtastic.core.network.service.ApiServiceImpl
import org.meshtastic.core.repository.AdminController
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.ConnectionStateProvider
import org.meshtastic.core.repository.LocationRepository
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.MessagingController
import org.meshtastic.core.repository.NeighborInfoResponseProvider
import org.meshtastic.core.repository.NodeController
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.QueryController
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.RadioTransportFactory
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.core.repository.TracerouteResponseProvider
import org.meshtastic.core.service.RadioControllerImpl
import org.meshtastic.core.service.ServiceRepositoryImpl
import org.meshtastic.desktop.DesktopBuildConfig
import org.meshtastic.desktop.DesktopNotificationManager
import org.meshtastic.desktop.notification.DesktopMeshNotificationManager
import org.meshtastic.desktop.notification.DesktopOS
import org.meshtastic.desktop.notification.LinuxNotificationSender
import org.meshtastic.desktop.notification.MacOSNotificationSender
import org.meshtastic.desktop.notification.NativeNotificationSender
import org.meshtastic.desktop.notification.WindowsNotificationSender
import org.meshtastic.desktop.radio.DesktopMessageQueue
import org.meshtastic.desktop.radio.DesktopRadioTransportFactory
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
import org.meshtastic.desktop.stub.NoopLocationRepository
import org.meshtastic.desktop.stub.NoopMQTTRepository
import org.meshtastic.desktop.stub.NoopMagneticFieldProvider
import org.meshtastic.desktop.stub.NoopMeshLocationManager
import org.meshtastic.desktop.stub.NoopMeshWorkerManager
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
import org.meshtastic.feature.discovery.ai.AlgorithmicSummaryProvider
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
import org.meshtastic.feature.docs.ai.AIDocAssistant
import org.meshtastic.feature.docs.ai.KeywordFallbackAssistant
import org.meshtastic.feature.docs.translation.DocTranslationService
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
import org.meshtastic.feature.messaging.translation.MessageTranslationService
import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
import org.meshtastic.core.ble.di.module as coreBleModule
import org.meshtastic.core.common.di.module as coreCommonModule
import org.meshtastic.core.data.di.module as coreDataModule
import org.meshtastic.core.database.di.module as coreDatabaseModule
import org.meshtastic.core.datastore.di.module as coreDatastoreModule
import org.meshtastic.core.di.di.module as coreDiModule
import org.meshtastic.core.domain.di.module as coreDomainModule
import org.meshtastic.core.network.di.module as coreNetworkModule
import org.meshtastic.core.prefs.di.module as corePrefsModule
import org.meshtastic.core.repository.di.module as coreRepositoryModule
import org.meshtastic.core.service.di.module as coreServiceModule
import org.meshtastic.core.takserver.di.module as coreTakServerModule
import org.meshtastic.core.ui.di.module as coreUiModule
import org.meshtastic.desktop.di.module as desktopDiModule
import org.meshtastic.feature.connections.di.module as featureConnectionsModule
import org.meshtastic.feature.discovery.di.module as featureDiscoveryModule
import org.meshtastic.feature.docs.di.module as featureDocsModule
import org.meshtastic.feature.firmware.di.module as featureFirmwareModule
import org.meshtastic.feature.intro.di.module as featureIntroModule
import org.meshtastic.feature.map.di.module as featureMapModule
import org.meshtastic.feature.messaging.di.module as featureMessagingModule
import org.meshtastic.feature.node.di.module as featureNodeModule
import org.meshtastic.feature.settings.di.module as featureSettingsModule
import org.meshtastic.feature.wifiprovision.di.module as featureWifiProvisionModule
import org.koin.core.annotation.Module
import org.meshtastic.core.ble.di.CoreBleModule
import org.meshtastic.core.common.di.CoreCommonModule
import org.meshtastic.core.data.di.CoreDataModule
import org.meshtastic.core.database.di.CoreDatabaseModule
import org.meshtastic.core.datastore.di.CoreDatastoreModule
import org.meshtastic.core.network.di.CoreNetworkModule
import org.meshtastic.core.prefs.di.CorePrefsModule
import org.meshtastic.core.service.di.CoreServiceModule
import org.meshtastic.core.takserver.di.CoreTakServerModule
import org.meshtastic.core.ui.di.CoreUiModule
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
import org.meshtastic.feature.discovery.di.FeatureDiscoveryModule
import org.meshtastic.feature.docs.di.FeatureDocsModule
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
import org.meshtastic.feature.intro.di.FeatureIntroModule
import org.meshtastic.feature.map.di.FeatureMapModule
import org.meshtastic.feature.messaging.di.FeatureMessagingModule
import org.meshtastic.feature.node.di.FeatureNodeModule
import org.meshtastic.feature.settings.di.FeatureSettingsModule
import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
/**
* Koin module for the Desktop target.
* Aggregate Koin module for the Desktop target — the single entry [DesktopKoinApp] points at.
*
* Includes the generated Koin K2 modules from core KMP libraries (which provide real implementations of prefs, data
* repositories, managers, datastore data sources, use cases, and ViewModels from `commonMain`).
*
* Only truly platform-specific interfaces are stubbed here — things that require Android APIs (BLE/USB transport,
* notifications, WorkManager, location services, broadcasts, widgets).
*
* Platform infrastructure (DataStores, Room database, Lifecycle) is provided by [desktopPlatformModule].
* Includes the `commonMain` module classes from the core KMP libraries (prefs, data repositories, managers, datastore
* data sources, use cases, and ViewModels), then the desktop-specific modules. The desktop modules come last so their
* bindings win over any `commonMain` default they deliberately replace.
*/
fun desktopModule() = module {
// Include generated Koin K2 modules from core KMP libraries (commonMain implementations)
includes(
org.meshtastic.core.di.di.CoreDiModule().coreDiModule(),
org.meshtastic.core.common.di.CoreCommonModule().coreCommonModule(),
org.meshtastic.core.datastore.di.CoreDatastoreModule().coreDatastoreModule(),
org.meshtastic.core.prefs.di.CorePrefsModule().corePrefsModule(),
org.meshtastic.core.database.di.CoreDatabaseModule().coreDatabaseModule(),
org.meshtastic.core.data.di.CoreDataModule().coreDataModule(),
org.meshtastic.core.domain.di.CoreDomainModule().coreDomainModule(),
org.meshtastic.core.repository.di.CoreRepositoryModule().coreRepositoryModule(),
org.meshtastic.core.network.di.CoreNetworkModule().coreNetworkModule(),
org.meshtastic.core.ble.di.CoreBleModule().coreBleModule(),
org.meshtastic.core.ui.di.CoreUiModule().coreUiModule(),
org.meshtastic.core.service.di.CoreServiceModule().coreServiceModule(),
org.meshtastic.core.takserver.di.CoreTakServerModule().coreTakServerModule(),
org.meshtastic.feature.settings.di.FeatureSettingsModule().featureSettingsModule(),
org.meshtastic.feature.node.di.FeatureNodeModule().featureNodeModule(),
org.meshtastic.feature.messaging.di.FeatureMessagingModule().featureMessagingModule(),
org.meshtastic.feature.connections.di.FeatureConnectionsModule().featureConnectionsModule(),
org.meshtastic.feature.map.di.FeatureMapModule().featureMapModule(),
org.meshtastic.feature.discovery.di.FeatureDiscoveryModule().featureDiscoveryModule(),
org.meshtastic.feature.firmware.di.FeatureFirmwareModule().featureFirmwareModule(),
org.meshtastic.feature.docs.di.FeatureDocsModule().featureDocsModule(),
org.meshtastic.feature.intro.di.FeatureIntroModule().featureIntroModule(),
org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule().featureWifiProvisionModule(),
org.meshtastic.desktop.di.DesktopDiModule().desktopDiModule(),
desktopPlatformStubsModule(),
)
}
/**
* Stubs for truly platform-specific interfaces that have no `commonMain` implementation. These require Android APIs
* (BLE/USB transport, notifications, WorkManager, location, broadcasts, widgets).
*/
@Suppress("LongMethod")
private fun desktopPlatformStubsModule() = module {
single<ServiceRepository> { ServiceRepositoryImpl() }
single<ConnectionStateProvider> { get<ServiceRepository>() }
single<TracerouteResponseProvider> { get<ServiceRepository>() }
single<NeighborInfoResponseProvider> { get<ServiceRepository>() }
single<ServiceStateWriter> { get<ServiceRepository>() }
single<RadioTransportFactory> {
DesktopRadioTransportFactory(
dispatchers = get(),
scanner = get(),
bluetoothRepository = get(),
connectionFactory = get(),
)
}
single<RadioController> {
RadioControllerImpl(
serviceRepository = get(),
nodeRepository = get(),
commandSender = get(),
nodeManager = get(),
radioInterfaceService = get(),
locationManager = get(),
packetRepository = lazy { get() },
dataHandler = lazy { get() },
analytics = get(),
meshPrefs = get(),
uiPrefs = get(),
databaseManager = get(),
notificationManager = get(),
messageProcessor = lazy { get() },
radioConfigRepository = get(),
scope = get<ServiceScope>(),
)
}
single<AdminController> { get<RadioController>() }
single<MessagingController> { get<RadioController>() }
single<NodeController> { get<RadioController>() }
single<QueryController> { get<RadioController>() }
single<NativeNotificationSender> {
when (DesktopOS.current()) {
DesktopOS.Linux -> LinuxNotificationSender()
DesktopOS.MacOS -> MacOSNotificationSender()
DesktopOS.Windows -> WindowsNotificationSender()
}
}
.onClose { sender ->
// Only the Linux sender holds a native handle; the others are stateless. `stopKoin()` in Main.kt is what
// drives this, after the Compose application loop has returned.
(sender as? AutoCloseable)?.close()
}
single { DesktopNotificationManager(prefs = get(), nativeSender = get()) }
single<NotificationManager> { get<DesktopNotificationManager>() }
single<MeshNotificationManager> { DesktopMeshNotificationManager(notificationManager = get()) }
single<PlatformAnalytics> { NoopPlatformAnalytics() }
single<AppWidgetUpdater> { NoopAppWidgetUpdater() }
single<MeshWorkerManager> { NoopMeshWorkerManager() }
single<MessageQueue> { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) }
single<MeshLocationManager> { NoopMeshLocationManager() }
single<LocationRepository> { NoopLocationRepository() }
single<MQTTRepository> { NoopMQTTRepository() }
single<CompassHeadingProvider> { NoopCompassHeadingProvider() }
single<PhoneLocationProvider> { NoopPhoneLocationProvider() }
single<MagneticFieldProvider> { NoopMagneticFieldProvider() }
// AI assistant: keyword-only fallback on desktop (no on-device model)
single<AIDocAssistant> { get<KeywordFallbackAssistant>() }
single<DiscoverySummaryAiProvider> { get<AlgorithmicSummaryProvider>() }
single<DocTranslationService> { NoOpDocTranslator() }
single<MessageTranslationService> { NoOpMessageTranslator() }
// Desktop uses the real ApiService implementation (no flavor stub needed)
single<ApiService> { ApiServiceImpl(client = get()) }
// Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android)
single<HttpClient> {
HttpClient(Java) {
engine {
protocolVersion = java.net.http.HttpClient.Version.HTTP_2
config { followRedirects(java.net.http.HttpClient.Redirect.NORMAL) }
}
install(ContentNegotiation) { json(get<Json>()) }
install(DefaultRequest) {
url(HttpClientDefaults.API_BASE_URL)
header(HttpHeaders.UserAgent, "Meshtastic-Desktop/${DesktopBuildConfig.VERSION_NAME}")
}
install(HttpTimeout) {
requestTimeoutMillis = HttpClientDefaults.REQUEST_TIMEOUT_MS
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
}
install(HttpRequestRetry) { configureDefaultRetry() }
if (DesktopBuildConfig.IS_DEBUG) {
install(Logging) {
logger = KermitHttpLogger
level = LogLevel.INFO
}
}
}
}
// Desktop has no bundled Android assets; repositories seed from the network instead.
single<BundledAssetReader> { BundledAssetReader { null } }
}
@Module(
includes =
[
org.meshtastic.core.di.di.CoreDiModule::class,
CoreCommonModule::class,
CoreBleModule::class,
CoreDataModule::class,
org.meshtastic.core.domain.di.CoreDomainModule::class,
CoreDatabaseModule::class,
org.meshtastic.core.repository.di.CoreRepositoryModule::class,
CoreDatastoreModule::class,
CorePrefsModule::class,
CoreServiceModule::class,
CoreNetworkModule::class,
CoreTakServerModule::class,
CoreUiModule::class,
FeatureNodeModule::class,
FeatureMessagingModule::class,
FeatureConnectionsModule::class,
FeatureMapModule::class,
FeatureSettingsModule::class,
FeatureDiscoveryModule::class,
FeatureDocsModule::class,
FeatureFirmwareModule::class,
FeatureIntroModule::class,
FeatureWifiProvisionModule::class,
DesktopDiModule::class,
DesktopPlatformModule::class,
DesktopPreferencesDataStoreModule::class,
DesktopProtoDataStoreModule::class,
DesktopRuntimeModule::class,
DesktopStubsModule::class,
DesktopAiModule::class,
],
)
class DesktopKoinModule
@@ -16,89 +16,21 @@
*/
package org.meshtastic.desktop.di
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.core.okio.OkioSerializer
import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import okio.FileSystem
import okio.Path.Companion.toPath
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.di.PROCESS_LIFECYCLE
import org.meshtastic.core.database.desktopDataDir
import org.meshtastic.core.datastore.di.CoreChannelSetDataStore
import org.meshtastic.core.datastore.di.CoreLocalConfigDataStore
import org.meshtastic.core.datastore.di.CoreLocalStatsDataStore
import org.meshtastic.core.datastore.di.CoreModuleConfigDataStore
import org.meshtastic.core.datastore.di.CorePreferencesDataStore
import org.meshtastic.core.datastore.di.DataStoreScope
import org.meshtastic.core.datastore.di.asCoreChannelSetDataStore
import org.meshtastic.core.datastore.di.asCoreLocalConfigDataStore
import org.meshtastic.core.datastore.di.asCoreLocalStatsDataStore
import org.meshtastic.core.datastore.di.asCoreModuleConfigDataStore
import org.meshtastic.core.datastore.di.asCorePreferencesDataStore
import org.meshtastic.core.datastore.di.asDataStoreScope
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.AnalyticsDataStore
import org.meshtastic.core.prefs.di.AppDataStore
import org.meshtastic.core.prefs.di.CustomEmojiDataStore
import org.meshtastic.core.prefs.di.FilterDataStore
import org.meshtastic.core.prefs.di.HomoglyphEncodingDataStore
import org.meshtastic.core.prefs.di.MapConsentDataStore
import org.meshtastic.core.prefs.di.MapDataStore
import org.meshtastic.core.prefs.di.MapTileProviderDataStore
import org.meshtastic.core.prefs.di.MeshDataStore
import org.meshtastic.core.prefs.di.MeshLogDataStore
import org.meshtastic.core.prefs.di.RadioDataStore
import org.meshtastic.core.prefs.di.UiDataStore
import org.meshtastic.core.prefs.di.asAnalyticsDataStore
import org.meshtastic.core.prefs.di.asAppDataStore
import org.meshtastic.core.prefs.di.asCustomEmojiDataStore
import org.meshtastic.core.prefs.di.asFilterDataStore
import org.meshtastic.core.prefs.di.asHomoglyphEncodingDataStore
import org.meshtastic.core.prefs.di.asMapConsentDataStore
import org.meshtastic.core.prefs.di.asMapDataStore
import org.meshtastic.core.prefs.di.asMapTileProviderDataStore
import org.meshtastic.core.prefs.di.asMeshDataStore
import org.meshtastic.core.prefs.di.asMeshLogDataStore
import org.meshtastic.core.prefs.di.asRadioDataStore
import org.meshtastic.core.prefs.di.asUiDataStore
import org.meshtastic.desktop.DesktopBuildConfig
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.LocalStats
/** Creates a file-backed [DataStore]<[Preferences]> at the given path under the data directory. */
private fun prefsStore(name: String, scope: DataStoreScope): DataStore<Preferences> {
val dir = desktopDataDir() + "/datastore"
FileSystem.SYSTEM.createDirectories(dir.toPath())
return PreferenceDataStoreFactory.createWithPath(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
scope = scope,
produceFile = { "$dir/$name.preferences_pb".toPath() },
)
}
/**
* Synthetic [LifecycleOwner] that stays permanently in [Lifecycle.State.RESUMED]. Replaces Android's
* `ProcessLifecycleOwner` for desktop.
*/
private class DesktopProcessLifecycleOwner : LifecycleOwner {
internal class DesktopProcessLifecycleOwner : LifecycleOwner {
private val registry = LifecycleRegistry(this)
init {
@@ -110,91 +42,30 @@ private class DesktopProcessLifecycleOwner : LifecycleOwner {
}
/**
* Desktop platform infrastructure module.
* Desktop platform infrastructure module: [BuildConfigProvider] and the process [Lifecycle].
*
* Provides all platform-specific bindings that the real KMP `commonMain` implementations need:
* - Named [DataStore]<[Preferences]> instances (12 preference stores + 1 core preferences store)
* - Proto [DataStore] instances (LocalConfig, ModuleConfig, ChannelSet, LocalStats)
* - [Lifecycle] (`ProcessLifecycle`)
* - [BuildConfigProvider]
* The DataStore instances the `commonMain` implementations need live in [DesktopPreferencesDataStoreModule] and
* [DesktopProtoDataStoreModule].
*/
fun desktopPlatformModule() = module {
// Application-lifetime scope shared by all DataStore instances. Per the DataStore docs:
// "The Job within this context dictates the lifecycle of the DataStore's internal operations.
// Ensure it is an application-scoped context that is not canceled by UI lifecycle events."
// DataStore has no close() API — the in-memory cache is released only when this Job is cancelled
// (at process exit). Using SupervisorJob so a single store's failure doesn't cascade.
single<DataStoreScope> { CoroutineScope(get<CoroutineDispatchers>().io + SupervisorJob()).asDataStoreScope() }
@Module
class DesktopPlatformModule {
includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule())
// -- Build config (values generated at build time by generateDesktopBuildConfig) --
single<BuildConfigProvider> {
object : BuildConfigProvider {
override val isDebug: Boolean = DesktopBuildConfig.IS_DEBUG
override val applicationId: String = DesktopBuildConfig.APPLICATION_ID
override val versionCode: Int = DesktopBuildConfig.VERSION_CODE
override val versionName: String = DesktopBuildConfig.VERSION_NAME
override val absoluteMinFwVersion: String = DesktopBuildConfig.ABS_MIN_FW_VERSION
override val minFwVersion: String = DesktopBuildConfig.MIN_FW_VERSION
}
/** Values generated at build time by `generateDesktopBuildConfig`. */
@Single
fun buildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
override val isDebug: Boolean = DesktopBuildConfig.IS_DEBUG
override val applicationId: String = DesktopBuildConfig.APPLICATION_ID
override val versionCode: Int = DesktopBuildConfig.VERSION_CODE
override val versionName: String = DesktopBuildConfig.VERSION_NAME
override val absoluteMinFwVersion: String = DesktopBuildConfig.ABS_MIN_FW_VERSION
override val minFwVersion: String = DesktopBuildConfig.MIN_FW_VERSION
}
// -- Process Lifecycle (stays RESUMED forever on desktop) --
single(named(PROCESS_LIFECYCLE)) { DesktopProcessLifecycleOwner().lifecycle }
}
/** Typed preference-datastore singletons for each preference domain. */
private fun desktopPreferencesDataStoreModule() = module {
single<AnalyticsDataStore> { prefsStore("analytics", get()).asAnalyticsDataStore() }
single<HomoglyphEncodingDataStore> { prefsStore("homoglyph_encoding", get()).asHomoglyphEncodingDataStore() }
single<AppDataStore> { prefsStore("app", get()).asAppDataStore() }
single<CustomEmojiDataStore> { prefsStore("custom_emoji", get()).asCustomEmojiDataStore() }
single<MapDataStore> { prefsStore("map", get()).asMapDataStore() }
single<MapConsentDataStore> { prefsStore("map_consent", get()).asMapConsentDataStore() }
single<MapTileProviderDataStore> { prefsStore("map_tile_provider", get()).asMapTileProviderDataStore() }
single<MeshDataStore> { prefsStore("mesh", get()).asMeshDataStore() }
single<RadioDataStore> { prefsStore("radio", get()).asRadioDataStore() }
single<UiDataStore> { prefsStore("ui", get()).asUiDataStore() }
single<MeshLogDataStore> { prefsStore("meshlog", get()).asMeshLogDataStore() }
single<FilterDataStore> { prefsStore("filter", get()).asFilterDataStore() }
single<CorePreferencesDataStore> { prefsStore("core_preferences", get()).asCorePreferencesDataStore() }
}
/** The path is an on-disk identity — changing it orphans existing user data. */
private fun <T> protoStore(
serializer: OkioSerializer<T>,
path: String,
produceNewData: () -> T,
scope: DataStoreScope,
): DataStore<T> = DataStoreFactory.create(
storage = OkioStorage(fileSystem = FileSystem.SYSTEM, serializer = serializer, producePath = { path.toPath() }),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { produceNewData() }),
scope = scope,
)
/** Proto [DataStore] instances (OkioStorage-backed). */
private fun desktopProtoDataStoreModule() = module {
val protoDir = desktopDataDir() + "/datastore"
FileSystem.SYSTEM.createDirectories(protoDir.toPath())
single<CoreLocalConfigDataStore> {
protoStore(LocalConfigSerializer, "$protoDir/local_config.pb", { LocalConfig() }, get())
.asCoreLocalConfigDataStore()
}
single<CoreModuleConfigDataStore> {
protoStore(ModuleConfigSerializer, "$protoDir/module_config.pb", { LocalModuleConfig() }, get())
.asCoreModuleConfigDataStore()
}
single<CoreChannelSetDataStore> {
protoStore(ChannelSetSerializer, "$protoDir/channel_set.pb", { ChannelSet() }, get())
.asCoreChannelSetDataStore()
}
single<CoreLocalStatsDataStore> {
protoStore(LocalStatsSerializer, "$protoDir/local_stats.pb", { LocalStats() }, get())
.asCoreLocalStatsDataStore()
}
// LifecycleRegistry holds its owner weakly, so the owner is a definition too:
// once it is collected, addObserver silently stops registering.
@Single internal fun processLifecycleOwner(): DesktopProcessLifecycleOwner = DesktopProcessLifecycleOwner()
@Single
@Named(PROCESS_LIFECYCLE)
internal fun processLifecycle(owner: DesktopProcessLifecycleOwner): Lifecycle = owner.lifecycle
}
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.di
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import okio.FileSystem
import okio.Path.Companion.toPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.database.desktopDataDir
import org.meshtastic.core.datastore.di.CorePreferencesDataStore
import org.meshtastic.core.datastore.di.DataStoreScope
import org.meshtastic.core.datastore.di.asCorePreferencesDataStore
import org.meshtastic.core.prefs.di.AnalyticsDataStore
import org.meshtastic.core.prefs.di.AppDataStore
import org.meshtastic.core.prefs.di.CustomEmojiDataStore
import org.meshtastic.core.prefs.di.FilterDataStore
import org.meshtastic.core.prefs.di.HomoglyphEncodingDataStore
import org.meshtastic.core.prefs.di.MapConsentDataStore
import org.meshtastic.core.prefs.di.MapDataStore
import org.meshtastic.core.prefs.di.MapTileProviderDataStore
import org.meshtastic.core.prefs.di.MeshDataStore
import org.meshtastic.core.prefs.di.MeshLogDataStore
import org.meshtastic.core.prefs.di.RadioDataStore
import org.meshtastic.core.prefs.di.UiDataStore
import org.meshtastic.core.prefs.di.asAnalyticsDataStore
import org.meshtastic.core.prefs.di.asAppDataStore
import org.meshtastic.core.prefs.di.asCustomEmojiDataStore
import org.meshtastic.core.prefs.di.asFilterDataStore
import org.meshtastic.core.prefs.di.asHomoglyphEncodingDataStore
import org.meshtastic.core.prefs.di.asMapConsentDataStore
import org.meshtastic.core.prefs.di.asMapDataStore
import org.meshtastic.core.prefs.di.asMapTileProviderDataStore
import org.meshtastic.core.prefs.di.asMeshDataStore
import org.meshtastic.core.prefs.di.asMeshLogDataStore
import org.meshtastic.core.prefs.di.asRadioDataStore
import org.meshtastic.core.prefs.di.asUiDataStore
/** Typed preference-datastore singletons for each preference domain, one file each under the data directory. */
@Suppress("TooManyFunctions")
@Module
class DesktopPreferencesDataStoreModule {
@Single
fun analyticsDataStore(scope: DataStoreScope): AnalyticsDataStore =
prefsStore("analytics", scope).asAnalyticsDataStore()
@Single
fun homoglyphEncodingDataStore(scope: DataStoreScope): HomoglyphEncodingDataStore =
prefsStore("homoglyph_encoding", scope).asHomoglyphEncodingDataStore()
@Single fun appDataStore(scope: DataStoreScope): AppDataStore = prefsStore("app", scope).asAppDataStore()
@Single
fun customEmojiDataStore(scope: DataStoreScope): CustomEmojiDataStore =
prefsStore("custom_emoji", scope).asCustomEmojiDataStore()
@Single fun mapDataStore(scope: DataStoreScope): MapDataStore = prefsStore("map", scope).asMapDataStore()
@Single
fun mapConsentDataStore(scope: DataStoreScope): MapConsentDataStore =
prefsStore("map_consent", scope).asMapConsentDataStore()
@Single
fun mapTileProviderDataStore(scope: DataStoreScope): MapTileProviderDataStore =
prefsStore("map_tile_provider", scope).asMapTileProviderDataStore()
@Single fun meshDataStore(scope: DataStoreScope): MeshDataStore = prefsStore("mesh", scope).asMeshDataStore()
@Single fun radioDataStore(scope: DataStoreScope): RadioDataStore = prefsStore("radio", scope).asRadioDataStore()
@Single fun uiDataStore(scope: DataStoreScope): UiDataStore = prefsStore("ui", scope).asUiDataStore()
@Single
fun meshLogDataStore(scope: DataStoreScope): MeshLogDataStore = prefsStore("meshlog", scope).asMeshLogDataStore()
@Single
fun filterDataStore(scope: DataStoreScope): FilterDataStore = prefsStore("filter", scope).asFilterDataStore()
@Single
fun corePreferencesDataStore(scope: DataStoreScope): CorePreferencesDataStore =
prefsStore("core_preferences", scope).asCorePreferencesDataStore()
}
/** [name] is an on-disk identity — changing it orphans existing user data. */
private fun prefsStore(name: String, scope: DataStoreScope): DataStore<Preferences> {
val dir = desktopDataDir() + "/datastore"
FileSystem.SYSTEM.createDirectories(dir.toPath())
return PreferenceDataStoreFactory.createWithPath(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
scope = scope,
produceFile = { "$dir/$name.preferences_pb".toPath() },
)
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.di
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.core.okio.OkioSerializer
import androidx.datastore.core.okio.OkioStorage
import okio.FileSystem
import okio.Path.Companion.toPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.database.desktopDataDir
import org.meshtastic.core.datastore.di.CoreChannelSetDataStore
import org.meshtastic.core.datastore.di.CoreLocalConfigDataStore
import org.meshtastic.core.datastore.di.CoreLocalStatsDataStore
import org.meshtastic.core.datastore.di.CoreModuleConfigDataStore
import org.meshtastic.core.datastore.di.DataStoreScope
import org.meshtastic.core.datastore.di.asCoreChannelSetDataStore
import org.meshtastic.core.datastore.di.asCoreLocalConfigDataStore
import org.meshtastic.core.datastore.di.asCoreLocalStatsDataStore
import org.meshtastic.core.datastore.di.asCoreModuleConfigDataStore
import org.meshtastic.core.datastore.serializer.ChannelSetSerializer
import org.meshtastic.core.datastore.serializer.LocalConfigSerializer
import org.meshtastic.core.datastore.serializer.LocalStatsSerializer
import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.LocalStats
/** Proto [DataStore] instances (OkioStorage-backed). */
@Module
class DesktopProtoDataStoreModule {
@Single
fun localConfigDataStore(scope: DataStoreScope): CoreLocalConfigDataStore =
protoStore(LocalConfigSerializer, "local_config.pb", { LocalConfig() }, scope).asCoreLocalConfigDataStore()
@Single
fun moduleConfigDataStore(scope: DataStoreScope): CoreModuleConfigDataStore =
protoStore(ModuleConfigSerializer, "module_config.pb", { LocalModuleConfig() }, scope)
.asCoreModuleConfigDataStore()
@Single
fun channelSetDataStore(scope: DataStoreScope): CoreChannelSetDataStore =
protoStore(ChannelSetSerializer, "channel_set.pb", { ChannelSet() }, scope).asCoreChannelSetDataStore()
@Single
fun localStatsDataStore(scope: DataStoreScope): CoreLocalStatsDataStore =
protoStore(LocalStatsSerializer, "local_stats.pb", { LocalStats() }, scope).asCoreLocalStatsDataStore()
}
/** [fileName] is an on-disk identity — changing it orphans existing user data. */
private fun <T> protoStore(
serializer: OkioSerializer<T>,
fileName: String,
produceNewData: () -> T,
scope: DataStoreScope,
): DataStore<T> {
val dir = desktopDataDir() + "/datastore"
FileSystem.SYSTEM.createDirectories(dir.toPath())
return DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = serializer,
producePath = { "$dir/$fileName".toPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { produceNewData() }),
scope = scope,
)
}
@@ -0,0 +1,228 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.di
import io.ktor.client.HttpClient
import io.ktor.client.engine.java.Java
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.header
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.di.ServiceScope
import org.meshtastic.core.data.datasource.BundledAssetReader
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
import org.meshtastic.core.network.configureDefaultRetry
import org.meshtastic.core.network.service.ApiService
import org.meshtastic.core.network.service.ApiServiceImpl
import org.meshtastic.core.repository.AdminController
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.ConnectionStateProvider
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.MessagingController
import org.meshtastic.core.repository.NeighborInfoResponseProvider
import org.meshtastic.core.repository.NodeController
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.QueryController
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransportFactory
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.core.repository.TracerouteResponseProvider
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.service.RadioControllerImpl
import org.meshtastic.core.service.ServiceRepositoryImpl
import org.meshtastic.desktop.DesktopBuildConfig
import org.meshtastic.desktop.DesktopNotificationManager
import org.meshtastic.desktop.notification.DesktopMeshNotificationManager
import org.meshtastic.desktop.notification.DesktopOS
import org.meshtastic.desktop.notification.LinuxNotificationSender
import org.meshtastic.desktop.notification.MacOSNotificationSender
import org.meshtastic.desktop.notification.NativeNotificationSender
import org.meshtastic.desktop.notification.WindowsNotificationSender
import org.meshtastic.desktop.radio.DesktopMessageQueue
import org.meshtastic.desktop.radio.DesktopRadioTransportFactory
/**
* Desktop runtime wiring: the radio stack, notifications and networking the JVM host owns.
*
* These replace bindings that exist only in Android source sets, so nothing here duplicates a `commonMain` default.
*/
@Module
class DesktopRuntimeModule {
@Single(
binds =
[
ServiceRepository::class,
ConnectionStateProvider::class,
TracerouteResponseProvider::class,
NeighborInfoResponseProvider::class,
ServiceStateWriter::class,
],
)
fun serviceRepository(): ServiceRepository = ServiceRepositoryImpl()
@Single
fun radioTransportFactory(
dispatchers: CoroutineDispatchers,
scanner: BleScanner,
bluetoothRepository: BluetoothRepository,
connectionFactory: BleConnectionFactory,
): RadioTransportFactory = DesktopRadioTransportFactory(
dispatchers = dispatchers,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
)
@Suppress("LongParameterList")
@Single(
binds =
[
RadioController::class,
AdminController::class,
MessagingController::class,
NodeController::class,
QueryController::class,
],
)
fun radioController(
serviceRepository: ServiceRepository,
nodeRepository: NodeRepository,
commandSender: CommandSender,
nodeManager: NodeManager,
radioInterfaceService: RadioInterfaceService,
locationManager: MeshLocationManager,
packetRepository: Lazy<PacketRepository>,
dataHandler: Lazy<MeshDataHandler>,
analytics: PlatformAnalytics,
meshPrefs: MeshPrefs,
uiPrefs: UiPrefs,
databaseManager: DatabaseManager,
notificationManager: NotificationManager,
messageProcessor: Lazy<MeshMessageProcessor>,
radioConfigRepository: RadioConfigRepository,
scope: ServiceScope,
): RadioController = RadioControllerImpl(
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
commandSender = commandSender,
nodeManager = nodeManager,
radioInterfaceService = radioInterfaceService,
locationManager = locationManager,
packetRepository = packetRepository,
dataHandler = dataHandler,
analytics = analytics,
meshPrefs = meshPrefs,
uiPrefs = uiPrefs,
databaseManager = databaseManager,
notificationManager = notificationManager,
messageProcessor = messageProcessor,
radioConfigRepository = radioConfigRepository,
scope = scope,
)
/**
* Only the Linux sender holds a native handle; the others are stateless. `Main.kt` closes it explicitly during
* shutdown, because annotations have no `onClose` equivalent.
*/
@Single
fun nativeNotificationSender(): NativeNotificationSender = when (DesktopOS.current()) {
DesktopOS.Linux -> LinuxNotificationSender()
DesktopOS.MacOS -> MacOSNotificationSender()
DesktopOS.Windows -> WindowsNotificationSender()
}
@Single(binds = [DesktopNotificationManager::class, NotificationManager::class])
fun desktopNotificationManager(
prefs: NotificationPrefs,
nativeSender: NativeNotificationSender,
): DesktopNotificationManager = DesktopNotificationManager(prefs = prefs, nativeSender = nativeSender)
@Single
fun meshNotificationManager(notificationManager: NotificationManager): MeshNotificationManager =
DesktopMeshNotificationManager(notificationManager = notificationManager)
@Single
fun messageQueue(
packetRepository: PacketRepository,
radioController: RadioController,
dispatchers: CoroutineDispatchers,
): MessageQueue = DesktopMessageQueue(
packetRepository = packetRepository,
radioController = radioController,
dispatchers = dispatchers,
)
/** Desktop uses the real `ApiService` implementation over the JVM `HttpClient` below — no flavor stub needed. */
@Single fun apiService(apiServiceImpl: ApiServiceImpl): ApiService = apiServiceImpl
/** Ktor [HttpClient] for JVM/Desktop — the equivalent of `CoreNetworkAndroidModule`'s OkHttp-backed client. */
@Single
fun httpClient(json: Json): HttpClient = HttpClient(Java) {
engine {
protocolVersion = java.net.http.HttpClient.Version.HTTP_2
config { followRedirects(java.net.http.HttpClient.Redirect.NORMAL) }
}
install(ContentNegotiation) { json(json) }
install(DefaultRequest) {
url(HttpClientDefaults.API_BASE_URL)
header(HttpHeaders.UserAgent, "Meshtastic-Desktop/${DesktopBuildConfig.VERSION_NAME}")
}
install(HttpTimeout) {
requestTimeoutMillis = HttpClientDefaults.REQUEST_TIMEOUT_MS
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
}
install(HttpRequestRetry) { configureDefaultRetry() }
if (DesktopBuildConfig.IS_DEBUG) {
install(Logging) {
logger = KermitHttpLogger
level = LogLevel.INFO
}
}
}
/** Desktop has no bundled Android assets; repositories seed from the network instead. */
@Single fun bundledAssetReader(): BundledAssetReader = BundledAssetReader { null }
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.di
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.LocationRepository
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
import org.meshtastic.desktop.stub.NoopLocationRepository
import org.meshtastic.desktop.stub.NoopMQTTRepository
import org.meshtastic.desktop.stub.NoopMagneticFieldProvider
import org.meshtastic.desktop.stub.NoopMeshLocationManager
import org.meshtastic.desktop.stub.NoopMeshWorkerManager
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
/**
* Stubs for interfaces whose only real implementation needs Android APIs — WorkManager, widgets, location, sensors and
* analytics. [MQTTRepository] is the exception: it has a working `commonMain` implementation, and this binding
* deliberately shadows it because desktop does not run the MQTT bridge.
*/
@Module
class DesktopStubsModule {
@Single fun platformAnalytics(): PlatformAnalytics = NoopPlatformAnalytics()
@Single fun appWidgetUpdater(): AppWidgetUpdater = NoopAppWidgetUpdater()
@Single fun meshWorkerManager(): MeshWorkerManager = NoopMeshWorkerManager()
@Single fun meshLocationManager(): MeshLocationManager = NoopMeshLocationManager()
@Single fun locationRepository(): LocationRepository = NoopLocationRepository()
@Single fun mqttRepository(): MQTTRepository = NoopMQTTRepository()
@Single fun compassHeadingProvider(): CompassHeadingProvider = NoopCompassHeadingProvider()
@Single fun phoneLocationProvider(): PhoneLocationProvider = NoopPhoneLocationProvider()
@Single fun magneticFieldProvider(): MagneticFieldProvider = NoopMagneticFieldProvider()
}
@@ -42,7 +42,7 @@ import org.meshtastic.proto.Telemetry
*
* Android-only concepts (notification channels, foreground-service state updates) are intentionally no-ops.
*
* Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the
* Registered manually in `DesktopRuntimeModule` -- do **not** add `@Single` to avoid double-registration with the
* `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
*/
@Suppress("TooManyFunctions")
@@ -35,7 +35,7 @@ import org.meshtastic.core.repository.RadioTransportFactory
* Desktop implementation of [RadioTransportFactory] delegating multiplatform transports (BLE, TCP) and providing
* platform-specific transports (USB/Serial) via jSerialComm.
*
* Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid double-registration with
* Registered manually in [DesktopRuntimeModule] — do NOT add @Single to avoid double-registration with
* the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule.
*/
class DesktopRadioTransportFactory(
@@ -57,7 +57,7 @@ import org.meshtastic.proto.Position as ProtoPosition
* real `commonMain` implementations wired through the generated Koin K2 modules.
*
* As real desktop implementations become available (e.g., serial transport, TCP transport), they replace individual
* stubs in [desktopModule].
* stubs in [org.meshtastic.desktop.di.DesktopStubsModule].
*/
private const val TAG = "NoopStub"
@@ -22,24 +22,28 @@ import io.ktor.client.engine.HttpClientEngine
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.dsl.koinApplication
import org.koin.dsl.module
import org.koin.dsl.onClose
import org.koin.plugin.module.dsl.koinApplication
import org.koin.test.verify.verify
import org.meshtastic.core.ble.BleLogFormat
import org.meshtastic.core.ble.BleLogLevel
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.desktop.stub.NoopMQTTRepository
import org.meshtastic.feature.docs.translation.DocTranslationService
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
import org.meshtastic.feature.messaging.translation.MessageTranslationService
import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
import kotlin.test.Test
import kotlin.test.assertTrue
import kotlin.test.assertIs
@OptIn(KoinExperimentalAPI::class)
class DesktopKoinTest {
@OptIn(KoinExperimentalAPI::class)
@Test
fun `verify desktop koin modules`() {
// This test validates the full Koin DI graph for the Desktop target.
// It includes the main desktopModule (repositories, use cases, ViewModels, stubs)
// and the desktopPlatformModule (DataStores, Room database, lifecycle).
module { includes(desktopModule(), desktopPlatformModule()) }
// Validates the full Koin DI graph for the Desktop target: the core KMP modules (repositories, use cases,
// ViewModels) plus the desktop-specific platform, datastore, runtime, stub and AI modules.
DesktopKoinModule()
.module()
.verify(
extraTypes =
listOf(
@@ -61,18 +65,26 @@ class DesktopKoinTest {
}
@Test
fun `closing a koin container fires onClose for instantiated singles`() {
// Pins the mechanism desktopModule() relies on: LinuxNotificationSender's native teardown
// (notify_uninit) runs only because Koin invokes onClose when the container closes, which
// Main.kt triggers via stopKoin() once the Compose application loop returns. A Koin upgrade
// that changed this would silently reinstate the leak, so it is asserted rather than assumed.
var closed = false
val app = koinApplication {
modules(module { single<AutoCloseable> { AutoCloseable { closed = true } }.onClose { it?.close() } })
fun `desktop bindings win over the shared graph`() {
// @Configuration modules load before the ones listed in @KoinApplication, and Koin is last-wins, so which
// binding survives is ordering-dependent. MQTTRepository is the live case: core:network commonMain declares
// MQTTRepositoryImpl, and desktop must shadow it. verify() only checks definitions exist, never who won.
val app = koinApplication<DesktopKoinApp>()
try {
val koin = app.koin
assertIs<NoopMQTTRepository>(koin.get<MQTTRepository>())
assertIs<NoOpMessageTranslator>(koin.get<MessageTranslationService>())
assertIs<NoOpDocTranslator>(koin.get<DocTranslationService>())
} finally {
app.close()
}
app.koin.get<AutoCloseable>() // onClose only fires for singles that were actually instantiated
app.close()
}
assertTrue(closed, "Expected Koin to invoke onClose when the container is closed")
@Test
fun `typed bootstrap loads the module graph`() {
// koinApplication<T>() is a K2 compiler plugin stub. If the plugin fails to transform it, the stub throws
// NotImplementedError at runtime. This is the production bootstrap path Main.kt takes via startKoin<T>.
val app = koinApplication<DesktopKoinApp>()
app.close()
}
}