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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
Manual testing is recommended. The legacy keystroke-driven test harness has been removed.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -200,18 +243,7 @@ rm -rf _build
|
||||
|
||||
### Testing
|
||||
|
||||
Run the regression test suite:
|
||||
|
||||
```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
|
||||
```
|
||||
Manual testing is recommended. The legacy keystroke-driven test harness has been removed.
|
||||
|
||||
## Self-voicing
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
||||
|
||||
pkgname=cthulhu
|
||||
pkgver=2026.01.06
|
||||
pkgver=2026.01.10
|
||||
pkgrel=1
|
||||
pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca"
|
||||
url="https://git.stormux.org/storm/cthulhu"
|
||||
@@ -69,6 +69,9 @@ optdepends=(
|
||||
# nvda2cthulhu plugin (optional)
|
||||
'python-msgpack: Msgpack decoding for nvda2cthulhu'
|
||||
'python-tornado: WebSocket server for nvda2cthulhu'
|
||||
|
||||
# Window Title Reader plugin (optional)
|
||||
'python-xlib: X11 access for Wine window title plugin'
|
||||
)
|
||||
makedepends=(
|
||||
git
|
||||
@@ -88,11 +91,6 @@ b2sums=(
|
||||
'SKIP'
|
||||
)
|
||||
|
||||
prepare() {
|
||||
cd cthulhu
|
||||
git checkout testing
|
||||
}
|
||||
|
||||
pkgver() {
|
||||
cd cthulhu
|
||||
grep "^version = " src/cthulhu/cthulhuVersion.py | sed 's/version = "\(.*\)"/\1/'
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
project('cthulhu',
|
||||
version: '2026.01.06-testing',
|
||||
version: '2026.01.10-master',
|
||||
meson_version: '>= 1.0.0',
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -23,5 +23,5 @@
|
||||
# Forked from Orca screen reader.
|
||||
# Cthulhu project: https://git.stormux.org/storm/cthulhu
|
||||
|
||||
version = "2026.01.06"
|
||||
codeName = "testing"
|
||||
version = "2026.01.10"
|
||||
codeName = "master"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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('self_voice')
|
||||
subdir('SSIPProxy')
|
||||
subdir('WindowTitleReader')
|
||||
|
||||
@@ -494,7 +494,7 @@ presentChatRoomLast = False
|
||||
presentLiveRegionFromInactiveTab = False
|
||||
|
||||
# Plugins
|
||||
activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'IndentationAudio']
|
||||
activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'IndentationAudio', 'WindowTitleReader']
|
||||
pluginSources = []
|
||||
|
||||
# AI Assistant settings (disabled by default for opt-in behavior)
|
||||
|
||||
Reference in New Issue
Block a user