diff --git a/desktop/README.md b/desktop/README.md index 975cd59e2..7a9daf9c8 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -55,9 +55,9 @@ The module depends on the JVM variants of KMP modules: **DI:** A Koin DI graph is bootstrapped in `Main.kt` with platform-specific implementations injected. -**UI:** JetBrains Compose for Desktop with Material 3 theming. Desktop acts as a thin host shell, delegating almost entirely to fully shared KMP UI modules. Includes native macOS notification support (via `TrayState` and `bundleID` identification) and a monochrome SVG tray icon for a native look and feel. +**UI:** JetBrains Compose for Desktop with Material 3 theming. Desktop acts as a thin host shell, delegating almost entirely to fully shared KMP UI modules. Includes native OS notifications (macOS `UNUserNotificationCenter`, Linux libnotify, Windows toast) and a monochrome SVG tray icon for a native look and feel. -**Notifications:** Implements the common `NotificationManager` interface via `DesktopNotificationManager`. Repository-level notifications (messages, node events, alerts) are collected in `Main.kt` and forwarded to the system tray. macOS requires a consistent `bundleID` (configured in `build.gradle.kts`) and the `NSUserNotificationAlertStyle` key in `Info.plist` for notifications to appear correctly in the distributable. +**Notifications:** Implements the common `NotificationManager` interface via `DesktopNotificationManager`. Repository-level notifications (messages, node events, alerts) are collected in `Main.kt` and forwarded to platform-native senders; the app falls back to Compose `TrayState` notifications only if native delivery fails. macOS requires a consistent `bundleID` (configured in `build.gradle.kts`) for proper app attribution. **Localization:** Desktop exposes a language picker, persisting the selected BCP-47 tag in `UiPreferencesDataSource.locale`. `Main.kt` applies the override to the JVM default `Locale` and uses a `staticCompositionLocalOf`-backed recomposition trigger so Compose Multiplatform `stringResource()` calls update immediately without recreating the Navigation 3 backstack. diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt index 2b4272430..628498b2a 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/DesktopNotificationManager.kt @@ -33,9 +33,9 @@ import androidx.compose.ui.window.Notification as ComposeNotification /** * Desktop notification manager that dispatches domain [Notification] objects to native OS notifications. * - * 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. + * Uses platform-specific [NativeNotificationSender] implementations (libnotify on Linux, `UNUserNotificationCenter` 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. * diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt index 703407926..f9e67a871 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSender.kt @@ -17,63 +17,48 @@ package org.meshtastic.desktop.notification import co.touchlab.kermit.Logger +import com.sun.jna.Function +import com.sun.jna.NativeLibrary +import com.sun.jna.Pointer import org.meshtastic.core.repository.Notification -import java.util.concurrent.TimeUnit - -private const val PROCESS_TIMEOUT_SECONDS = 5L +import java.util.UUID /** - * 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. + * Sends notifications through macOS UserNotifications (`UNUserNotificationCenter`) via JNA + Objective-C runtime. This + * ensures notifications are attributed to the app bundle instead of Script Editor. */ -class MacOSNotificationSender : NativeNotificationSender { +class MacOSNotificationSender private constructor(private val bridge: MacNotificationBridge) : + NativeNotificationSender { + constructor() : this(JnaMacNotificationBridge()) - override fun send(notification: Notification): Boolean = runCommand(buildCommand(notification)) + internal constructor(bridge: MacNotificationBridge, unused: Unit = Unit) : this(bridge) - /** - * 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") + @Volatile private var authorizationRequested = false - // 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\"", - ) + override fun send(notification: Notification): Boolean { + if (!bridge.isAvailable) return false + + if (!authorizationRequested) { + synchronized(this) { + if (!authorizationRequested) { + val authOk = bridge.requestAuthorization(DEFAULT_AUTHORIZATION_OPTIONS) + if (!authOk) { + Logger.w { "UNUserNotificationCenter authorization request failed" } + } + authorizationRequested = true + } } - 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") + return bridge.post( + title = notification.title, + message = notification.message, + subtitle = categorySubtitle(notification.category), + playSound = !notification.isSilent, + ) } - private fun categorySubtitle(category: Notification.Category): String = when (category) { + internal fun categorySubtitle(category: Notification.Category): String = when (category) { Notification.Category.Message -> "Message" Notification.Category.NodeEvent -> "Node Event" Notification.Category.Battery -> "Low Battery" @@ -81,27 +66,134 @@ class MacOSNotificationSender : NativeNotificationSender { 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 + // UNAuthorizationOptions: badge(1 << 0), sound(1 << 1), alert(1 << 2) + internal const val DEFAULT_AUTHORIZATION_OPTIONS = 0b111L } } + +internal interface MacNotificationBridge { + val isAvailable: Boolean + + fun requestAuthorization(options: Long): Boolean + + fun post(title: String, message: String, subtitle: String, playSound: Boolean): Boolean +} + +private class JnaMacNotificationBridge : MacNotificationBridge { + private val objc: NativeLibrary? + private val objcGetClass: Function? + private val selRegisterName: Function? + private val objcMsgSend: Function? + + init { + val loaded = + if (!isMacOs()) { + LoadedBridge(null, null, null, null) + } else { + try { + NativeLibrary.getInstance("UserNotifications") + val nativeObjc = NativeLibrary.getInstance("objc") + LoadedBridge( + objc = nativeObjc, + objcGetClass = nativeObjc.getFunction("objc_getClass"), + selRegisterName = nativeObjc.getFunction("sel_registerName"), + objcMsgSend = nativeObjc.getFunction("objc_msgSend"), + ) + } catch (e: UnsatisfiedLinkError) { + Logger.w(e) { "Failed to initialize macOS notification bridge" } + LoadedBridge(null, null, null, null) + } catch (e: SecurityException) { + Logger.w(e) { "Failed to initialize macOS notification bridge" } + LoadedBridge(null, null, null, null) + } + } + + objc = loaded.objc + objcGetClass = loaded.objcGetClass + selRegisterName = loaded.selRegisterName + objcMsgSend = loaded.objcMsgSend + } + + override val isAvailable: Boolean + get() = objc != null && objcGetClass != null && selRegisterName != null && objcMsgSend != null + + override fun requestAuthorization(options: Long): Boolean = runCatching { + val centerClass = classRef("UNUserNotificationCenter") ?: return false + val center = msg(centerClass, selector("currentNotificationCenter")) ?: return false + msg(center, selector("requestAuthorizationWithOptions:completionHandler:"), options, Pointer.NULL) + true + } + .getOrElse { e -> + Logger.w(e) { "Failed to request UNUserNotificationCenter authorization" } + false + } + + override fun post(title: String, message: String, subtitle: String, playSound: Boolean): Boolean = runCatching { + val contentClass = classRef("UNMutableNotificationContent") ?: return false + val requestClass = classRef("UNNotificationRequest") ?: return false + val centerClass = classRef("UNUserNotificationCenter") ?: return false + + val content = msg(msg(contentClass, selector("alloc")), selector("init")) ?: return false + msg(content, selector("setTitle:"), nsString(title) ?: return false) + msg(content, selector("setBody:"), nsString(message) ?: return false) + msg(content, selector("setSubtitle:"), nsString(subtitle) ?: return false) + + if (playSound) { + val soundClass = classRef("UNNotificationSound") + val defaultSound = soundClass?.let { msg(it, selector("defaultSound")) } + if (defaultSound != null) { + msg(content, selector("setSound:"), defaultSound) + } + } + + val request = + msg( + requestClass, + selector("requestWithIdentifier:content:trigger:"), + nsString(UUID.randomUUID().toString()) ?: return false, + content, + Pointer.NULL, + ) ?: return false + + val center = msg(centerClass, selector("currentNotificationCenter")) ?: return false + msg(center, selector("addNotificationRequest:withCompletionHandler:"), request, Pointer.NULL) + true + } + .getOrElse { e -> + Logger.w(e) { "Failed to post macOS notification" } + false + } + + private fun classRef(name: String): Pointer? = objcGetClass?.invoke(Pointer::class.java, arrayOf(name)) as? Pointer + + private fun selector(name: String): Pointer = + selRegisterName?.invoke(Pointer::class.java, arrayOf(name)) as? Pointer + ?: error("Unable to resolve selector '$name'") + + private fun nsString(value: String): Pointer? { + val nsStringClass = classRef("NSString") ?: return null + return msg(nsStringClass, selector("stringWithUTF8String:"), value) + } + + private fun msg(receiver: Pointer?, selector: Pointer, vararg args: Any?): Pointer? { + val function = objcMsgSend + val target = receiver + if (function == null || target == null) return null + val callArgs = arrayOfNulls(args.size + 2) + callArgs[0] = target + callArgs[1] = selector + args.copyInto(callArgs, destinationOffset = 2) + return function.invoke(Pointer::class.java, callArgs) as? Pointer + } + + private fun isMacOs(): Boolean = + System.getProperty("os.name", "").lowercase().let { it.contains("mac") || it.contains("darwin") } + + private data class LoadedBridge( + val objc: NativeLibrary?, + val objcGetClass: Function?, + val selRegisterName: Function?, + val objcMsgSend: Function?, + ) +} diff --git a/desktop/src/main/resources/icon.icns b/desktop/src/main/resources/icon.icns index ca858909d..c13e828f3 100644 Binary files a/desktop/src/main/resources/icon.icns and b/desktop/src/main/resources/icon.icns differ diff --git a/desktop/src/main/resources/icon.ico b/desktop/src/main/resources/icon.ico index e47432eaa..08a8ff100 100644 Binary files a/desktop/src/main/resources/icon.ico and b/desktop/src/main/resources/icon.ico differ diff --git a/desktop/src/main/resources/icon.png b/desktop/src/main/resources/icon.png index 11c5db18c..457c16e98 100644 Binary files a/desktop/src/main/resources/icon.png and b/desktop/src/main/resources/icon.png differ diff --git a/desktop/src/test/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSenderTest.kt b/desktop/src/test/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSenderTest.kt index 221139b0b..e53885cc3 100644 --- a/desktop/src/test/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSenderTest.kt +++ b/desktop/src/test/kotlin/org/meshtastic/desktop/notification/MacOSNotificationSenderTest.kt @@ -24,62 +24,78 @@ 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]) + fun `returns false when bridge unavailable`() { + val bridge = FakeBridge(available = false) + val sender = MacOSNotificationSender(bridge) + + val result = sender.send(Notification(title = "Hi", message = "There")) + + assertFalse(result) + assertEquals(0, bridge.authorizationCalls) + assertEquals(0, bridge.postCalls.size) } @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") + fun `requests authorization once then posts notification`() { + val bridge = FakeBridge(available = true) + val sender = MacOSNotificationSender(bridge) + + val first = + sender.send(Notification(title = "One", message = "First", category = Notification.Category.Battery)) + val second = + sender.send(Notification(title = "Two", message = "Second", category = Notification.Category.Alert)) + + assertTrue(first) + assertTrue(second) + assertEquals(1, bridge.authorizationCalls) + assertEquals(MacOSNotificationSender.DEFAULT_AUTHORIZATION_OPTIONS, bridge.lastAuthorizationOptions) + assertEquals(2, bridge.postCalls.size) + assertEquals("Low Battery", bridge.postCalls[0].subtitle) + assertEquals("Alert", bridge.postCalls[1].subtitle) } @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") + fun `silent notification disables sound`() { + val bridge = FakeBridge(available = true) + val sender = MacOSNotificationSender(bridge) + + sender.send(Notification(title = "Quiet", message = "Shhh", isSilent = true)) + + assertEquals(1, bridge.postCalls.size) + assertFalse(bridge.postCalls[0].playSound) } @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]) + fun `non-silent notification enables sound`() { + val bridge = FakeBridge(available = true) + val sender = MacOSNotificationSender(bridge) + + sender.send(Notification(title = "Loud", message = "Ping", isSilent = false)) + + assertEquals(1, bridge.postCalls.size) + assertTrue(bridge.postCalls[0].playSound) } - @Test - fun `special characters are not interpolated into script`() { - val notification = Notification(title = "It's \"tricky\" & ", 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") + private class FakeBridge(private val available: Boolean) : MacNotificationBridge { + override val isAvailable: Boolean + get() = available + + var authorizationCalls: Int = 0 + var lastAuthorizationOptions: Long? = null + val postCalls = mutableListOf() + + override fun requestAuthorization(options: Long): Boolean { + authorizationCalls += 1 + lastAuthorizationOptions = options + return true + } + + override fun post(title: String, message: String, subtitle: String, playSound: Boolean): Boolean { + postCalls += PostCall(title = title, message = message, subtitle = subtitle, playSound = playSound) + return true } - // But args should contain the raw content - val dashDashIdx = cmd.indexOf("--") - assertEquals("It's \"tricky\" & ", 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]) - } + private data class PostCall(val title: String, val message: String, val subtitle: String, val playSound: Boolean) }