fix(tray): prefer libayatana-appindicator-glib when available (#1019)

Use the maintained AyatanaAppIndicatorGlib 2.0 indicator when it is installed, mirroring the existing Gtk.Menu into a Gio.Menu whose actions keep stable, never-reused names and fire the original items. Fall back to the GTK 3 indicators otherwise; the AppImage stays on its bundled GTK 3 indicator, and the .deb recommends the glib typelib.

Fixes #1013
This commit is contained in:
Jatin K Malik
2026-10-25 14:49:34 -07:00
committed by GitHub
parent 9b4aded3a2
commit 750d64dc9b
10 changed files with 730 additions and 59 deletions
+37 -27
View File
@@ -13,6 +13,7 @@ import time
from concurrent.futures import Future, ThreadPoolExecutor
from typing import Any, Dict, Optional
from .utils.host_process import running_from_appimage
from .utils.vosk_model_info import SUPPORTED_LANGUAGES
from .version import __version__
@@ -200,33 +201,17 @@ def check_dependencies():
"GTK3 (install with: sudo apt install python3-gi gir1.2-gtk-3.0)"
)
# Prefer Ayatana AppIndicator (maintained; registers on KDE Plasma).
# Match tray_indicator.py: canonical Ayatana, rare lowercase typelib, then
# legacy Canonical AppIndicator3 as last resort.
try:
import gi
gi.require_version("AyatanaAppIndicator3", "0.1")
from gi.repository import AyatanaAppIndicator3 # noqa: F401
except (ImportError, ValueError) as e:
logger.debug("AyatanaAppIndicator3 import failed: %s", e)
try:
import gi
gi.require_version("AyatanaAppindicator3", "0.1")
from gi.repository import AyatanaAppindicator3 # noqa: F401
except (ImportError, ValueError) as e2:
logger.debug("AyatanaAppindicator3 import failed: %s", e2)
try:
import gi
gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3 # noqa: F401
except (ImportError, ValueError) as e3:
logger.debug("AppIndicator3 import failed: %s", e3)
missing_system_deps.append(
"AppIndicator3/AyatanaAppIndicator3 - Required for system tray icon"
)
# Match tray_indicator.py, which never loads the glib indicator from the
# AppImage: the bundle's GLib is older than that library needs.
indicator_typelibs = [
(namespace, version)
for namespace, version in _APPINDICATOR_TYPELIBS
if namespace != "AyatanaAppIndicatorGlib" or not running_from_appimage()
]
if not any(_typelib_available(namespace, version) for namespace, version in indicator_typelibs):
missing_system_deps.append(
"AppIndicator3/AyatanaAppIndicator3 - Required for system tray icon"
)
# Keyboard backends are optional and checked lazily by the shortcut manager.
# Importing pynput can fail on Wayland/X-less sessions even when installed.
@@ -283,6 +268,31 @@ def check_dependencies():
return True
# Tray indicator typelibs, most preferred first (same order as
# tray_indicator.py): libayatana-appindicator-glib, Ayatana AppIndicator
# (maintained; registers on KDE Plasma), its rare lowercase typelib, then
# the legacy Canonical AppIndicator3 as last resort.
_APPINDICATOR_TYPELIBS = (
("AyatanaAppIndicatorGlib", "2.0"),
("AyatanaAppIndicator3", "0.1"),
("AyatanaAppindicator3", "0.1"),
("AppIndicator3", "0.1"),
)
def _typelib_available(namespace: str, version: str) -> bool:
"""Return True when the GObject Introspection namespace can be imported."""
try:
import gi
gi.require_version(namespace, version)
getattr(__import__("gi.repository", fromlist=[namespace]), namespace)
except (ImportError, ValueError, AttributeError) as e:
logger.debug("%s import failed: %s", namespace, e)
return False
return True
def check_display_available():
"""Check if a display is available for GTK."""
try:
+53 -20
View File
@@ -15,23 +15,42 @@ from typing import Any, Callable, Optional, Sequence, cast
import gi
from ..utils.host_process import host_env, running_from_appimage
# 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("AyatanaAppIndicator3", "0.1")
from gi.repository import AyatanaAppIndicator3 as AppIndicator3
except (ImportError, ValueError):
# Prefer libayatana-appindicator-glib: libayatana-appindicator (GTK 3) now
# logs "libayatana-appindicator is deprecated" at startup and points here.
# The glib library takes a GMenuModel instead of a Gtk.Menu, which
# tray_menu_model bridges. The AppImage skips it: its bundled GLib is older
# than the library needs and it does not ship the library, so a host copy on
# GI's default search path would load against the bundle's GLib.
# Otherwise 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.
INDICATOR_USES_GMENU = False
if not running_from_appimage():
try:
gi.require_version("AyatanaAppindicator3", "0.1")
from gi.repository import AyatanaAppindicator3 as AppIndicator3
gi.require_version("AyatanaAppIndicatorGlib", "2.0")
from gi.repository import AyatanaAppIndicatorGlib as AppIndicator
INDICATOR_USES_GMENU = True
except (ImportError, ValueError):
gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3
pass
if not INDICATOR_USES_GMENU:
try:
gi.require_version("AyatanaAppIndicator3", "0.1")
from gi.repository import AyatanaAppIndicator3 as AppIndicator
except (ImportError, ValueError):
try:
gi.require_version("AyatanaAppindicator3", "0.1")
from gi.repository import AyatanaAppindicator3 as AppIndicator
except (ImportError, ValueError):
gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3 as AppIndicator
from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk
@@ -47,7 +66,6 @@ from ..speech_recognition.diarization import (
transcribe_audio_file,
)
from ..suspend_handler import SuspendHandler
from ..utils.host_process import host_env
from ..utils.resource_manager import ResourceManager
from ..utils.update_checker import DEFAULT_CHECK_AUTOMATICALLY, ReleaseInfo
from ..utils.update_monitor import UpdateMonitor
@@ -63,6 +81,7 @@ from .keyboard_backends import (
from .keyboard_shortcuts import KeyboardShortcutManager
from .settings_dialog import ModelDownloadDialog, SettingsDialog, recommended_model_for_engine
from .transcription_history import DEFAULT_MAX_ITEMS, TranscriptionHistory
from .tray_menu_model import GMenuIndicator
logger = logging.getLogger(__name__)
@@ -544,20 +563,21 @@ class TrayIndicator:
initial_icon = self._icon_keys["default"]
try:
if FLATPAK_ID:
self.indicator = AppIndicator3.Indicator.new(
indicator = AppIndicator.Indicator.new(
APP_ID,
initial_icon,
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
AppIndicator.IndicatorCategory.APPLICATION_STATUS,
)
else:
self.indicator = AppIndicator3.Indicator.new_with_path(
indicator = AppIndicator.Indicator.new_with_path(
APP_ID,
initial_icon,
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
AppIndicator.IndicatorCategory.APPLICATION_STATUS,
ICON_DIR,
)
self.indicator.set_icon_theme_path(ICON_DIR)
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
indicator.set_icon_theme_path(ICON_DIR)
self.indicator = GMenuIndicator(indicator, Gio) if INDICATOR_USES_GMENU else indicator
self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
self.indicator.set_title("Vocalinux")
self._indicator_title = "Vocalinux"
except Exception as e:
@@ -623,6 +643,7 @@ class TrayIndicator:
else:
self._show_update_menu_item(self._pending_update)
self._gateway_stop_menu_item.hide()
self._sync_indicator_menu()
self._gateway_manager = get_gateway_embed_manager()
self._gateway_manager.add_listener(self._on_gateway_status_for_tray)
# Runtime detect + leftover compose probe (Quit does not stop compose).
@@ -848,6 +869,12 @@ class TrayIndicator:
separator = Gtk.SeparatorMenuItem()
self.menu.append(separator)
def _sync_indicator_menu(self) -> None:
"""Re-export the Gtk menu after a change when the indicator mirrors it."""
indicator = getattr(self, "indicator", None)
if isinstance(indicator, GMenuIndicator):
indicator.refresh_menu()
def _on_recognition_state_changed(self, state: RecognitionState):
"""
Handle changes in the speech recognition state.
@@ -1284,6 +1311,7 @@ class TrayIndicator:
if isinstance(item, Gtk.MenuItem) and item.get_label() == label:
item.set_sensitive(enabled)
break
self._sync_indicator_menu()
def _set_cancel_menu_item_visible(self, visible: bool) -> None:
item = getattr(self, "_cancel_menu_item", None)
@@ -1291,6 +1319,7 @@ class TrayIndicator:
return
item.set_sensitive(visible)
item.set_visible(visible)
self._sync_indicator_menu()
def _on_start_clicked(self, widget):
"""Handle click on the Start Voice Typing menu item."""
@@ -1335,6 +1364,7 @@ class TrayIndicator:
submenu.show_all()
self._history_menu_item.set_submenu(submenu)
self._sync_indicator_menu()
return False # Remove idle callback
@staticmethod
@@ -1463,6 +1493,7 @@ class TrayIndicator:
self._maybe_notify_update(release)
elif self._update_menu_item is not None:
self._update_menu_item.hide()
self._sync_indicator_menu()
def _show_update_menu_item(self, release: ReleaseInfo) -> None:
"""Reveal the tray menu entry that opens About / release notes."""
@@ -1474,6 +1505,7 @@ class TrayIndicator:
"Open Settings → About for release notes and download links"
)
self._update_menu_item.show()
self._sync_indicator_menu()
def _maybe_notify_update(self, release: ReleaseInfo) -> None:
"""Send one desktop notification per new version when notifications are on."""
@@ -1769,6 +1801,7 @@ class TrayIndicator:
item.show()
else:
item.hide()
self._sync_indicator_menu()
return False
def _on_stop_local_gateway_clicked(self, widget: Any) -> None:
+172
View File
@@ -0,0 +1,172 @@
"""
GMenuModel bridge for the libayatana-appindicator-glib tray backend.
libayatana-appindicator (GTK 3) takes a Gtk.Menu and now warns at startup
that it is deprecated. Its successor, libayatana-appindicator-glib
(``AyatanaAppIndicatorGlib`` 2.0), takes a GMenuModel plus a GActionGroup
instead. The tray keeps building and mutating its Gtk.Menu exactly as it
does for the GTK 3 backend; this module mirrors that menu into a Gio.Menu
whose actions activate the original Gtk items, so every existing callback
keeps working unchanged.
"""
from __future__ import annotations
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
#: Action namespace the glib indicator exports its action group under.
ACTION_PREFIX = "indicator"
# A menu layout is a tuple of sections (Gtk separators split them). Each
# entry is ("item", label, action_name) or ("submenu", label, sub_layout).
_Layout = tuple[tuple[tuple[str, Any, Any], ...], ...]
def _is_separator(item: Any) -> bool:
"""True for a Gtk.SeparatorMenuItem: it has no label and no submenu."""
return not item.get_label() and item.get_submenu() is None
class MenuModelBridge:
"""Mirror a Gtk.Menu tree into a Gio.Menu and Gio.SimpleActionGroup.
Hidden items are left out, separators start a new section, submenus are
mirrored recursively, and item sensitivity maps to action enabled state.
Each Gtk item keeps one action name for as long as it stays in the menu,
and a name is never handed to another item: a tray host can deliver a
click from the menu it rendered before the last :meth:`sync`, and that
click must reach the item it showed or nothing. Call :meth:`sync` after
changing the Gtk menu.
"""
def __init__(self, gtk_menu: Any, gio: Any) -> None:
self._gtk_menu = gtk_menu
self._gio = gio
self.menu_model = gio.Menu()
self.action_group = gio.SimpleActionGroup()
self._items: dict[str, Any] = {}
self._names: dict[Any, str] = {}
self._next_name = 0
self._layout: Optional[_Layout] = None
self.sync()
def sync(self) -> None:
"""Bring the menu model and actions in line with the Gtk menu."""
items: dict[str, Any] = {}
names: dict[Any, str] = {}
layout = self._describe(self._gtk_menu, items, names)
# Items that left the menu lose their name for good.
self._names = names
for name, item in items.items():
action = self.action_group.lookup_action(name)
if action is None:
action = self._gio.SimpleAction.new(name, None)
action.connect("activate", self._on_action_activate, name)
self.action_group.add_action(action)
action.set_enabled(bool(item.get_sensitive()))
for name in self._items.keys() - items.keys():
self.action_group.remove_action(name)
self._items = items
# Only rebuild the model when what the user sees changed: a rebuild
# makes the tray host re-read the whole menu.
if layout != self._layout:
self.menu_model.remove_all()
self._fill(self.menu_model, layout)
self._layout = layout
def _describe(self, gtk_menu: Any, items: dict[str, Any], names: dict[Any, str]) -> _Layout:
"""Snapshot the visible structure of ``gtk_menu``, naming each leaf action.
``items`` collects the visible leaves by action name; ``names`` collects
every leaf still in the menu, hidden ones included, so an item that is
hidden and shown again keeps its name.
"""
sections: list[tuple[tuple[str, Any, Any], ...]] = []
current: list[tuple[str, Any, Any]] = []
for item in gtk_menu.get_children():
submenu = item.get_submenu()
leaf = submenu is None and not _is_separator(item)
if leaf:
names[item] = self._action_name(item)
if not item.get_visible():
continue
if submenu is not None:
current.append(("submenu", item.get_label(), self._describe(submenu, items, names)))
elif leaf:
items[names[item]] = item
current.append(("item", item.get_label(), names[item]))
else:
if current:
sections.append(tuple(current))
current = []
if current:
sections.append(tuple(current))
return tuple(sections)
def _action_name(self, item: Any) -> str:
"""Return ``item``'s action name, assigning a never-used one on first sight."""
name = self._names.get(item)
if name is None:
name = f"item-{self._next_name}"
self._next_name += 1
return name
def _fill(self, menu: Any, layout: _Layout) -> None:
"""Append ``layout`` to ``menu`` as one section per Gtk separator group."""
for section in layout:
section_menu = self._gio.Menu()
for kind, label, payload in section:
if kind == "submenu":
submenu = self._gio.Menu()
self._fill(submenu, payload)
section_menu.append_submenu(label, submenu)
else:
section_menu.append(label, f"{ACTION_PREFIX}.{payload}")
menu.append_section(None, section_menu)
def _on_action_activate(self, _action: Any, _parameter: Any, name: str) -> None:
"""Forward an activated action to its Gtk item's ``activate`` handlers."""
item = self._items.get(name)
if item is None:
logger.debug("Tray action %s no longer maps to a menu item", name)
return
if not item.get_visible() or not item.get_sensitive():
logger.debug("Tray action %s targets a hidden or disabled item", name)
return
item.activate()
class GMenuIndicator:
"""Give an ``AyatanaAppIndicatorGlib.Indicator`` the GTK 3 indicator's API.
The tray calls ``set_menu`` with a Gtk.Menu and ``set_icon_full`` as it
does on the GTK 3 backend; everything else passes through unchanged.
"""
def __init__(self, indicator: Any, gio: Any) -> None:
self._indicator = indicator
self._gio = gio
self._bridge: Optional[MenuModelBridge] = None
def set_menu(self, gtk_menu: Any) -> None:
"""Export ``gtk_menu`` as the indicator's menu model and actions."""
self._bridge = MenuModelBridge(gtk_menu, self._gio)
self._indicator.set_menu(self._bridge.menu_model)
self._indicator.set_actions(self._bridge.action_group)
def refresh_menu(self) -> None:
"""Re-mirror the Gtk menu after its items changed."""
if self._bridge is not None:
self._bridge.sync()
def set_icon_full(self, icon_name: str, description: str) -> None:
"""Set the icon; the glib library names this ``set_icon``."""
self._indicator.set_icon(icon_name, description)
def __getattr__(self, name: str) -> Any:
return getattr(self._indicator, name)
+5
View File
@@ -20,6 +20,11 @@ from typing import Dict, Mapping, Optional
_KEEP = ("APPDIR", "APPIMAGE")
def running_from_appimage() -> bool:
"""True when this process runs from the AppImage bundle (``APPDIR`` set)."""
return bool(os.environ.get("APPDIR"))
def _points_into(value: str, appdir: str) -> bool:
if not value:
return False