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