33 Commits

Author SHA1 Message Date
Storm Dragon 75ad2f0dec Plugins exposed via d-bus remote. 2026-01-11 23:21:52 -05:00
Storm Dragon d3c48b1e84 New window title reading plugin implemented. Watches for window title changes and reads them. Enable/disable with cthulhu+control+shift+W. 2026-01-11 22:52:07 -05:00
Storm Dragon 2ccd118cc5 Hopefully stable code. 2026-01-10 19:53:11 -05:00
Storm Dragon c53ef6606e Merge branch 'testing' 2026-01-10 19:52:27 -05:00
Storm Dragon 404e87db25 Fixed a bug in PKGBUILD. 2026-01-08 16:21:22 -05:00
Storm Dragon 5cf9e66895 Ooops, missed a merge conflict string. 2026-01-07 12:15:28 -05:00
Storm Dragon d8101b37b9 Changes ready for wider testing. 2026-01-07 11:56:17 -05:00
Storm Dragon 2ca4948ab3 Merge branch 'testing' version 2025.01.02 2026-01-02 19:10:50 -05:00
Storm Dragon c0aecfdb9f Merge branch 'testing' wanted to squeeze in a final feature for this release. Ported over Fenrir's quick speech settings keybindings, control speech with cthulhu+arrow keys. Save settings with cthulhu+control+shift+S 2026-01-02 10:46:27 -05:00
Storm Dragon 79ac07e8dc Updated version. 2026-01-02 09:50:33 -05:00
Storm Dragon e3e58adfbe Merge branch 'testing' Tag RC 1 2026-01-02 09:45:19 -05:00
Storm Dragon 8ff74bb83a Merge branch 'testing' 2026-01-01 00:17:30 -05:00
Storm Dragon 6f45ad61cf Preparing for releases, no new features, only testing and bug fixes until new version is tagged. 2025-12-31 13:26:45 -05:00
Storm Dragon e94af432c2 Merge branch 'testing' 2025-12-30 14:30:09 -05:00
Storm Dragon ef18ae7cbc Oops, fixed meson.build 2025-12-30 05:33:58 -05:00
Storm Dragon 4f210406d3 Moving closer to tagged release. 2025-12-30 05:30:50 -05:00
Storm Dragon c3d604f4a1 Preparing for new release. 2025-12-28 17:04:35 -05:00
Storm Dragon eef509a5a1 Merge branch 'testing' RC 1 for tagged release. 2025-12-22 19:51:06 -05:00
Storm Dragon 4be007bf7d Merged everything into master, at a decent point to save progress. 2025-12-12 20:21:53 -05:00
Storm Dragon ad6de50f9b Merged testing. 2025-08-22 00:31:32 -04:00
Storm Dragon a044bfaade Updated PKGBUILD. 2025-08-19 19:30:08 -04:00
Storm Dragon f9b408a1d2 Preparing for tagged release. 2025-08-19 19:27:07 -04:00
Storm Dragon f0e7f14806 Fixed merge conflicts. 2025-08-19 19:24:35 -04:00
Storm Dragon 220e84afa4 PKGBUILD updated. 2025-06-06 18:07:40 -04:00
Storm Dragon 5d48f4770c latest version with plugin code fixed. 2025-06-06 18:00:35 -04:00
Storm Dragon 81cc4627f7 Merge branch 'testing' plugin with keybindings bug potentially fixed. 2025-06-06 17:58:58 -04:00
Storm Dragon d36b664319 Merge branch 'testing'
Plugins are in a much better state now, mostly working. The exception is, plugins that create a keyboard shortcut don't actually bind the shortcut. That one is turning out to be a lot harder to fix than I originally thought.
2025-04-05 16:32:17 -04:00
Storm Dragon 3f7d60763d Merge branch 'testing'
Plugins are currently broken as Cthulhu moves over to pluggy. Libpeas and pygobject no longer play nicely together after latest updates. I really did not want to make a new release yet, because it is not ready, but a screen reader that at least reads instead of crashing at launch is better than nothing.
2025-04-03 20:17:14 -04:00
Storm Dragon 6bbf3d0e67 Merge branch 'testing' latest changes merged. 2024-12-22 19:04:57 -05:00
Storm Dragon cbe3424e29 Fix the version.py file in preparation for merging. 2024-12-22 19:04:39 -05:00
Storm Dragon 327ad99e49 Preparing for stable tag release. 2024-12-18 19:49:25 -05:00
Storm Dragon c46cf1c939 Merge branch 'testing' fixed preferences GUI. 2024-12-18 19:45:59 -05:00
Storm Dragon a97bb30ed3 New version system merged. 2024-12-18 11:42:52 -05:00
17 changed files with 625 additions and 41 deletions
+1 -10
View File
@@ -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
+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
+44 -12
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
@@ -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
+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.
+4 -6
View File
@@ -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
View File
@@ -1,5 +1,5 @@
project('cthulhu',
version: '2026.01.06-testing',
version: '2026.01.10-master',
meson_version: '>= 1.0.0',
)
+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)
+2 -2
View File
@@ -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"
+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)
@@ -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
+1
View File
@@ -14,3 +14,4 @@ subdir('SimplePluginSystem')
subdir('hello_world')
subdir('self_voice')
subdir('SSIPProxy')
subdir('WindowTitleReader')
+1 -1
View File
@@ -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)