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
+4
View File
@@ -82,6 +82,10 @@ overrides:
- ibus
# Desktop notifications.
- gir1.2-notify-0.7
# Tray backend the app prefers when present: libayatana-appindicator
# (GTK 3) logs a deprecation warning at startup. Debian 13+ ships it;
# apt skips the recommendation where it does not exist (Debian 12).
- gir1.2-ayatanaappindicatorglib-2.0
rpm:
depends:
- python3 >= 3.11
+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
+25 -1
View File
@@ -95,6 +95,28 @@ def test_appimage_build_ships_transitive_typelibs():
assert "Need at least one of:" in text
# Typelibs the app never requests inside the AppImage, so the bundle need not
# seed them. libayatana-appindicator-glib needs a newer GLib than the bundle
# carries; tray_indicator.py and main.py skip it when running_from_appimage().
SKIPPED_IN_APPIMAGE = frozenset({"AyatanaAppIndicatorGlib-2.0"})
def test_typelibs_skipped_in_appimage_are_guarded_by_running_from_appimage():
for module in ("ui/tray_indicator.py", "main.py"):
text = (REPO_ROOT / "src" / "vocalinux" / module).read_text(encoding="utf-8")
assert "AyatanaAppIndicatorGlib" in text
assert "running_from_appimage()" in text, f"{module} must skip the glib indicator"
def test_running_from_appimage_follows_appdir(monkeypatch):
from vocalinux.utils.host_process import running_from_appimage
monkeypatch.delenv("APPDIR", raising=False)
assert not running_from_appimage()
monkeypatch.setenv("APPDIR", "/tmp/.mount_vocalinux")
assert running_from_appimage()
def test_every_typelib_the_app_requires_is_seeded():
"""A required typelib the bundle does not seed sends GI to the host's search
path — the openSUSE/Fedora breakage from #585."""
@@ -106,7 +128,9 @@ def test_every_typelib_the_app_requires_is_seeded():
):
required.add(f"{namespace}-{version}")
assert required, "no gi.require_version calls found; has the app stopped using GI?"
missing = sorted(name for name in required if name not in seeded)
missing = sorted(
name for name in required if name not in seeded and name not in SKIPPED_IN_APPIMAGE
)
assert not missing, f"the app requires these typelibs; TYPELIBS does not seed them: {missing}"
+27 -5
View File
@@ -735,13 +735,34 @@ class TestCheckDependencies(unittest.TestCase):
result = check_dependencies()
self.assertFalse(result)
def test_check_dependencies_accepts_appindicator_glib_alone(self):
"""libayatana-appindicator-glib satisfies the tray dependency on its own."""
def require_version_side_effect(name, version):
if name in ("AyatanaAppIndicator3", "AyatanaAppindicator3", "AppIndicator3"):
raise ValueError(f"{name} not found")
mock_gi = MagicMock()
mock_gi.require_version = MagicMock(side_effect=require_version_side_effect)
with patch.dict(
"sys.modules",
{
"gi": mock_gi,
"gi.repository": MagicMock(Gtk=MagicMock(), AyatanaAppIndicatorGlib=MagicMock()),
"requests": MagicMock(),
},
):
with patch("vocalinux.main.logger"):
self.assertTrue(check_dependencies())
def test_check_dependencies_missing_appindicator_with_ayatana_fallback(self):
"""Test when legacy AppIndicator3 is missing but Ayatana is available."""
# Prefer Ayatana; legacy AppIndicator3 is only a fallback.
def require_version_side_effect(name, version):
if name == "AppIndicator3":
raise ValueError("AppIndicator3 not found")
if name in ("AyatanaAppIndicatorGlib", "AppIndicator3"):
raise ValueError(f"{name} not found")
# AyatanaAppIndicator3 works fine
mock_gi = MagicMock()
@@ -769,7 +790,7 @@ class TestCheckDependencies(unittest.TestCase):
"""Test when Ayatana is missing but legacy AppIndicator3 is available."""
def require_version_side_effect(name, version):
if name in ("AyatanaAppIndicator3", "AyatanaAppindicator3"):
if name in ("AyatanaAppIndicatorGlib", "AyatanaAppIndicator3", "AyatanaAppindicator3"):
raise ValueError(f"{name} not found")
mock_gi = MagicMock()
@@ -796,8 +817,8 @@ class TestCheckDependencies(unittest.TestCase):
"""Test rare lowercase AyatanaAppindicator3 typelib is accepted."""
def require_version_side_effect(name, version):
if name == "AyatanaAppIndicator3":
raise ValueError("AyatanaAppIndicator3 not found")
if name in ("AyatanaAppIndicatorGlib", "AyatanaAppIndicator3"):
raise ValueError(f"{name} not found")
mock_gi = MagicMock()
mock_gi.require_version = MagicMock(side_effect=require_version_side_effect)
@@ -825,6 +846,7 @@ class TestCheckDependencies(unittest.TestCase):
# Make gi.require_version raise ValueError for all AppIndicator variants
def require_version_side_effect(name, version):
if name in (
"AyatanaAppIndicatorGlib",
"AppIndicator3",
"AyatanaAppIndicator3",
"AyatanaAppindicator3",
+8
View File
@@ -214,6 +214,14 @@ def test_host_tools_are_recommended_in_both_formats() -> None:
assert tool in deps["rpm"]["recommends"], f"{tool} missing from rpm recommends"
def test_deb_recommends_the_appindicator_glib_tray_backend() -> None:
"""The tray prefers libayatana-appindicator-glib; the GTK 3 typelib stays a
hard dependency so Debian 12, which lacks the glib one, still installs."""
deps = _nfpm()["overrides"]["deb"]
assert "gir1.2-ayatanaappindicatorglib-2.0" in deps["recommends"]
assert "gir1.2-ayatanaappindicator3-0.1" in deps["depends"]
def test_the_pins_are_digests_rather_than_names() -> None:
for name, (kind, value, source) in _pins().items():
if kind == "sha256":
+97 -6
View File
@@ -943,7 +943,7 @@ class TestTrayIndicator(unittest.TestCase):
def test_init_indicator_creation_failure_shows_error_dialog(self):
with patch(
"vocalinux.ui.tray_indicator.AppIndicator3.Indicator.new_with_path",
"vocalinux.ui.tray_indicator.AppIndicator.Indicator.new_with_path",
side_effect=Exception("boom"),
):
with patch.object(
@@ -968,7 +968,7 @@ class TestTrayIndicator(unittest.TestCase):
with patch("vocalinux.ui.tray_indicator.os.path.exists", return_value=False):
with patch(
"vocalinux.ui.tray_indicator.AppIndicator3.Indicator.new_with_path",
"vocalinux.ui.tray_indicator.AppIndicator.Indicator.new_with_path",
side_effect=Exception("fresh-boom"),
):
with patch(
@@ -1333,14 +1333,42 @@ class TestAppIndicatorImportFallback(unittest.TestCase):
)
return repo
def test_prefers_ayatana_appindicator3_when_available(self):
def test_prefers_appindicator_glib_over_gtk3_ayatana(self):
sentinel = MagicMock(name="AyatanaAppIndicatorGlib")
sys.modules["gi"] = mock_gi
sys.modules["gi.repository"] = self._fake_repository(
AyatanaAppIndicatorGlib=sentinel,
AyatanaAppIndicator3=MagicMock(name="AyatanaAppIndicator3"),
)
import vocalinux.ui.tray_indicator as tray
self.assertIs(tray.AppIndicator, sentinel)
self.assertTrue(tray.INDICATOR_USES_GMENU)
def test_appimage_skips_appindicator_glib(self):
sentinel = MagicMock(name="AyatanaAppIndicator3")
sys.modules["gi"] = mock_gi
sys.modules["gi.repository"] = self._fake_repository(
AyatanaAppIndicatorGlib=MagicMock(name="AyatanaAppIndicatorGlib"),
AyatanaAppIndicator3=sentinel,
)
with patch.dict(os.environ, {"APPDIR": "/tmp/.mount_vocalinux"}):
import vocalinux.ui.tray_indicator as tray
self.assertIs(tray.AppIndicator, sentinel)
self.assertFalse(tray.INDICATOR_USES_GMENU)
def test_prefers_ayatana_appindicator3_when_glib_missing(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)
self.assertIs(tray.AppIndicator, sentinel)
self.assertFalse(tray.INDICATOR_USES_GMENU)
def test_falls_back_to_lowercase_ayatana_variant(self):
sentinel = MagicMock(name="AyatanaAppindicator3")
@@ -1349,7 +1377,8 @@ class TestAppIndicatorImportFallback(unittest.TestCase):
import vocalinux.ui.tray_indicator as tray
self.assertIs(tray.AppIndicator3, sentinel)
self.assertIs(tray.AppIndicator, sentinel)
self.assertFalse(tray.INDICATOR_USES_GMENU)
def test_falls_back_to_legacy_appindicator3_when_no_ayatana_available(self):
sentinel = MagicMock(name="AppIndicator3")
@@ -1358,7 +1387,69 @@ class TestAppIndicatorImportFallback(unittest.TestCase):
import vocalinux.ui.tray_indicator as tray
self.assertIs(tray.AppIndicator3, sentinel)
self.assertIs(tray.AppIndicator, sentinel)
self.assertFalse(tray.INDICATOR_USES_GMENU)
class TestIndicatorBackendWiring(TestTrayIndicator):
"""_init_indicator wraps the glib indicator and keeps its menu in sync."""
def _init_with_backend(self, uses_gmenu: bool) -> MagicMock:
raw = MagicMock(name="raw-indicator")
with (
patch("vocalinux.ui.tray_indicator.INDICATOR_USES_GMENU", uses_gmenu),
patch(
"vocalinux.ui.tray_indicator.AppIndicator.Indicator.new_with_path",
return_value=raw,
),
patch("vocalinux.ui.tray_indicator.FLATPAK_ID", None),
):
self.tray_indicator._init_indicator()
return raw
def test_glib_backend_wraps_indicator_in_gmenu_adapter(self):
from vocalinux.ui.tray_menu_model import GMenuIndicator
raw = self._init_with_backend(uses_gmenu=True)
self.assertIsInstance(self.tray_indicator.indicator, GMenuIndicator)
raw.set_menu.assert_called_once()
raw.set_actions.assert_called_once()
raw.set_icon_theme_path.assert_called()
def test_flatpak_glib_backend_creates_indicator_without_icon_path(self):
from vocalinux.ui.tray_menu_model import GMenuIndicator
raw = MagicMock(name="raw-indicator")
with (
patch("vocalinux.ui.tray_indicator.INDICATOR_USES_GMENU", True),
patch(
"vocalinux.ui.tray_indicator.AppIndicator.Indicator.new", return_value=raw
) as new,
patch("vocalinux.ui.tray_indicator.FLATPAK_ID", "com.vocalinux.Vocalinux"),
):
self.tray_indicator._init_indicator()
new.assert_called_once()
self.assertIsInstance(self.tray_indicator.indicator, GMenuIndicator)
raw.set_icon_theme_path.assert_not_called()
raw.set_menu.assert_called_once()
def test_gtk3_backend_uses_indicator_directly(self):
raw = self._init_with_backend(uses_gmenu=False)
self.assertIs(self.tray_indicator.indicator, raw)
raw.set_menu.assert_called_once_with(self.tray_indicator.menu)
def test_menu_mutations_refresh_the_glib_menu(self):
self._init_with_backend(uses_gmenu=True)
with patch.object(self.tray_indicator.indicator, "refresh_menu") as refresh:
self.tray_indicator._set_menu_item_enabled("Start Voice Typing", False)
self.tray_indicator._set_cancel_menu_item_visible(True)
self.tray_indicator._sync_gateway_stop_menu(None)
self.tray_indicator._apply_update_status(False, None, notify=False)
self.assertEqual(refresh.call_count, 4)
class TestTdrzDownloadDialog(TestTrayIndicator):
+302
View File
@@ -0,0 +1,302 @@
"""
Tests for the Gtk.Menu → GMenuModel bridge used with libayatana-appindicator-glib.
Gio and the Gtk menu are small recording fakes, so the tests check what the
bridge exports without needing GObject Introspection or a display.
"""
from __future__ import annotations
from typing import Any, Callable, Optional
from unittest.mock import MagicMock
from vocalinux.ui.tray_menu_model import ACTION_PREFIX, GMenuIndicator, MenuModelBridge
class FakeGioMenu:
"""Records appended entries the way Gio.Menu exposes them."""
def __init__(self) -> None:
self.entries: list[tuple[str, Any, Any]] = []
self.rebuilds = 0
def append(self, label: str, detailed_action: str) -> None:
self.entries.append(("item", label, detailed_action))
def append_submenu(self, label: str, submenu: "FakeGioMenu") -> None:
self.entries.append(("submenu", label, submenu))
def append_section(self, label: Optional[str], section: "FakeGioMenu") -> None:
self.entries.append(("section", label, section))
def remove_all(self) -> None:
self.entries = []
self.rebuilds += 1
class FakeAction:
def __init__(self, name: str) -> None:
self.name = name
self.enabled = True
self._handlers: list[tuple[Callable[..., None], tuple[Any, ...]]] = []
def connect(self, _signal: str, handler: Callable[..., None], *user_data: Any) -> None:
self._handlers.append((handler, user_data))
def set_enabled(self, enabled: bool) -> None:
self.enabled = enabled
def activate(self) -> None:
for handler, user_data in self._handlers:
handler(self, None, *user_data)
class FakeActionGroup:
def __init__(self) -> None:
self.actions: dict[str, FakeAction] = {}
def lookup_action(self, name: str) -> Optional[FakeAction]:
return self.actions.get(name)
def add_action(self, action: FakeAction) -> None:
self.actions[action.name] = action
def remove_action(self, name: str) -> None:
del self.actions[name]
class FakeSimpleAction:
@staticmethod
def new(name: str, _parameter_type: Any) -> FakeAction:
return FakeAction(name)
class FakeGio:
Menu = FakeGioMenu
SimpleActionGroup = FakeActionGroup
SimpleAction = FakeSimpleAction
class FakeMenuItem:
"""Stand-in for Gtk.MenuItem / Gtk.SeparatorMenuItem (label "" = separator)."""
def __init__(
self,
label: str = "",
*,
visible: bool = True,
sensitive: bool = True,
submenu: Optional["FakeMenu"] = None,
) -> None:
self.label = label
self.visible = visible
self.sensitive = sensitive
self.submenu = submenu
self.activations = 0
def get_label(self) -> str:
return self.label
def get_visible(self) -> bool:
return self.visible
def get_sensitive(self) -> bool:
return self.sensitive
def get_submenu(self) -> Optional["FakeMenu"]:
return self.submenu
def activate(self) -> None:
self.activations += 1
class FakeMenu:
def __init__(self, *items: FakeMenuItem) -> None:
self.items = list(items)
def get_children(self) -> list[FakeMenuItem]:
return list(self.items)
def _sections(menu: FakeGioMenu) -> list[list[tuple[str, Any, Any]]]:
return [section.entries for kind, _label, section in menu.entries if kind == "section"]
def _labels(menu: FakeGioMenu) -> list[list[str]]:
return [[label for _kind, label, _payload in section] for section in _sections(menu)]
def test_separators_split_sections_and_hidden_items_are_skipped():
start = FakeMenuItem("Start")
menu = FakeMenu(
start,
FakeMenuItem("Cancel", visible=False),
FakeMenuItem(),
FakeMenuItem(), # back-to-back separators must not leave an empty section
FakeMenuItem("Quit"),
)
bridge = MenuModelBridge(menu, FakeGio)
assert _labels(bridge.menu_model) == [["Start"], ["Quit"]]
first = _sections(bridge.menu_model)[0][0]
assert first[2].startswith(f"{ACTION_PREFIX}.")
def test_actions_follow_sensitivity_and_activate_the_gtk_item():
start = FakeMenuItem("Start")
stop = FakeMenuItem("Stop", sensitive=False)
bridge = MenuModelBridge(FakeMenu(start, stop), FakeGio)
names = [payload.split(".", 1)[1] for _k, _l, payload in _sections(bridge.menu_model)[0]]
start_action, stop_action = (bridge.action_group.actions[name] for name in names)
assert start_action.enabled is True
assert stop_action.enabled is False
start_action.activate()
assert start.activations == 1
stop.sensitive = True
bridge.sync()
assert stop_action.enabled is True
def test_submenus_are_mirrored_with_their_own_actions():
snippet = FakeMenuItem("hello world")
history = FakeMenuItem(
"Recent Snippets",
submenu=FakeMenu(snippet, FakeMenuItem(), FakeMenuItem("Clear History")),
)
bridge = MenuModelBridge(FakeMenu(history), FakeGio)
kind, label, submenu = _sections(bridge.menu_model)[0][0]
assert (kind, label) == ("submenu", "Recent Snippets")
assert _labels(submenu) == [["hello world"], ["Clear History"]]
action_name = _sections(submenu)[0][0][2].split(".", 1)[1]
bridge.action_group.actions[action_name].activate()
assert snippet.activations == 1
def test_sync_rebuilds_only_when_the_visible_layout_changes():
update = FakeMenuItem("Update Available…", visible=False)
bridge = MenuModelBridge(FakeMenu(FakeMenuItem("Start"), update), FakeGio)
rebuilds = bridge.menu_model.rebuilds
bridge.sync()
assert bridge.menu_model.rebuilds == rebuilds
update.visible = True
update.label = "Update Available (v1.0.0)…"
bridge.sync()
assert bridge.menu_model.rebuilds == rebuilds + 1
assert _labels(bridge.menu_model) == [["Start", "Update Available (v1.0.0)…"]]
def test_replaced_submenu_items_drop_stale_actions():
old = FakeMenuItem("old snippet")
history = FakeMenuItem("Recent Snippets", submenu=FakeMenu(old))
bridge = MenuModelBridge(FakeMenu(FakeMenuItem("Start"), history), FakeGio)
assert len(bridge.action_group.actions) == 2
history.submenu = FakeMenu(FakeMenuItem("(no snippets yet)", sensitive=False))
bridge.sync()
assert len(bridge.action_group.actions) == 2
history.submenu = FakeMenu()
bridge.sync()
assert len(bridge.action_group.actions) == 1
def test_gmenu_indicator_adapts_gtk3_calls():
raw = MagicMock()
indicator = GMenuIndicator(raw, FakeGio)
indicator.set_menu(FakeMenu(FakeMenuItem("Start")))
raw.set_menu.assert_called_once()
raw.set_actions.assert_called_once()
exported = raw.set_menu.call_args[0][0]
assert _labels(exported) == [["Start"]]
indicator.set_icon_full("vocalinux-microphone", "Microphone on")
raw.set_icon.assert_called_once_with("vocalinux-microphone", "Microphone on")
indicator.set_title("Vocalinux")
raw.set_title.assert_called_once_with("Vocalinux")
def test_refresh_before_set_menu_is_a_no_op():
GMenuIndicator(MagicMock(), FakeGio).refresh_menu()
def _action_names(menu: FakeGioMenu) -> dict[str, str]:
"""Map each top-level item label to its action name."""
return {
label: payload.split(".", 1)[1]
for section in _sections(menu)
for kind, label, payload in section
if kind == "item"
}
def _first_submenu_action(menu: FakeGioMenu) -> str:
submenu = _sections(menu)[0][0][2]
return str(_sections(submenu)[0][0][2]).split(".", 1)[1]
def test_hiding_an_item_never_hands_its_action_to_a_neighbour():
"""A click from the previous menu reaches its own item or nothing."""
cancel = FakeMenuItem("Cancel")
transcribe = FakeMenuItem("Transcribe Audio File…")
bridge = MenuModelBridge(FakeMenu(FakeMenuItem("Start"), cancel, transcribe), FakeGio)
before = _action_names(bridge.menu_model)
stale_cancel = bridge.action_group.actions[before["Cancel"]]
cancel.visible = False
bridge.sync()
assert _action_names(bridge.menu_model) == {
"Start": before["Start"],
"Transcribe Audio File…": before["Transcribe Audio File…"],
}
assert before["Cancel"] not in bridge.action_group.actions
stale_cancel.activate()
assert (cancel.activations, transcribe.activations) == (0, 0)
cancel.visible = True
bridge.sync()
assert _action_names(bridge.menu_model)["Cancel"] == before["Cancel"]
def test_rebuilt_submenu_items_get_fresh_names():
old = FakeMenuItem("old snippet")
history = FakeMenuItem("Recent Snippets", submenu=FakeMenu(old))
bridge = MenuModelBridge(FakeMenu(history), FakeGio)
old_name = _first_submenu_action(bridge.menu_model)
stale = bridge.action_group.actions[old_name]
new = FakeMenuItem("new snippet")
history.submenu = FakeMenu(new)
bridge.sync()
new_name = _first_submenu_action(bridge.menu_model)
assert new_name != old_name
assert old_name not in bridge.action_group.actions
stale.activate()
assert (old.activations, new.activations) == (0, 0)
bridge.action_group.actions[new_name].activate()
assert new.activations == 1
def test_activation_ignores_items_changed_since_the_last_sync():
cancel = FakeMenuItem("Cancel")
stop = FakeMenuItem("Stop")
bridge = MenuModelBridge(FakeMenu(cancel, stop), FakeGio)
names = _action_names(bridge.menu_model)
cancel.visible = False
stop.sensitive = False
bridge.action_group.actions[names["Cancel"]].activate()
bridge.action_group.actions[names["Stop"]].activate()
assert (cancel.activations, stop.activations) == (0, 0)