diff --git a/packaging/native/nfpm.yaml b/packaging/native/nfpm.yaml index 6f53cdd..111a98b 100644 --- a/packaging/native/nfpm.yaml +++ b/packaging/native/nfpm.yaml @@ -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 diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index bb8ef70..f7b6040 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -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: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 77366f7..ab12a75 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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: diff --git a/src/vocalinux/ui/tray_menu_model.py b/src/vocalinux/ui/tray_menu_model.py new file mode 100644 index 0000000..f77a079 --- /dev/null +++ b/src/vocalinux/ui/tray_menu_model.py @@ -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) diff --git a/src/vocalinux/utils/host_process.py b/src/vocalinux/utils/host_process.py index fc05fd8..94e78ad 100644 --- a/src/vocalinux/utils/host_process.py +++ b/src/vocalinux/utils/host_process.py @@ -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 diff --git a/tests/test_appimage_packaging.py b/tests/test_appimage_packaging.py index 8197e50..6cbdfd4 100644 --- a/tests/test_appimage_packaging.py +++ b/tests/test_appimage_packaging.py @@ -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}" diff --git a/tests/test_main.py b/tests/test_main.py index a5f03c3..0609ad7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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", diff --git a/tests/test_native_packaging.py b/tests/test_native_packaging.py index 3175655..1d614a0 100644 --- a/tests/test_native_packaging.py +++ b/tests/test_native_packaging.py @@ -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": diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 99218d6..02cdd7a 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -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): diff --git a/tests/test_tray_menu_model.py b/tests/test_tray_menu_model.py new file mode 100644 index 0000000..4d4fbd9 --- /dev/null +++ b/tests/test_tray_menu_model.py @@ -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)