From f61220c6439b329bf21050bd1554742a11ee5fbf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 13:54:50 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01YQTVwy8RBj7spdEK3aEc3i --- CLAUDE.md | 2 +- desktop/README.md | 12 ++- desktop/build.gradle.kts | 4 + .../greenart7c3/nostrsigner/desktop/Main.kt | 65 ++++++++++++--- .../nostrsigner/desktop/NativeTray.kt | 79 +++++++++++++++++++ gradle/libs.versions.toml | 1 + 6 files changed, 149 insertions(+), 14 deletions(-) create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt diff --git a/CLAUDE.md b/CLAUDE.md index 880b5546..f3a5de68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/desktop/README.md b/desktop/README.md index 2ea253e4..d2d28bbb 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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 diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 2dec8e3e..59cc5068 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -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. 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 4148822f..5b92ddf7 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -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, ) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt new file mode 100644 index 00000000..be5ac350 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt @@ -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 + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2b1c1513..354eca0f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }