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:
co-authored by
Alexander
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
c0217a8aec
commit
db07590761
@@ -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
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user