Fix missing KDE tray icon by preferring Ayatana AppIndicator (#621)

* Fix missing KDE tray icon by preferring Ayatana AppIndicator

Fedora and Arch installs pull libappindicator-gtk3, Canonical's original
AppIndicator library that hasn't had a real release since ~2013. It loads
fine and Indicator.new_with_path()/set_status() run without raising, but
it never registers a StatusNotifierItem with KDE's StatusNotifierWatcher,
so the tray icon silently never shows up on Plasma. Confirmed on Fedora 44
/ Plasma 6.7 by checking RegisteredStatusNotifierItems over D-Bus directly
before and after the fix.

The actively maintained Ayatana fork registers correctly and is already
what the Debian, Ubuntu, openSUSE and Gentoo branches of install.sh
prefer. Reorder the gi import in tray_indicator.py to try
AyatanaAppIndicator3 first and fall back to the legacy AppIndicator3
typelib only if neither Ayatana variant is available.

Update install.sh so Fedora and Arch installs pull the Ayatana package
too, via a shared install_preferred_appindicator() helper that falls back
to the legacy package if Ayatana isn't in the repos (e.g. RHEL/CentOS
without EPEL).

* Add tests for the AppIndicator/Ayatana import fallback chain

The reordered try/except in tray_indicator.py wasn't exercised by the
existing tests: they replace gi.repository with a MagicMock, which
auto-creates any attribute on access and so can never raise the
ImportError the fallback chain is built to catch.

Add a small test class that swaps in a plain object exposing only a
chosen subset of AppIndicator symbols, so importing anything else raises
a real ImportError, and verify each of the three branches (Ayatana,
lowercase Ayatana variant, legacy AppIndicator3) resolves correctly.

* Retry Ayatana install even when legacy AppIndicator is already present

