desktop: show a tray icon on Wayland/Hyprland via StatusNotifierItem

AWT's SystemTray speaks only the legacy XEmbed protocol, which Wayland
compositors (Hyprland, Sway, GNOME/KDE Wayland) don't provide, so no
tray icon appeared and "close to tray" quit the app instead of
minimizing. Add the dorkbox SystemTray library and route the tray
through it on Linux (NativeTray.kt): it publishes a freedesktop
StatusNotifierItem / AppIndicator, which waybar and the desktop shells
host, so the icon and its Open / Lock now / Quit menu now show on
Wayland as well as X11. Windows/macOS keep the existing AWT/Compose Tray.

Window visibility moves to a MutableStateFlow so the tray's menu
callbacks (which fire on the library's own thread) can toggle it safely.
Tray creation is fully guarded and returns null when no backend exists,
falling back to quit-on-close; AMBER_DISABLE_TRAY=1 force-skips it.

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 2e954f82d5
commit bca6716499
6 changed files with 149 additions and 14 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Git hooks are auto-installed via the root `build.gradle.kts` preBuild task — n
## Modules
- `:app` — the Android app (everything below in Architecture refers to it)
- `:desktop` — Compose for Desktop (JVM) NIP-46 signer for Windows/macOS/Linux; standalone port that mirrors the Android permission model against `quartz-jvm` (no NIP-55). `./gradlew :desktop:run` to launch, `:desktop:packageDistributionForCurrentOs` to package. Its core mirrors `NotificationSubscription`/`EventNotificationConsumer`/`BunkerRequestUtils` in `desktop/.../core/BunkerEngine.kt` — behavior changes to the Android bunker flow should be ported there too. Keys are AES-encrypted via a PKCS12 Java KeyStore (`DesktopKeyStore`) whose password lives in the OS credential store (Keychain / Credential Manager / Secret Service via `java-keyring`, file fallback — see `KeystorePassword.resolve`); an opt-in `PassphraseLock` instead wraps the master key with Argon2id and adds a startup/auto-lock gate that evicts key material and gates the bunker engine — and, while set, encrypts the per-account database (apps/permissions/history/logs) at rest via `writeSecure`/`readSecure`. State is JSON files per account (no Room). See `desktop/README.md`.
- `:desktop` — Compose for Desktop (JVM) NIP-46 signer for Windows/macOS/Linux; standalone port that mirrors the Android permission model against `quartz-jvm` (no NIP-55). `./gradlew :desktop:run` to launch, `:desktop:packageDistributionForCurrentOs` to package. Its core mirrors `NotificationSubscription`/`EventNotificationConsumer`/`BunkerRequestUtils` in `desktop/.../core/BunkerEngine.kt` — behavior changes to the Android bunker flow should be ported there too. Keys are AES-encrypted via a PKCS12 Java KeyStore (`DesktopKeyStore`) whose password lives in the OS credential store (Keychain / Credential Manager / Secret Service via `java-keyring`, file fallback — see `KeystorePassword.resolve`); an opt-in `PassphraseLock` instead wraps the master key with Argon2id and adds a startup/auto-lock gate that evicts key material and gates the bunker engine — and, while set, encrypts the per-account database (apps/permissions/history/logs) at rest via `writeSecure`/`readSecure`. State is JSON files per account (no Room). Desktop notifications go through the OS-native channel (`core/Notifier.kt`: freedesktop `notify-send`/`gdbus` on Linux incl. Wayland/Hyprland, `osascript` on macOS, AWT tray on Windows), decoupled from the tray. The tray itself uses the dorkbox SystemTray library on Linux (`NativeTray.kt`) so it publishes a StatusNotifierItem/AppIndicator that shows on Wayland compositors, and the AWT/Compose `Tray` on Windows/macOS; `AMBER_DISABLE_TRAY=1` skips the native tray. See `desktop/README.md`.
## Architecture
+11 -1
View File
@@ -23,7 +23,17 @@ for the JVM) and mirrors the mobile UI and permission model.
- System tray: closing the window minimizes Amber to the tray so it keeps
answering requests (with Open / Lock now / Quit menu), and new approval
requests raise a system notification and bring the window back — both
configurable under Settings → Desktop
configurable under Settings → Desktop. The tray uses the freedesktop
StatusNotifierItem / AppIndicator protocol on Linux (via the dorkbox
SystemTray library), so the icon shows on Wayland compositors such as
Hyprland/Sway (through waybar's tray module) and on GNOME/KDE, not just
X11. On Wayland this needs an SNI host and, for AppIndicator, the
`libayatana-appindicator` runtime library. Set `AMBER_DISABLE_TRAY=1` to
skip the tray entirely if the native stack misbehaves.
- Notifications go through the OS-native channel: the freedesktop
notification daemon (mako, dunst, swaync, GNOME Shell, …) via `notify-send`
or `gdbus` on Linux — so they work on Hyprland/Wayland — `osascript` on
macOS, and the AWT tray notification on Windows
- Optional passphrase lock (see Key storage below)
- Native desktop layout: sidebar navigation with an account switcher, dense
list views, and keyboard shortcuts
+4
View File
@@ -23,6 +23,10 @@ dependencies {
// OS credential stores (macOS Keychain, Windows Credential Manager,
// freedesktop Secret Service) for the keystore password.
implementation(libs.java.keyring)
// Cross-platform system tray. Unlike AWT's SystemTray it speaks the
// freedesktop StatusNotifierItem / AppIndicator protocol, so a tray icon
// shows on Wayland compositors (Hyprland, Sway, GNOME) via waybar etc.
implementation(libs.dorkbox.systemtray)
runtimeOnly(libs.slf4j.nop)
// Argon2id for the optional passphrase lock.
@@ -1,5 +1,6 @@
package com.greenart7c3.nostrsigner.desktop
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -110,11 +111,52 @@ fun main() {
val pending by AmberDesktop.engine.pending.collectAsState()
val settings by SettingsStore.settings.collectAsState()
val language by Strings.currentLanguage.collectAsState()
var windowVisible by remember { mutableStateOf(true) }
val lockStatus by PassphraseLock.state.collectAsState()
// Window visibility is a flow so the native tray's menu callbacks (which
// fire on the tray library's own thread) can toggle it safely.
val windowVisibleState = remember { MutableStateFlow(true) }
val windowVisible by windowVisibleState.collectAsState()
val trayState = rememberTrayState()
// AWT may claim support but fail to actually add the icon (e.g. bare
// X servers without a notification area); treat that as unsupported.
val trayUsable = remember { isTraySupported && runCatching { java.awt.SystemTray.getSystemTray() }.isSuccess }
val isLinux = remember {
System.getProperty("os.name").lowercase().let { it.contains("linux") || it.contains("nix") || it.contains("nux") }
}
// AWT's tray only works with an XEmbed host, which excludes Wayland; on
// Linux we use the dorkbox tray (StatusNotifierItem/AppIndicator)
// instead, and keep AWT for Windows/macOS where it works well.
val awtTrayUsable = remember {
!isLinux && isTraySupported && runCatching { java.awt.SystemTray.getSystemTray() }.isSuccess
}
val nativeTray = remember {
// Escape hatch for environments where the native GTK/tray stack
// misbehaves: set AMBER_DISABLE_TRAY to skip it (the app then just
// exits on window close, and notifications still work).
if (isLinux && System.getenv("AMBER_DISABLE_TRAY") == null) {
NativeTray.create(
iconStream = { NativeTray::class.java.getResourceAsStream("/icon.png") },
tooltip = Strings.get("d_tray_tooltip", language),
openLabel = Strings.get("d_tray_open", language),
lockLabel = Strings.get("d_lock_now", language),
quitLabel = Strings.get("d_tray_quit", language),
onToggle = { windowVisibleState.value = !windowVisibleState.value },
onLock = { PassphraseLock.lock() },
onQuit = { java.awt.EventQueue.invokeLater { exitApplication() } },
)
} else {
null
}
}
DisposableEffect(Unit) { onDispose { nativeTray?.shutdown() } }
LaunchedEffect(pending.size, windowVisible, lockStatus, language) {
nativeTray?.update(
tooltip = if (pending.isEmpty()) Strings.get("d_tray_tooltip", language) else Strings.format("d_tray_pending", pending.size, language = language),
openLabel = if (windowVisible) Strings.get("d_tray_hide", language) else Strings.get("d_tray_open", language),
lockLabel = Strings.get("d_lock_now", language),
showLock = lockStatus == PassphraseLock.Status.UNLOCKED,
)
}
val trayUsable = awtTrayUsable || nativeTray != null
val trayActive = trayUsable && settings.closeToTray
// Notify and surface the window whenever a new approval request arrives.
@@ -129,7 +171,7 @@ fun main() {
// 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) {
if (!delivered && awtTrayUsable) {
trayState.sendNotification(
Notification(
title = "Amber",
@@ -139,22 +181,21 @@ fun main() {
)
}
}
windowVisible = true
windowVisibleState.value = true
}
seenPending = pending.size
}
if (trayUsable) {
val lockStatus by PassphraseLock.state.collectAsState()
if (awtTrayUsable) {
Tray(
icon = painterResource("icon.png"),
state = trayState,
tooltip = if (pending.isEmpty()) Strings.get("d_tray_tooltip", language) else Strings.format("d_tray_pending", pending.size, language = language),
onAction = { windowVisible = true },
onAction = { windowVisibleState.value = true },
menu = {
Item(
if (windowVisible) Strings.get("d_tray_hide", language) else Strings.get("d_tray_open", language),
onClick = { windowVisible = !windowVisible },
onClick = { windowVisibleState.value = !windowVisibleState.value },
)
if (lockStatus == PassphraseLock.Status.UNLOCKED) {
Item(Strings.get("d_lock_now", language), onClick = { PassphraseLock.lock() })
@@ -168,7 +209,7 @@ fun main() {
Window(
onCloseRequest = {
if (trayActive) {
windowVisible = false
windowVisibleState.value = false
} else {
exitApplication()
}
@@ -181,7 +222,7 @@ fun main() {
handleShortcut(
event,
hideWindow = {
if (trayActive) windowVisible = false else exitApplication()
if (trayActive) windowVisibleState.value = false else exitApplication()
},
quit = ::exitApplication,
)
@@ -0,0 +1,79 @@
package com.greenart7c3.nostrsigner.desktop
import com.greenart7c3.nostrsigner.desktop.core.AmberLogger
import dorkbox.systemTray.MenuItem
import dorkbox.systemTray.Separator
import dorkbox.systemTray.SystemTray
import java.awt.event.ActionListener
/**
* A tray icon backed by the dorkbox SystemTray library, used on Linux.
*
* Unlike AWT's `SystemTray` (XEmbed only), dorkbox publishes a freedesktop
* StatusNotifierItem / AppIndicator, which is what modern Wayland compositors
* expose — so the icon and menu show up in waybar on Hyprland/Sway and in
* GNOME/KDE, as well as on X11. It is imperative and lives outside Compose:
* build it once, call [update] when state changes, and [shutdown] on exit.
*/
class NativeTray private constructor(
private val tray: SystemTray,
private val openItem: MenuItem,
private val lockItem: MenuItem,
) {
/** Reflect the current pending count, window visibility and lock state. */
fun update(tooltip: String, openLabel: String, lockLabel: String, showLock: Boolean) {
runCatching {
tray.setTooltip(tooltip)
openItem.text = openLabel
lockItem.text = lockLabel
lockItem.enabled = showLock
}.onFailure { AmberLogger.d("NativeTray", "update failed: ${it.message}") }
}
fun shutdown() {
runCatching { tray.shutdown() }
}
companion object {
/**
* Try to create the tray. Returns null when no tray backend is
* available (dorkbox found neither AppIndicator/SNI nor GtkStatusIcon
* nor a Swing tray), so the caller can fall back gracefully.
*/
fun create(
iconStream: () -> java.io.InputStream?,
tooltip: String,
openLabel: String,
lockLabel: String,
quitLabel: String,
onToggle: () -> Unit,
onLock: () -> Unit,
onQuit: () -> Unit,
): NativeTray? {
val tray = try {
SystemTray.get()
} catch (t: Throwable) {
AmberLogger.e("NativeTray", "SystemTray.get() failed", t as? Exception)
null
} ?: return null
return try {
iconStream()?.use { tray.setImage(it) }
tray.setTooltip(tooltip)
val menu = tray.menu
val openItem = MenuItem(openLabel, ActionListener { onToggle() })
val lockItem = MenuItem(lockLabel, ActionListener { onLock() })
val quitItem = MenuItem(quitLabel, ActionListener { onQuit() })
menu.add(openItem)
menu.add(lockItem)
menu.add(Separator())
menu.add(quitItem)
NativeTray(tray, openItem, lockItem)
} catch (t: Throwable) {
AmberLogger.e("NativeTray", "Failed to build the tray", t as? Exception)
runCatching { tray.shutdown() }
null
}
}
}
}
+1
View File
@@ -90,6 +90,7 @@ kmptor-resource-exec = { module = "io.matthewnelson.kmp-tor:resource-exec-tor",
secp256k1-jni-jvm = { module = "fr.acinq.secp256k1:secp256k1-kmp-jni-jvm", version.ref = "secp256k1Jni" }
leakcanary = { group = "com.android.tools.studio.leakcanary", name = "leakcanary", version.ref = "leakcanary" }
java-keyring = { module = "com.github.javakeyring:java-keyring", version = "1.0.4" }
dorkbox-systemtray = { module = "com.dorkbox:SystemTray", version = "4.4" }
slf4j-nop = { module = "org.slf4j:slf4j-nop", version = "2.0.7" }
bouncycastle = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.84" }