feat(desktop): native OS notifications via libnotify/osascript/PowerShell (#5253)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-04-27 15:53:53 -05:00
committed by GitHub
parent 3174493ca6
commit b60f29d772
15 changed files with 933 additions and 16 deletions

View File

@@ -331,8 +331,11 @@ dependencies {
implementation(libs.koin.annotations)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.jna)
testRuntimeOnly(libs.junit.vintage.engine)
testImplementation(libs.koin.test)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(kotlin("test"))
}

View File

@@ -40,6 +40,12 @@
-dontwarn sun.misc.Unsafe
-dontwarn java.lang.invoke.**
# ---- JNA (Java Native Access) — used by LinuxNotificationSender for libnotify ---
# JNA uses reflection to bind native methods; keep its core and callback classes.
-keep class com.sun.jna.** { *; }
-keep class com.sun.jna.ptr.** { *; }
-dontwarn com.sun.jna.**
# ---- jSerialComm Android stubs (cross-platform serial library) --------------
# jSerialComm bundles Android shims that reference android.* classes; harmless
# on JVM/desktop but ProGuard fails the build on unresolved program classes

View File

@@ -17,32 +17,50 @@
package org.meshtastic.desktop
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.desktop.notification.NativeNotificationSender
import androidx.compose.ui.window.Notification as ComposeNotification
/**
* Desktop notification manager that bridges domain [Notification] objects to Compose Desktop tray notifications.
* Desktop notification manager that dispatches domain [Notification] objects to native OS notifications.
*
* Notifications are emitted via [notifications] and collected by the tray composable in [Main.kt]. Respects user
* preferences for message, node-event, and low-battery categories.
* Uses platform-specific [NativeNotificationSender] implementations (notify-send on Linux, osascript on macOS,
* PowerShell toast on Windows) for proper native look-and-feel. Falls back to Compose Desktop tray notifications (via
* [fallbackNotifications]) when the native sender is unavailable or fails.
*
* All native sends are dispatched on a background scope to avoid blocking callers.
*
* Registered manually in `desktopPlatformStubsModule` -- do **not** add `@Single` to avoid double-registration with the
* `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule].
*/
class DesktopNotificationManager(private val prefs: NotificationPrefs) : NotificationManager {
class DesktopNotificationManager(
private val prefs: NotificationPrefs,
private val nativeSender: NativeNotificationSender,
) : NotificationManager {
@Suppress("InjectDispatcher")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
Logger.i { "DesktopNotificationManager initialized" }
Logger.i { "DesktopNotificationManager initialized (native sender: ${nativeSender::class.simpleName})" }
}
private val _notifications = MutableSharedFlow<ComposeNotification>(extraBufferCapacity = 10)
private val _fallbackNotifications = MutableSharedFlow<ComposeNotification>(extraBufferCapacity = 10)
/** Flow of Compose [ComposeNotification] objects to be forwarded to [TrayState.sendNotification]. */
val notifications: SharedFlow<ComposeNotification> = _notifications.asSharedFlow()
/**
* Fallback flow of Compose [ComposeNotification] objects, emitted only when the native sender fails. Collected by
* the tray composable in Main.kt as a last resort.
*/
val fallbackNotifications: SharedFlow<ComposeNotification> = _fallbackNotifications.asSharedFlow()
override fun dispatch(notification: Notification) {
val enabled =
@@ -55,9 +73,18 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
}
Logger.d { "DesktopNotificationManager dispatch: category=${notification.category}, enabled=$enabled" }
if (!enabled) return
scope.launch {
val success = nativeSender.send(notification)
if (!success) {
Logger.w { "Native notification failed, falling back to tray: ${notification.title}" }
emitFallback(notification)
}
}
}
private fun emitFallback(notification: Notification) {
val composeType =
when (notification.type) {
Notification.Type.None -> ComposeNotification.Type.None
@@ -65,16 +92,16 @@ class DesktopNotificationManager(private val prefs: NotificationPrefs) : Notific
Notification.Type.Warning -> ComposeNotification.Type.Warning
Notification.Type.Error -> ComposeNotification.Type.Error
}
val success = _notifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
Logger.d { "DesktopNotificationManager emit: success=$success, title=${notification.title}" }
_fallbackNotifications.tryEmit(ComposeNotification(notification.title, notification.message, composeType))
}
override fun cancel(id: Int) {
// Desktop tray notifications cannot be cancelled once sent via TrayState.
// Native OS notifications are fire-and-forget; cancel is best-effort.
Logger.d { "cancel($id) — not supported by current native senders" }
}
override fun cancelAll() {
// Desktop tray notifications cannot be cleared once sent via TrayState.
// Native OS notifications are fire-and-forget; cancelAll is best-effort.
Logger.d { "cancelAll() — not supported by current native senders" }
}
}

