From 2ea6c2e2fbfee48c523f29d67bc336723b57da36 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:39:04 +0000 Subject: [PATCH] feat(di): enable Koin compile-time safety (#7127) --- .github/workflows/reusable-check.yml | 7 +- .skills/navigation-and-di/SKILL.md | 6 +- .../org/meshtastic/app/di/FdroidAiModule.kt | 2 + .../meshtastic/app/di/AppFunctionsModule.kt | 2 + .../org/meshtastic/app/di/GoogleAiModule.kt | 2 + .../app/map/prefs/di/GoogleMapsKoinModule.kt | 2 + .../org/meshtastic/app/MeshUtilApplication.kt | 7 +- .../app/di/FdroidBindingWinnerTest.kt | 44 +++ .../src/main/kotlin/KoinConventionPlugin.kt | 14 +- .../desktop/DesktopNotificationManager.kt | 2 +- .../kotlin/org/meshtastic/desktop/Main.kt | 19 +- .../org/meshtastic/desktop/QuitHandler.kt | 70 ++++ .../meshtastic/desktop/di/DesktopAiModule.kt | 41 +++ .../meshtastic/desktop/di/DesktopKoinApp.kt | 26 ++ .../desktop/di/DesktopKoinModule.kt | 310 ++++-------------- .../desktop/di/DesktopPlatformModule.kt | 179 ++-------- .../di/DesktopPreferencesDataStoreModule.kt | 112 +++++++ .../desktop/di/DesktopProtoDataStoreModule.kt | 88 +++++ .../desktop/di/DesktopRuntimeModule.kt | 228 +++++++++++++ .../desktop/di/DesktopStubsModule.kt | 65 ++++ .../DesktopMeshNotificationManager.kt | 2 +- .../radio/DesktopRadioTransportFactory.kt | 2 +- .../org/meshtastic/desktop/stub/NoopStubs.kt | 2 +- .../meshtastic/desktop/di/DesktopKoinTest.kt | 52 +-- 24 files changed, 838 insertions(+), 446 deletions(-) create mode 100644 androidApp/src/testFdroid/kotlin/org/meshtastic/app/di/FdroidBindingWinnerTest.kt create mode 100644 desktopApp/src/main/kotlin/org/meshtastic/desktop/QuitHandler.kt create mode 100644 desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopAiModule.kt create mode 100644 desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinApp.kt create mode 100644 desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPreferencesDataStoreModule.kt create mode 100644 desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopProtoDataStoreModule.kt create mode 100644 desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt create mode 100644 desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index cbe605ddfd..5fc95f0d19 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -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 diff --git a/.skills/navigation-and-di/SKILL.md b/.skills/navigation-and-di/SKILL.md index 4119a91a08..d4ac220849 100644 --- a/.skills/navigation-and-di/SKILL.md +++ b/.skills/navigation-and-di/SKILL.md @@ -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 { - `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class. - `startKoin()` (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 diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt index 581989fe08..751a56a130 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt @@ -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 diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/AppFunctionsModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/AppFunctionsModule.kt index 3ad3cf4fd8..79f69f49ed 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/di/AppFunctionsModule.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/AppFunctionsModule.kt @@ -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) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt index 96d02beff5..bde53a56af 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt @@ -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( diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt index 94f7dec30c..aab5864350 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt @@ -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 { diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt index 9831bf4348..0bcf3f71c6 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt @@ -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() } diff --git a/androidApp/src/testFdroid/kotlin/org/meshtastic/app/di/FdroidBindingWinnerTest.kt b/androidApp/src/testFdroid/kotlin/org/meshtastic/app/di/FdroidBindingWinnerTest.kt new file mode 100644 index 0000000000..1808d775a1 --- /dev/null +++ b/androidApp/src/testFdroid/kotlin/org/meshtastic/app/di/FdroidBindingWinnerTest.kt @@ -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 . + */ +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() + try { + val koin = app.koin + assertIs(koin.get()) + assertIs(koin.get()) + } finally { + app.close() + } + } +} diff --git a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt index 214bbed8d1..6e79955aac 100644 --- a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt @@ -29,13 +29,10 @@ class KoinConventionPlugin : Plugin { // 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 { } } } + +/** 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") diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index 51d4e24f04..09e792a754 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -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( diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt index ac78296328..84681d7ace 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -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) { + 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) { // 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 {} } + LaunchedEffect(Unit) { publishExitApplication(::exitApplication) } val systemLocale = remember { Locale.getDefault() } val uiViewModel = remember { koinApp.koin.get() } val httpClient = remember { koinApp.koin.get() } @@ -167,9 +170,11 @@ fun main(args: Array) { 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() 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" } diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/QuitHandler.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/QuitHandler.kt new file mode 100644 index 0000000000..7c602e19dd --- /dev/null +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/QuitHandler.kt @@ -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 . + */ +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(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 diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopAiModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopAiModule.kt new file mode 100644 index 0000000000..a3f512eb71 --- /dev/null +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopAiModule.kt @@ -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 . + */ +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() +} diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinApp.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinApp.kt new file mode 100644 index 0000000000..4d4b37963c --- /dev/null +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinApp.kt @@ -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 . + */ +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 diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index ac5dc2eec2..f7d6d77505 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -14,258 +14,70 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -@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 { ServiceRepositoryImpl() } - single { get() } - single { get() } - single { get() } - single { get() } - single { - DesktopRadioTransportFactory( - dispatchers = get(), - scanner = get(), - bluetoothRepository = get(), - connectionFactory = get(), - ) - } - single { - 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(), - ) - } - single { get() } - single { get() } - single { get() } - single { get() } - single { - 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 { get() } - single { DesktopMeshNotificationManager(notificationManager = get()) } - single { NoopPlatformAnalytics() } - single { NoopAppWidgetUpdater() } - single { NoopMeshWorkerManager() } - single { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) } - single { NoopMeshLocationManager() } - single { NoopLocationRepository() } - single { NoopMQTTRepository() } - single { NoopCompassHeadingProvider() } - single { NoopPhoneLocationProvider() } - single { NoopMagneticFieldProvider() } - - // AI assistant: keyword-only fallback on desktop (no on-device model) - single { get() } - single { get() } - single { NoOpDocTranslator() } - single { NoOpMessageTranslator() } - - // Desktop uses the real ApiService implementation (no flavor stub needed) - single { ApiServiceImpl(client = get()) } - - // Ktor HttpClient for JVM/Desktop (equivalent of CoreNetworkAndroidModule on Android) - single { - HttpClient(Java) { - engine { - protocolVersion = java.net.http.HttpClient.Version.HTTP_2 - config { followRedirects(java.net.http.HttpClient.Redirect.NORMAL) } - } - install(ContentNegotiation) { json(get()) } - 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 { 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 diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt index ea79c13596..985a418c4a 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt @@ -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 { - 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 { CoroutineScope(get().io + SupervisorJob()).asDataStoreScope() } +@Module +class DesktopPlatformModule { - includes(desktopPreferencesDataStoreModule(), desktopProtoDataStoreModule()) - - // -- Build config (values generated at build time by generateDesktopBuildConfig) -- - single { - 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 { prefsStore("analytics", get()).asAnalyticsDataStore() } - single { prefsStore("homoglyph_encoding", get()).asHomoglyphEncodingDataStore() } - single { prefsStore("app", get()).asAppDataStore() } - single { prefsStore("custom_emoji", get()).asCustomEmojiDataStore() } - single { prefsStore("map", get()).asMapDataStore() } - single { prefsStore("map_consent", get()).asMapConsentDataStore() } - single { prefsStore("map_tile_provider", get()).asMapTileProviderDataStore() } - single { prefsStore("mesh", get()).asMeshDataStore() } - single { prefsStore("radio", get()).asRadioDataStore() } - single { prefsStore("ui", get()).asUiDataStore() } - single { prefsStore("meshlog", get()).asMeshLogDataStore() } - single { prefsStore("filter", get()).asFilterDataStore() } - single { prefsStore("core_preferences", get()).asCorePreferencesDataStore() } -} - -/** The path is an on-disk identity — changing it orphans existing user data. */ -private fun protoStore( - serializer: OkioSerializer, - path: String, - produceNewData: () -> T, - scope: DataStoreScope, -): DataStore = 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 { - protoStore(LocalConfigSerializer, "$protoDir/local_config.pb", { LocalConfig() }, get()) - .asCoreLocalConfigDataStore() - } - - single { - protoStore(ModuleConfigSerializer, "$protoDir/module_config.pb", { LocalModuleConfig() }, get()) - .asCoreModuleConfigDataStore() - } - - single { - protoStore(ChannelSetSerializer, "$protoDir/channel_set.pb", { ChannelSet() }, get()) - .asCoreChannelSetDataStore() - } - - single { - 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 } diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPreferencesDataStoreModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPreferencesDataStoreModule.kt new file mode 100644 index 0000000000..8851e98363 --- /dev/null +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPreferencesDataStoreModule.kt @@ -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 . + */ +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 { + val dir = desktopDataDir() + "/datastore" + FileSystem.SYSTEM.createDirectories(dir.toPath()) + return PreferenceDataStoreFactory.createWithPath( + corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), + scope = scope, + produceFile = { "$dir/$name.preferences_pb".toPath() }, + ) +} diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopProtoDataStoreModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopProtoDataStoreModule.kt new file mode 100644 index 0000000000..98eae1b611 --- /dev/null +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopProtoDataStoreModule.kt @@ -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 . + */ +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 protoStore( + serializer: OkioSerializer, + fileName: String, + produceNewData: () -> T, + scope: DataStoreScope, +): DataStore { + 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, + ) +} diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt new file mode 100644 index 0000000000..265f7e4d96 --- /dev/null +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopRuntimeModule.kt @@ -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 . + */ +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, + dataHandler: Lazy, + analytics: PlatformAnalytics, + meshPrefs: MeshPrefs, + uiPrefs: UiPrefs, + databaseManager: DatabaseManager, + notificationManager: NotificationManager, + messageProcessor: Lazy, + 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 } +} diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt new file mode 100644 index 0000000000..c15033acc6 --- /dev/null +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopStubsModule.kt @@ -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 . + */ +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() +} diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt index 50a98858da..a111c43a8f 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt @@ -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") diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt index d7f7d13b28..efa79d1d9c 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt @@ -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( diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index e7a0627551..cd69ea5c38 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -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" diff --git a/desktopApp/src/test/kotlin/org/meshtastic/desktop/di/DesktopKoinTest.kt b/desktopApp/src/test/kotlin/org/meshtastic/desktop/di/DesktopKoinTest.kt index 364aead5ba..9ab331e0aa 100644 --- a/desktopApp/src/test/kotlin/org/meshtastic/desktop/di/DesktopKoinTest.kt +++ b/desktopApp/src/test/kotlin/org/meshtastic/desktop/di/DesktopKoinTest.kt @@ -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 { 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() + try { + val koin = app.koin + assertIs(koin.get()) + assertIs(koin.get()) + assertIs(koin.get()) + } finally { + app.close() } - app.koin.get() // 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() 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. + val app = koinApplication() + app.close() } }