feat(activation): D-Bus activation for compositor global shortcuts (#568)

Rebased onto latest main (conflict-resolution squash for PR #568).

Co-authored-by: Alexander <ab@digital-benefits.de>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Web-Benefits
2026-09-29 21:19:29 -07:00
committed by GitHub
co-authored by Alexander Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent c0217a8aec
commit db07590761
10 changed files with 1077 additions and 9 deletions
+316
View File
@@ -0,0 +1,316 @@
"""
Tests for D-Bus based external activation.
Covers:
- CLI dispatch: --toggle/--start/--stop forward the right D-Bus command.
- Config gate: external mode skips the internal hotkey listener.
- D-Bus handlers route to the correct recognition callbacks.
"""
import sys
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
from vocalinux.dbus_service import VocalinuxDBusService
# gi is mocked globally by conftest. All vocalinux imports are done lazily
# inside the individual tests. Importing them at module level would bind their
# `gi.repository` references at collection time and clash with test_main.py,
# which replaces sys.modules["gi"] / sys.modules["gi.repository"] wholesale --
# leading to MagicMock identity mismatches (e.g. GLib.SOURCE_REMOVE) in
# unrelated tests that run in the same session.
# -- CLI dispatch ----------------------------------------------------------
@pytest.mark.parametrize(
"flag,command",
[("--toggle", "toggle"), ("--start", "start"), ("--stop", "stop")],
)
def test_cli_flag_sends_matching_command(flag, command):
"""Each trigger flag forwards the matching command and exits with 0."""
from vocalinux.main import main
with patch("vocalinux.dbus_service.send_command", return_value=True) as mock_send:
with patch.object(sys, "argv", ["vocalinux", flag]):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
mock_send.assert_called_once_with(command)
def test_cli_flag_exits_nonzero_when_no_instance():
"""When no instance answers, the trigger exits with a non-zero code."""
from vocalinux.main import main
with patch("vocalinux.dbus_service.send_command", return_value=False):
with patch.object(sys, "argv", ["vocalinux", "--toggle"]):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
def test_no_trigger_flag_does_not_dispatch():
"""Without a trigger flag, main() proceeds past the dispatch shortcut."""
from vocalinux.main import main
# Mock single_instance via sys.modules (not patch("...acquire_lock")) so we
# don't import the real module and set it as a vocalinux package attribute,
# which would defeat patch.dict-based single-instance tests elsewhere.
mock_single_instance = MagicMock()
mock_single_instance.acquire_lock.return_value = True
with patch("vocalinux.dbus_service.send_command") as mock_send:
# check_dependencies returns False so main() exits early after dispatch.
with patch.dict(sys.modules, {"vocalinux.single_instance": mock_single_instance}):
with patch("vocalinux.main.check_dependencies", return_value=False):
with patch.object(sys, "argv", ["vocalinux"]):
with pytest.raises(SystemExit):
main()
mock_send.assert_not_called()
# Config-gate tests for TrayIndicator._setup_keyboard_shortcuts live in
# test_tray_indicator_ext.py (TestExternalActivationGate). They require the real
# tray_indicator module, which must not be imported this early in the session --
# doing so freezes tray_indicator.GLib against a stale gi.repository mock and
# breaks unrelated GLib-identity assertions elsewhere.
# -- D-Bus handlers --------------------------------------------------------
@pytest.mark.parametrize(
"method,expected",
[("Toggle", "on_toggle"), ("Start", "on_start"), ("Stop", "on_stop")],
)
def test_method_call_routes_to_callback(method, expected):
"""A D-Bus method call invokes the matching callback on the main thread."""
from vocalinux import dbus_service
callbacks = {
"on_toggle": MagicMock(),
"on_start": MagicMock(),
"on_stop": MagicMock(),
}
service = dbus_service.VocalinuxDBusService(
on_toggle=callbacks["on_toggle"],
on_start=callbacks["on_start"],
on_stop=callbacks["on_stop"],
)
invocation = MagicMock()
# Make idle_add run the marshalled call synchronously for the assertion.
with patch.object(dbus_service.GLib, "idle_add", side_effect=lambda fn, *a: fn(*a)):
service._handle_method_call(
MagicMock(),
"sender",
dbus_service.OBJECT_PATH,
dbus_service.INTERFACE_NAME,
method,
None,
invocation,
)
callbacks[expected].assert_called_once_with()
for name, cb in callbacks.items():
if name != expected:
cb.assert_not_called()
invocation.return_value.assert_called_once()
def test_unknown_method_returns_error():
"""An unknown method name returns a D-Bus error and no callback runs."""
from vocalinux import dbus_service
on_toggle = MagicMock()
service = dbus_service.VocalinuxDBusService(
on_toggle=on_toggle, on_start=MagicMock(), on_stop=MagicMock()
)
invocation = MagicMock()
service._handle_method_call(
MagicMock(),
"sender",
dbus_service.OBJECT_PATH,
dbus_service.INTERFACE_NAME,
"Bogus",
None,
invocation,
)
invocation.return_error_literal.assert_called_once()
on_toggle.assert_not_called()
def test_shutdown_releases_name_and_object():
"""shutdown() unregisters the object and unowns the bus name, idempotently."""
from vocalinux import dbus_service
service = dbus_service.VocalinuxDBusService(
on_toggle=MagicMock(), on_start=MagicMock(), on_stop=MagicMock()
)
# Simulate a successful registration having happened.
connection = MagicMock()
service._connection = connection
service._registration_id = 42
service._owner_id = 7
with patch.object(dbus_service.Gio, "bus_unown_name") as mock_unown:
service.shutdown()
connection.unregister_object.assert_called_once_with(42)
mock_unown.assert_called_once_with(7)
# A second shutdown must be a no-op (ids cleared).
connection.unregister_object.reset_mock()
mock_unown.reset_mock()
service.shutdown()
connection.unregister_object.assert_not_called()
mock_unown.assert_not_called()
def test_send_command_uses_proxy_call():
"""send_command forwards the mapped method name through a D-Bus proxy."""
from vocalinux import dbus_service
mock_proxy = MagicMock()
with patch.object(dbus_service.Gio.DBusProxy, "new_for_bus_sync", return_value=mock_proxy):
result = dbus_service.send_command("toggle")
assert result is True
args = mock_proxy.call_sync.call_args.args
assert args[0] == "Toggle"
def test_send_command_rejects_unknown():
"""An unknown command name raises ValueError."""
from vocalinux import dbus_service
with pytest.raises(ValueError):
dbus_service.send_command("explode")
def test_send_command_returns_false_without_session_bus():
"""A generic failure (e.g. no session bus) yields False, not a traceback."""
from vocalinux import dbus_service
# GLib.Error is a MagicMock under the test harness, so patch it to a real
# exception class; the raised error is a different type and must fall
# through to the generic handler.
with patch.object(dbus_service.GLib, "Error", RuntimeError):
with patch.object(
dbus_service.Gio.DBusProxy,
"new_for_bus_sync",
side_effect=ValueError("no session bus"),
):
assert dbus_service.send_command("toggle") is False
def test_send_command_returns_false_on_glib_error():
"""A GLib.Error from the proxy call is caught and yields False."""
from vocalinux import dbus_service
# Patch GLib.Error to a real class so both the raise and the `except` clause
# reference the same type and the GLib.Error branch is exercised.
with patch.object(dbus_service.GLib, "Error", RuntimeError):
with patch.object(
dbus_service.Gio.DBusProxy,
"new_for_bus_sync",
side_effect=RuntimeError("bus error"),
):
assert dbus_service.send_command("toggle") is False
# -- Service lifecycle & error paths --------------------------------------
def _make_service() -> "VocalinuxDBusService":
from vocalinux import dbus_service
return dbus_service.VocalinuxDBusService(
on_toggle=MagicMock(), on_start=MagicMock(), on_stop=MagicMock()
)
def test_register_handles_bus_own_name_failure():
"""A failure while requesting the bus name is logged, not raised."""
from vocalinux import dbus_service
# Patch GLib.Error to a real class so both the raise and the `except` clause
# reference the same type and the GLib.Error branch is exercised.
with patch.object(dbus_service.GLib, "Error", RuntimeError):
with patch.object(dbus_service.Gio, "bus_own_name", side_effect=RuntimeError("no bus")):
# __init__ calls _register(); it must not propagate the error.
service = _make_service()
assert service._owner_id == 0
def test_on_bus_acquired_registers_object():
"""_on_bus_acquired stores the connection and registers the control object."""
from vocalinux import dbus_service
service = _make_service()
connection = MagicMock()
connection.register_object.return_value = 99
service._on_bus_acquired(connection, dbus_service.BUS_NAME)
assert service._connection is connection
assert service._registration_id == 99
connection.register_object.assert_called_once()
def test_on_bus_acquired_handles_register_failure():
"""A failed object registration is caught and leaves the id unset."""
from vocalinux import dbus_service
service = _make_service()
connection = MagicMock()
connection.register_object.side_effect = RuntimeError("register failed")
# Patch GLib.Error to a real class so both the raise and the `except` clause
# reference the same type and the GLib.Error branch is exercised.
with patch.object(dbus_service.GLib, "Error", RuntimeError):
service._on_bus_acquired(connection, "com.vocalinux.Vocalinux")
assert service._registration_id == 0
def test_name_acquired_and_lost_are_safe_to_call():
"""The name-acquired/lost callbacks only log and never raise."""
service = _make_service()
connection = MagicMock()
service._on_name_acquired(connection, "com.vocalinux.Vocalinux")
service._on_name_lost(connection, "com.vocalinux.Vocalinux")
def test_invoke_swallows_callback_errors():
"""_invoke logs a raising callback and still returns SOURCE_REMOVE."""
from vocalinux import dbus_service
failing = MagicMock(side_effect=RuntimeError("boom"))
result = dbus_service.VocalinuxDBusService._invoke(failing)
failing.assert_called_once_with()
assert result == dbus_service.GLib.SOURCE_REMOVE
def test_shutdown_swallows_unregister_and_unown_errors():
"""shutdown() ignores failures from unregister_object and bus_unown_name."""
from vocalinux import dbus_service
service = _make_service()
connection = MagicMock()
connection.unregister_object.side_effect = RuntimeError("unregister failed")
service._connection = connection
service._registration_id = 42
service._owner_id = 7
# Patch GLib.Error to a real class so both the raise and the `except` clause
# reference the same type and the GLib.Error branch is exercised.
with patch.object(dbus_service.GLib, "Error", RuntimeError):
with patch.object(
dbus_service.Gio, "bus_unown_name", side_effect=RuntimeError("unown failed")
):
# Must not raise despite both cleanup calls failing.
service.shutdown()
assert service._registration_id == 0
assert service._owner_id == 0
+60 -1
View File
@@ -8,7 +8,7 @@ import os
import unittest
def _get_source_code():
def _get_source_code() -> str:
"""Read the settings dialog source file."""
source_path = os.path.join(
os.path.dirname(__file__),
@@ -253,5 +253,64 @@ class TestConfigManagerShortcuts(unittest.TestCase):
self.assertEqual(config.get("shortcuts", "mode"), "push_to_talk")
class TestExternalActivationToggle(unittest.TestCase):
"""Source-inspection tests for the external-activation switch.
The dialog subclasses a mocked Gtk.Dialog, so its methods can't be called
directly; we assert the wiring is present in source (same approach as
TestSettingsDialogShortcutsSection above).
"""
def setUp(self):
self.source_code = _get_source_code()
def test_switch_widget_created(self):
"""The external-activation switch is created in the shortcuts section."""
self.assertIn("self.disable_internal_hotkey_switch = Gtk.Switch()", self.source_code)
def test_switch_signal_connected(self):
"""The switch is wired to its state-set handler."""
self.assertIn("self.disable_internal_hotkey_switch.connect(", self.source_code)
self.assertIn("self._on_disable_internal_hotkey_toggled", self.source_code)
def test_handler_persists_config_key(self):
"""The handler saves the disable_internal_hotkey flag."""
self.assertIn(
'self.config_manager.set("shortcuts", "disable_internal_hotkey", disabled)',
self.source_code,
)
self.assertIn("self.config_manager.save_settings()", self.source_code)
def test_handler_live_applies_via_callback(self):
"""Toggling live-applies by invoking the listener update callback."""
self.assertIn("self.hotkey_listener_update_callback()", self.source_code)
def test_handler_guards_during_initialization(self):
"""The handler is inert while the dialog is initializing/applying."""
self.assertIn(
"def _on_disable_internal_hotkey_toggled(self, widget: Gtk.Switch, state: bool)"
" -> bool:",
self.source_code,
)
self.assertIn("if self._initializing or self._applying_settings:", self.source_code)
def test_sensitivity_helper_greys_internal_rows(self):
"""External activation greys out the built-in shortcut rows."""
self.assertIn(
"def _update_internal_hotkey_sensitivity(self, disabled: bool) -> None:",
self.source_code,
)
self.assertIn(
"for row in (self.mode_row, self.shortcut_row, self.custom_shortcut_row):",
self.source_code,
)
self.assertIn("row.set_sensitive(not disabled)", self.source_code)
def test_initial_state_loaded(self):
"""The switch's initial state is loaded from config during populate."""
self.assertIn("self.disable_internal_hotkey_switch.set_active(", self.source_code)
self.assertIn('"shortcuts", "disable_internal_hotkey", False', self.source_code)
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -302,6 +302,9 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
indicator = TrayIndicator.__new__(TrayIndicator)
indicator.speech_engine = MagicMock()
indicator.config_manager = MagicMock()
indicator.config_manager.get_bool.return_value = False
indicator._external_activation_unavailable = False
indicator._setup_keyboard_shortcuts = MagicMock()
indicator._input_monitor = None
indicator._settle_timer_id = None
@@ -343,6 +346,19 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
assert calls[1][0][0] == 2
assert calls[1][0][1] == indicator._start_input_device_monitor
@patch("vocalinux.ui.tray_indicator.GLib")
def test_on_system_resume_skips_monitor_for_external_activation(self, mock_glib):
"""External activation promises no /dev/input access; a resume must
not open the device monitor that (re)starts the internal listener."""
indicator = self._make_tray_indicator()
indicator.config_manager.get_bool.return_value = True
indicator._on_system_resume()
calls = mock_glib.timeout_add_seconds.call_args_list
assert len(calls) == 1
assert calls[0][0][1] == indicator._reinit_speech_after_resume
@patch("vocalinux.ui.tray_indicator.Gio")
@patch("vocalinux.ui.tray_indicator.GLib")
def test_start_input_device_monitor_sets_up_gio_monitor(self, mock_glib, mock_gio):
@@ -497,6 +513,7 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
indicator.speech_engine.reinitialize_after_resume = MagicMock()
indicator.speech_engine.is_auto_paused = False
indicator.config_manager = MagicMock()
indicator._external_activation_unavailable = False
indicator._setup_keyboard_shortcuts = MagicMock()
indicator._input_monitor = None
indicator._settle_timer_id = None
@@ -565,6 +582,7 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
def test_system_resume_skips_reinit_when_auto_paused(self, mock_glib):
indicator = self._make_tray_indicator()
indicator.speech_engine.is_auto_paused = True
indicator.config_manager.get_bool.return_value = False
indicator._start_input_device_monitor = MagicMock()
indicator._on_system_resume()
@@ -578,6 +596,7 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
def test_system_resume_reinits_when_not_auto_paused(self, mock_glib):
indicator = self._make_tray_indicator()
indicator.speech_engine.is_auto_paused = False
indicator.config_manager.get_bool.return_value = False
indicator._reinit_speech_after_resume = MagicMock()
indicator._start_input_device_monitor = MagicMock()
+202
View File
@@ -513,5 +513,207 @@ class TestTrayIndicatorSignalHandling(unittest.TestCase):
assert isinstance(result, bool)
class TestExternalActivationGate(unittest.TestCase):
"""Tests for the D-Bus external-activation config gate.
These live here (and not in test_dbus_activation.py) so that the real
tray_indicator module is imported late in the session -- after the tests
that swap sys.modules["gi.repository"] at runtime. Importing it earlier
would freeze tray_indicator.GLib against a stale mock and break unrelated
GLib-identity assertions (e.g. in test_suspend_handler.py).
"""
@staticmethod
def _fake_indicator(active: bool) -> MagicMock:
"""Minimal stand-in for calling _setup_keyboard_shortcuts in isolation.
``active`` stubs _external_activation_active() directly rather than
the config it normally derives from -- that derivation (including the
once-failed-stays-off persistence) has its own tests below.
"""
fake = MagicMock()
fake.shortcut_manager.active = False
fake.config_manager.get_str.return_value = "toggle"
fake._external_activation_active.return_value = active
return fake
def test_external_mode_skips_listener_start(self):
"""With the option enabled, the evdev/pynput listener is not started."""
from vocalinux.ui.tray_indicator import TrayIndicator
fake = self._fake_indicator(active=True)
TrayIndicator._setup_keyboard_shortcuts(fake)
fake.shortcut_manager.start.assert_not_called()
fake.shortcut_manager.register_toggle_callback.assert_called_once_with(None)
def test_default_mode_starts_listener(self):
"""With the option disabled (default), the listener starts as before."""
from vocalinux.ui.tray_indicator import TrayIndicator
fake = self._fake_indicator(active=False)
TrayIndicator._setup_keyboard_shortcuts(fake)
fake.shortcut_manager.start.assert_called_once()
def test_reconfigure_stops_active_recognition_first(self):
"""An active push-to-talk session is stopped before the listener that
would have delivered its release callback is torn down, so it cannot
be left running with no way back to idle."""
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
fake = self._fake_indicator(active=True)
fake.speech_engine.state = RecognitionState.LISTENING
TrayIndicator._setup_keyboard_shortcuts(fake)
fake._stop_recognition.assert_called_once_with()
def test_reconfigure_leaves_idle_recognition_alone(self):
"""No spurious stop is issued when nothing is active."""
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
fake = self._fake_indicator(active=False)
fake.speech_engine.state = RecognitionState.IDLE
TrayIndicator._setup_keyboard_shortcuts(fake)
fake._stop_recognition.assert_not_called()
class TestExternalActivationActiveGate(unittest.TestCase):
"""Tests for _external_activation_active(): the saved setting, unless the
D-Bus service has already failed to register this run."""
@staticmethod
def _fake_indicator(disable_internal_hotkey: bool, unavailable: bool) -> MagicMock:
fake = MagicMock()
fake.config_manager.get_bool.return_value = disable_internal_hotkey
fake._external_activation_unavailable = unavailable
return fake
def test_true_when_configured_and_available(self):
from vocalinux.ui.tray_indicator import TrayIndicator
fake = self._fake_indicator(disable_internal_hotkey=True, unavailable=False)
assert TrayIndicator._external_activation_active(fake) is True
def test_false_when_not_configured(self):
from vocalinux.ui.tray_indicator import TrayIndicator
fake = self._fake_indicator(disable_internal_hotkey=False, unavailable=False)
assert TrayIndicator._external_activation_active(fake) is False
def test_false_once_unavailable_even_if_still_configured(self):
"""Once the service has failed, the saved setting is overridden for
the rest of the process -- it does not retry."""
from vocalinux.ui.tray_indicator import TrayIndicator
fake = self._fake_indicator(disable_internal_hotkey=True, unavailable=True)
assert TrayIndicator._external_activation_active(fake) is False
class TestDBusRegistrationFailedFallback(unittest.TestCase):
"""Tests for falling back to the internal listener when the D-Bus service
could not register, so the user is never left with no activation path."""
@staticmethod
def _fake_indicator(disable_internal_hotkey: bool) -> MagicMock:
fake = MagicMock()
fake.config_manager.get_bool.return_value = disable_internal_hotkey
return fake
@patch("vocalinux.ui.tray_indicator.notifications")
def test_falls_back_when_external_activation_configured(self, mock_notifications):
from vocalinux.ui.tray_indicator import TrayIndicator
fake = self._fake_indicator(disable_internal_hotkey=True)
TrayIndicator._on_dbus_registration_failed(fake)
assert fake._external_activation_unavailable is True
fake._setup_keyboard_shortcuts.assert_called_once_with()
mock_notifications.notify.assert_called_once()
@patch("vocalinux.ui.tray_indicator.notifications")
def test_fallback_persists_across_a_later_reconfigure(self, mock_notifications):
"""The exact regression this fixes: a reconfigure after the fallback
(mode change, settings toggle, resume) must not re-disable the only
working activation path just because the saved setting still asks
for external activation."""
from vocalinux.ui.tray_indicator import TrayIndicator
with patch.object(TrayIndicator, "__init__", lambda self: None):
indicator = TrayIndicator.__new__(TrayIndicator)
indicator._external_activation_unavailable = False
indicator.config_manager = MagicMock()
indicator.config_manager.get_bool.return_value = True
indicator.config_manager.get_str.return_value = "toggle"
indicator.speech_engine = MagicMock()
indicator.shortcut_manager = MagicMock()
indicator.shortcut_manager.active = False
indicator._on_dbus_registration_failed()
indicator.shortcut_manager.start.assert_called_once()
# A later reconfigure -- e.g. the mode combo changing in Settings --
# calls this with no knowledge of the earlier failure.
indicator.shortcut_manager.reset_mock()
indicator._setup_keyboard_shortcuts()
indicator.shortcut_manager.start.assert_called_once()
def test_noop_when_internal_listener_already_active(self):
"""Nothing to fall back to/from if the internal listener was already
the active mode."""
from vocalinux.ui.tray_indicator import TrayIndicator
fake = self._fake_indicator(disable_internal_hotkey=False)
TrayIndicator._on_dbus_registration_failed(fake)
fake._setup_keyboard_shortcuts.assert_not_called()
class TestExternalTriggerHandlers(unittest.TestCase):
"""Tests for the D-Bus external Start/Stop handlers on TrayIndicator.
These use normal (non push-to-talk) start semantics and guard on the
current recognition state, so repeated `vocalinux --start`/`--stop` calls
are idempotent.
"""
def test_external_start_starts_when_idle(self):
"""_external_start starts recognition only when currently idle."""
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
fake = MagicMock()
fake.speech_engine.state = RecognitionState.IDLE
TrayIndicator._external_start(fake)
fake.speech_engine.start_recognition.assert_called_once_with()
def test_external_start_noop_when_already_running(self):
"""_external_start does nothing if recognition is already active."""
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
fake = MagicMock()
fake.speech_engine.state = RecognitionState.LISTENING
TrayIndicator._external_start(fake)
fake.speech_engine.start_recognition.assert_not_called()
def test_external_stop_stops_when_active(self):
"""_external_stop stops recognition when it is not idle."""
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
fake = MagicMock()
fake.speech_engine.state = RecognitionState.LISTENING
TrayIndicator._external_stop(fake)
fake.speech_engine.stop_recognition.assert_called_once_with()
def test_external_stop_noop_when_idle(self):
"""_external_stop does nothing if recognition is already idle."""
from vocalinux.common_types import RecognitionState
from vocalinux.ui.tray_indicator import TrayIndicator
fake = MagicMock()
fake.speech_engine.state = RecognitionState.IDLE
TrayIndicator._external_stop(fake)
fake.speech_engine.stop_recognition.assert_not_called()
if __name__ == "__main__":
unittest.main()