View File

@@ -213,7 +213,7 @@ private fun ApplicationScope.MeshtasticDesktopApp(
val windowState = rememberWindowState()
LaunchedEffect(Unit) {
notificationManager.notifications.collect { notification -> trayState.sendNotification(notification) }
notificationManager.fallbackNotifications.collect { notification -> trayState.sendNotification(notification) }
}
WindowBoundsManager(desktopPrefs, windowState) { isWindowReady = true }

View File

@@ -61,6 +61,11 @@ import org.meshtastic.core.service.ServiceRepositoryImpl
import org.meshtastic.desktop.DesktopBuildConfig
import org.meshtastic.desktop.DesktopNotificationManager
import org.meshtastic.desktop.notification.DesktopMeshServiceNotifications
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
@@ -165,7 +170,14 @@ private fun desktopPlatformStubsModule() = module {
locationManager = get(),
)
}
single { DesktopNotificationManager(prefs = get()) }
single<NativeNotificationSender> {
when (DesktopOS.current()) {
DesktopOS.Linux -> LinuxNotificationSender()
DesktopOS.MacOS -> MacOSNotificationSender()
DesktopOS.Windows -> WindowsNotificationSender()
}
}
single { DesktopNotificationManager(prefs = get(), nativeSender = get()) }
single<NotificationManager> { get<DesktopNotificationManager>() }
single<MeshServiceNotifications> { DesktopMeshServiceNotifications(notificationManager = get()) }
single<PlatformAnalytics> { NoopPlatformAnalytics() }

View File

@@ -0,0 +1,36 @@
/*
* 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.notification
/** Detected host operating system for platform-specific notification dispatch. */
enum class DesktopOS {
Linux,
MacOS,
Windows,
;
companion object {
fun current(): DesktopOS {
val name = System.getProperty("os.name", "").lowercase()
return when {
name.contains("mac") || name.contains("darwin") -> MacOS
name.contains("win") -> Windows
else -> Linux
}
}
}
}

View File

