desktop: load libayatana-appindicator so the tray works on Hyprland

The dorkbox tray returned null on Hyprland even with the tray host and
libayatana-appindicator installed. Root cause: dorkbox 4.4's AppIndicator
loader only probes the legacy libappindicator3.so names and never the
Ayatana fork, which is all modern distros (Arch/Omarchy, recent
Debian/Fedora) ship — so it finds no library and the backend fails.

Bridge it: before initializing the tray, symlink the system's
libayatana-appindicator3 under the GTK3 libappindicator3 names dorkbox
looks for into a private dir and prepend that to jna.library.path (which
JNA re-reads per load). No root or user-side symlink needed; skipped when
a real libappindicator3 is already present.

Also create the tray before Compose/AWT starts (dorkbox must own GTK
loading), prefer GTK3, and route the tray's Quit through the app for a
clean exit.

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 814754e80a
commit 9f12ff55a1
4 changed files with 175 additions and 36 deletions
+6 -3
View File
@@ -27,9 +27,12 @@ for the JVM) and mirrors the mobile UI and permission model.
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.
X11. It needs an SNI host (e.g. waybar's `tray` module — note it may sit
inside a `group`/drawer that you expand to reveal the icon) and the
`libayatana-appindicator` runtime library; Amber automatically bridges the
Ayatana library to the legacy `libappindicator3` names dorkbox looks for,
so no compat symlink is required. `AMBER_TRAY_TYPE=Gtk|AppIndicator|AutoDetect`
forces the backend and `AMBER_DISABLE_TRAY=1` skips the tray entirely.
- 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
@@ -103,7 +103,38 @@ object Session {
}
}
/**
* Holds the native (dorkbox) tray and the window-visibility/quit signals it
* drives. The tray is built in [main] before Compose starts, so this bridges
* its off-thread menu callbacks to Compose via flows.
*/
private object DesktopTray {
val isLinux = System.getProperty("os.name").lowercase().let { it.contains("linux") || it.contains("nix") || it.contains("nux") }
val windowVisible = MutableStateFlow(true)
val quitRequested = MutableStateFlow(false)
@Volatile
var instance: NativeTray? = null
}
fun main() {
// The dorkbox tray MUST be created before Compose/AWT initializes GTK:
// dorkbox has to own GTK loading, otherwise the AppIndicator backend fails
// to start and SystemTray.get() returns null even when
// libayatana-appindicator is installed. So build it here, first thing.
if (DesktopTray.isLinux && System.getenv("AMBER_DISABLE_TRAY") == null) {
DesktopTray.instance = NativeTray.create(
iconStream = { NativeTray::class.java.getResourceAsStream("/icon.png") },
tooltip = Strings.get("d_tray_tooltip"),
openLabel = Strings.get("d_tray_open"),
lockLabel = Strings.get("d_lock_now"),
quitLabel = Strings.get("d_tray_quit"),
onToggle = { DesktopTray.windowVisible.value = !DesktopTray.windowVisible.value },
onLock = { PassphraseLock.lock() },
onQuit = { DesktopTray.quitRequested.value = true },
)
}
Session.boot()
application {
@@ -112,40 +143,21 @@ fun main() {
val settings by SettingsStore.settings.collectAsState()
val language by Strings.currentLanguage.collectAsState()
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 windowVisible by DesktopTray.windowVisible.collectAsState()
val quitRequested by DesktopTray.quitRequested.collectAsState()
val trayState = rememberTrayState()
val isLinux = DesktopTray.isLinux
val nativeTray = DesktopTray.instance
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.
// Linux we use the dorkbox tray (created above) 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
}
}
// The tray's Quit routes here so we can exit the Compose app cleanly.
LaunchedEffect(quitRequested) { if (quitRequested) exitApplication() }
DisposableEffect(Unit) { onDispose { nativeTray?.shutdown() } }
LaunchedEffect(pending.size, windowVisible, lockStatus, language) {
nativeTray?.update(
@@ -186,7 +198,7 @@ fun main() {
)
}
}
windowVisibleState.value = true
DesktopTray.windowVisible.value = true
}
seenPending = pending.size
}
@@ -196,11 +208,11 @@ fun main() {
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 = { windowVisibleState.value = true },
onAction = { DesktopTray.windowVisible.value = true },
menu = {
Item(
if (windowVisible) Strings.get("d_tray_hide", language) else Strings.get("d_tray_open", language),
onClick = { windowVisibleState.value = !windowVisibleState.value },
onClick = { DesktopTray.windowVisible.value = !DesktopTray.windowVisible.value },
)
if (lockStatus == PassphraseLock.Status.UNLOCKED) {
Item(Strings.get("d_lock_now", language), onClick = { PassphraseLock.lock() })
@@ -214,7 +226,7 @@ fun main() {
Window(
onCloseRequest = {
if (trayActive) {
windowVisibleState.value = false
DesktopTray.windowVisible.value = false
} else {
exitApplication()
}
@@ -227,7 +239,7 @@ fun main() {
handleShortcut(
event,
hideWindow = {
if (trayActive) windowVisibleState.value = false else exitApplication()
if (trayActive) DesktopTray.windowVisible.value = false else exitApplication()
},
quit = ::exitApplication,
)
@@ -5,6 +5,8 @@ import dorkbox.systemTray.MenuItem
import dorkbox.systemTray.Separator
import dorkbox.systemTray.SystemTray
import java.awt.event.ActionListener
import java.io.File
import java.nio.file.Files
/**
* A tray icon backed by the dorkbox SystemTray library, used on Linux.
@@ -45,6 +47,71 @@ class NativeTray private constructor(
private fun isWayland(): Boolean = !System.getenv("WAYLAND_DISPLAY").isNullOrBlank() ||
System.getenv("XDG_SESSION_TYPE").equals("wayland", ignoreCase = true)
/**
* dorkbox 4.4 only looks for the legacy `libappindicator3.so` family of
* names, never the Ayatana fork. Modern distros (Arch/Omarchy, recent
* Debian/Ubuntu, Fedora) ship only `libayatana-appindicator3.so.1`, so
* dorkbox finds nothing and the AppIndicator backend fails.
*
* Bridge that by symlinking the Ayatana library under the GTK3 names
* dorkbox probes into a private dir and prepending it to
* `jna.library.path` (which JNA re-reads per load). No root needed.
* Skipped when a real `libappindicator3.so.1` is already present.
*/
private val SYSTEM_LIB_DIRS = listOf(
"/usr/lib",
"/usr/lib64",
"/usr/local/lib",
"/usr/lib/x86_64-linux-gnu",
"/lib/x86_64-linux-gnu",
"/usr/lib/aarch64-linux-gnu",
"/lib/aarch64-linux-gnu",
)
private fun ensureAppIndicatorLibrary() {
runCatching {
val tmp = File(System.getProperty("java.io.tmpdir"), "amber-appindicator-shim")
val shim = buildAppIndicatorShim(SYSTEM_LIB_DIRS, tmp) ?: return
val prop = "jna.library.path"
val existing = System.getProperty(prop)
System.setProperty(prop, if (existing.isNullOrBlank()) shim else "$shim${File.pathSeparator}$existing")
AmberLogger.i("NativeTray", "AppIndicator shim ready at $shim (jna.library.path)")
}.onFailure { AmberLogger.e("NativeTray", "AppIndicator shim failed: ${it.message}") }
}
/**
* If only the Ayatana appindicator library is present, symlink it under
* the GTK3 names dorkbox probes into [shimDir] and return that dir; else
* null (legacy lib already there, or no Ayatana lib at all). Package-
* visible for testing.
*/
internal fun buildAppIndicatorShim(libDirs: List<String>, shimDir: File): String? {
// Nothing to do if the legacy library dorkbox wants already exists.
if (libDirs.any { File(it, "libappindicator3.so.1").exists() || File(it, "libappindicator3.so").exists() }) return null
val ayatana = libDirs.asSequence()
.flatMap { dir -> sequenceOf("libayatana-appindicator3.so.1", "libayatana-appindicator3.so").map { File(dir, it) } }
.firstOrNull { it.exists() } ?: return null
shimDir.mkdirs()
// Only the GTK3 names — we force PREFER_GTK3, and pointing a GTK2
// name at a GTK3 lib would mismatch.
listOf(
"libappindicator3.so",
"libappindicator3.so.1",
"libappindicator3-1.so",
"libappindicator-gtk3.so",
"libappindicator-gtk3-1.so",
).forEach { name ->
val link = File(shimDir, name).toPath()
runCatching {
Files.deleteIfExists(link)
Files.createSymbolicLink(link, ayatana.toPath())
}
}
return shimDir.absolutePath
}
private fun chosenTrayType(): SystemTray.TrayType {
System.getenv("AMBER_TRAY_TYPE")?.let { name ->
runCatching { return SystemTray.TrayType.valueOf(name) }
@@ -73,8 +140,10 @@ class NativeTray private constructor(
val type = chosenTrayType()
runCatching {
if (type != SystemTray.TrayType.AutoDetect) SystemTray.FORCE_TRAY_TYPE = type
SystemTray.PREFER_GTK3 = true
if (System.getenv("AMBER_DEBUG") != null) SystemTray.DEBUG = true
}
if (type == SystemTray.TrayType.AppIndicator) ensureAppIndicatorLibrary()
AmberLogger.i("NativeTray", "initializing tray (wayland=${isWayland()}, backend=$type)")
val tray = try {
@@ -0,0 +1,55 @@
package com.greenart7c3.nostrsigner.desktop
import java.io.File
import java.nio.file.Files
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The Ayatana -> legacy appindicator symlink shim that lets dorkbox 4.4 (which
* only searches libappindicator3.so names) load libayatana-appindicator3.
*/
class AppIndicatorShimTest {
private fun tmp(name: String): File = Files.createTempDirectory(name).toFile().apply { deleteOnExit() }
@Test
fun createsGtk3SymlinksPointingAtAyatanaWhenOnlyAyatanaPresent() {
val lib = tmp("libdir")
val ayatana = File(lib, "libayatana-appindicator3.so.1")
ayatana.writeText("fake so")
val shimDir = tmp("shim").also { it.delete() } // let the code mkdirs
val result = NativeTray.buildAppIndicatorShim(listOf(lib.absolutePath), shimDir)
assertEquals(shimDir.absolutePath, result)
val expected = listOf(
"libappindicator3.so",
"libappindicator3.so.1",
"libappindicator3-1.so",
"libappindicator-gtk3.so",
"libappindicator-gtk3-1.so",
)
expected.forEach { name ->
val link = File(shimDir, name)
assertTrue("$name should exist", link.exists())
assertTrue("$name should be a symlink", Files.isSymbolicLink(link.toPath()))
assertEquals(ayatana.toPath().toRealPath(), link.toPath().toRealPath())
}
}
@Test
fun skipsWhenLegacyLibraryAlreadyPresent() {
val lib = tmp("libdir")
File(lib, "libappindicator3.so.1").writeText("real legacy")
File(lib, "libayatana-appindicator3.so.1").writeText("ayatana")
assertNull(NativeTray.buildAppIndicatorShim(listOf(lib.absolutePath), tmp("shim")))
}
@Test
fun skipsWhenNoAppindicatorLibraryAtAll() {
val lib = tmp("libdir")
assertNull(NativeTray.buildAppIndicatorShim(listOf(lib.absolutePath), tmp("shim")))
}
}