diff --git a/README-REMOTE-CONTROLLER.md b/README-REMOTE-CONTROLLER.md index cc8626c..281fdca 100644 --- a/README-REMOTE-CONTROLLER.md +++ b/README-REMOTE-CONTROLLER.md @@ -138,6 +138,24 @@ are categorized as **Commands**, **Runtime Getters**, and **Runtime Setters**: You can discover and execute these for each module. +### Plugin Modules + +Plugins that expose D-Bus decorators are automatically registered as modules using the naming +convention `Plugin_` (e.g., `Plugin_GameMode`, `Plugin_WindowTitleReader`). Use +`ListModules` to discover available plugin modules at runtime. + +### PluginSystemManager Module + +The `PluginSystemManager` module provides session-only plugin control: + +- `ListPlugins` +- `ListActivePlugins` +- `IsPluginActive` (parameterized) +- `SetPluginActive` (parameterized) +- `RescanPlugins` + +These calls do **not** persist changes to user preferences. + ### Discovering Module Capabilities #### List Commands for a Module diff --git a/README.md b/README.md index d1189eb..48c3ee6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,49 @@ toolkit, OpenOffice/LibreOffice, Gecko, WebKitGtk, and KDE Qt toolkit. - **External integration**: Other applications can speak through Cthulhu - **Simple protocol**: `echo "text" | socat - UNIX-CLIENT:/tmp/cthulhu.sock` +## D-Bus Remote Controller + +Cthulhu exposes a D-Bus service for external automation and integrations. + +### Service Details +- **Service Name**: `org.stormux.Cthulhu.Service` +- **Main Object Path**: `/org/stormux/Cthulhu/Service` +- **Module Object Paths**: `/org/stormux/Cthulhu/Service/` + +### Discovering Capabilities + +```bash +# List registered modules +gdbus call --session --dest org.stormux.Cthulhu.Service \ + --object-path /org/stormux/Cthulhu/Service \ + --method org.stormux.Cthulhu.Service.ListModules + +# List commands on a module +gdbus call --session --dest org.stormux.Cthulhu.Service \ + --object-path /org/stormux/Cthulhu/Service/ModuleName \ + --method org.stormux.Cthulhu.Module.ListCommands +``` + +### Plugin Modules + +Plugins that expose D-Bus decorators are automatically registered as modules using the naming +convention `Plugin_` (for example, `Plugin_GameMode`, `Plugin_WindowTitleReader`). + +### PluginSystemManager Module + +The `PluginSystemManager` module provides **session-only** plugin control (no preference changes): + +- `ListPlugins` +- `ListActivePlugins` +- `IsPluginActive` (parameterized) +- `SetPluginActive` (parameterized) +- `RescanPlugins` + +### More Documentation + +See `README-REMOTE-CONTROLLER.md` and `REMOTE-CONTROLLER-COMMANDS.md` for the full D-Bus API +and usage examples. + ## Dependencies ### Core Requirements diff --git a/REMOTE-CONTROLLER-COMMANDS.md b/REMOTE-CONTROLLER-COMMANDS.md index bff8419..5655766 100644 --- a/REMOTE-CONTROLLER-COMMANDS.md +++ b/REMOTE-CONTROLLER-COMMANDS.md @@ -49,13 +49,22 @@ busctl --user call org.stormux.Cthulhu.Service /org/stormux/Cthulhu/Service \ ## Module-Level Commands -Currently, no additional modules are exposed via D-Bus beyond the base service commands. +Module-level commands are available and can be discovered via `ListModules`. Two key additions are: -**Planned modules** (to be implemented): -- `SpeechAndVerbosityManager` - Speech settings control (muting, verbosity, punctuation, etc.) -- `TypingEchoManager` - Typing echo settings (character/word/sentence echo) -- `DefaultScript` - Core Cthulhu commands -- Additional navigation and presenter modules +### PluginSystemManager + +Session-only plugin control (does not persist preferences): + +- `ListPlugins` +- `ListActivePlugins` +- `IsPluginActive` (parameterized) +- `SetPluginActive` (parameterized) +- `RescanPlugins` + +### Plugin Modules + +Plugins that expose D-Bus decorators are automatically registered as modules using the naming +convention `Plugin_` (e.g., `Plugin_GameMode`, `Plugin_WindowTitleReader`). See [README-REMOTE-CONTROLLER.md](README-REMOTE-CONTROLLER.md) for comprehensive D-Bus API documentation and usage examples. diff --git a/src/cthulhu/cthulhu.py b/src/cthulhu/cthulhu.py index d81477d..2f180b9 100644 --- a/src/cthulhu/cthulhu.py +++ b/src/cthulhu/cthulhu.py @@ -648,6 +648,11 @@ def _start_dbus_service(): typing_echo_manager = typing_echo_presenter.getManager() dbus_service.get_remote_controller().register_decorated_module("TypingEchoManager", typing_echo_manager) + # Register plugin system manager + debug.printMessage(debug.LEVEL_INFO, 'CTHULHU: Registering PluginSystemManager D-Bus module', True) + plugin_manager = cthulhuApp.getPluginSystemManager() + dbus_service.get_remote_controller().register_decorated_module("PluginSystemManager", plugin_manager) + except Exception as e: msg = f"CTHULHU: Failed to start D-Bus service: {e}" debug.printMessage(debug.LEVEL_SEVERE, msg, True) diff --git a/src/cthulhu/dbus_service.py b/src/cthulhu/dbus_service.py index c2f5ebe..45b2057 100644 --- a/src/cthulhu/dbus_service.py +++ b/src/cthulhu/dbus_service.py @@ -30,7 +30,7 @@ __license__ = "LGPL" import enum import inspect import os -from typing import Callable, Optional +from typing import Any, Callable, Optional try: from dasbus.connection import SessionMessageBus @@ -151,6 +151,22 @@ def _extract_function_parameters(func: Callable) -> list[tuple[str, str]]: return parameters +def _filter_kwargs_for_callable(method: Callable, kwargs: dict[str, Any]) -> dict[str, Any]: + """Filters kwargs down to what the callable accepts.""" + + try: + sig = inspect.signature(method) + except (TypeError, ValueError): + return kwargs + + for param in sig.parameters.values(): + if param.kind == inspect.Parameter.VAR_KEYWORD: + return kwargs + + allowed = set(sig.parameters.keys()) + allowed.discard("self") + return {key: value for key, value in kwargs.items() if key in allowed} + class _HandlerInfo: """Stores processed information about a function exposed via D-Bus.""" @@ -691,7 +707,13 @@ class CthulhuRemoteController: if script is None: manager = script_manager.get_manager() script = manager.get_default_script() - rv = method(script=script, event=event, notify_user=notify_user) + kwargs = { + "script": script, + "event": event, + "notify_user": notify_user + } + call_kwargs = _filter_kwargs_for_callable(method, kwargs) + rv = method(**call_kwargs) _get_input_event_manager().get_manager().process_remote_controller_event(event) return rv return _wrapper @@ -715,7 +737,13 @@ class CthulhuRemoteController: if script is None: manager = script_manager.get_manager() script = manager.get_default_script() - rv = method(script=script, event=event, **kwargs) + merged_kwargs = { + "script": script, + "event": event + } + merged_kwargs.update(kwargs) + call_kwargs = _filter_kwargs_for_callable(method, merged_kwargs) + rv = method(**call_kwargs) _get_input_event_manager().get_manager().process_remote_controller_event(event) return rv return _wrapper diff --git a/src/cthulhu/plugin.py b/src/cthulhu/plugin.py index 257d362..bb35bc4 100644 --- a/src/cthulhu/plugin.py +++ b/src/cthulhu/plugin.py @@ -63,6 +63,12 @@ class Plugin: self.version = plugin_info.get_version() self.description = plugin_info.get_description() + def get_dbus_module_name(self): + """Return the D-Bus module name for this plugin, or an empty string to opt out.""" + if not self.module_name: + return '' + return f"Plugin_{self.module_name}" + @cthulhu_hookimpl def activate(self, plugin=None): """Activate the plugin. Override this in subclasses.""" diff --git a/src/cthulhu/plugin_system_manager.py b/src/cthulhu/plugin_system_manager.py index a6a27ed..affa873 100644 --- a/src/cthulhu/plugin_system_manager.py +++ b/src/cthulhu/plugin_system_manager.py @@ -19,6 +19,8 @@ import shutil import subprocess from enum import IntEnum +from . import dbus_service + # Import pluggy if available try: import pluggy @@ -638,6 +640,118 @@ class PluginSystemManager: active_instances.append(plugin_info.instance) return active_instances + def _resolve_plugin_info(self, plugin_name): + if not plugin_name: + return None + + if not self._plugins: + self.rescanPlugins() + + plugin_name_lower = plugin_name.lower() + candidates = [] + + for info in self.plugins: + module_name = info.get_module_name() + canonical_name = info.get_canonical_name() + if plugin_name == module_name: + return info + if plugin_name_lower == module_name.lower(): + candidates.append(info) + elif plugin_name_lower == canonical_name.lower(): + candidates.append(info) + + if not candidates: + return None + + for info in candidates: + if info.builtin: + return info + for info in candidates: + if info.preferred_alias: + return info + return candidates[0] + + def _get_plugin_dbus_module_name(self, plugin_info, plugin_instance): + if not plugin_info or not plugin_instance: + return '' + if hasattr(plugin_instance, "get_dbus_module_name"): + module_name = plugin_instance.get_dbus_module_name() + else: + module_name = f"Plugin_{plugin_info.get_module_name()}" + return module_name or '' + + def _register_plugin_dbus_module(self, plugin_info): + if not plugin_info or not plugin_info.instance: + return + + module_name = self._get_plugin_dbus_module_name(plugin_info, plugin_info.instance) + if not module_name: + return + + try: + controller = dbus_service.get_remote_controller() + controller.register_decorated_module(module_name, plugin_info.instance) + logger.info(f"Registered D-Bus module for plugin {plugin_info.get_module_name()}: {module_name}") + except Exception as error: + logger.error(f"Failed to register D-Bus module for plugin {plugin_info.get_module_name()}: {error}") + + def _deregister_plugin_dbus_module(self, plugin_info): + if not plugin_info or not plugin_info.instance: + return + + module_name = self._get_plugin_dbus_module_name(plugin_info, plugin_info.instance) + if not module_name: + return + + try: + controller = dbus_service.get_remote_controller() + controller.deregister_module_commands(module_name) + logger.info(f"Deregistered D-Bus module for plugin {plugin_info.get_module_name()}: {module_name}") + except Exception as error: + logger.error(f"Failed to deregister D-Bus module for plugin {plugin_info.get_module_name()}: {error}") + + @dbus_service.command + def list_plugins(self): + """Returns a list of available plugin module names.""" + if not self._plugins: + self.rescanPlugins() + return [info.get_module_name() for info in self.plugins] + + @dbus_service.command + def list_active_plugins(self): + """Returns a list of currently active plugin module names.""" + return [info.get_module_name() for info in self.plugins if info.loaded] + + @dbus_service.parameterized_command + def is_plugin_active(self, plugin_name: str) -> bool: + """Returns True if the specified plugin is active.""" + plugin_info = self._resolve_plugin_info(plugin_name) + if not plugin_info: + return False + return self.isPluginActive(plugin_info) + + @dbus_service.parameterized_command + def set_plugin_active(self, plugin_name: str, active: bool) -> bool: + """Enable or disable a plugin for this session only.""" + plugin_info = self._resolve_plugin_info(plugin_name) + if not plugin_info: + return False + + if plugin_info.builtin and not active: + logger.warning(f"Plugin {plugin_info.get_module_name()} is builtin and cannot be disabled") + return False + + self.setPluginActive(plugin_info, active) + if active: + return plugin_info.loaded + return not plugin_info.loaded + + @dbus_service.command + def rescan_plugins(self): + """Rescans plugin directories and refreshes the plugin list.""" + self.rescanPlugins() + return True + def setActivePlugins(self, activePlugins): """Set active plugins and sync their state.""" logger.info(f"PLUGIN SYSTEM: setActivePlugins called with: {activePlugins}") @@ -939,6 +1053,8 @@ class PluginSystemManager: pluginInfo.loaded = True logger.info(f"Successfully loaded plugin: {module_name}") + self._register_plugin_dbus_module(pluginInfo) + # Register any global keybindings from the plugin self.register_plugin_global_keybindings(pluginInfo.instance) @@ -974,6 +1090,8 @@ class PluginSystemManager: except Exception as e: logger.error(f"Error deactivating plugin {module_name}: {e}") + self._deregister_plugin_dbus_module(pluginInfo) + # Unregister from pluggy self.plugin_manager.unregister(plugin_instance)