@@ -0,0 +1,189 @@
/*
* 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.notification
import co.touchlab.kermit.Logger
import com.sun.jna.Library
import com.sun.jna.Native
import com.sun.jna.Pointer
import com.sun.jna.Structure
import com.sun.jna.ptr.PointerByReference
import org.meshtastic.core.repository.Notification
/**
* JNA bindings for libnotify (libnotify.so / libnotify-4.so).
*
* Only the minimal API surface needed for fire-and-forget desktop notifications is exposed. See:
* https://developer-old.gnome.org/libnotify/stable/
*/
@Suppress("FunctionNaming", "FunctionParameterNaming", "ktlint:standard:function-naming")
private interface LibNotify : Library {
fun notify_init(app_name: String): Boolean
fun notify_notification_new(summary: String, body: String?, icon: String?): Pointer?
fun notify_notification_set_urgency(notification: Pointer, urgency: Int)
fun notify_notification_set_category(notification: Pointer, category: String)
fun notify_notification_set_hint(notification: Pointer, key: String, value: Pointer?)
fun notify_notification_show(notification: Pointer, error: PointerByReference?): Boolean
fun notify_uninit()
}
/** Minimal GLib bindings for GVariant creation and GObject ref-counting. */
@Suppress("FunctionNaming", "FunctionParameterNaming", "ktlint:standard:function-naming")
private interface GLib : Library {
fun g_object_unref(obj: Pointer)
fun g_variant_new_boolean(value: Boolean): Pointer
fun g_variant_new_string(string: String): Pointer
}
/** JNA mapping of GLib's `GError` struct for extracting error diagnostics from libnotify. */
@Suppress("MagicNumber")
@Structure.FieldOrder("domain", "code", "message")
class GErrorStruct(p: Pointer?) : Structure(p) {
@JvmField var domain: Int = 0
@JvmField var code: Int = 0
@JvmField var message: Pointer? = null
init {
if (p != null) read()
}
val errorMessage: String
get() = message?.getString(0) ?: "unknown error"
}
/** libnotify urgency levels matching `NotifyUrgency` enum. */
private object NotifyUrgency {
const val LOW = 0
const val NORMAL = 1
const val CRITICAL = 2
}
/**
* Sends notifications via libnotify on Linux, called directly through JNA.
*
* This avoids shelling out to `notify-send` and gives direct access to the notification daemon via D-Bus, providing
* proper urgency, category, and sound suppression support.
*
* Requires `libnotify` (typically `libnotify4` or `libnotify.so.4`) to be installed on the system. Falls back
* gracefully if the library cannot be loaded.
*/
class LinuxNotificationSender(
private val appName: String = "Meshtastic",
private val desktopEntry: String = appName.lowercase(),
) : NativeNotificationSender {
private val lib: LibNotify?
private val glib: GLib?
init {
var loadedLib: LibNotify? = null
var loadedGLib: GLib? = null
try {
loadedLib = Native.load("notify", LibNotify::class.java) as LibNotify
loadedGLib = Native.load("gobject-2.0", GLib::class.java) as GLib
if (loadedLib.notify_init(appName)) {
Logger.i { "libnotify initialized for '$appName'" }
} else {
Logger.w { "notify_init('$appName') returned false" }
loadedLib = null
loadedGLib = null
}
} catch (e: UnsatisfiedLinkError) {
Logger.w(e) { "libnotify not available — native Linux notifications disabled" }
loadedLib = null
loadedGLib = null
}
lib = loadedLib
glib = loadedGLib
}
/** Whether libnotify was successfully loaded and initialized. */
val isAvailable: Boolean
get() = lib != null
@Suppress("ReturnCount")
override fun send(notification: Notification): Boolean {
val libnotify = lib ?: return false
val ptr =
libnotify.notify_notification_new(
notification.title,
notification.message,
null, // icon — could be set to an app icon path in the future
)
?: run {
Logger.w { "notify_notification_new returned null" }
return false
}
applyMetadata(libnotify, ptr, notification)
val errorRef = PointerByReference()
return try {
val shown = libnotify.notify_notification_show(ptr, errorRef)
if (!shown) {
val errMsg = errorRef.value?.let { GErrorStruct(it).errorMessage } ?: "unknown"
Logger.w { "notify_notification_show failed for '${notification.title}': $errMsg" }
}
shown
} finally {
glib?.g_object_unref(ptr)
}
}
private fun applyMetadata(libnotify: LibNotify, ptr: Pointer, notification: Notification) {
val urgency =
when (notification.type) {
Notification.Type.Error -> NotifyUrgency.CRITICAL
Notification.Type.Warning -> NotifyUrgency.NORMAL
else -> NotifyUrgency.LOW
}
libnotify.notify_notification_set_urgency(ptr, urgency)
val category =
when (notification.category) {
Notification.Category.Message -> "im.received"
Notification.Category.Battery -> "device.warning"
Notification.Category.Alert -> "device.error"
Notification.Category.NodeEvent -> "network"
Notification.Category.Service -> "device"
}
libnotify.notify_notification_set_category(ptr, category)
// desktop-entry hint associates notifications with the app's .desktop file,
// enabling proper icon resolution and notification grouping by the daemon.
glib?.let { g ->
libnotify.notify_notification_set_hint(ptr, "desktop-entry", g.g_variant_new_string(desktopEntry))
}
if (notification.isSilent) {
glib?.let { g ->
libnotify.notify_notification_set_hint(ptr, "suppress-sound", g.g_variant_new_boolean(true))
}
}
}
}

View File

