diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt index 129cd788..4148822f 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -20,6 +20,7 @@ import com.greenart7c3.nostrsigner.desktop.core.AccountManager import com.greenart7c3.nostrsigner.desktop.core.AccountsStore import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount +import com.greenart7c3.nostrsigner.desktop.core.Notifier import com.greenart7c3.nostrsigner.desktop.core.PassphraseLock import com.greenart7c3.nostrsigner.desktop.core.SettingsStore import com.greenart7c3.nostrsigner.desktop.core.Strings @@ -27,8 +28,10 @@ import com.greenart7c3.nostrsigner.desktop.core.describe import com.greenart7c3.nostrsigner.desktop.ui.App import com.greenart7c3.nostrsigner.desktop.ui.NostrSignerTheme import com.greenart7c3.nostrsigner.desktop.ui.handleShortcut +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** Desktop counterpart of `AccountStateViewModel`: which account is active. */ object Session { @@ -119,14 +122,22 @@ fun main() { LaunchedEffect(pending.size) { if (pending.size > seenPending) { val request = pending.last() - if (settings.showNotifications && trayUsable) { - trayState.sendNotification( - Notification( - title = "Amber", - message = "${request.appName} ${request.type.describe(request.kind, language)}", - type = Notification.Type.Info, - ), - ) + if (settings.showNotifications) { + val message = "${request.appName} ${request.type.describe(request.kind, language)}" + // Prefer the OS-native notification channel (freedesktop / + // notify-send on Linux, incl. Wayland/Hyprland; osascript on + // macOS). Only fall back to the AWT tray notification — which + // needs a usable system tray — when there is no native channel. + val delivered = withContext(Dispatchers.IO) { Notifier.notify("Amber", message) } + if (!delivered && trayUsable) { + trayState.sendNotification( + Notification( + title = "Amber", + message = message, + type = Notification.Type.Info, + ), + ) + } } windowVisible = true } diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Notifier.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Notifier.kt new file mode 100644 index 00000000..a86b87e5 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Notifier.kt @@ -0,0 +1,71 @@ +package com.greenart7c3.nostrsigner.desktop.core + +import java.util.concurrent.TimeUnit + +/** + * Cross-platform desktop notifications. + * + * The Compose/AWT tray notification (`TrayIcon.displayMessage`) only works + * where AWT has a usable system tray — which excludes Wayland compositors + * such as Hyprland, Sway or GNOME/Wayland, where `SystemTray.isSupported()` + * is false. There, and on X11 in general, the right thing is to talk to the + * freedesktop.org notification daemon (mako, dunst, swaync, GNOME Shell, …). + * + * [notify] delivers through the native channel for the current OS and returns + * `true` when it dispatched a notification. It returns `false` only when no + * native channel is available (notably Windows, where the caller should fall + * back to the AWT tray notification) so the caller can decide what to do next. + */ +object Notifier { + private val os = System.getProperty("os.name").lowercase() + + /** Send a notification. Returns true if a native channel handled it. */ + fun notify(title: String, message: String): Boolean = when { + os.contains("linux") || os.contains("nix") || os.contains("nux") -> linux(title, message) + os.contains("mac") || os.contains("darwin") -> mac(title, message) + else -> false // Windows: let the caller use the AWT tray notification. + } + + /** + * freedesktop.org notifications. Prefer libnotify's `notify-send`; if it is + * not installed, talk to the notification daemon directly over D-Bus with + * `gdbus` (part of glib, present on essentially every desktop Linux). + */ + private fun linux(title: String, message: String): Boolean { + // `--` stops option parsing so a summary/body starting with '-' is safe. + if (run("notify-send", "-a", "Amber", "-u", "normal", "--", title, message)) return true + return run( + "gdbus", "call", "--session", + "--dest", "org.freedesktop.Notifications", + "--object-path", "/org/freedesktop/Notifications", + "--method", "org.freedesktop.Notifications.Notify", + "Amber", "0", "", title, message, "[]", "{}", "5000", + ) + } + + private fun mac(title: String, message: String): Boolean { + val script = "display notification ${appleScriptString(message)} with title ${appleScriptString(title)}" + return run("osascript", "-e", script) + } + + private fun appleScriptString(s: String): String = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + + /** Test hook: exercises the process plumbing without depending on the OS. */ + internal fun tryCommand(command: List): Boolean = run(*command.toTypedArray()) + + /** + * Launch a command, returning true if it ran and exited successfully (or is + * still running after a short grace period). A missing binary throws + * IOException and yields false so the next channel can be tried. + */ + private fun run(vararg command: String): Boolean = try { + val process = ProcessBuilder(*command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + if (process.waitFor(4, TimeUnit.SECONDS)) process.exitValue() == 0 else true + } catch (e: Exception) { + AmberLogger.d("Notifier", "notification command failed: ${command.firstOrNull()} — ${e.message}") + false + } +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/NotifierFunctionalTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/NotifierFunctionalTest.kt new file mode 100644 index 00000000..ef85424d --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/NotifierFunctionalTest.kt @@ -0,0 +1,29 @@ +package com.greenart7c3.nostrsigner.desktop + +import com.greenart7c3.nostrsigner.desktop.core.Notifier +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +/** + * End-to-end check that on Linux [Notifier.notify] shells out to the + * freedesktop `notify-send`. Enabled only when the harness has put a + * recording stub named `notify-send` first on PATH and set AMBER_NOTIFY_OUT. + */ +class NotifierFunctionalTest { + @Test + fun linuxNotifyInvokesNotifySendWithSummaryAndBody() { + val out = System.getenv("AMBER_NOTIFY_OUT") + assumeTrue("stub not configured", out != null) + assumeTrue(System.getProperty("os.name").lowercase().contains("linux")) + + val ok = Notifier.notify("Amber", "TestApp wants you to sign a Short text note") + assertTrue("notify() should report delivered", ok) + + val args = File(out!!).readLines() + assertTrue("app name flag", args.contains("Amber")) + assertTrue("summary present", args.contains("Amber")) + assertTrue("body present", args.any { it.contains("Short text note") }) + } +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/NotifierTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/NotifierTest.kt new file mode 100644 index 00000000..e1d6ad11 --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/NotifierTest.kt @@ -0,0 +1,28 @@ +package com.greenart7c3.nostrsigner.desktop + +import com.greenart7c3.nostrsigner.desktop.core.Notifier +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +/** The external-process plumbing behind desktop notifications. */ +class NotifierTest { + @Test + fun missingBinaryReportsFailureSoTheNextChannelCanBeTried() { + assertFalse(Notifier.tryCommand(listOf("amber-no-such-binary-zzz", "hi"))) + } + + @Test + fun successfulCommandReportsDelivered() { + // `true` exits 0 on any POSIX system; skip elsewhere (e.g. bare Windows). + assumeTrue(!System.getProperty("os.name").lowercase().contains("win")) + assertTrue(Notifier.tryCommand(listOf("true"))) + } + + @Test + fun failingCommandReportsFailure() { + assumeTrue(!System.getProperty("os.name").lowercase().contains("win")) + assertFalse(Notifier.tryCommand(listOf("false"))) + } +}