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
+208
View File
@@ -0,0 +1,208 @@
"""
Session-bus D-Bus service for Vocalinux.
Exposes ``com.vocalinux.Vocalinux`` with ``Toggle``/``Start``/``Stop`` methods
so an external trigger (e.g. a KDE Plasma global shortcut running
``vocalinux --toggle``) can control a running instance without the internal
evdev/pynput hotkey listener reading ``/dev/input``.
Mirrors the GDBus usage already present in ``suspend_handler.py``.
"""
import logging
from typing import Callable, Optional
from gi.repository import Gio, GLib
logger = logging.getLogger(__name__)
BUS_NAME = "com.vocalinux.Vocalinux"
OBJECT_PATH = "/com/vocalinux/Vocalinux"
INTERFACE_NAME = "com.vocalinux.Vocalinux"
# Maps CLI trigger names to the D-Bus method names exposed below.
COMMAND_METHODS = {
"toggle": "Toggle",
"start": "Start",
"stop": "Stop",
}
_INTROSPECTION_XML = """
<node>
<interface name="com.vocalinux.Vocalinux">
<method name="Toggle"/>
<method name="Start"/>
<method name="Stop"/>
</interface>
</node>
"""
class VocalinuxDBusService:
"""Owns the Vocalinux session-bus name and dispatches control methods.
Method calls are marshalled onto the GLib/GTK main thread via
:func:`GLib.idle_add` before the callbacks run, so recognition start/stop
happens on the same thread as the rest of the tray indicator.
Usage::
service = VocalinuxDBusService(
on_toggle=indicator._toggle_recognition,
on_start=indicator._start_recognition,
on_stop=indicator._stop_recognition,
)
# ... later, on shutdown:
service.shutdown()
"""
def __init__(
self,
on_toggle: Callable[[], None],
on_start: Callable[[], None],
on_stop: Callable[[], None],
on_registration_failed: Optional[Callable[[], None]] = None,
) -> None:
self._callbacks = {
"Toggle": on_toggle,
"Start": on_start,
"Stop": on_stop,
}
self._on_registration_failed = on_registration_failed
self._owner_id = 0
self._registration_id = 0
self._connection: Optional[Gio.DBusConnection] = None
self._node_info = Gio.DBusNodeInfo.new_for_xml(_INTROSPECTION_XML)
self._register()
def _register(self) -> None:
"""Request ownership of the bus name on the session bus."""
try:
self._owner_id = Gio.bus_own_name(
Gio.BusType.SESSION,
BUS_NAME,
Gio.BusNameOwnerFlags.NONE,
self._on_bus_acquired,
self._on_name_acquired,
self._on_name_lost,
)
logger.info("Requested D-Bus name %s on session bus", BUS_NAME)
except GLib.Error:
logger.warning("Failed to own D-Bus name %s", BUS_NAME, exc_info=True)
self._notify_registration_failed()
def _on_bus_acquired(self, connection: Gio.DBusConnection, name: str) -> None:
"""Register the control object once the bus connection is available."""
self._connection = connection
try:
self._registration_id = connection.register_object(
OBJECT_PATH,
self._node_info.interfaces[0],
self._handle_method_call,
None,
None,
)
except GLib.Error:
logger.warning("Failed to register D-Bus object", exc_info=True)
self._notify_registration_failed()
def _on_name_acquired(self, connection: Gio.DBusConnection, name: str) -> None:
logger.info("Acquired D-Bus name %s", name)
def _on_name_lost(self, connection: Gio.DBusConnection, name: str) -> None:
logger.warning("Could not acquire or lost D-Bus name %s", name)
self._notify_registration_failed()
def _notify_registration_failed(self) -> None:
"""Tell the caller the service is not reachable on the bus.
Without this, a config that also disables the internal evdev/pynput
listener would leave the user with no way to start or stop dictation.
"""
if self._on_registration_failed is not None:
self._on_registration_failed()
def _handle_method_call(
self,
connection: Gio.DBusConnection,
sender: str,
object_path: str,
interface_name: str,
method_name: str,
parameters: GLib.Variant,
invocation: Gio.DBusMethodInvocation,
) -> None:
"""Dispatch an incoming method call to the matching callback."""
callback = self._callbacks.get(method_name)
if callback is None:
invocation.return_error_literal(
Gio.dbus_error_quark(),
Gio.DBusError.UNKNOWN_METHOD,
f"Unknown method: {method_name}",
)
return
# Marshal onto the GTK main thread; recognition start/stop is expected
# to run there, matching the rest of the tray indicator.
GLib.idle_add(self._invoke, callback)
invocation.return_value(None)
@staticmethod
def _invoke(callback: Callable[[], None]) -> bool:
try:
callback()
except Exception:
logger.error("Error in D-Bus method handler", exc_info=True)
return GLib.SOURCE_REMOVE # type: ignore[no-any-return]
def shutdown(self) -> None:
"""Unregister the object and release the bus name."""
if self._registration_id and self._connection is not None:
try:
self._connection.unregister_object(self._registration_id)
except GLib.Error:
logger.debug("Failed to unregister D-Bus object", exc_info=True)
self._registration_id = 0
if self._owner_id:
try:
Gio.bus_unown_name(self._owner_id)
except GLib.Error:
logger.debug("Failed to unown D-Bus name", exc_info=True)
self._owner_id = 0
logger.info("D-Bus service shut down")
def send_command(command: str) -> bool:
"""Forward a control command to a running instance over the session bus.
Args:
command: One of ``"toggle"``, ``"start"``, ``"stop"``.
Returns:
True if the call reached a running instance, False otherwise.
"""
method = COMMAND_METHODS.get(command)
if method is None:
raise ValueError(f"Unknown command: {command}")
try:
proxy = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SESSION,
Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION,
None,
BUS_NAME,
OBJECT_PATH,
INTERFACE_NAME,
None,
)
proxy.call_sync(method, None, Gio.DBusCallFlags.NONE, -1, None)
return True
except GLib.Error as exc:
logger.error("Failed to send '%s' command via D-Bus: %s", command, exc)
return False
except Exception as exc:
# e.g. no session bus available (DBUS_SESSION_BUS_ADDRESS unset in an
# SSH/systemd context). Guarantee a deterministic non-zero CLI exit
# instead of an unhandled traceback.
logger.error("Failed to send '%s' command (no session bus?): %s", command, exc)
return False
+61 -5
View File
@@ -7,6 +7,7 @@ import argparse
import atexit
import logging
import sys
from typing import Optional
from .utils.vosk_model_info import SUPPORTED_LANGUAGES
from .version import __version__
@@ -91,9 +92,58 @@ def parse_arguments():
action="store_true",
help="Start minimized to system tray",
)
# External activation triggers: forward a control command to a running
# instance over D-Bus (e.g. from a KDE Plasma global shortcut) and exit.
parser.add_argument(
"--toggle",
action="store_true",
help="Toggle voice typing on a running instance (via D-Bus) and exit",
)
parser.add_argument(
"--start",
action="store_true",
help="Start voice typing on a running instance (via D-Bus) and exit",
)
parser.add_argument(
"--stop",
action="store_true",
help="Stop voice typing on a running instance (via D-Bus) and exit",
)
return parser.parse_args()
# CLI flags that trigger a running instance instead of starting a new one.
_TRIGGER_FLAGS = ("toggle", "start", "stop")
def _selected_trigger(args: argparse.Namespace) -> Optional[str]:
"""Return the external-activation command requested via CLI, if any."""
for name in _TRIGGER_FLAGS:
# Explicit `is True` guards against MagicMock args in tests, whose
# attributes are truthy by default.
if getattr(args, name, False) is True:
return name
return None
def _dispatch_trigger(command: str) -> int:
"""Forward a control command to a running instance over D-Bus.
Returns a process exit code (0 on success, 1 if no instance is reachable).
"""
from .dbus_service import send_command
if send_command(command):
logger.info("Sent '%s' command to running Vocalinux instance", command)
return 0
logger.error(
"Could not reach a running Vocalinux instance to '%s'. Is Vocalinux running?",
command,
)
return 1
def check_dependencies():
"""Check for required dependencies and provide helpful error messages."""
missing_system_deps = []
@@ -252,6 +302,17 @@ def main():
# another instance already holds the single-instance lock
args = parse_arguments()
# Configure debug logging if requested
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
logger.debug("Debug logging enabled")
# External activation: forward the command to a running instance over D-Bus
# and exit without acquiring the lock or starting a second full instance.
trigger = _selected_trigger(args)
if trigger is not None:
sys.exit(_dispatch_trigger(trigger))
# Check for single instance BEFORE any initialization
from . import single_instance
@@ -279,11 +340,6 @@ def main():
# Register cleanup to release lock on exit
atexit.register(single_instance.release_lock)
# Configure debug logging if requested
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
logger.debug("Debug logging enabled")
# Check dependencies first (before importing GTK-dependent modules)
if not check_dependencies():
logger.error("Cannot start Vocalinux due to missing dependencies")
+4
View File
@@ -148,6 +148,10 @@ DEFAULT_CONFIG = {
"shortcuts": {
"toggle_recognition": "right_alt+right_alt",
"mode": "push_to_talk", # "toggle" or "push_to_talk"
# When True, the internal evdev/pynput listener is not started; activation
# comes in over D-Bus instead (e.g. a KDE Plasma global shortcut running
# `vocalinux --toggle`). Avoids /dev/input access and the input group.
"disable_internal_hotkey": False,
# Pure-modifier gestures: "ctrl+ctrl", "alt+alt", "shift+shift" (and
# left_/right_ variants) — double-tap (toggle) or hold (push_to_talk).
# Modifier+key combos are also supported, e.g. "alt+r", "ctrl+alt+r",
+63 -2
View File
@@ -2424,6 +2424,7 @@ class SettingsDialog(Gtk.Dialog):
pending_update: Optional[ReleaseInfo] = None,
update_status_callback: callable = None,
overlay_enabled_callback: Optional[Callable[[bool], None]] = None,
hotkey_listener_update_callback: Optional[Callable[[], None]] = None,
):
super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
# Force window decorations (title-bar close) on all WMs. An in-window
@@ -2437,6 +2438,7 @@ class SettingsDialog(Gtk.Dialog):
self.shortcut_update_callback = shortcut_update_callback
self.update_status_callback = update_status_callback
self.overlay_enabled_callback = overlay_enabled_callback
self.hotkey_listener_update_callback = hotkey_listener_update_callback
self._test_active = False
self._test_result = ""
self._initializing = True # Flag to prevent auto-apply during initialization
@@ -4110,6 +4112,22 @@ class SettingsDialog(Gtk.Dialog):
description="Configure the shortcut to control voice recognition",
)
# External activation: drive start/stop from a desktop/compositor global
# shortcut (bound to `vocalinux --toggle`) instead of the built-in key
# listener. Avoids reading /dev/input (no keylogging, no `input` group).
self.disable_internal_hotkey_switch = Gtk.Switch()
self.disable_internal_hotkey_switch.set_tooltip_text(
"Turn off the built-in key listener and trigger voice typing from a "
"desktop global shortcut bound to 'vocalinux --toggle'. Avoids reading "
"/dev/input (no keylogging, no 'input' group needed)."
)
external_row = PreferenceRow(
title="External Activation (Desktop Shortcut)",
subtitle="Use a compositor global shortcut instead of the built-in key listener",
widget=self.disable_internal_hotkey_switch,
)
group.add_row(external_row)
# Mode selection (Toggle vs Push-to-Talk)
self.shortcut_mode_combo = Gtk.ComboBoxText()
_style_combo(self.shortcut_mode_combo)
@@ -4129,12 +4147,12 @@ class SettingsDialog(Gtk.Dialog):
if not self.shortcut_mode_combo.set_active_id(current_mode):
self.shortcut_mode_combo.set_active_id(DEFAULT_SHORTCUT_MODE)
mode_row = PreferenceRow(
self.mode_row = PreferenceRow(
title="Shortcut Mode",
subtitle="How the shortcut behaves",
widget=self.shortcut_mode_combo,
)
group.add_row(mode_row)
group.add_row(self.mode_row)
# Shortcut selection combo
self.shortcut_combo = Gtk.ComboBoxText()
@@ -4231,10 +4249,47 @@ class SettingsDialog(Gtk.Dialog):
# Connect signals
self.shortcut_combo.connect("changed", self._on_shortcut_changed)
self.shortcut_mode_combo.connect("changed", self._on_shortcut_mode_changed)
self.disable_internal_hotkey_switch.connect(
"state-set", self._on_disable_internal_hotkey_toggled
)
# Update UI based on initial mode
self._update_shortcut_ui_for_mode(current_mode)
def _update_internal_hotkey_sensitivity(self, disabled: bool) -> None:
"""Grey out the built-in shortcut controls when external activation is on."""
for row in (self.mode_row, self.shortcut_row, self.custom_shortcut_row):
row.set_sensitive(not disabled)
if disabled:
self.shortcut_info_label.set_text(
"External activation is on: the built-in key listener is off. "
"Bind a desktop global shortcut to 'vocalinux --toggle' to start/stop."
)
else:
# Restore the mode-appropriate hint.
self._update_shortcut_ui_for_mode(
self.config_manager.get_str("shortcuts", "mode", "toggle")
)
def _on_disable_internal_hotkey_toggled(self, widget: Gtk.Switch, state: bool) -> bool:
"""Handle toggle of the external-activation switch."""
if self._initializing or self._applying_settings:
return False
disabled = bool(state)
logger.info(f"External activation (internal hotkey disabled) toggled: {disabled}")
self.config_manager.set("shortcuts", "disable_internal_hotkey", disabled)
self.config_manager.save_settings()
self._update_internal_hotkey_sensitivity(disabled)
# Live-apply: start or stop the built-in listener without a restart.
if self.hotkey_listener_update_callback:
self.hotkey_listener_update_callback()
return False
def _is_preset_shortcut(self, shortcut: str) -> bool:
"""Return True if shortcut is one of the built-in double-tap presets."""
return shortcut in SUPPORTED_SHORTCUTS
@@ -5943,6 +5998,12 @@ class SettingsDialog(Gtk.Dialog):
if not self.model_keepalive_timeout_combo.set_active_id(str(timeout_seconds)):
self.model_keepalive_timeout_combo.set_active_id("300")
disable_internal_hotkey = self.config_manager.get_bool(
"shortcuts", "disable_internal_hotkey", False
)
self.disable_internal_hotkey_switch.set_active(disable_internal_hotkey)
self._update_internal_hotkey_sensitivity(disable_internal_hotkey)
available_engines = get_available_engines()
available_count = 0
+95 -1
View File
@@ -37,6 +37,7 @@ from gi.repository import GdkPixbuf, Gio, GLib, GObject, Gtk
# Import local modules - Use protocols to avoid circular imports
from ..auto_pause_monitor import DEFAULT_POLL_INTERVAL_SECONDS, AutoPauseMonitor
from ..common_types import RecognitionState, SpeechRecognitionManagerProtocol, TextInjectorProtocol
from ..dbus_service import VocalinuxDBusService
from ..gateway_embed import GatewayStatus, get_gateway_embed_manager
from ..model_keepalive import DEFAULT_IDLE_TIMEOUT_SECONDS, ModelKeepAlive
from ..suspend_handler import SuspendHandler
@@ -131,6 +132,11 @@ class TrayIndicator:
# Shared with main() and the settings dialog: separate instances would
# overwrite each other's saves with stale in-memory copies.
self.config_manager = get_shared_config_manager()
# Set once by _on_dbus_registration_failed and never cleared: the
# service does not retry, so every later reconfigure (mode change,
# settings toggle, resume) must keep honoring the fallback rather
# than reapplying a disable_internal_hotkey setting D-Bus cannot serve.
self._external_activation_unavailable = False
self._syncing_autostart_menu = False
# Get configured shortcut and mode from config
@@ -227,8 +233,63 @@ class TrayIndicator:
# Set up keyboard shortcuts with mode support
self._setup_keyboard_shortcuts()
# Register the session-bus service so external triggers (e.g. a KDE
# Plasma global shortcut running `vocalinux --toggle`) can control this
# running instance. Handlers marshal onto the GTK main thread.
self._dbus_service = VocalinuxDBusService(
on_toggle=self._toggle_recognition,
on_start=self._external_start,
on_stop=self._external_stop,
on_registration_failed=self._on_dbus_registration_failed,
)
def _external_activation_active(self) -> bool:
"""Whether the internal listener should stay off right now.
True only when the saved setting asks for it *and* the D-Bus service
has not already failed to register this run. The service does not
retry, so once it has failed, this stays False for the rest of the
process regardless of the saved setting — every later reconfigure
(mode change, settings toggle, resume) must keep using the fallback
instead of re-disabling the only working activation path.
"""
if self._external_activation_unavailable:
return False
return self.config_manager.get_bool("shortcuts", "disable_internal_hotkey", False)
def _on_dbus_registration_failed(self) -> None:
"""Fall back to the internal listener if external activation cannot work.
Runs when the D-Bus service could not claim its bus name or register
its object (no session bus, name already owned, etc.). If the internal
evdev/pynput listener is also disabled, the user would otherwise be
left with no way to start or stop dictation at all.
"""
if not self.config_manager.get_bool("shortcuts", "disable_internal_hotkey", False):
return
logger.error(
"D-Bus activation unavailable and the internal listener is disabled; "
"falling back to the internal listener"
)
notifications.notify(
"External activation unavailable",
"The D-Bus service could not start, so Vocalinux is using the "
"internal keyboard shortcut instead. Check Settings -> Shortcuts.",
"dialog-warning",
)
self._external_activation_unavailable = True
self._setup_keyboard_shortcuts()
def _setup_keyboard_shortcuts(self):
"""Set up keyboard shortcuts based on configured mode."""
# Reconfiguring (e.g. live-toggling external activation) tears down the
# release callback below. A push-to-talk session held at that moment
# would then never see its release, leaving recognition and the
# microphone running with no way back except another control surface.
if self.speech_engine.state != RecognitionState.IDLE:
logger.info("Stopping active recognition before reconfiguring shortcuts")
self._stop_recognition()
# Stop existing shortcut manager if running
if self.shortcut_manager.active:
logger.info("Stopping existing shortcut manager before reconfiguration")
@@ -239,6 +300,13 @@ class TrayIndicator:
self.shortcut_manager.register_press_callback(None)
self.shortcut_manager.register_release_callback(None)
# External-activation mode: skip the internal evdev/pynput listener
# entirely so no /dev/input access is required. Activation then comes
# in over D-Bus (see VocalinuxDBusService).
if self._external_activation_active():
logger.info("Internal hotkey listener disabled (external activation via D-Bus)")
return
# Get configured mode from config
mode = self.config_manager.get_str("shortcuts", "mode", DEFAULT_SHORTCUT_MODE)
logger.info(f"Setting up keyboard shortcuts with mode: {mode}")
@@ -471,6 +539,21 @@ class TrayIndicator:
if self.speech_engine.state == RecognitionState.IDLE:
self.speech_engine.start_recognition(mode="push_to_talk")
def _external_start(self) -> None:
"""Start recognition for an external (D-Bus) trigger.
Uses normal start semantics — not push-to-talk — so a single
`vocalinux --start` transcribes immediately/with silence detection
rather than deferring until a Stop.
"""
if self.speech_engine.state == RecognitionState.IDLE:
self.speech_engine.start_recognition()
def _external_stop(self) -> None:
"""Stop recognition for an external (D-Bus) trigger."""
if self.speech_engine.state != RecognitionState.IDLE:
self.speech_engine.stop_recognition()
def _stop_recognition(self):
"""Stop voice recognition (for push-to-talk mode)."""
if self.speech_engine.state != RecognitionState.IDLE:
@@ -843,6 +926,7 @@ class TrayIndicator:
available, release, notify=False
),
overlay_enabled_callback=self.set_overlay_enabled,
hotkey_listener_update_callback=self._setup_keyboard_shortcuts,
)
dialog.connect("response", self._on_settings_dialog_response)
dialog.connect("destroy", self._on_settings_dialog_destroyed)
@@ -1098,7 +1182,14 @@ class TrayIndicator:
logger.info("Skipping resume reinit: auto-pause still active")
else:
GLib.timeout_add_seconds(2, self._reinit_speech_after_resume)
GLib.timeout_add_seconds(2, self._start_input_device_monitor)
# External-activation mode never started the /dev/input listener in
# the first place; watching it here on every resume would open the
# very file descriptor that mode promises to avoid.
if self._external_activation_active():
logger.info("Skipping input device monitor: external activation via D-Bus")
else:
GLib.timeout_add_seconds(2, self._start_input_device_monitor)
def _reinit_speech_after_resume(self):
try:
@@ -1224,6 +1315,9 @@ class TrayIndicator:
if getattr(self, "_update_monitor", None) is not None:
self._update_monitor.shutdown()
if getattr(self, "_dbus_service", None) is not None:
self._dbus_service.shutdown()
self._cleanup_input_monitor()
# Stop the keyboard shortcut manager