diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index 5c12f8e..db8a8da 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -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 diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index c67f521..c0050a4 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -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) diff --git a/tests/test_main_args_deps.py b/tests/test_main_args_deps.py index 1cfd022..3b502f3 100644 --- a/tests/test_main_args_deps.py +++ b/tests/test_main_args_deps.py @@ -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") diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 36e557c..7c89a95 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -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")