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:
+37
-27
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user