feat: enhance desktop notification styling and taskbar integration

- Implement native taskbar and dock icon support using the AWT `Taskbar` API in the desktop entry point.
- Refactor notification icon resolution to use URI strings directly, removing the need to copy resources to temporary files.
- Introduce a notification styling mechanism that automatically prefixes notification titles based on their category (e.g., "Message", "Node", "Battery").
- Update `DesktopSystemNotifier` to provide both small and large icons to `knotify` for improved native presentation.
- Ensure the `AppConfig` for notifications is initialized with the resolved application icon path.
- Update project documentation to reflect the move toward native default styling for notifications.
This commit is contained in:
James Rich
2026-03-24 11:41:38 -05:00
parent e8b39370c1
commit b313582e47
3 changed files with 64 additions and 26 deletions

View File

@@ -38,7 +38,7 @@ The module depends on the JVM variants of KMP 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.
**Notifications:** `DesktopNotificationManager` uses DesktopNotifyKT (`io.github.kdroidfilter:knotify`) for desktop notifications with native default styling. 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.

View File

@@ -23,8 +23,6 @@ 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)
@@ -59,10 +57,11 @@ class DesktopSystemNotifierImpl(
private companion object {
const val APP_NAME = "Meshtastic"
private const val APP_ICON_RESOURCE = "icon.png"
const val NOTIFICATION_ICON_RESOURCE = "icon.png"
@Volatile private var knotifyConfigured = false
@Volatile private var notificationIconPath: String? = null
private val notificationIconPath: String? by lazy { resolveResourcePath(NOTIFICATION_ICON_RESOURCE) }
private fun configureKnotifyIfNeeded() {
if (knotifyConfigured) return
@@ -70,11 +69,11 @@ class DesktopSystemNotifierImpl(
synchronized(this) {
if (knotifyConfigured) return
notificationIconPath = resolveResourceToTempPath(APP_ICON_RESOURCE)
runCatching {
NotificationInitializer.configure(
AppConfig(
appName = APP_NAME,
smallIcon = notificationIconPath,
),
)
knotifyConfigured = true
@@ -83,34 +82,52 @@ class DesktopSystemNotifierImpl(
}
}
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 {
private fun sendWithKnotify(notification: Notification): Boolean {
val styled = style(notification)
return runCatching {
notification(
title = notification.title,
message = notification.message,
largeIcon = notificationIconPath,
title = styled.title,
message = styled.message,
smallIcon = notificationIconPath,
largeIcon = notificationIconPath,
onFailed = { Logger.w { "DesktopNotifyKT failed to display notification" } },
).send()
true
}
.onFailure { Logger.w(it) { "DesktopNotifyKT threw while sending notification" } }
.getOrDefault(false)
}
private fun style(notification: Notification): Notification {
val prefix =
when (notification.category) {
Notification.Category.Message -> "Message"
Notification.Category.NodeEvent -> "Node"
Notification.Category.Battery -> "Battery"
Notification.Category.Alert -> "Alert"
Notification.Category.Service -> "Service"
}
val title =
when {
notification.title.startsWith("$prefix: ") -> notification.title
else -> "$prefix: ${notification.title}"
}
return notification.copy(title = title)
}
private fun resolveResourcePath(resourceName: String): String? {
return runCatching {
Thread.currentThread()
.contextClassLoader
?.getResource(resourceName)
?.toURI()
?.toString()
}
.onFailure { Logger.w(it) { "Unable to resolve notification icon resource: $resourceName" } }
.getOrNull()
}
}
}

View File

@@ -73,7 +73,10 @@ import org.meshtastic.desktop.di.desktopModule
import org.meshtastic.desktop.di.desktopPlatformModule
import org.meshtastic.desktop.ui.DesktopMainScreen
import java.awt.Desktop
import java.awt.Taskbar
import java.io.ByteArrayInputStream
import java.util.Locale
import javax.imageio.ImageIO
/**
* Meshtastic Desktop — the first non-Android target for the shared KMP module graph.
@@ -115,6 +118,8 @@ private fun classpathPainterResource(path: String): Painter {
fun main(args: Array<String>) = application(exitProcessOnExit = false) {
Logger.i { "Meshtastic Desktop — Starting" }
LaunchedEffect(Unit) { setTaskbarDockIcon("icon.png") }
val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
val systemLocale = remember { Locale.getDefault() }
val uiViewModel = remember { koinApp.koin.get<UIViewModel>() }
@@ -299,6 +304,22 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
}
}
private fun setTaskbarDockIcon(resourcePath: String) {
if (!Taskbar.isTaskbarSupported()) return
val taskbar = Taskbar.getTaskbar()
if (!taskbar.isSupported(Taskbar.Feature.ICON_IMAGE)) return
runCatching {
val stream = Thread.currentThread().contextClassLoader?.getResourceAsStream(resourcePath) ?: return
stream.use {
val image = ImageIO.read(ByteArrayInputStream(it.readAllBytes())) ?: return
taskbar.iconImage = image
}
}
.onFailure { Logger.w(it) { "Unable to set dock/taskbar icon from $resourcePath" } }
}
/** Replaces the backstack with a single top-level destination route. */
private fun navigateTopLevel(backStack: MutableList<NavKey>, route: NavKey) {
backStack.add(route)