Plugins exposed via d-bus remote.

This commit is contained in:
Storm Dragon
2026-01-11 23:21:52 -05:00
parent d3c48b1e84
commit 75ad2f0dec
7 changed files with 236 additions and 9 deletions
+18
View File
@@ -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_<ModuleName>` (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
+43
View File
@@ -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/<ModuleName>`
### 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_<ModuleName>` (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
+15 -6
View File
@@ -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_<ModuleName>` (e.g., `Plugin_GameMode`, `Plugin_WindowTitleReader`).
See [README-REMOTE-CONTROLLER.md](README-REMOTE-CONTROLLER.md) for comprehensive D-Bus API documentation and usage examples.
+5
View File
@@ -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)
+31 -3
View File
@@ -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
+6
View File
@@ -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."""
+118
View File
@@ -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)