diff --git a/install.sh b/install.sh index 9161791..4a216d0 100755 --- a/install.sh +++ b/install.sh @@ -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) diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 7e95262..1619180 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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 diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index e7881f7..71fa64f 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -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)