install_preferred_appindicator() previously skipped the Ayatana install
whenever either package was already present, so re-running install.sh
on an existing Fedora/Arch setup with libappindicator-gtk3 from a prior
install would never pick up Ayatana, leaving the KDE tray broken for
exactly the users this fix targets. Now it only short-circuits when
Ayatana itself is installed, tries Ayatana first regardless of legacy
presence, and only falls back to a warning about the already-installed
legacy package if the Ayatana install fails.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
Kacper Paczos
2026-08-04 03:33:40 +05:30
committed by GitHub
co-authored by Jatin K Malik
parent 5eff933d73
commit afc4b37c80
3 changed files with 125 additions and 7 deletions
+43 -2
View File
@@ -1451,6 +1451,43 @@ pacman_package_installed() {
pacman -Q "$1" >/dev/null 2>&1
}
# Install an AppIndicator/StatusNotifierItem provider, preferring the
# actively maintained Ayatana fork over the legacy Canonical package
# (unmaintained since ~2013). The legacy package can install and import
# without error yet silently fail to register a tray icon with KDE's
# StatusNotifierWatcher, leaving no icon and no logged error. $1 is the
# package-manager install command (e.g. "sudo dnf install -y"), $2/$3 are
# the Ayatana/legacy package names for that package manager, $4 is the
# "is this package installed" checker function name.
install_preferred_appindicator() {
local install_cmd="$1"
local ayatana_pkg="$2"
local legacy_pkg="$3"
local checker="$4"
if "$checker" "$ayatana_pkg"; then
return 0
fi
if $install_cmd "$ayatana_pkg" 2>/dev/null; then
print_info "Installed $ayatana_pkg (Ayatana AppIndicator; required for a working KDE tray icon)"
return 0
fi
if "$checker" "$legacy_pkg"; then
print_info "$ayatana_pkg not available; $legacy_pkg is already installed but may not show a tray icon on KDE Plasma"
return 0
fi
print_info "$ayatana_pkg not available, falling back to $legacy_pkg (may not show a tray icon on KDE Plasma)..."
if $install_cmd "$legacy_pkg"; then
return 0
fi
print_error "Failed to install an AppIndicator package (tried $ayatana_pkg and $legacy_pkg)"
return 1
}
suse_python_package_prefix() {
python3 -c 'import sys; print(f"python{sys.version_info.major}{sys.version_info.minor}")' 2>/dev/null || echo "python3"
}
@@ -1588,8 +1625,8 @@ install_system_dependencies() {
local APT_PACKAGES_DEBIAN_BASE="python3-pip python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-ibus-1.0 libcairo2-dev cmake python3-dev build-essential portaudio19-dev python3-venv pkg-config wget curl unzip vulkan-tools libvulkan-dev $VULKAN_SHADER_PKG xclip xsel wl-clipboard $PYWHISPERCPP_BUILD_DEPS"
local APT_PACKAGES_DEBIAN_11_12="$APT_PACKAGES_DEBIAN_BASE libgirepository1.0-dev gir1.2-ayatanaappindicator3-0.1"
local APT_PACKAGES_DEBIAN_13_PLUS="$APT_PACKAGES_DEBIAN_BASE libgirepository-2.0-dev gir1.2-ayatanaappindicator3-0.1 util-linux-extra"
local DNF_PACKAGES="python3-pip python3-gobject gtk3 libappindicator-gtk3 ibus-devel gobject-introspection-devel python3-devel portaudio-devel python3-virtualenv pkg-config cmake wget curl unzip vulkan-tools vulkan-loader-devel glslang xclip xsel wl-clipboard"
local PACMAN_PACKAGES="python-pip python-gobject gtk3 libappindicator-gtk3 ibus gobject-introspection python-cairo portaudio python-virtualenv pkg-config cmake wget curl unzip base-devel vulkan-tools vulkan-headers glslang xclip xsel wl-clipboard"
local DNF_PACKAGES="python3-pip python3-gobject gtk3 ibus-devel gobject-introspection-devel python3-devel portaudio-devel python3-virtualenv pkg-config cmake wget curl unzip vulkan-tools vulkan-loader-devel glslang xclip xsel wl-clipboard"
local PACMAN_PACKAGES="python-pip python-gobject gtk3 ibus gobject-introspection python-cairo portaudio python-virtualenv pkg-config cmake wget curl unzip base-devel vulkan-tools vulkan-headers glslang xclip xsel wl-clipboard"
local ZYPPER_PACKAGES="gtk3 ibus-devel gobject-introspection-devel portaudio-devel pkg-config cmake wget curl unzip xclip xsel wl-clipboard typelib-1_0-Notify-0_7 libnotify4"
# Gentoo uses Portage and different package naming convention
local EMERGE_PACKAGES="dev-python/pygobject:3 x11-libs/gtk+:3 dev-libs/libayatana-appindicator media-libs/portaudio dev-lang/python:3.9 pkgconf cmake dev-util/glslang x11-misc/xclip x11-misc/xsel gui-apps/wl-clipboard"
@@ -1678,6 +1715,8 @@ install_system_dependencies() {
else
print_info "All required packages are already installed."
fi
install_preferred_appindicator "$INSTALL_CMD" "libayatana-appindicator-gtk3" "libappindicator-gtk3" dnf_package_installed || exit 1
;;
arch)
@@ -1701,6 +1740,8 @@ install_system_dependencies() {
else
print_info "All required packages are already installed."
fi
install_preferred_appindicator "sudo pacman -S --noconfirm" "libayatana-appindicator" "libappindicator-gtk3" pacman_package_installed || exit 1
;;
suse)
+10 -5
View File
@@ -14,16 +14,21 @@ import gi
# Import GTK
gi.require_version("Gtk", "3.0")
# Prefer Ayatana AppIndicator: it is the actively maintained fork and the one
# most distros ship for KDE/SNI compatibility. The original Canonical
# AppIndicator3 (unmaintained since ~2013) can be present alongside it (e.g.
# Fedora's libappindicator-gtk3) but fails to register a StatusNotifierItem
# on modern KDE Plasma, leaving the tray icon invisible with no error logged.
try:
gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3
gi.require_version("AyatanaAppIndicator3", "0.1")
from gi.repository import AyatanaAppIndicator3 as AppIndicator3
except (ImportError, ValueError):
try:
gi.require_version("AyatanaAppIndicator3", "0.1")
from gi.repository import AyatanaAppIndicator3 as AppIndicator3
except (ImportError, ValueError):
gi.require_version("AyatanaAppindicator3", "0.1")
from gi.repository import AyatanaAppindicator3 as AppIndicator3
except (ImportError, ValueError):
gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3
from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk
+72
View File
@@ -8,6 +8,7 @@ The tests focus on the business logic of the TrayIndicator class.
import os
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
@@ -634,3 +635,74 @@ class TestTrayIndicatorFlatpakIcons(unittest.TestCase):
self.assertEqual(names["default"], "com.vocalinux.Vocalinux-microphone-off")
self.assertEqual(names["active"], "com.vocalinux.Vocalinux-microphone")
self.assertEqual(names["processing"], "com.vocalinux.Vocalinux-microphone-process")
class TestAppIndicatorImportFallback(unittest.TestCase):
"""The AppIndicator/Ayatana import chain must prefer the actively
maintained Ayatana fork and only fall back to the legacy Canonical
AppIndicator3 typelib when no Ayatana variant is installed.
Unlike the rest of this file, these tests use a bare object instead of
a MagicMock for ``gi.repository``: MagicMock auto-creates any attribute
on access, so it can never reproduce the real ``ImportError`` a missing
typelib raises, which is exactly the behavior this fallback chain
depends on.
"""
def setUp(self):
self.addCleanup(self._restore_gi_repository)
self._clear_tray_indicator_module()
def tearDown(self):
self._clear_tray_indicator_module()
@staticmethod
def _clear_tray_indicator_module():
for mod in [k for k in list(sys.modules.keys()) if "tray_indicator" in k]:
del sys.modules[mod]
@staticmethod
def _restore_gi_repository():
sys.modules["gi"] = mock_gi
sys.modules["gi.repository"] = mock_gi_repository
@staticmethod
def _fake_repository(**appindicator_attrs):
"""A gi.repository stand-in exposing only the given AppIndicator
symbols, so importing anything else raises a real ImportError."""
repo = SimpleNamespace(
Gtk=mock_gtk,
GLib=mock_glib,
GObject=mock_gobject,
GdkPixbuf=mock_gdkpixbuf,
Gio=MagicMock(name="Gio"),
**appindicator_attrs,
)
return repo
def test_prefers_ayatana_appindicator3_when_available(self):
sentinel = MagicMock(name="AyatanaAppIndicator3")
sys.modules["gi"] = mock_gi
sys.modules["gi.repository"] = self._fake_repository(AyatanaAppIndicator3=sentinel)
import vocalinux.ui.tray_indicator as tray
self.assertIs(tray.AppIndicator3, sentinel)
def test_falls_back_to_lowercase_ayatana_variant(self):
sentinel = MagicMock(name="AyatanaAppindicator3")
sys.modules["gi"] = mock_gi
sys.modules["gi.repository"] = self._fake_repository(AyatanaAppindicator3=sentinel)
import vocalinux.ui.tray_indicator as tray
self.assertIs(tray.AppIndicator3, sentinel)
def test_falls_back_to_legacy_appindicator3_when_no_ayatana_available(self):
sentinel = MagicMock(name="AppIndicator3")
sys.modules["gi"] = mock_gi
sys.modules["gi.repository"] = self._fake_repository(AppIndicator3=sentinel)
import vocalinux.ui.tray_indicator as tray
self.assertIs(tray.AppIndicator3, sentinel)