mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-06 03:18:23 +00:00
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:
@@ -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")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user