desktop: deliver notifications via the freedesktop daemon (Wayland/Hyprland)

Notifications were gated behind a usable AWT system tray and sent with
TrayIcon.displayMessage, so they never appeared on Wayland compositors
like Hyprland where AWT reports no tray. Add a platform Notifier that
talks to the OS-native channel — freedesktop notify-send (falling back
to gdbus over org.freedesktop.Notifications) on Linux, osascript on
macOS — decoupled from the tray, and only fall back to the AWT tray
notification when no native channel exists (e.g. Windows).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQTVwy8RBj7spdEK3aEc3i
This commit is contained in:
Claude
2026-09-28 10:12:12 -03:00
committed by greenart7c3
parent 89135c61c7
commit 2e954f82d5
4 changed files with 147 additions and 8 deletions
@@ -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
}
@@ -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<String>): 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
}
}
@@ -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") })
}
}
@@ -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")))
}
}