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