diff --git a/desktop/README.md b/desktop/README.md index 86a9d711..4fba0614 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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 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 95171156..f07a6dd1 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -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, ) diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt index 954dff1b..b0fe4664 100644 --- a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/NativeTray.kt @@ -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, 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 { diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AppIndicatorShimTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AppIndicatorShimTest.kt new file mode 100644 index 00000000..69a5901c --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/AppIndicatorShimTest.kt @@ -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"))) + } +}