fix(tray): clarify missing GNOME AppIndicator support on Debian (#385)

* fix(tray): detect missing AppIndicator watcher on GNOME

* test(tray): cover AppIndicator watcher and failure branches
This commit is contained in:
Jatin K Malik
2026-04-05 18:12:16 -07:00
committed by GitHub
parent 45a69d5946
commit 410f6b27d8
4 changed files with 322 additions and 10 deletions
+44
View File
@@ -129,6 +129,10 @@ def check_dependencies():
" sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-ayatanaappindicator3-0.1"
)
logger.error("")
logger.error(" NOTE: On GNOME Shell (default on Debian), you also need:")
logger.error(" sudo apt install gnome-shell-extension-appindicator")
logger.error(" Then log out and back in. Ubuntu includes this by default.")
logger.error("")
logger.error(" Fedora:")
logger.error(" sudo dnf install python3-gobject gtk3 libappindicator-gtk3")
logger.error("")
@@ -171,6 +175,35 @@ def check_display_available():
return False
def check_appindicator_support():
try:
from gi.repository import Gio
proxy = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SESSION,
Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION,
None,
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
None,
)
names_variant = proxy.call_sync(
"ListNames",
None,
Gio.DBusCallFlags.NONE,
-1,
None,
)
if names_variant is not None:
name_list = names_variant.unpack()[0]
return "org.kde.StatusNotifierWatcher" in name_list
except Exception:
pass
return True
def main():
"""Main entry point for the application."""
# Check for single instance BEFORE any initialization
@@ -216,6 +249,17 @@ def main():
if not check_display_available():
sys.exit(1)
if not check_appindicator_support():
logger.warning("No StatusNotifierWatcher found on D-Bus session bus.")
logger.warning("The system tray icon may not appear.")
logger.warning("")
logger.warning("If you are using GNOME Shell, install the AppIndicator extension:")
logger.warning(" Debian: sudo apt install gnome-shell-extension-appindicator")
logger.warning(" Fedora: sudo dnf install gnome-shell-extension-appindicator")
logger.warning(" Arch: sudo pacman -S gnome-shell-extension-appindicator")
logger.warning("")
logger.warning("After installing, log out and back in (or restart GNOME Shell).")
# Now it's safe to import GTK-dependent modules
from .common_types import RecognitionState
from .speech_recognition import recognition_manager
+93 -10
View File
@@ -190,17 +190,26 @@ class TrayIndicator:
exists = os.path.exists(path)
logger.info(f"Icon '{name}' ({path}): {'exists' if exists else 'missing'}")
# Create the indicator with absolute path to the default icon
self.indicator = AppIndicator3.Indicator.new_with_path(
APP_ID,
DEFAULT_ICON,
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
ICON_DIR,
)
self.indicator.set_icon_theme_path(ICON_DIR)
try:
self.indicator = AppIndicator3.Indicator.new_with_path(
APP_ID,
DEFAULT_ICON,
AppIndicator3.IndicatorCategory.APPLICATION_STATUS,
ICON_DIR,
)
self.indicator.set_icon_theme_path(ICON_DIR)
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
except Exception as e:
logger.error(f"Failed to create AppIndicator: {e}")
GLib.idle_add(self._show_appindicator_error_dialog, str(e))
return False
# Set the indicator status
self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
if not self._check_status_notifier_watcher():
logger.warning(
"No StatusNotifierWatcher on D-Bus session bus; tray icon may not appear. "
"On GNOME, install gnome-shell-extension-appindicator."
)
GLib.idle_add(self._show_missing_watcher_dialog)
# Create the menu
self.menu = Gtk.Menu()
@@ -233,6 +242,74 @@ class TrayIndicator:
return False # Remove idle callback
@staticmethod
def _check_status_notifier_watcher() -> bool:
try:
proxy = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SESSION,
Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION,
None,
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
None,
)
names_variant = proxy.call_sync(
"ListNames",
None,
Gio.DBusCallFlags.NONE,
-1,
None,
)
if names_variant is not None:
name_list = names_variant.unpack()[0]
return "org.kde.StatusNotifierWatcher" in name_list
except Exception:
pass
return True
def _show_missing_watcher_dialog(self):
dialog = Gtk.MessageDialog(
flags=Gtk.DialogFlags.MODAL,
message_type=Gtk.MessageType.WARNING,
buttons=Gtk.ButtonsType.OK,
text="System tray icon may not appear",
)
dialog.format_secondary_text(
"Vocalinux could not detect AppIndicator support in your desktop environment.\n"
"\n"
"If you are using GNOME Shell, install the AppIndicator extension:\n"
" sudo apt install gnome-shell-extension-appindicator\n"
"\n"
"Then log out and back in (or press Alt+F2, type 'r', press Enter).\n"
"\n"
"Keyboard shortcuts will still work even without the tray icon."
)
dialog.connect("response", lambda d, _: d.destroy())
dialog.show()
return False
def _show_appindicator_error_dialog(self, error_detail: str):
dialog = Gtk.MessageDialog(
flags=Gtk.DialogFlags.MODAL,
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK,
text="Failed to initialize system tray",
)
dialog.format_secondary_text(
f"AppIndicator could not be started:\n{error_detail}\n"
"\n"
"Make sure the required packages are installed:\n"
" sudo apt install gir1.2-ayatanaappindicator3-0.1\n"
"\n"
"On GNOME Shell, you also need:\n"
" sudo apt install gnome-shell-extension-appindicator"
)
dialog.connect("response", lambda d, _: d.destroy())
dialog.show()
return False
def _toggle_recognition(self):
"""Toggle the recognition state between IDLE and LISTENING."""
if self.speech_engine.state == RecognitionState.IDLE:
@@ -335,6 +412,9 @@ class TrayIndicator:
Args:
state: The current recognition state
"""
if not hasattr(self, "indicator"):
return False
if state == RecognitionState.IDLE:
self.indicator.set_icon_full(self.icon_names["default"], "Microphone off")
self._set_menu_item_enabled("Start Voice Typing", True)
@@ -362,6 +442,9 @@ class TrayIndicator:
label: The label of the menu item
enabled: Whether the item should be enabled
"""
if not hasattr(self, "menu"):
return
for item in self.menu.get_children():
if isinstance(item, Gtk.MenuItem) and item.get_label() == label:
item.set_sensitive(enabled)
+128
View File
@@ -183,6 +183,65 @@ class TestCheckDisplayAvailable(unittest.TestCase):
assert result is False
class TestCheckAppIndicatorSupport(unittest.TestCase):
def test_check_appindicator_support_true_when_watcher_present(self):
from vocalinux.main import check_appindicator_support
mock_proxy = MagicMock()
mock_names_variant = MagicMock()
mock_names_variant.unpack.return_value = (
["org.freedesktop.DBus", "org.kde.StatusNotifierWatcher"],
)
mock_proxy.call_sync.return_value = mock_names_variant
mock_gio = MagicMock()
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
with patch.dict(
sys.modules,
{
"gi": MagicMock(),
"gi.repository": MagicMock(Gio=mock_gio),
},
):
assert check_appindicator_support() is True
def test_check_appindicator_support_false_when_watcher_missing(self):
from vocalinux.main import check_appindicator_support
mock_proxy = MagicMock()
mock_names_variant = MagicMock()
mock_names_variant.unpack.return_value = (["org.freedesktop.DBus"],)
mock_proxy.call_sync.return_value = mock_names_variant
mock_gio = MagicMock()
mock_gio.DBusProxy.new_for_bus_sync.return_value = mock_proxy
with patch.dict(
sys.modules,
{
"gi": MagicMock(),
"gi.repository": MagicMock(Gio=mock_gio),
},
):
assert check_appindicator_support() is False
def test_check_appindicator_support_true_on_exception(self):
from vocalinux.main import check_appindicator_support
mock_gio = MagicMock()
mock_gio.DBusProxy.new_for_bus_sync.side_effect = RuntimeError("dbus unavailable")
with patch.dict(
sys.modules,
{
"gi": MagicMock(),
"gi.repository": MagicMock(Gio=mock_gio),
},
):
assert check_appindicator_support() is True
class TestMainFunction(unittest.TestCase):
"""Tests for the main() function."""
@@ -340,6 +399,75 @@ class TestMainFunction(unittest.TestCase):
main()
assert exc_info.value.code == 1
@patch("vocalinux.main.logging")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.main.check_display_available")
@patch("vocalinux.main.check_appindicator_support")
@patch("vocalinux.main.parse_arguments")
@patch("vocalinux.main.atexit")
def test_main_logs_warning_when_appindicator_support_missing(
self,
mock_atexit,
mock_parse_args,
mock_check_appindicator,
mock_check_display,
mock_check_deps,
mock_logging,
):
from vocalinux.main import main
mock_check_deps.return_value = True
mock_check_display.return_value = True
mock_check_appindicator.return_value = False
mock_args = MagicMock()
mock_args.debug = False
mock_args.wayland = False
mock_args.start_minimized = False
mock_args.engine = None
mock_args.model = None
mock_args.language = None
mock_parse_args.return_value = mock_args
mock_single_instance = MagicMock()
mock_single_instance.acquire_lock.return_value = True
mock_config_manager = MagicMock()
mock_config_manager.return_value.get_settings.return_value = {
"speech_recognition": {},
"audio": {},
"general": {"first_run": False},
}
with patch.dict(
sys.modules,
{
"vocalinux.single_instance": mock_single_instance,
"vocalinux.common_types": MagicMock(),
"vocalinux.speech_recognition": MagicMock(
recognition_manager=MagicMock(
SpeechRecognitionManager=MagicMock(side_effect=Exception("stop"))
)
),
"vocalinux.text_injection.text_injector": MagicMock(),
"vocalinux.ui.tray_indicator": MagicMock(),
"vocalinux.ui.action_handler": MagicMock(),
"vocalinux.ui.config_manager": mock_config_manager,
"vocalinux.ui.logging_manager": MagicMock(),
"vocalinux.text_injection": MagicMock(start_ibus_daemon=MagicMock()),
"vocalinux.ui.first_run_dialog": MagicMock(),
"vocalinux.ui.autostart_manager": MagicMock(),
},
):
with patch("vocalinux.main.logger") as mock_logger:
with pytest.raises(SystemExit):
main()
mock_logger.warning.assert_any_call(
"No StatusNotifierWatcher found on D-Bus session bus."
)
mock_logger.warning.assert_any_call("The system tray icon may not appear.")
@patch("vocalinux.main.logging")
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.main.check_display_available")
+57
View File
@@ -453,3 +453,60 @@ class TestTrayIndicator(unittest.TestCase):
mock_about_dialog.destroy.assert_called_once()
# set_logo should NOT be called due to the error
mock_about_dialog.set_logo.assert_not_called()
def test_check_status_notifier_watcher_true_when_present(self):
mock_proxy = MagicMock()
mock_names_variant = MagicMock()
mock_names_variant.unpack.return_value = (
["org.freedesktop.DBus", "org.kde.StatusNotifierWatcher"],
)
mock_proxy.call_sync.return_value = mock_names_variant
with patch(
"vocalinux.ui.tray_indicator.Gio.DBusProxy.new_for_bus_sync",
return_value=mock_proxy,
):
assert self.tray_indicator._check_status_notifier_watcher() is True
def test_check_status_notifier_watcher_false_when_missing(self):
mock_proxy = MagicMock()
mock_names_variant = MagicMock()
mock_names_variant.unpack.return_value = (["org.freedesktop.DBus"],)
mock_proxy.call_sync.return_value = mock_names_variant
with patch(
"vocalinux.ui.tray_indicator.Gio.DBusProxy.new_for_bus_sync",
return_value=mock_proxy,
):
assert self.tray_indicator._check_status_notifier_watcher() is False
def test_check_status_notifier_watcher_true_on_exception(self):
with patch(
"vocalinux.ui.tray_indicator.Gio.DBusProxy.new_for_bus_sync",
side_effect=RuntimeError("dbus error"),
):
assert self.tray_indicator._check_status_notifier_watcher() is True
def test_init_indicator_missing_watcher_shows_dialog(self):
with patch.object(
self.tray_indicator,
"_check_status_notifier_watcher",
return_value=False,
):
with patch.object(self.tray_indicator, "_show_missing_watcher_dialog") as mock_dialog:
result = self.tray_indicator._init_indicator()
self.assertEqual(result, False)
mock_dialog.assert_called_once()
def test_init_indicator_creation_failure_shows_error_dialog(self):
with patch(
"vocalinux.ui.tray_indicator.AppIndicator3.Indicator.new_with_path",
side_effect=Exception("boom"),
):
with patch.object(
self.tray_indicator,
"_show_appindicator_error_dialog",
) as mock_error_dialog:
result = self.tray_indicator._init_indicator()
self.assertEqual(result, False)
mock_error_dialog.assert_called_once_with("boom")