@@ -0,0 +1,107 @@
/*
* 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.notification
import co.touchlab.kermit.Logger
import org.meshtastic.core.repository.Notification
import java.util.concurrent.TimeUnit
private const val PROCESS_TIMEOUT_SECONDS = 5L
/**
* Sends notifications via `osascript` on macOS, using AppleScript's `display notification` command.
*
* Content is passed as arguments to a pre-built script — never interpolated into the script source — to prevent
* injection from untrusted message text. The `on run argv` handler receives title/message as positional args.
*/
class MacOSNotificationSender : NativeNotificationSender {
override fun send(notification: Notification): Boolean = runCommand(buildCommand(notification))
/**
* Builds an `osascript` command that passes title and message as arguments to a safe `on run argv` handler.
*
* AppleScript's `on run argv` receives command-line arguments as a list, avoiding any need to escape quotes or
* special characters in user content.
*/
internal fun buildCommand(notification: Notification): List<String> = buildList {
add("osascript")
// Build the script as a safe handler that reads argv items
val scriptLines = buildList {
add("on run argv")
add(" set notifTitle to item 1 of argv")
add(" set notifMessage to item 2 of argv")
add(" set notifSubtitle to item 3 of argv")
add(" set isSilent to item 4 of argv")
if (notification.isSilent) {
add(" display notification notifMessage with title notifTitle subtitle notifSubtitle")
} else {
add(
" display notification notifMessage with title notifTitle" +
" subtitle notifSubtitle sound name \"default\"",
)
}
add("end run")
}
// Pass each line with -e
for (line in scriptLines) {
add("-e")
add(line)
}
// Positional arguments after "--"
add("--")
add(notification.title)
add(notification.message)
add(categorySubtitle(notification.category))
add(if (notification.isSilent) "true" else "false")
}
private fun categorySubtitle(category: Notification.Category): String = when (category) {
Notification.Category.Message -> "Message"
Notification.Category.NodeEvent -> "Node Event"
Notification.Category.Battery -> "Low Battery"
Notification.Category.Alert -> "Alert"
Notification.Category.Service -> "Service"
}
private fun runCommand(command: List<String>): Boolean = try {
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val completed = process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!completed) {
process.destroyForcibly()
Logger.w { "osascript timed out after ${PROCESS_TIMEOUT_SECONDS}s" }
false
} else {
val exitCode = process.exitValue()
if (exitCode != 0) {
val stderr = process.inputStream.bufferedReader().readText().take(MAX_STDERR_CHARS)
Logger.w { "osascript exited $exitCode: $stderr" }
}
exitCode == 0
}
} catch (e: java.io.IOException) {
Logger.w(e) { "Failed to run osascript" }
false
}
companion object {
private const val MAX_STDERR_CHARS = 200
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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.notification
import org.meshtastic.core.repository.Notification
/**
* Sends a native OS notification. Implementations use [ProcessBuilder] with argument lists (never shell interpolation)
* to invoke platform-specific notification tools.
*
* Returns `true` if the notification was delivered (process exited 0), `false` otherwise.
*/
interface NativeNotificationSender {
fun send(notification: Notification): Boolean
}

View File

@@ -0,0 +1,106 @@
/*
* 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.notification
import co.touchlab.kermit.Logger
import org.meshtastic.core.repository.Notification
import java.util.concurrent.TimeUnit
private const val PROCESS_TIMEOUT_SECONDS = 5L
/**
* Sends toast notifications on Windows via PowerShell and the WinRT ToastNotificationManager API.
*
* Uses a self-contained PowerShell script passed via `-Command`. All user content is injected through `[xml]` escaping
* performed by PowerShell's XML parser — never through string interpolation in the script source. Title and message are
* passed as PowerShell `-ArgumentList` parameters.
*/
class WindowsNotificationSender(private val appName: String = "Meshtastic") : NativeNotificationSender {
override fun send(notification: Notification): Boolean = runCommand(buildCommand(notification))
internal fun buildCommand(notification: Notification): List<String> = buildList {
add("powershell.exe")
add("-NoProfile")
add("-NonInteractive")
add("-Command")
// Build a safe PowerShell script that takes $args[0] (title) and $args[1] (message) from -ArgumentList.
// Content is XML-escaped by PowerShell's [xml] cast, so injection-safe.
val silent = notification.isSilent
val audioElement =
if (silent) {
"<audio silent='true'/>"
} else {
"<audio src='ms-winsoundevent:Notification.Default'/>"
}
@Suppress("MaxLineLength")
val script = buildString {
append("\$title = \$args[0]; \$msg = \$args[1]; ")
append("[Windows.UI.Notifications.ToastNotificationManager,")
append(" Windows.UI.Notifications, ContentType = WindowsRuntime] > \$null; ")
append("[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] > \$null; ")
append("\$template = '<toast>")
append("<visual><binding template=\"ToastGeneric\">")
append("<text>{0}</text><text>{1}</text>")
append("</binding></visual>")
append(audioElement)
append("</toast>'; ")
append("\$xml = [Windows.Data.Xml.Dom.XmlDocument]::new(); ")
// Use string.Format for safe substitution — XML-escapes automatically
append("\$xml.LoadXml([string]::Format(\$template, ")
append("[System.Security.SecurityElement]::Escape(\$title), ")
append("[System.Security.SecurityElement]::Escape(\$msg))); ")
val safeAppName = appName.replace("'", "''")
append("\$notifier = [Windows.UI.Notifications.ToastNotificationManager]::")
append("CreateToastNotifier('$safeAppName'); ")
append("\$toast = [Windows.UI.Notifications.ToastNotification]::new(\$xml); ")
append("\$notifier.Show(\$toast)")
}
add(script)
// Title and message as positional arguments
add(notification.title)
add(notification.message)
}
private fun runCommand(command: List<String>): Boolean = try {
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val completed = process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS)
if (!completed) {
process.destroyForcibly()
Logger.w { "powershell toast timed out after ${PROCESS_TIMEOUT_SECONDS}s" }
false
} else {
val exitCode = process.exitValue()
if (exitCode != 0) {
val stderr = process.inputStream.bufferedReader().readText().take(MAX_STDERR_CHARS)
Logger.w { "powershell toast exited $exitCode: $stderr" }
}
exitCode == 0
}
} catch (e: java.io.IOException) {
Logger.w(e) { "Failed to run powershell toast" }
false
}
companion object {
private const val MAX_STDERR_CHARS = 200
}
}

