Compare commits
33 Commits
52a687c770
...
75ad2f0dec
| Author | SHA1 | Date | |
|---|---|---|---|
| 75ad2f0dec | |||
| d3c48b1e84 | |||
| 2ccd118cc5 | |||
| c53ef6606e | |||
| 404e87db25 | |||
| 5cf9e66895 | |||
| d8101b37b9 | |||
| 2ca4948ab3 | |||
| c0aecfdb9f | |||
| 79ac07e8dc | |||
| e3e58adfbe | |||
| 8ff74bb83a | |||
| 6f45ad61cf | |||
| e94af432c2 | |||
| ef18ae7cbc | |||
| 4f210406d3 | |||
| c3d604f4a1 | |||
| eef509a5a1 | |||
| 4be007bf7d | |||
| ad6de50f9b | |||
| a044bfaade | |||
| f9b408a1d2 | |||
| f0e7f14806 | |||
| 220e84afa4 | |||
| 5d48f4770c | |||
| 81cc4627f7 | |||
| d36b664319 | |||
| 3f7d60763d | |||
| 6bbf3d0e67 | |||
| cbe3424e29 | |||
| 327ad99e49 | |||
| c46cf1c939 | |||
| a97bb30ed3 |
@@ -37,16 +37,7 @@ meson install -C _build
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
```bash
|
Manual testing is recommended. The legacy keystroke-driven test harness has been removed.
|
||||||
# Run all tests
|
|
||||||
test/harness/runall.sh
|
|
||||||
|
|
||||||
# Run single test
|
|
||||||
test/harness/runone.sh <test.py> <app-name>
|
|
||||||
|
|
||||||
# App-specific tests
|
|
||||||
test/harness/runall.sh -a /path/to/app/tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,24 @@ are categorized as **Commands**, **Runtime Getters**, and **Runtime Setters**:
|
|||||||
|
|
||||||
You can discover and execute these for each module.
|
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
|
### Discovering Module Capabilities
|
||||||
|
|
||||||
#### List Commands for a Module
|
#### List Commands for a Module
|
||||||
|
|||||||
@@ -45,6 +45,49 @@ toolkit, OpenOffice/LibreOffice, Gecko, WebKitGtk, and KDE Qt toolkit.
|
|||||||
- **External integration**: Other applications can speak through Cthulhu
|
- **External integration**: Other applications can speak through Cthulhu
|
||||||
- **Simple protocol**: `echo "text" | socat - UNIX-CLIENT:/tmp/cthulhu.sock`
|
- **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
|
## Dependencies
|
||||||
|
|
||||||
### Core Requirements
|
### Core Requirements
|
||||||
@@ -200,18 +243,7 @@ rm -rf _build
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
Run the regression test suite:
|
Manual testing is recommended. The legacy keystroke-driven test harness has been removed.
|
||||||
|
|
||||||
```bash
|
|
||||||
# All tests
|
|
||||||
test/harness/runall.sh
|
|
||||||
|
|
||||||
# Single test
|
|
||||||
test/harness/runone.sh <test.py> <app-name>
|
|
||||||
|
|
||||||
# App-specific tests
|
|
||||||
test/harness/runall.sh -a /path/to/app/tests
|
|
||||||
```
|
|
||||||
|
|
||||||
## Self-voicing
|
## Self-voicing
|
||||||
|
|
||||||
|
|||||||
@@ -49,13 +49,22 @@ busctl --user call org.stormux.Cthulhu.Service /org/stormux/Cthulhu/Service \
|
|||||||
|
|
||||||
## Module-Level Commands
|
## 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):
|
### PluginSystemManager
|
||||||
- `SpeechAndVerbosityManager` - Speech settings control (muting, verbosity, punctuation, etc.)
|
|
||||||
- `TypingEchoManager` - Typing echo settings (character/word/sentence echo)
|
Session-only plugin control (does not persist preferences):
|
||||||
- `DefaultScript` - Core Cthulhu commands
|
|
||||||
- Additional navigation and presenter modules
|
- `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.
|
See [README-REMOTE-CONTROLLER.md](README-REMOTE-CONTROLLER.md) for comprehensive D-Bus API documentation and usage examples.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
||||||
|
|
||||||
pkgname=cthulhu
|
pkgname=cthulhu
|
||||||
pkgver=2026.01.06
|
pkgver=2026.01.10
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
|
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
|
||||||
url="https://git.stormux.org/storm/cthulhu"
|
url="https://git.stormux.org/storm/cthulhu"
|
||||||
@@ -69,6 +69,9 @@ optdepends=(
|
|||||||
# nvda2cthulhu plugin (optional)
|
# nvda2cthulhu plugin (optional)
|
||||||
'python-msgpack: Msgpack decoding for nvda2cthulhu'
|
'python-msgpack: Msgpack decoding for nvda2cthulhu'
|
||||||
'python-tornado: WebSocket server for nvda2cthulhu'
|
'python-tornado: WebSocket server for nvda2cthulhu'
|
||||||
|
|
||||||
|
# Window Title Reader plugin (optional)
|
||||||
|
'python-xlib: X11 access for Wine window title plugin'
|
||||||
)
|
)
|
||||||
makedepends=(
|
makedepends=(
|
||||||
git
|
git
|
||||||
@@ -88,11 +91,6 @@ b2sums=(
|
|||||||
'SKIP'
|
'SKIP'
|
||||||
)
|
)
|
||||||
|
|
||||||
prepare() {
|
|
||||||
cd cthulhu
|
|
||||||
git checkout testing
|
|
||||||
}
|
|
||||||
|
|
||||||
pkgver() {
|
pkgver() {
|
||||||
cd cthulhu
|
cd cthulhu
|
||||||
grep "^version = " src/cthulhu/cthulhuVersion.py | sed 's/version = "\(.*\)"/\1/'
|
grep "^version = " src/cthulhu/cthulhuVersion.py | sed 's/version = "\(.*\)"/\1/'
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
project('cthulhu',
|
project('cthulhu',
|
||||||
version: '2026.01.06-testing',
|
version: '2026.01.10-master',
|
||||||
meson_version: '>= 1.0.0',
|
meson_version: '>= 1.0.0',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -648,6 +648,11 @@ def _start_dbus_service():
|
|||||||
typing_echo_manager = typing_echo_presenter.getManager()
|
typing_echo_manager = typing_echo_presenter.getManager()
|
||||||
dbus_service.get_remote_controller().register_decorated_module("TypingEchoManager", typing_echo_manager)
|
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:
|
except Exception as e:
|
||||||
msg = f"CTHULHU: Failed to start D-Bus service: {e}"
|
msg = f"CTHULHU: Failed to start D-Bus service: {e}"
|
||||||
debug.printMessage(debug.LEVEL_SEVERE, msg, True)
|
debug.printMessage(debug.LEVEL_SEVERE, msg, True)
|
||||||
|
|||||||
@@ -23,5 +23,5 @@
|
|||||||
# Forked from Orca screen reader.
|
# Forked from Orca screen reader.
|
||||||
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
||||||
|
|
||||||
version = "2026.01.06"
|
version = "2026.01.10"
|
||||||
codeName = "testing"
|
codeName = "master"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ __license__ = "LGPL"
|
|||||||
import enum
|
import enum
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
from typing import Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from dasbus.connection import SessionMessageBus
|
from dasbus.connection import SessionMessageBus
|
||||||
@@ -151,6 +151,22 @@ def _extract_function_parameters(func: Callable) -> list[tuple[str, str]]:
|
|||||||
|
|
||||||
return parameters
|
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:
|
class _HandlerInfo:
|
||||||
"""Stores processed information about a function exposed via D-Bus."""
|
"""Stores processed information about a function exposed via D-Bus."""
|
||||||
@@ -691,7 +707,13 @@ class CthulhuRemoteController:
|
|||||||
if script is None:
|
if script is None:
|
||||||
manager = script_manager.get_manager()
|
manager = script_manager.get_manager()
|
||||||
script = manager.get_default_script()
|
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)
|
_get_input_event_manager().get_manager().process_remote_controller_event(event)
|
||||||
return rv
|
return rv
|
||||||
return _wrapper
|
return _wrapper
|
||||||
@@ -715,7 +737,13 @@ class CthulhuRemoteController:
|
|||||||
if script is None:
|
if script is None:
|
||||||
manager = script_manager.get_manager()
|
manager = script_manager.get_manager()
|
||||||
script = manager.get_default_script()
|
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)
|
_get_input_event_manager().get_manager().process_remote_controller_event(event)
|
||||||
return rv
|
return rv
|
||||||
return _wrapper
|
return _wrapper
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ class Plugin:
|
|||||||
self.version = plugin_info.get_version()
|
self.version = plugin_info.get_version()
|
||||||
self.description = plugin_info.get_description()
|
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
|
@cthulhu_hookimpl
|
||||||
def activate(self, plugin=None):
|
def activate(self, plugin=None):
|
||||||
"""Activate the plugin. Override this in subclasses."""
|
"""Activate the plugin. Override this in subclasses."""
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
|
||||||
|
from . import dbus_service
|
||||||
|
|
||||||
# Import pluggy if available
|
# Import pluggy if available
|
||||||
try:
|
try:
|
||||||
import pluggy
|
import pluggy
|
||||||
@@ -638,6 +640,118 @@ class PluginSystemManager:
|
|||||||
active_instances.append(plugin_info.instance)
|
active_instances.append(plugin_info.instance)
|
||||||
return active_instances
|
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):
|
def setActivePlugins(self, activePlugins):
|
||||||
"""Set active plugins and sync their state."""
|
"""Set active plugins and sync their state."""
|
||||||
logger.info(f"PLUGIN SYSTEM: setActivePlugins called with: {activePlugins}")
|
logger.info(f"PLUGIN SYSTEM: setActivePlugins called with: {activePlugins}")
|
||||||
@@ -939,6 +1053,8 @@ class PluginSystemManager:
|
|||||||
pluginInfo.loaded = True
|
pluginInfo.loaded = True
|
||||||
logger.info(f"Successfully loaded plugin: {module_name}")
|
logger.info(f"Successfully loaded plugin: {module_name}")
|
||||||
|
|
||||||
|
self._register_plugin_dbus_module(pluginInfo)
|
||||||
|
|
||||||
# Register any global keybindings from the plugin
|
# Register any global keybindings from the plugin
|
||||||
self.register_plugin_global_keybindings(pluginInfo.instance)
|
self.register_plugin_global_keybindings(pluginInfo.instance)
|
||||||
|
|
||||||
@@ -974,6 +1090,8 @@ class PluginSystemManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deactivating plugin {module_name}: {e}")
|
logger.error(f"Error deactivating plugin {module_name}: {e}")
|
||||||
|
|
||||||
|
self._deregister_plugin_dbus_module(pluginInfo)
|
||||||
|
|
||||||
# Unregister from pluggy
|
# Unregister from pluggy
|
||||||
self.plugin_manager.unregister(plugin_instance)
|
self.plugin_manager.unregister(plugin_instance)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
windowtitlereader_python_sources = files([
|
||||||
|
'__init__.py',
|
||||||
|
'plugin.py'
|
||||||
|
])
|
||||||
|
|
||||||
|
python3.install_sources(
|
||||||
|
windowtitlereader_python_sources,
|
||||||
|
subdir: 'cthulhu/plugins/WindowTitleReader'
|
||||||
|
)
|
||||||
|
|
||||||
|
install_data(
|
||||||
|
'plugin.info',
|
||||||
|
install_dir: python3.get_install_dir() / 'cthulhu' / 'plugins' / 'WindowTitleReader'
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
name = Window Title Reader
|
||||||
|
version = 1.0.0
|
||||||
|
description = Toggle window title reader, including Wine Desktop titles
|
||||||
|
authors = Stormux <storm_dragon@stormux.org>
|
||||||
|
website = https://git.stormux.org/storm/cthulhu
|
||||||
|
copyright = Copyright 2026
|
||||||
|
builtin = false
|
||||||
|
hidden = false
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
#
|
||||||
|
# Copyright (c) 2026 Stormux
|
||||||
|
#
|
||||||
|
# This library is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU Lesser General Public
|
||||||
|
# License as published by the Free Software Foundation; either
|
||||||
|
# version 2.1 of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This library is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
# Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public
|
||||||
|
# License along with this library; if not, write to the
|
||||||
|
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
|
||||||
|
# Boston MA 02110-1301 USA.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""Window Title Reader plugin for Cthulhu."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
from cthulhu import debug
|
||||||
|
from cthulhu.plugin import Plugin, cthulhu_hookimpl
|
||||||
|
|
||||||
|
xlibAvailable = True
|
||||||
|
xlibImportError = None
|
||||||
|
try:
|
||||||
|
from Xlib import X
|
||||||
|
from Xlib import display as xDisplay
|
||||||
|
from Xlib import error as xError
|
||||||
|
except Exception as error:
|
||||||
|
xlibAvailable = False
|
||||||
|
xlibImportError = error
|
||||||
|
X = None
|
||||||
|
xDisplay = None
|
||||||
|
xError = None
|
||||||
|
|
||||||
|
pluginLogger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class WindowTitleReader(Plugin):
|
||||||
|
"""Speak the active window title when toggled on."""
|
||||||
|
|
||||||
|
def __init__(self, *positionalArgs, **keywordArgs):
|
||||||
|
super().__init__(*positionalArgs, **keywordArgs)
|
||||||
|
self._activated = False
|
||||||
|
self._enabled = False
|
||||||
|
self._pollSourceId = None
|
||||||
|
self._pollIntervalMs = 100
|
||||||
|
self._lastTitle = None
|
||||||
|
self._display = None
|
||||||
|
self._root = None
|
||||||
|
self._atoms = {}
|
||||||
|
self._wineDesktopLabel = "Wine Desktop"
|
||||||
|
self._kbToggleTracking = None
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Plugin initialized", True)
|
||||||
|
|
||||||
|
@cthulhu_hookimpl
|
||||||
|
def activate(self, plugin=None):
|
||||||
|
if plugin is not None and plugin is not self:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._activated:
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Already activated", True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._register_keybinding()
|
||||||
|
self._activated = True
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Activated", True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@cthulhu_hookimpl
|
||||||
|
def deactivate(self, plugin=None):
|
||||||
|
if plugin is not None and plugin is not self:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._stop_tracking()
|
||||||
|
self._activated = False
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, "WindowTitleReader: Deactivated", True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _register_keybinding(self):
|
||||||
|
if not self.app:
|
||||||
|
debug.printMessage(
|
||||||
|
debug.LEVEL_INFO,
|
||||||
|
"WindowTitleReader: No app reference; cannot register keybinding",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
gestureString = "kb:cthulhu+control+shift+w"
|
||||||
|
description = "Toggle window title reader"
|
||||||
|
|
||||||
|
self._kbToggleTracking = self.registerGestureByString(
|
||||||
|
self._toggle_tracking,
|
||||||
|
description,
|
||||||
|
gestureString,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._kbToggleTracking:
|
||||||
|
debug.printMessage(
|
||||||
|
debug.LEVEL_INFO,
|
||||||
|
f"WindowTitleReader: Registered keybinding {gestureString}",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
debug.printMessage(
|
||||||
|
debug.LEVEL_INFO,
|
||||||
|
f"WindowTitleReader: Failed to register keybinding {gestureString}",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _toggle_tracking(self, script=None, inputEvent=None):
|
||||||
|
if self._enabled:
|
||||||
|
self._stop_tracking()
|
||||||
|
self._present_message("Window title reader off")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not xlibAvailable:
|
||||||
|
self._present_message("Window title reader unavailable")
|
||||||
|
debug.printMessage(
|
||||||
|
debug.LEVEL_INFO,
|
||||||
|
f"WindowTitleReader: python-xlib unavailable: {xlibImportError}",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self._start_tracking():
|
||||||
|
self._present_message("Window title reader on")
|
||||||
|
else:
|
||||||
|
self._present_message("Window title reader unavailable")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _present_message(self, messageText):
|
||||||
|
if not self.app:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
appState = self.app.getDynamicApiManager().getAPI("CthulhuState")
|
||||||
|
if appState and appState.activeScript:
|
||||||
|
appState.activeScript.presentMessage(messageText, resetStyles=False)
|
||||||
|
except Exception:
|
||||||
|
pluginLogger.exception("WindowTitleReader: Failed to present message")
|
||||||
|
|
||||||
|
def _start_tracking(self):
|
||||||
|
if self._enabled:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._display = xDisplay.Display()
|
||||||
|
self._root = self._display.screen().root
|
||||||
|
self._init_atoms()
|
||||||
|
self._pollSourceId = GLib.timeout_add(self._pollIntervalMs, self._poll_window_title)
|
||||||
|
self._enabled = True
|
||||||
|
self._poll_window_title()
|
||||||
|
return True
|
||||||
|
except Exception as error:
|
||||||
|
debug.printMessage(
|
||||||
|
debug.LEVEL_INFO,
|
||||||
|
f"WindowTitleReader: Failed to start tracking: {error}",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
pluginLogger.exception("WindowTitleReader: Failed to start tracking")
|
||||||
|
self._stop_tracking()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _stop_tracking(self):
|
||||||
|
if self._pollSourceId is not None:
|
||||||
|
GLib.source_remove(self._pollSourceId)
|
||||||
|
self._pollSourceId = None
|
||||||
|
|
||||||
|
self._enabled = False
|
||||||
|
self._lastTitle = None
|
||||||
|
self._cleanup_display()
|
||||||
|
|
||||||
|
def _cleanup_display(self):
|
||||||
|
if self._display is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._display.close()
|
||||||
|
except Exception:
|
||||||
|
pluginLogger.exception("WindowTitleReader: Failed to close display")
|
||||||
|
|
||||||
|
self._display = None
|
||||||
|
self._root = None
|
||||||
|
self._atoms = {}
|
||||||
|
|
||||||
|
def _init_atoms(self):
|
||||||
|
self._atoms = {
|
||||||
|
"NET_ACTIVE_WINDOW": self._display.intern_atom("_NET_ACTIVE_WINDOW"),
|
||||||
|
"NET_WM_NAME": self._display.intern_atom("_NET_WM_NAME"),
|
||||||
|
"WM_NAME": self._display.intern_atom("WM_NAME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _poll_window_title(self):
|
||||||
|
if not self._enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
activeWindow = self._get_active_window()
|
||||||
|
if not activeWindow:
|
||||||
|
self._lastTitle = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
windowTitle = self._get_current_title(activeWindow)
|
||||||
|
if not windowTitle:
|
||||||
|
self._lastTitle = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
if windowTitle != self._lastTitle:
|
||||||
|
self._present_title(windowTitle)
|
||||||
|
self._lastTitle = windowTitle
|
||||||
|
except Exception as error:
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, f"WindowTitleReader: Poll error: {error}", True)
|
||||||
|
pluginLogger.exception("WindowTitleReader: Poll failed")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _present_title(self, titleText):
|
||||||
|
if not self.app:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
appState = self.app.getDynamicApiManager().getAPI("CthulhuState")
|
||||||
|
if appState and appState.activeScript:
|
||||||
|
appState.activeScript.presentMessage(titleText, resetStyles=False)
|
||||||
|
except Exception:
|
||||||
|
pluginLogger.exception("WindowTitleReader: Failed to present title")
|
||||||
|
|
||||||
|
def _get_current_title(self, activeWindow):
|
||||||
|
activeTitle = self._get_window_title(activeWindow)
|
||||||
|
if not activeTitle:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if self._is_wine_desktop_title(activeTitle):
|
||||||
|
return self._get_wine_desktop_title(activeWindow)
|
||||||
|
|
||||||
|
return activeTitle
|
||||||
|
|
||||||
|
def _get_wine_desktop_title(self, desktopWindow):
|
||||||
|
focusWindow = self._get_focus_window()
|
||||||
|
if focusWindow and focusWindow.id != desktopWindow.id:
|
||||||
|
focusTitle = self._get_window_title(focusWindow)
|
||||||
|
if focusTitle and not self._is_wine_desktop_title(focusTitle):
|
||||||
|
return focusTitle
|
||||||
|
|
||||||
|
return self._find_child_title(desktopWindow)
|
||||||
|
|
||||||
|
def _find_child_title(self, rootWindow):
|
||||||
|
try:
|
||||||
|
childQueue = list(rootWindow.query_tree().children)
|
||||||
|
except xError.XError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
checkedCount = 0
|
||||||
|
while childQueue and checkedCount < 200:
|
||||||
|
window = childQueue.pop(0)
|
||||||
|
checkedCount += 1
|
||||||
|
titleText = self._get_window_title(window)
|
||||||
|
if titleText and not self._is_wine_desktop_title(titleText):
|
||||||
|
return titleText
|
||||||
|
|
||||||
|
try:
|
||||||
|
childQueue.extend(window.query_tree().children)
|
||||||
|
except xError.XError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _is_wine_desktop_title(self, titleText):
|
||||||
|
return self._wineDesktopLabel.lower() in titleText.lower()
|
||||||
|
|
||||||
|
def _get_window_title(self, window):
|
||||||
|
if not window:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
netTitle = self._get_text_property(window, "NET_WM_NAME")
|
||||||
|
if netTitle:
|
||||||
|
return netTitle
|
||||||
|
|
||||||
|
return self._get_text_property(window, "WM_NAME")
|
||||||
|
|
||||||
|
def _get_text_property(self, window, atomName):
|
||||||
|
atom = self._atoms.get(atomName)
|
||||||
|
if not atom:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
propertyData = window.get_full_property(atom, X.AnyPropertyType)
|
||||||
|
except xError.XError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if not propertyData or propertyData.value is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
value = propertyData.value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip("\x00")
|
||||||
|
|
||||||
|
try:
|
||||||
|
rawBytes = bytes(value)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
decodedText = self._decode_property_bytes(rawBytes)
|
||||||
|
return decodedText.strip("\x00")
|
||||||
|
|
||||||
|
def _decode_property_bytes(self, rawBytes):
|
||||||
|
for encoding in ("utf-8", "latin-1"):
|
||||||
|
try:
|
||||||
|
return rawBytes.decode(encoding, errors="replace")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _get_active_window(self):
|
||||||
|
if not self._root:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
propertyData = self._root.get_full_property(
|
||||||
|
self._atoms["NET_ACTIVE_WINDOW"],
|
||||||
|
X.AnyPropertyType,
|
||||||
|
)
|
||||||
|
except xError.XError:
|
||||||
|
propertyData = None
|
||||||
|
|
||||||
|
if propertyData and propertyData.value:
|
||||||
|
try:
|
||||||
|
windowId = int(propertyData.value[0])
|
||||||
|
return self._display.create_resource_object("window", windowId)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return self._get_focus_window()
|
||||||
|
|
||||||
|
def _get_focus_window(self):
|
||||||
|
if not self._display:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
focusInfo = self._display.get_input_focus()
|
||||||
|
focusWindow = focusInfo.focus
|
||||||
|
if not focusWindow or focusWindow.id == 0:
|
||||||
|
return None
|
||||||
|
if self._root and focusWindow.id == self._root.id:
|
||||||
|
return None
|
||||||
|
return focusWindow
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -14,3 +14,4 @@ subdir('SimplePluginSystem')
|
|||||||
subdir('hello_world')
|
subdir('hello_world')
|
||||||
subdir('self_voice')
|
subdir('self_voice')
|
||||||
subdir('SSIPProxy')
|
subdir('SSIPProxy')
|
||||||
|
subdir('WindowTitleReader')
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ presentChatRoomLast = False
|
|||||||
presentLiveRegionFromInactiveTab = False
|
presentLiveRegionFromInactiveTab = False
|
||||||
|
|
||||||
# Plugins
|
# Plugins
|
||||||
activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'IndentationAudio']
|
activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'IndentationAudio', 'WindowTitleReader']
|
||||||
pluginSources = []
|
pluginSources = []
|
||||||
|
|
||||||
# AI Assistant settings (disabled by default for opt-in behavior)
|
# AI Assistant settings (disabled by default for opt-in behavior)
|
||||||
|
|||||||
Reference in New Issue
Block a user