feat: integrate native desktop notifications and system tray

- Replace Compose-based notifications with `knotify` (DesktopNotifyKT) to provide native OS notification support.
- Introduce `DesktopSystemNotifier` and `DesktopSystemNotifierImpl` to handle notification delivery and automatic app icon resource resolution.
- Migrate the system tray implementation to `ComposeNativeTray` for improved native menu interactions across Linux, macOS, and Windows.
- Refactor `DesktopNotificationManager` to delegate notification lifecycle management to the new system notifier, decoupling it from the Compose UI.
- Add unit tests for `DesktopSystemNotifier` and `DesktopNotificationManager` covering notification dispatching and preference filtering.
- Update `Main.kt` and project documentation to reflect the new tray and notification architecture.
This commit is contained in:
James Rich
2026-03-24 10:39:30 -05:00
parent b45bc9be90
commit e8b39370c1
8 changed files with 302 additions and 46 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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<ComposeNotification>(extraBufferCapacity = 10)
val notifications: SharedFlow<ComposeNotification> = _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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -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<String>) = 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<DesktopNotificationManager>() }
val alertManager = remember { koinApp.koin.get<org.meshtastic.core.ui.util.AlertManager>() }
val desktopPrefs = remember { koinApp.koin.get<DesktopPreferencesDataSource>() }
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<String>) = 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 =

View File

@@ -0,0 +1,112 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop
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<Notification>()
val canceledIds = mutableListOf<Int>()
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
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Notification>()
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"))
}
}

View File

@@ -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" }