From b60f29d7725f6989e2b5d8943a3698acfed58ce7 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:53:53 -0500 Subject: [PATCH] feat(desktop): native OS notifications via libnotify/osascript/PowerShell (#5253) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- desktop/build.gradle.kts | 3 + desktop/proguard-rules.pro | 6 + .../desktop/DesktopNotificationManager.kt | 55 +++-- .../kotlin/org/meshtastic/desktop/Main.kt | 2 +- .../desktop/di/DesktopKoinModule.kt | 14 +- .../desktop/notification/DesktopOS.kt | 36 ++++ .../notification/LinuxNotificationSender.kt | 189 ++++++++++++++++++ .../notification/MacOSNotificationSender.kt | 107 ++++++++++ .../notification/NativeNotificationSender.kt | 29 +++ .../notification/WindowsNotificationSender.kt | 106 ++++++++++ .../DesktopNotificationManagerTest.kt | 146 ++++++++++++++ .../LinuxNotificationSenderTest.kt | 77 +++++++ .../MacOSNotificationSenderTest.kt | 85 ++++++++ .../WindowsNotificationSenderTest.kt | 93 +++++++++ gradle/libs.versions.toml | 1 + 15 files changed, 933 insertions(+), 16 deletions(-) create mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopOS.kt create mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt create mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt create mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/notification/NativeNotificationSender.kt create mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/notification/WindowsNotificationSender.kt create mode 100644 desktop/src/test/kotlin/org/meshtastic/desktop/notification/DesktopNotificationManagerTest.kt create mode 100644 desktop/src/test/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSenderTest.kt create mode 100644 desktop/src/test/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSenderTest.kt create mode 100644 desktop/src/test/kotlin/org/meshtastic/desktop/notification/WindowsNotificationSenderTest.kt diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index f9c5b7276..17c6c5002 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -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")) } diff --git a/desktop/proguard-rules.pro b/desktop/proguard-rules.pro index f02c2c6cd..2615e7c93 100644 --- a/desktop/proguard-rules.pro +++ b/desktop/proguard-rules.pro @@ -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 diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index e3c7f8b19..2b4272430 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -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(extraBufferCapacity = 10) + private val _fallbackNotifications = MutableSharedFlow(extraBufferCapacity = 10) - /** Flow of Compose [ComposeNotification] objects to be forwarded to [TrayState.sendNotification]. */ - val notifications: SharedFlow = _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 = _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" } } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt index d02dc3531..37fe28a02 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt @@ -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 } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 8ac634112..f7bd0b465 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -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 { + when (DesktopOS.current()) { + DesktopOS.Linux -> LinuxNotificationSender() + DesktopOS.MacOS -> MacOSNotificationSender() + DesktopOS.Windows -> WindowsNotificationSender() + } + } + single { DesktopNotificationManager(prefs = get(), nativeSender = get()) } single { get() } single { DesktopMeshServiceNotifications(notificationManager = get()) } single { NoopPlatformAnalytics() } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopOS.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopOS.kt new file mode 100644 index 000000000..7bdf2df36 --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopOS.kt @@ -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 . + */ +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 + } + } + } +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt new file mode 100644 index 000000000..c408f3e53 --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/LinuxNotificationSender.kt @@ -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 . + */ +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)) + } + } + } +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt new file mode 100644 index 000000000..703407926 --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt @@ -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 . + */ +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 = 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): 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 + } +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/NativeNotificationSender.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/NativeNotificationSender.kt new file mode 100644 index 000000000..e4d5c28a7 --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/NativeNotificationSender.kt @@ -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 . + */ +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 +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/WindowsNotificationSender.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/WindowsNotificationSender.kt new file mode 100644 index 000000000..ecb529549 --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/WindowsNotificationSender.kt @@ -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 . + */ +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 = 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) { + "