View File

@@ -0,0 +1,146 @@
/*
* 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.notification
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.desktop.DesktopNotificationManager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class DesktopNotificationManagerTest {
/** Fake [NativeNotificationSender] that records dispatched notifications and allows controlling success/failure. */
private class FakeNativeSender(var shouldSucceed: Boolean = true) : NativeNotificationSender {
val sent = mutableListOf<Notification>()
override fun send(notification: Notification): Boolean {
sent.add(notification)
return shouldSucceed
}
}
/** Simple [NotificationPrefs] with all categories enabled by default. */
private class FakeNotificationPrefs(
messages: Boolean = true,
nodeEvents: Boolean = true,
lowBattery: Boolean = true,
) : 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
}
}
@Test
fun `dispatch sends to native sender when enabled`() {
val sender = FakeNativeSender()
val manager = DesktopNotificationManager(FakeNotificationPrefs(), sender)
manager.dispatch(Notification(title = "Test", message = "Hello"))
Thread.sleep(ASYNC_WAIT_MS)
assertEquals(1, sender.sent.size)
assertEquals("Test", sender.sent[0].title)
}
@Test
fun `dispatch respects disabled message preference`() {
val sender = FakeNativeSender()
val manager = DesktopNotificationManager(FakeNotificationPrefs(messages = false), sender)
manager.dispatch(Notification(title = "Msg", message = "Hi", category = Notification.Category.Message))
Thread.sleep(ASYNC_WAIT_MS)
assertEquals(0, sender.sent.size, "Message notification should have been suppressed")
}
@Test
fun `alerts are always dispatched even when messages disabled`() {
val sender = FakeNativeSender()
val manager = DesktopNotificationManager(FakeNotificationPrefs(messages = false), sender)
manager.dispatch(Notification(title = "Alert", message = "Important", category = Notification.Category.Alert))
Thread.sleep(ASYNC_WAIT_MS)
assertEquals(1, sender.sent.size)
}
@Test
fun `fallback emitted when native sender fails`() {
val sender = FakeNativeSender(shouldSucceed = false)
val manager = DesktopNotificationManager(FakeNotificationPrefs(), sender)
var fallback: androidx.compose.ui.window.Notification? = null
// Collect on a real thread since dispatch uses Dispatchers.IO
val job =
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default).launch {
fallback = manager.fallbackNotifications.first()
}
// Give the collector coroutine time to subscribe before dispatching
Thread.sleep(SUBSCRIBE_WAIT_MS)
manager.dispatch(Notification(title = "Fallback", message = "Test"))
// Block the test thread briefly to let the IO dispatcher process
Thread.sleep(ASYNC_WAIT_MS)
assertNotNull(fallback, "Expected fallback notification to be emitted")
assertEquals("Fallback", fallback!!.title)
job.cancel()
}
@Test
fun `no fallback when native sender succeeds`() {
val sender = FakeNativeSender(shouldSucceed = true)
val manager = DesktopNotificationManager(FakeNotificationPrefs(), sender)
var fallback: androidx.compose.ui.window.Notification? = null
val job =
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Default).launch {
fallback = manager.fallbackNotifications.first()
}
manager.dispatch(Notification(title = "Success", message = "Test"))
Thread.sleep(ASYNC_WAIT_MS)
assertNull(fallback, "Should not emit fallback when native sender succeeds")
job.cancel()
}
companion object {
private const val ASYNC_WAIT_MS = 300L
private const val SUBSCRIBE_WAIT_MS = 100L
}
}

