diff --git a/desktop/README.md b/desktop/README.md index 5e177a548..cf8f38221 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -36,6 +36,10 @@ The module depends on the JVM variants of KMP modules: **UI:** JetBrains Compose for Desktop with Material 3 theming. Desktop acts as a thin host shell, delegating almost entirely to fully shared KMP UI modules. +**Tray:** Uses `ComposeNativeTray` (`io.github.kdroidfilter:composenativetray`) for native tray rendering and menu interactions across Linux, macOS, and Windows. + +**Notifications:** `DesktopNotificationManager` uses DesktopNotifyKT (`io.github.kdroidfilter:knotify`) for desktop notifications. If delivery fails at runtime, the notifier logs the failure as a non-blocking fallback. + **Localization:** Desktop exposes a language picker, persisting the selected BCP-47 tag in `UiPreferencesDataSource.locale`. `Main.kt` applies the override to the JVM default `Locale` and uses a `staticCompositionLocalOf`-backed recomposition trigger so Compose Multiplatform `stringResource()` calls update immediately without recreating the Navigation 3 backstack. ## Key Files diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index fe4e86c0c..4aebabc8a 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -155,6 +155,8 @@ dependencies { // Compose Desktop implementation(compose.desktop.currentOs) + implementation(libs.kdroid.composenativetray) + implementation(libs.kdroid.knotify) implementation(libs.compose.multiplatform.material3) implementation(libs.compose.multiplatform.materialIconsExtended) implementation(libs.compose.multiplatform.runtime) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index 5a871efd6..9f9d0424b 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -16,20 +16,16 @@ */ package org.meshtastic.desktop -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow import org.koin.core.annotation.Single import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.NotificationPrefs -import androidx.compose.ui.window.Notification as ComposeNotification @Single -class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager { - private val _notifications = MutableSharedFlow(extraBufferCapacity = 10) - val notifications: SharedFlow = _notifications.asSharedFlow() - +class DesktopNotificationManager( + private val prefs: NotificationPrefs, + private val systemNotifier: DesktopSystemNotifier, +) : NotificationManager { override fun dispatch(notification: Notification) { val enabled = when (notification.category) { @@ -42,22 +38,14 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific if (!enabled) return - val composeType = - when (notification.type) { - Notification.Type.None -> ComposeNotification.Type.None - Notification.Type.Info -> ComposeNotification.Type.Info - Notification.Type.Warning -> ComposeNotification.Type.Warning - Notification.Type.Error -> ComposeNotification.Type.Error - } - - _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType)) + systemNotifier.show(notification) } override fun cancel(id: Int) { - // Desktop Tray notifications cannot be cancelled once sent via TrayState + systemNotifier.cancel(id) } override fun cancelAll() { - // Desktop Tray notifications cannot be cleared once sent via TrayState + systemNotifier.cancelAll() } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt new file mode 100644 index 000000000..0f2260f0c --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt @@ -0,0 +1,116 @@ +/* + * 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 co.touchlab.kermit.Logger +import io.github.kdroidfilter.knotify.builder.AppConfig +import io.github.kdroidfilter.knotify.builder.ExperimentalNotificationsApi +import io.github.kdroidfilter.knotify.builder.NotificationInitializer +import io.github.kdroidfilter.knotify.builder.notification +import org.koin.core.annotation.Single +import org.meshtastic.core.repository.Notification +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +interface DesktopSystemNotifier { + fun show(notification: Notification) + + fun cancel(id: Int) + + fun cancelAll() +} + +@Single +class DesktopSystemNotifierImpl( + private val knotifySender: (Notification) -> Boolean = { notification -> sendWithKnotify(notification) }, +) : DesktopSystemNotifier { + + init { + configureKnotifyIfNeeded() + } + + override fun show(notification: Notification) { + if (!knotifySender(notification)) { + Logger.i { "Desktop notification fallback: ${notification.title} - ${notification.message}" } + } + } + + override fun cancel(id: Int) { + // The current OS notification backends are fire-and-forget. + } + + override fun cancelAll() { + // The current OS notification backends are fire-and-forget. + } + + private companion object { + const val APP_NAME = "Meshtastic" + private const val APP_ICON_RESOURCE = "icon.png" + + @Volatile private var knotifyConfigured = false + @Volatile private var notificationIconPath: String? = null + + private fun configureKnotifyIfNeeded() { + if (knotifyConfigured) return + + synchronized(this) { + if (knotifyConfigured) return + + notificationIconPath = resolveResourceToTempPath(APP_ICON_RESOURCE) + runCatching { + NotificationInitializer.configure( + AppConfig( + appName = APP_NAME, + ), + ) + knotifyConfigured = true + } + .onFailure { Logger.w(it) { "Failed to configure DesktopNotifyKT" } } + } + } + + private fun resolveResourceToTempPath(resourcePath: String): String? = + runCatching { + val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(resourcePath) + stream?.use { input -> + val suffix = "." + resourcePath.substringAfterLast('.', "png") + val tempFile = Files.createTempFile("meshtastic-knotify-icon-", suffix) + Files.copy(input, tempFile, StandardCopyOption.REPLACE_EXISTING) + tempFile.toFile().deleteOnExit() + tempFile.toAbsolutePath().toString() + } + } + .onFailure { Logger.w(it) { "Failed to resolve DesktopNotifyKT icon resource: $resourcePath" } } + .getOrNull() + + @OptIn(ExperimentalNotificationsApi::class) + private fun sendWithKnotify(notification: Notification): Boolean = + runCatching { + notification( + title = notification.title, + message = notification.message, + largeIcon = notificationIconPath, + smallIcon = notificationIconPath, + onFailed = { Logger.w { "DesktopNotifyKT failed to display notification" } }, + ).send() + true + } + .onFailure { Logger.w(it) { "DesktopNotifyKT threw while sending notification" } } + .getOrDefault(false) + } +} + diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index 96b121524..da4a5b8e7 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -41,12 +41,9 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Notification -import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberTrayState import androidx.compose.ui.window.rememberWindowState import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberNavBackStack @@ -58,6 +55,7 @@ import coil3.memory.MemoryCache import coil3.network.ktor3.KtorNetworkFetcherFactory import coil3.request.crossfade import coil3.svg.SvgDecoder +import com.kdroid.composetray.tray.api.Tray import kotlinx.coroutines.flow.first import okio.Path.Companion.toPath import org.jetbrains.skia.Image @@ -169,18 +167,12 @@ fun main(args: Array) = application(exitProcessOnExit = false) { var isAppVisible by remember { mutableStateOf(true) } var isWindowReady by remember { mutableStateOf(false) } - val trayState = rememberTrayState() val appIcon = classpathPainterResource("icon.png") - val notificationManager = remember { koinApp.koin.get() } val alertManager = remember { koinApp.koin.get() } val desktopPrefs = remember { koinApp.koin.get() } val windowState = rememberWindowState() - LaunchedEffect(Unit) { - notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) } - } - LaunchedEffect(Unit) { val initialWidth = desktopPrefs.windowWidth.first() val initialHeight = desktopPrefs.windowHeight.first() @@ -208,25 +200,13 @@ fun main(args: Array) = application(exitProcessOnExit = false) { } Tray( - state = trayState, icon = appIcon, - menu = { - Item("Show Meshtastic", onClick = { isAppVisible = true }) - Item( - "Test Notification", - onClick = { - trayState.sendNotification( - Notification( - "Meshtastic", - "This is a test notification from the System Tray", - Notification.Type.Info, - ), - ) - }, - ) - Item("Quit", onClick = ::exitApplication) - }, - ) + tooltip = "Meshtastic Desktop", + primaryAction = { isAppVisible = true }, + ) { + Item(label = "Show Meshtastic") { isAppVisible = true } + Item(label = "Quit") { exitApplication() } + } if (isWindowReady && isAppVisible) { val backStack = diff --git a/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopNotificationManagerTest.kt b/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopNotificationManagerTest.kt new file mode 100644 index 000000000..8fe0a9b87 --- /dev/null +++ b/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopNotificationManagerTest.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 + +import kotlinx.coroutines.flow.MutableStateFlow +import org.meshtastic.core.repository.Notification +import org.meshtastic.core.repository.NotificationPrefs +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopNotificationManagerTest { + + @Test + fun `dispatch sends enabled message notifications`() { + val notifier = RecordingDesktopSystemNotifier() + val prefs = TestNotificationPrefs(messages = true, nodeEvents = true, lowBattery = true) + val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier) + + val notification = Notification(title = "T", message = "M", category = Notification.Category.Message) + manager.dispatch(notification) + + assertEquals(listOf(notification), notifier.shown) + } + + @Test + fun `dispatch skips disabled categories`() { + val notifier = RecordingDesktopSystemNotifier() + val prefs = TestNotificationPrefs(messages = false, nodeEvents = true, lowBattery = true) + val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier) + + manager.dispatch(Notification(title = "T", message = "M", category = Notification.Category.Message)) + + assertEquals(emptyList(), notifier.shown) + } + + @Test + fun `cancel delegates to notifier`() { + val notifier = RecordingDesktopSystemNotifier() + val prefs = TestNotificationPrefs(messages = true, nodeEvents = true, lowBattery = true) + val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier) + + manager.cancel(42) + + assertEquals(listOf(42), notifier.canceledIds) + } + + @Test + fun `cancelAll delegates to notifier`() { + val notifier = RecordingDesktopSystemNotifier() + val prefs = TestNotificationPrefs(messages = true, nodeEvents = true, lowBattery = true) + val manager = DesktopNotificationManager(prefs = prefs, systemNotifier = notifier) + + manager.cancelAll() + + assertEquals(1, notifier.cancelAllCalls) + } + + private class RecordingDesktopSystemNotifier : DesktopSystemNotifier { + val shown = mutableListOf() + val canceledIds = mutableListOf() + var cancelAllCalls: Int = 0 + + override fun show(notification: Notification) { + shown += notification + } + + override fun cancel(id: Int) { + canceledIds += id + } + + override fun cancelAll() { + cancelAllCalls += 1 + } + } + + private class TestNotificationPrefs( + messages: Boolean, + nodeEvents: Boolean, + lowBattery: Boolean, + ) : NotificationPrefs { + override val messagesEnabled = MutableStateFlow(messages) + override val nodeEventsEnabled = MutableStateFlow(nodeEvents) + override val lowBatteryEnabled = MutableStateFlow(lowBattery) + + override fun setMessagesEnabled(enabled: Boolean) { + messagesEnabled.value = enabled + } + + override fun setNodeEventsEnabled(enabled: Boolean) { + nodeEventsEnabled.value = enabled + } + + override fun setLowBatteryEnabled(enabled: Boolean) { + lowBatteryEnabled.value = enabled + } + } +} + diff --git a/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopSystemNotifierTest.kt b/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopSystemNotifierTest.kt new file mode 100644 index 000000000..860ec40f6 --- /dev/null +++ b/desktop/src/test/kotlin/org/meshtastic/desktop/DesktopSystemNotifierTest.kt @@ -0,0 +1,50 @@ +/* + * 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 org.meshtastic.core.repository.Notification +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopSystemNotifierTest { + + @Test + fun `show forwards notification to knotify sender`() { + val sent = mutableListOf() + val notifier = + DesktopSystemNotifierImpl( + knotifySender = { notification -> + sent += notification + true + }, + ) + + val notification = Notification(title = "T", message = "M") + notifier.show(notification) + + assertEquals(listOf(notification), sent) + } + + @Test + fun `show does not throw when knotify sender reports failure`() { + val notifier = + DesktopSystemNotifierImpl(knotifySender = { false }) + + notifier.show(Notification(title = "T", message = "M")) + } +} + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 769890213..9c77d6072 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,6 +69,8 @@ nordic-dfu = "2.11.0" kmqtt = "1.0.0" jmdns = "3.6.3" qrcode-kotlin = "4.5.0" +compose-native-tray = "1.1.0" +knotify = "0.4.3" [libraries] # AndroidX @@ -202,6 +204,8 @@ aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +kdroid-composenativetray = { module = "io.github.kdroidfilter:composenativetray", version.ref = "compose-native-tray" } +kdroid-knotify = { module = "io.github.kdroidfilter:knotify", version.ref = "knotify" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } dd-sdk-android-compose = { module = "com.datadoghq:dd-sdk-android-compose", version.ref = "dd-sdk-android" }