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