diff --git a/desktop/README.md b/desktop/README.md index cf8f38221..353a4a4e6 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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. diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt index 0f2260f0c..e418c0972 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopSystemNotifier.kt @@ -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() + } } } - diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index da4a5b8e7..133c87c40 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -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) = 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() } @@ -299,6 +304,22 @@ fun main(args: Array) = 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, route: NavKey) { backStack.add(route)