View File

@@ -0,0 +1,77 @@
/*
* 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.notification
import org.meshtastic.core.repository.Notification
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Tests for [LinuxNotificationSender].
*
* These are integration-ish tests that verify the JNA-based sender can be instantiated. On CI or environments without
* libnotify, the sender gracefully reports [LinuxNotificationSender.isAvailable] = false and `send()` returns false. On
* a Linux dev machine with libnotify installed, these tests will actually display notifications.
*/
class LinuxNotificationSenderTest {
private val sender = LinuxNotificationSender(appName = "MeshtasticTest")
@Test
fun `sender initializes without crashing`() {
// Just verifying construction doesn't throw — isAvailable depends on the host having libnotify
// This is a smoke test; the actual JNA call is validated by the availability check
@Suppress("ktlint:standard:backing-property-naming")
val available = sender.isAvailable
available.toString() // use the val to satisfy lint
}
@Test
fun `send returns false when libnotify unavailable`() {
if (sender.isAvailable) return // skip on systems with libnotify — would actually show a notification
val result = sender.send(Notification(title = "Test", message = "Hello"))
assertTrue(!result, "Expected send() to return false when libnotify is not available")
}
@Test
fun `send with all notification types does not crash`() {
if (!sender.isAvailable) return // skip on systems without libnotify
for (type in Notification.Type.entries) {
val result = sender.send(Notification(title = "Type: $type", message = "Testing $type", type = type))
assertTrue(result, "Expected send() to succeed for type $type")
}
}
@Test
fun `send with all categories does not crash`() {
if (!sender.isAvailable) return // skip on systems without libnotify
for (category in Notification.Category.entries) {
val result =
sender.send(Notification(title = "Cat: $category", message = "Testing $category", category = category))
assertTrue(result, "Expected send() to succeed for category $category")
}
}
@Test
fun `silent notification does not crash`() {
if (!sender.isAvailable) return
val result = sender.send(Notification(title = "Silent", message = "Shhh", isSilent = true))
assertTrue(result, "Expected silent send() to succeed")
}
}

View File

