mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-11 16:15:24 -04:00
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:
@@ -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"))
|
||||
}
|
||||
|
||||
|
||||
6
desktop/proguard-rules.pro
vendored
6
desktop/proguard-rules.pro
vendored
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user