@@ -0,0 +1,85 @@
/*
* 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.notification
import org.meshtastic.core.repository.Notification
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MacOSNotificationSenderTest {
private val sender = MacOSNotificationSender()
@Test
fun `command starts with osascript`() {
val notification = Notification(title = "Hi", message = "There")
val cmd = sender.buildCommand(notification)
assertEquals("osascript", cmd[0])
}
@Test
fun `non-silent notification includes sound name`() {
val notification = Notification(title = "Hi", message = "There", isSilent = false)
val cmd = sender.buildCommand(notification)
val script = cmd.filter { it.contains("display notification") }.joinToString(" ")
assertTrue(script.contains("sound name \"default\""), "Expected sound name in: $script")
}
@Test
fun `silent notification omits sound name`() {
val notification = Notification(title = "Quiet", message = "Shhh", isSilent = true)
val cmd = sender.buildCommand(notification)
val script = cmd.filter { it.contains("display notification") }.joinToString(" ")
assertFalse(script.contains("sound name"), "Expected no sound name in: $script")
}
@Test
fun `title and message passed as positional args after double dash`() {
val notification = Notification(title = "My Title", message = "My Message")
val cmd = sender.buildCommand(notification)
val dashDashIdx = cmd.indexOf("--")
assertTrue(dashDashIdx > 0, "Expected '--' separator in command")
assertEquals("My Title", cmd[dashDashIdx + 1])
assertEquals("My Message", cmd[dashDashIdx + 2])
}
@Test
fun `special characters are not interpolated into script`() {
val notification = Notification(title = "It's \"tricky\" & <bad>", message = "'; drop table;")
val cmd = sender.buildCommand(notification)
// Script lines should not contain the user content — only argv references
val scriptLines = cmd.filter { it.contains("notifTitle") || it.contains("notifMessage") }
for (line in scriptLines) {
assertFalse(line.contains("tricky"), "User content leaked into script: $line")
assertFalse(line.contains("drop table"), "User content leaked into script: $line")
}
// But args should contain the raw content
val dashDashIdx = cmd.indexOf("--")
assertEquals("It's \"tricky\" & <bad>", cmd[dashDashIdx + 1])
assertEquals("'; drop table;", cmd[dashDashIdx + 2])
}
@Test
fun `battery category becomes subtitle`() {
val notification = Notification(title = "Bat", message = "Low", category = Notification.Category.Battery)
val cmd = sender.buildCommand(notification)
val dashDashIdx = cmd.indexOf("--")
assertEquals("Low Battery", cmd[dashDashIdx + 3])
}
}

View File

@@ -0,0 +1,93 @@
/*
* 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.notification
import org.meshtastic.core.repository.Notification
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WindowsNotificationSenderTest {
private val sender = WindowsNotificationSender(appName = "TestApp")
@Test
fun `command starts with powershell`() {
val notification = Notification(title = "Hi", message = "There")
val cmd = sender.buildCommand(notification)
assertEquals("powershell.exe", cmd[0])
assertTrue(cmd.contains("-NoProfile"))
assertTrue(cmd.contains("-NonInteractive"))
}
@Test
fun `silent notification sets audio silent`() {
val notification = Notification(title = "Quiet", message = "Shhh", isSilent = true)
val cmd = sender.buildCommand(notification)
val script = cmd[cmd.indexOf("-Command") + 1]
assertTrue(script.contains("silent='true'"), "Expected silent audio in: $script")
}
@Test
fun `non-silent notification uses default sound`() {
val notification = Notification(title = "Loud", message = "Hey", isSilent = false)
val cmd = sender.buildCommand(notification)
val script = cmd[cmd.indexOf("-Command") + 1]
assertTrue(script.contains("Notification.Default"), "Expected default sound in: $script")
}
@Test
fun `title and message passed as positional args`() {
val notification = Notification(title = "My Title", message = "My Message")
val cmd = sender.buildCommand(notification)
// Last two args should be the title and message
assertEquals("My Message", cmd.last())
assertEquals("My Title", cmd[cmd.size - 2])
}
@Test
fun `user content is not interpolated into script template`() {
val notification = Notification(title = "'; Drop-Database", message = "<script>alert(1)</script>")
val cmd = sender.buildCommand(notification)
val script = cmd[cmd.indexOf("-Command") + 1]
// Script should use $args[0] and $args[1], not raw user content
assertFalse(script.contains("Drop-Database"), "User title leaked into script: $script")
assertFalse(script.contains("<script>"), "User message leaked into script: $script")
// But the positional args should carry the raw content
assertEquals("<script>alert(1)</script>", cmd.last())
assertEquals("'; Drop-Database", cmd[cmd.size - 2])
}
@Test
fun `script uses SecurityElement Escape for XML safety`() {
val notification = Notification(title = "Test", message = "Test")
val cmd = sender.buildCommand(notification)
val script = cmd[cmd.indexOf("-Command") + 1]
assertTrue(script.contains("[System.Security.SecurityElement]::Escape"), "Expected XML escaping in: $script")
}
@Test
fun `appName with single quotes is escaped in script`() {
val evilSender = WindowsNotificationSender(appName = "Mesh'Evil")
val notification = Notification(title = "Test", message = "Test")
val cmd = evilSender.buildCommand(notification)
val script = cmd[cmd.indexOf("-Command") + 1]
// Single quotes should be doubled in PowerShell single-quoted strings
assertTrue(script.contains("Mesh''Evil"), "Expected doubled quote in: $script")
}
}

View File

@@ -263,6 +263,7 @@ spotless-gradlePlugin = { module = "com.diffplug.spotless:spotless-plugin-gradle
test-retry-gradlePlugin = { module = "org.gradle:test-retry-gradle-plugin", version.ref = "testRetry" }
jmdns = { module = "org.jmdns:jmdns", version.ref = "jmdns" }
jna = { module = "net.java.dev.jna:jna", version = "5.18.1" }
[plugins]
# Android