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.
This commit is contained in:
Storm Dragon 2025-04-03 20:17:14 -04:00
commit 3f7d60763d
39 changed files with 992 additions and 1026 deletions

View File

@ -46,6 +46,7 @@ AM_PATH_PYTHON(3.3)
AM_CHECK_PYMOD(gi,,,[AC_MSG_ERROR(Could not find python module: gi)])
AM_CHECK_PYMOD(json,,,[AC_MSG_ERROR(Could not find python module: json)])
AM_CHECK_PYMOD(pluggy,,[pluggy_available="yes"],[pluggy_available="no"])
AM_CHECK_PYMOD(brlapi,,[brlapi_available="yes"],[brlapi_available="no"])
AM_CHECK_PYMOD(speechd,,[speechd_available="yes"],[speechd_available="no"])
AC_ARG_WITH([liblouis],
@ -129,9 +130,9 @@ src/cthulhu/plugins/HelloCthulhu/Makefile
src/cthulhu/plugins/PluginManager/Makefile
src/cthulhu/plugins/Clipboard/Makefile
src/cthulhu/plugins/DisplayVersion/Makefile
src/cthulhu/plugins/HelloWorld/Makefile
src/cthulhu/plugins/hello_world/Makefile
src/cthulhu/plugins/CapsLockHack/Makefile
src/cthulhu/plugins/SelfVoice/Makefile
src/cthulhu/plugins/self_voice/Makefile
src/cthulhu/plugins/Date/Makefile
src/cthulhu/plugins/Time/Makefile
src/cthulhu/plugins/MouseReview/Makefile
@ -168,6 +169,7 @@ if test "$have_libpeas" = "no"; then
fi
echo
echo Use pluggy: $pluggy_available
echo Use speech-dispatcher: $speechd_available
echo Use brltty: $brlapi_available
echo Use liblouis: $louis_available

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist gettext-domain="slint">
<schema id="toggle.screenreader" path="/toggle/screenreader/">
<key name="binding" type="s">
<default>'&lt;Shift&gt;&lt;Alt&gt;v'</default>
<summary>Keybinding</summary>
<description>Keybinding associated to toggle screen reader.</description>
</key>
<key name="action" type="s">
<default>'/opt/I38/scripts/toggle_screenreader.sh 1>/dev/null'</default>
<summary>Command</summary>
<description>Command to toggle the screen reader between orca and cthulhu.</description>
</key>
<key name="name" type="s">
<default>'Toggle screenreader'</default>
<summary>Name</summary>
<description>Description associated to toggle screen reader.</description>
</key>
</schema>
</schemalist>

View File

@ -34,6 +34,94 @@ __copyright__ = "Copyright (c) 2004-2009 Sun Microsystems Inc." \
__license__ = "LGPL"
import faulthandler
class APIHelper:
"""Helper class for plugin API interactions, including keybindings."""
def __init__(self, app):
"""Initialize the APIHelper.
Arguments:
- app: the Cthulhu application
"""
self.app = app
self._gestureBindings = {}
def registerGestureByString(self, function, name, gestureString,
inputEventType='default', normalizer='cthulhu',
learnModeEnabled=True, contextName=None):
"""Register a gesture by string.
Arguments:
- function: the function to call when the gesture is performed
- name: a human-readable name for this gesture
- gestureString: string representation of the gesture (e.g., 'kb:cthulhu+z')
- inputEventType: the type of input event
- normalizer: the normalizer to use
- learnModeEnabled: whether this should be available in learn mode
- contextName: the context for this gesture (e.g., plugin name)
Returns the binding ID or None if registration failed
"""
if not gestureString.startswith("kb:"):
return None
# Extract the key portion from the gesture string
key = gestureString.split(":", 1)[1]
# Handle Cthulhu modifier specially
if "cthulhu+" in key.lower():
from . import keybindings
key = key.lower().replace("cthulhu+", "")
# Create a keybinding handler
class GestureHandler:
def __init__(self, function, description):
self.function = function
self.description = description
def __call__(self, script, inputEvent):
return self.function(script, inputEvent)
handler = GestureHandler(function, name)
# Register the binding with the active script
from . import cthulhu_state
if cthulhu_state.activeScript:
bindings = cthulhu_state.activeScript.getKeyBindings()
binding = keybindings.KeyBinding(
key,
keybindings.defaultModifierMask,
keybindings.CTHULHU_MODIFIER_MASK,
handler)
bindings.add(binding)
# Store binding for later reference
if contextName not in self._gestureBindings:
self._gestureBindings[contextName] = []
self._gestureBindings[contextName].append(binding)
return binding
return None
def unregisterShortcut(self, binding, contextName=None):
"""Unregister a previously registered shortcut.
Arguments:
- binding: the binding to unregister
- contextName: the context for this gesture
"""
# Remove from script's keybindings
from . import cthulhu_state
if cthulhu_state.activeScript:
bindings = cthulhu_state.activeScript.getKeyBindings()
bindings.remove(binding)
# Remove from our tracking
if contextName in self._gestureBindings:
if binding in self._gestureBindings[contextName]:
self._gestureBindings[contextName].remove(binding)
import gi
import importlib
import os
@ -74,7 +162,7 @@ from .ax_object import AXObject
from .ax_utilities import AXUtilities
from .input_event import BrailleEvent
from . import cmdnames
from . import plugin_system_manager
from . import plugin_system_manager # This will now be your pluggy-based implementation
from . import guilabels
from . import acss
from . import text_attribute_names
@ -920,7 +1008,6 @@ class Cthulhu(GObject.Object):
GObject.Object.__init__(self)
# add members
self.resourceManager = resource_manager.ResourceManager(self)
self.APIHelper = plugin_system_manager.APIHelper(self)
self.eventManager = _eventManager
self.settingsManager = _settingsManager
self.scriptManager = _scriptManager
@ -928,6 +1015,7 @@ class Cthulhu(GObject.Object):
self.dynamicApiManager = dynamic_api_manager.DynamicApiManager(self)
self.translationManager = translation_manager.TranslationManager(self)
self.debugManager = debug
self.APIHelper = APIHelper(self)
self.createCompatAPI()
self.pluginSystemManager = plugin_system_manager.PluginSystemManager(self)
def getAPIHelper(self):
@ -987,6 +1075,7 @@ class Cthulhu(GObject.Object):
# cthulhu lets say, special compat handling....
self.getDynamicApiManager().registerAPI('EmitRegionChanged', emitRegionChanged)
self.getDynamicApiManager().registerAPI('LoadUserSettings', loadUserSettings)
self.getDynamicApiManager().registerAPI('APIHelper', self.APIHelper)
cthulhuApp = Cthulhu()

View File

@ -23,5 +23,5 @@
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
version = "2024.12.22"
version = "2025.04.03"
codeName = "testing"

View File

@ -1,185 +1,89 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# 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.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
import os, inspect
import gi
from gi.repository import GObject
"""Base class for Cthulhu plugins using pluggy."""
class Plugin():
#__gtype_name__ = 'BasePlugin'
import os
import logging
def __init__(self, *args, **kwargs):
self.API = None
self.pluginInfo = None
self.moduleDir = ''
self.hidden = False
self.moduleName = ''
# Import pluggy for hook specifications
try:
import pluggy
cthulhu_hookimpl = pluggy.HookimplMarker("cthulhu")
PLUGGY_AVAILABLE = True
except ImportError:
# Fallback if pluggy is not available
def cthulhu_hookimpl(func=None, **kwargs):
"""Fallback decorator when pluggy is not available.
This is a no-op decorator that returns the original function.
It allows the code to continue working without pluggy, though
plugins will be disabled.
"""
if func is None:
return lambda f: f
return func
PLUGGY_AVAILABLE = False
logging.getLogger(__name__).info("Pluggy not available, plugins will be disabled")
logger = logging.getLogger(__name__)
class Plugin:
"""Base class for Cthulhu plugins."""
def __init__(self):
"""Initialize the plugin with default attributes."""
self.app = None
self.plugin_info = None
self.module_name = ''
self.name = ''
self.version = ''
self.website = ''
self.authors = []
self.buildIn = False
self.description = ''
self.iconName = ''
self.copyright = ''
self.dependencies = False
self.helpUri = ''
self.dataDir = ''
self.translationContext = None
def setApp(self, app):
def set_app(self, app):
"""Set the application reference."""
self.app = app
self.dynamicApiManager = app.getDynamicApiManager()
self.signalManager = app.getSignalManager()
def getApp(self):
return self.app
def setPluginInfo(self, pluginInfo):
self.pluginInfo = pluginInfo
self.updatePluginInfoAttributes()
def getPluginInfo(self):
return self.pluginInfo
def updatePluginInfoAttributes(self):
self.moduleDir = ''
self.hidden = False
self.moduleName = ''
self.name = ''
self.version = ''
self.website = ''
self.authors = []
self.buildIn = False
self.description = ''
self.iconName = ''
self.copyright = ''
self.dependencies = False
self.helpUri = ''
self.dataDir = ''
pluginInfo = self.getPluginInfo()
if pluginInfo == None:
def set_plugin_info(self, plugin_info):
"""Set plugin information and extract relevant attributes."""
self.plugin_info = plugin_info
if plugin_info:
self.module_name = getattr(plugin_info, 'module_name', '')
self.name = getattr(plugin_info, 'name', '')
self.version = getattr(plugin_info, 'version', '')
self.description = getattr(plugin_info, 'description', '')
@cthulhu_hookimpl
def activate(self, plugin=None):
"""Activate the plugin. Override this in subclasses."""
if plugin is not None and plugin is not self:
return
self.moduleName = self.getApp().getPluginSystemManager().getPluginModuleName(pluginInfo)
self.name = self.getApp().getPluginSystemManager().getPluginName(pluginInfo)
self.version = self.getApp().getPluginSystemManager().getPluginVersion(pluginInfo)
self.moduleDir = self.getApp().getPluginSystemManager().getPluginModuleDir(pluginInfo)
self.buildIn = self.getApp().getPluginSystemManager().isPluginBuildIn(pluginInfo)
self.description = self.getApp().getPluginSystemManager().getPluginDescription(pluginInfo)
self.hidden = self.getApp().getPluginSystemManager().isPluginHidden(pluginInfo)
self.website = self.getApp().getPluginSystemManager().getPluginWebsite(pluginInfo)
self.authors = self.getApp().getPluginSystemManager().getPluginAuthors(pluginInfo)
self.iconName = self.getApp().getPluginSystemManager().getPluginIconName(pluginInfo)
self.copyright = self.getApp().getPluginSystemManager().getPluginCopyright(pluginInfo)
self.dependencies = self.getApp().getPluginSystemManager().getPluginDependencies(pluginInfo)
logger.info(f"Activating plugin: {self.name}")
#settings = self.getApp().getPluginSystemManager().getPluginSettings(pluginInfo)
#hasDependencies = self.getApp().getPluginSystemManager().hasPluginDependency(pluginInfo)
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin. Override this in subclasses."""
if plugin is not None and plugin is not self:
return
logger.info(f"Deactivating plugin: {self.name}")
#externalData = self.getApp().getPluginSystemManager().getPluginExternalData(pluginInfo)
self.helpUri = self.getApp().getPluginSystemManager().getPlugingetHelpUri(pluginInfo)
self.dataDir = self.getApp().getPluginSystemManager().getPluginDataDir(pluginInfo)
self.updateTranslationContext()
def updateTranslationContext(self, domain = None, localeDir = None, language = None, fallbackToCthulhuTranslation = True):
self.translationContext = None
useLocaleDir = '{}/locale/'.format(self.getModuleDir())
if localeDir:
if os.path.isdir(localeDir):
useLocaleDir = localeDir
useName = self.getModuleName()
useDomain = useName
if domain:
useDomain = domain
useLanguage = None
if language:
useLanguage = language
self.translationContext = self.getApp().getTranslationManager().initTranslation(useName, domain=useDomain, localeDir=useLocaleDir, language=useLanguage, fallbackToCthulhuTranslation=fallbackToCthulhuTranslation)
# Point _ to the translation object in the globals namespace of the caller frame
try:
callerFrame = inspect.currentframe().f_back
# Install our gettext and ngettext function to the upper frame
callerFrame.f_globals['_'] = self.translationContext.gettext
callerFrame.f_globals['ngettext'] = self.translationContext.ngettext
finally:
del callerFrame # Avoid reference problems with frames (per python docs)
def getTranslationContext(self):
return self.translationContext
def isPluginBuildIn(self):
return self.buildIn
def isPluginHidden(self):
return self.hidden
def getAuthors(self):
return self.authors
def getCopyright(self):
return self.copyright
def getDataDir(self):
return self.dataDir
def getDependencies(self):
return self.dependencies
def getDescription(self):
return self.description
def getgetHelpUri(self):
return self.helpUri
def getIconName(self):
return self.iconName
def getModuleDir(self):
return self.moduleDir
def getModuleName(self):
return self.moduleName
def getName(self):
return self.name
def getVersion(self):
return self.version
def getWebsite(self):
return self.website
def getSetting(key):
#self.getModuleName())
def registerGestureByString(self, function, name, gestureString, learnModeEnabled=True):
"""Register a gesture by string."""
if self.app:
api_helper = self.app.getAPIHelper()
if api_helper:
return api_helper.registerGestureByString(
function,
name,
gestureString,
'default',
'cthulhu',
learnModeEnabled,
contextName=self.module_name
)
return None
def setSetting(key, value):
#self.getModuleName())
pass
def registerGestureByString(self, function, name, gestureString, learnModeEnabled = True):
keybinding = self.getApp().getAPIHelper().registerGestureByString(function, name, gestureString, 'default', 'cthulhu', learnModeEnabled, contextName = self.getModuleName())
return keybinding
def unregisterShortcut(self, function, name, gestureString, learnModeEnabled = True):
ok = self.getApp().getAPIHelper().unregisterShortcut(keybinding, contextName = self.getModuleName())
return ok
def registerSignal(self, signalName, signalFlag = GObject.SignalFlags.RUN_LAST, closure = GObject.TYPE_NONE, accumulator=()):
ok = self.signalManager.registerSignal(signalName, signalFlag, closure, accumulator, contextName = self.getModuleName())
return ok
def unregisterSignal(self, signalName):
# how to unregister?
pass
def connectSignal(self, signalName, function, param = None):
signalID = self.signalManager.connectSignal(signalName, function, param, contextName = self.getModuleName())
return signalID
def disconnectSignalByFunction(self, function):
# need get mapped function
mappedFunction = function
self.signalManager.disconnectSignalByFunction(mappedFunction, contextName = self.getModuleName())
def registerAPI(self, key, value, application = ''):
ok = self.dynamicApiManager.registerAPI(key, value, application = application, contextName = self.getModuleName())
return ok
def unregisterAPI(self, key, application = ''):
self.dynamicApiManager.unregisterAPI(key, application = application, contextName = self.getModuleName())

View File

@ -1,544 +1,343 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# 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.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
"""PluginManager for loading cthulhu plugins."""
import os, inspect, sys, tarfile, shutil
"""Plugin System Manager for Cthulhu using pluggy."""
import os
import inspect
import importlib.util
import logging
from enum import IntEnum
version = sys.version[:3] # we only need major.minor version.
if version in ["3.3","3.4"]:
from importlib.machinery import SourceFileLoader
else: # Python 3.5+, no support for python < 3.3.
import importlib.util
# Import pluggy if available
try:
import pluggy
PLUGGY_AVAILABLE = True
except ImportError:
PLUGGY_AVAILABLE = False
logging.getLogger(__name__).info("Pluggy not available, plugins will be disabled")
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
gi.require_version('Atspi', '2.0')
from gi.repository import Atspi
from cthulhu import resource_manager
class API(GObject.GObject):
"""Interface that gives access to all the objects of Cthulhu."""
def __init__(self, app):
GObject.GObject.__init__(self)
self.app = app
logger = logging.getLogger(__name__)
class PluginType(IntEnum):
"""Types of plugins we support, depending on their directory location."""
# pylint: disable=comparison-with-callable,inconsistent-return-statements,no-else-return
# SYSTEM: provides system wide plugins
"""Types of plugins we support."""
SYSTEM = 1
# USER: provides per user plugin
USER = 2
def __str__(self):
if self.value == PluginType.SYSTEM:
return _("System plugin")
elif self.value == PluginType.USER:
return _("User plugin")
def get_root_dir(self):
"""Returns the directory where this type of plugins can be found."""
if self.value == PluginType.SYSTEM:
return os.path.dirname(os.path.realpath(os.path.abspath(inspect.getfile(inspect.currentframe())))) + '/plugins'
current_file = inspect.getfile(inspect.currentframe())
current_dir = os.path.dirname(os.path.realpath(os.path.abspath(current_file)))
return os.path.join(current_dir, 'plugins')
elif self.value == PluginType.USER:
return os.path.expanduser('~') + '/.local/share/cthulhu/plugins'
return os.path.expanduser('~/.local/share/cthulhu/plugins')
class PluginSystemManager():
"""Cthulhu Plugin Manager to handle a set of plugins.
Attributes:
DEFAULT_LOADERS (tuple): Default loaders used by the plugin manager. For
possible values see
https://developer.gnome.org/libpeas/stable/PeasEngine.html#peas-engine-enable-loader
"""
DEFAULT_LOADERS = ("python3", )
class PluginInfo:
"""Information about a plugin."""
def __init__(self, name, module_name, module_dir, metadata=None):
self.name = name
self.module_name = module_name
self.module_dir = module_dir
self.metadata = metadata or {}
self.builtin = False
self.hidden = False
self.module = None
self.instance = None
self.loaded = False
def get_module_name(self):
return self.module_name
def get_name(self):
return self.metadata.get('name', self.name)
def get_version(self):
return self.metadata.get('version', '0.0.0')
def get_description(self):
return self.metadata.get('description', '')
def get_module_dir(self):
return self.module_dir
class PluginSystemManager:
"""Cthulhu Plugin Manager using pluggy."""
def __init__(self, app):
self.app = app
self.engine = Peas.Engine.get_default()
for loader in self.DEFAULT_LOADERS:
self.engine.enable_loader(loader)
# Initialize plugin manager
if PLUGGY_AVAILABLE:
self.plugin_manager = pluggy.PluginManager("cthulhu")
self._setupPluginsDir()
self._setupExtensionSet()
# Define hook specifications
hook_spec = pluggy.HookspecMarker("cthulhu")
class CthulhuHookSpecs:
@hook_spec
def activate(self, plugin=None):
"""Called when the plugin is activated."""
pass
@hook_spec
def deactivate(self, plugin=None):
"""Called when the plugin is deactivated."""
pass
self.plugin_manager.add_hookspecs(CthulhuHookSpecs)
else:
self.plugin_manager = None
# Plugin storage
self._plugins = {} # module_name -> PluginInfo
self._active_plugins = []
# Create plugin directories
self._setup_plugin_dirs()
def _setup_plugin_dirs(self):
"""Ensure plugin directories exist."""
os.makedirs(PluginType.SYSTEM.get_root_dir(), exist_ok=True)
os.makedirs(PluginType.USER.get_root_dir(), exist_ok=True)
if self.app:
self.gsettingsManager = self.app.getSettingsManager()
# settings else:
# settings self.gsettingsManager = gsettings_manager.getSettingsManager(self.app)
self._activePlugins = []
self._ignorePluginModulePath = []
@property
def plugins(self):
"""Gets the engine's plugin list."""
return self.engine.get_plugin_list()
@classmethod
def getPluginType(cls, pluginInfo):
"""Gets the PluginType for the specified Peas.PluginInfo."""
paths = [pluginInfo.get_data_dir(), PluginType.SYSTEM.get_root_dir()]
if os.path.commonprefix(paths) == PluginType.SYSTEM.get_root_dir():
return PluginType.SYSTEM
return PluginType.USER
"""Get all available plugins."""
return list(self._plugins.values())
def getExtension(self, pluginInfo):
if not pluginInfo:
return None
return self.extension_set.get_extension(pluginInfo)
def rescanPlugins(self):
self.engine.garbage_collect()
self.engine.rescan_plugins()
def getApp(self):
return self.app
def getPluginInfoByName(self, pluginName, pluginType=PluginType.USER):
"""Gets the plugin info for the specified plugin name.
Args:
pluginName (str): The name from the .plugin file of the module.
Returns:
Peas.PluginInfo: The plugin info if it exists. Otherwise, `None`.
"""
for pluginInfo in self.plugins:
if pluginInfo.get_module_name() == pluginName and PluginSystemManager.getPluginType(pluginInfo) == pluginType:
return pluginInfo
return None
def getActivePlugins(self):
return self._activePlugins
def setActivePlugins(self, activePlugins):
self._activePlugins = activePlugins
self.syncAllPluginsActive()
def isPluginBuildIn(self, pluginInfo):
return pluginInfo.is_builtin()
def isPluginHidden(self, pluginInfo):
return pluginInfo.is_hidden()
def getPluginAuthors(self, pluginInfo):
return pluginInfo.get_authors()
def getPluginCopyright(self, pluginInfo):
return pluginInfo.get_copyright()
def getPluginDataDir(self, pluginInfo):
return pluginInfo.get_data_dir()
def getPluginDependencies(self, pluginInfo):
return pluginInfo.get_dependencies()
def getPluginDescription(self, pluginInfo):
return pluginInfo.get_description()
def getPlugingetHelpUri(self, pluginInfo):
return pluginInfo.get_help_uri()
def getPluginIconName(self, pluginInfo):
return pluginInfo.get_icon_name()
def getPluginModuleDir(self, pluginInfo):
return pluginInfo.get_module_dir()
def getPluginModuleName(self, pluginInfo):
return pluginInfo.get_module_name()
def getPluginName(self, pluginInfo):
return pluginInfo.get_name()
def getPluginSettings(self, pluginInfo):
return pluginInfo.get_settings()
def getPluginVersion(self, pluginInfo):
return pluginInfo.get_version()
def getPluginWebsite(self, pluginInfo):
return pluginInfo.get_website()
# has_dependency and get_external_data seems broken-> takes exactly 2 arguments (1 given) but documentation doesnt say any parameter
#def hasPluginDependency(self, pluginInfo):
# return pluginInfo.has_dependency()
#def getPluginExternalData(self, pluginInfo):
# return pluginInfo.get_external_data()
def isPluginAvailable(self, pluginInfo):
try:
return pluginInfo.is_available()
except:
return False
def isPluginLoaded(self, pluginInfo):
try:
return pluginInfo.is_loaded()
except:
return False
def getIgnoredPlugins(self):
return self._ignorePluginModulePath
def setIgnoredPlugins(self, pluginModulePath, ignored):
if pluginModulePath.endswith('/'):
pluginModulePath = pluginModulePath[:-1]
if ignored:
if not pluginModulePath in self.getIgnoredPlugins():
self._ignorePluginModulePath.append(pluginModulePath)
else:
if pluginModulePath in self.getIgnoredPlugins():
self._ignorePluginModulePath.remove(pluginModulePath)
def rescanPlugins(self):
"""Scan for plugins in the plugin directories."""
old_plugins = self._plugins.copy()
self._plugins = {}
# Scan system and user plugins
self._scan_plugins_in_directory(PluginType.SYSTEM.get_root_dir())
self._scan_plugins_in_directory(PluginType.USER.get_root_dir())
# Preserve state for already loaded plugins
for name, old_info in old_plugins.items():
if name in self._plugins and old_info.loaded:
self._plugins[name].loaded = True
self._plugins[name].instance = old_info.instance
self._plugins[name].module = old_info.module
def _scan_plugins_in_directory(self, directory):
"""Scan for plugins in a directory."""
if not os.path.exists(directory) or not os.path.isdir(directory):
return
for item in os.listdir(directory):
plugin_dir = os.path.join(directory, item)
if not os.path.isdir(plugin_dir):
continue
plugin_file = os.path.join(plugin_dir, "plugin.py")
metadata_file = os.path.join(plugin_dir, "plugin.info")
if os.path.isfile(plugin_file):
# Extract plugin info
module_name = os.path.basename(plugin_dir)
metadata = self._load_plugin_metadata(metadata_file)
plugin_info = PluginInfo(
metadata.get('name', module_name),
module_name,
plugin_dir,
metadata
)
# Check if it's a built-in or hidden plugin
plugin_info.builtin = metadata.get('builtin', 'false').lower() == 'true'
plugin_info.hidden = metadata.get('hidden', 'false').lower() == 'true'
self._plugins[module_name] = plugin_info
def _load_plugin_metadata(self, metadata_file):
"""Load plugin metadata from a file."""
metadata = {}
if os.path.isfile(metadata_file):
try:
with open(metadata_file, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
key, value = line.split('=', 1)
metadata[key.strip()] = value.strip()
except Exception as e:
logger.error(f"Error loading plugin metadata: {e}")
return metadata
def getActivePlugins(self):
"""Get the list of active plugin names."""
return self._active_plugins
def setActivePlugins(self, activePlugins):
"""Set active plugins and sync their state."""
self._active_plugins = activePlugins
self.syncAllPluginsActive()
def setPluginActive(self, pluginInfo, active):
if self.isPluginBuildIn(pluginInfo):
"""Set the active state of a plugin."""
if pluginInfo.builtin:
active = True
pluginName = self.getPluginModuleName(pluginInfo)
if active:
if not pluginName in self.getActivePlugins():
if self.loadPlugin(pluginInfo):
self._activePlugins.append(pluginName )
else:
if pluginName in self.getActivePlugins():
if self.unloadPlugin(pluginInfo):
self._activePlugins.remove(pluginName )
def isPluginActive(self, pluginInfo):
if self.isPluginBuildIn(pluginInfo):
return True
if self.isPluginLoaded(pluginInfo):
return True
active_plugin_names = self.getActivePlugins()
return self.getPluginModuleName(pluginInfo) in active_plugin_names
def syncAllPluginsActive(self, ForceAllPlugins=False):
self.unloadAllPlugins(ForceAllPlugins)
self.loadAllPlugins(ForceAllPlugins)
def loadAllPlugins(self, ForceAllPlugins=False):
"""Loads plugins from settings."""
pluginName = pluginInfo.get_module_name()
if active:
if pluginName not in self.getActivePlugins():
if self.loadPlugin(pluginInfo):
self._active_plugins.append(pluginName)
else:
if pluginName in self.getActivePlugins():
if self.unloadPlugin(pluginInfo):
self._active_plugins.remove(pluginName)
def isPluginActive(self, pluginInfo):
"""Check if a plugin is active."""
if pluginInfo.builtin:
return True
if pluginInfo.loaded:
return True
return pluginInfo.get_module_name() in self.getActivePlugins()
def syncAllPluginsActive(self):
"""Sync the active state of all plugins."""
# First unload inactive plugins
for pluginInfo in self.plugins:
if self.isPluginActive(pluginInfo) or ForceAllPlugins:
if not self.isPluginActive(pluginInfo) and pluginInfo.loaded:
self.unloadPlugin(pluginInfo)
# Then load active plugins
for pluginInfo in self.plugins:
if self.isPluginActive(pluginInfo) and not pluginInfo.loaded:
self.loadPlugin(pluginInfo)
def loadPlugin(self, pluginInfo):
resourceManager = self.getApp().getResourceManager()
moduleName = pluginInfo.get_module_name()
try:
if pluginInfo not in self.plugins:
print("Plugin missing: {}".format(moduleName))
return False
resourceManager.addResourceContext(moduleName)
self.engine.load_plugin(pluginInfo)
except Exception as e:
print('loadPlugin:',e)
"""Load a plugin."""
# Skip if pluggy is not available
if not PLUGGY_AVAILABLE:
logger.info(f"Skipping plugin {pluginInfo.get_name()}: pluggy not available")
return False
return True
def unloadAllPlugins(self, ForceAllPlugins=False):
"""Loads plugins from settings."""
for pluginInfo in self.plugins:
if not self.isPluginActive(pluginInfo) or ForceAllPlugins:
self.unloadPlugin(pluginInfo)
module_name = pluginInfo.get_module_name()
try:
# Already loaded?
if pluginInfo.loaded:
return True
# Import plugin module
plugin_file = os.path.join(pluginInfo.get_module_dir(), "plugin.py")
spec = importlib.util.spec_from_file_location(module_name, plugin_file)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
pluginInfo.module = module
# Find Plugin class
plugin_class = None
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (inspect.isclass(attr) and
attr.__module__ == module.__name__ and
hasattr(attr, 'activate')):
plugin_class = attr
break
if not plugin_class:
logger.error(f"No plugin class found in {module_name}")
return False
# Create and initialize plugin instance
plugin_instance = plugin_class()
pluginInfo.instance = plugin_instance
# Ensure plugins have a reference to the app
plugin_instance.app = self.getApp()
if hasattr(plugin_instance, 'set_app'):
plugin_instance.set_app(self.getApp())
if hasattr(plugin_instance, 'set_plugin_info'):
plugin_instance.set_plugin_info(pluginInfo)
# Register with pluggy and activate
self.plugin_manager.register(plugin_instance)
try:
self.plugin_manager.hook.activate(plugin=plugin_instance)
except Exception as e:
logger.error(f"Error activating plugin {module_name}: {e}")
return False
pluginInfo.loaded = True
logger.info(f"Loaded plugin: {module_name}")
return True
except Exception as e:
logger.error(f"Failed to load plugin {module_name}: {e}")
return False
def unloadPlugin(self, pluginInfo):
resourceManager = self.getApp().getResourceManager()
moduleName = pluginInfo.get_module_name()
try:
if pluginInfo not in self.plugins:
print("Plugin missing: {}".format(moduleName))
return False
if self.isPluginBuildIn(pluginInfo):
return False
self.engine.unload_plugin(pluginInfo)
self.getApp().getResourceManager().removeResourceContext(moduleName)
self.engine.garbage_collect()
except Exception as e:
print('unloadPlugin:',e)
"""Unload a plugin."""
# Skip if pluggy is not available
if not PLUGGY_AVAILABLE:
return False
return True
def installPlugin(self, pluginFilePath, pluginType=PluginType.USER):
if not self.isValidPluginFile(pluginFilePath):
return False
pluginFolder = pluginType.get_root_dir()
if not pluginFolder.endswith('/'):
pluginFolder += '/'
if not os.path.exists(pluginFolder):
os.mkdir(pluginFolder)
else:
if not os.path.isdir(pluginFolder):
return False
try:
with tarfile.open(pluginFilePath) as tar:
tar.extractall(path=pluginFolder)
except Exception as e:
print(e)
pluginModulePath = self.getModuleDirByPluginFile(pluginFilePath)
if pluginModulePath != '':
pluginModulePath = pluginFolder + pluginModulePath
self.setIgnoredPlugins(pluginModulePath[:-1], False) # without ending /
print('install', pluginFilePath)
self.callPackageTriggers(pluginModulePath, 'onPostInstall')
self.rescanPlugins()
if pluginInfo.builtin:
return False
return True
def getModuleDirByPluginFile(self, pluginFilePath):
if not isinstance(pluginFilePath, str):
return ''
if pluginFilePath == '':
return ''
if not os.path.exists(pluginFilePath):
return ''
try:
with tarfile.open(pluginFilePath) as tar:
tarMembers = tar.getmembers()
for tarMember in tarMembers:
if tarMember.isdir():
return tarMember.name
except Exception as e:
print(e)
return ''
def isValidPluginFile(self, pluginFilePath):
if not isinstance(pluginFilePath, str):
return False
if pluginFilePath == '':
return False
if not os.path.exists(pluginFilePath):
return False
pluginFolder = ''
pluginFileExists = False
packageFileExists = False
try:
with tarfile.open(pluginFilePath) as tar:
tarMembers = tar.getmembers()
for tarMember in tarMembers:
if tarMember.isdir():
if pluginFolder == '':
pluginFolder = tarMember.name
if tarMember.isfile():
if tarMember.name.endswith('.plugin'):
pluginFileExists = True
if tarMember.name.endswith('package.py'):
pluginFileExists = True
if not tarMember.name.startswith(pluginFolder):
return False
except Exception as e:
print(e)
return False
return pluginFileExists
def uninstallPlugin(self, pluginInfo):
if self.isPluginBuildIn(pluginInfo):
return False
# do we want to allow removing system plugins?
if PluginSystemManager.getPluginType(pluginInfo) == PluginType.SYSTEM:
return False
pluginFolder = pluginInfo.get_data_dir()
if not pluginFolder.endswith('/'):
pluginFolder += '/'
if not os.path.isdir(pluginFolder):
return False
if self.isPluginActive(pluginInfo):
self.setPluginActive(pluginInfo, False)
SettingsManager = self.app.getSettingsManager()
# TODO SettingsManager.set_settings_value_list('active-plugins', self.getActivePlugins())
self.callPackageTriggers(pluginFolder, 'onPreUninstall')
module_name = pluginInfo.get_module_name()
try:
shutil.rmtree(pluginFolder, ignore_errors=True)
except Exception as e:
print(e)
return False
self.setIgnoredPlugins(pluginFolder, True)
self.rescanPlugins()
# Not loaded?
if not pluginInfo.loaded:
return True
return True
def callPackageTriggers(self, pluginPath, trigger):
if not os.path.exists(pluginPath):
# Deactivate plugin
plugin_instance = pluginInfo.instance
if plugin_instance:
try:
self.plugin_manager.hook.deactivate(plugin=plugin_instance)
except Exception as e:
logger.error(f"Error deactivating plugin {module_name}: {e}")
# Unregister from pluggy
self.plugin_manager.unregister(plugin_instance)
# Clean up
pluginInfo.instance = None
pluginInfo.loaded = False
logger.info(f"Unloaded plugin: {module_name}")
return True
except Exception as e:
logger.error(f"Failed to unload plugin {module_name}: {e}")
return False
def unloadAllPlugins(self, ForceAllPlugins=False):
"""Unload all plugins."""
if not PLUGGY_AVAILABLE:
return
if not pluginPath.endswith('/'):
pluginPath += '/'
packageModulePath = pluginPath + 'package.py'
if not os.path.isfile(packageModulePath):
return
if not os.access(packageModulePath, os.R_OK):
return
package = self.getApp().getAPIHelper().importModule('package', packageModulePath)
if trigger == 'onPostInstall':
try:
package.onPostInstall(pluginPath, self.getApp())
except Exception as e:
print(e)
elif trigger == 'onPreUninstall':
try:
package.onPreUninstall(pluginPath, self.getApp())
except Exception as e:
print(e)
def _setupExtensionSet(self):
plugin_iface = API(self.getApp())
self.extension_set = Peas.ExtensionSet.new(self.engine,
Peas.Activatable,
["object"],
[plugin_iface])
self.extension_set.connect("extension-removed",
self.__extensionRemoved)
self.extension_set.connect("extension-added",
self.__extensionAdded)
def _setupPluginsDir(self):
system_plugins_dir = PluginType.SYSTEM.get_root_dir()
user_plugins_dir = PluginType.USER.get_root_dir()
if os.path.exists(user_plugins_dir):
self.engine.add_search_path(user_plugins_dir)
if os.path.exists(system_plugins_dir):
self.engine.add_search_path(system_plugins_dir)
def __extensionRemoved(self, unusedSet, pluginInfo, extension):
extension.deactivate()
def __extensionAdded(self, unusedSet, pluginInfo, extension):
extension.setApp(self.getApp())
extension.setPluginInfo(pluginInfo)
extension.activate()
def __loadedPlugins(self, engine, unusedSet):
"""Handles the changing of the loaded plugin list."""
self.getApp().settings.ActivePlugins = engine.get_property("loaded-plugins")
class APIHelper():
def __init__(self, app):
self.app = app
self.cthulhuKeyBindings = None
'''
_pluginAPIManager.seCthulhuAPI('Logger', _logger)
_pluginAPIManager.setCthulhuAPI('SettingsManager', _settingsManager)
_pluginAPIManager.setCthulhuAPI('ScriptManager', _scriptManager)
_pluginAPIManager.setCthulhuAPI('EventManager', _eventManager)
_pluginAPIManager.setCthulhuAPI('Speech', speech)
_pluginAPIManager.setCthulhuAPI('Sound', sound)
_pluginAPIManager.setCthulhuAPI('Braille', braille)
_pluginAPIManager.setCthulhuAPI('Debug', debug)
_pluginAPIManager.setCthulhuAPI('Messages', messages)
_pluginAPIManager.setCthulhuAPI('MouseReview', mouse_review)
_pluginAPIManager.setCthulhuAPI('NotificationMessages', notification_messages)
_pluginAPIManager.setCthulhuAPI('CthulhuState', cthulhu_state)
_pluginAPIManager.setCthulhuAPI('CthulhuPlatform', cthulhu_platform)
_pluginAPIManager.setCthulhuAPI('Settings', settings)
_pluginAPIManager.setCthulhuAPI('Keybindings', keybindings)
'''
def outputMessage(self, Message, interrupt=False):
settings = self.app.getDynamicApiManager().getAPI('Settings')
braille = self.app.getDynamicApiManager().getAPI('Braille')
speech = self.app.getDynamicApiManager().getAPI('Speech')
if speech != None:
if (settings.enableSpeech):
if interrupt:
speech.cancel()
if Message != '':
speech.speak(Message)
if braille != None:
if (settings.enableBraille):
braille.displayMessage(Message)
def createInputEventHandler(self, function, name, learnModeEnabled=True):
EventManager = self.app.getDynamicApiManager().getAPI('EventManager')
newInputEventHandler = EventManager.input_event.InputEventHandler(function, name, learnModeEnabled)
return newInputEventHandler
def registerGestureByString(self, function, name, gestureString, profile, application, learnModeEnabled = True, contextName = None):
gestureList = gestureString.split(',')
registeredGestures = []
for gesture in gestureList:
if gesture.startswith('kb:'):
shortcutString = gesture[3:]
registuredGesture = self.registerShortcutByString(function, name, shortcutString, profile, application, learnModeEnabled, contextName=contextName)
if registuredGesture:
registeredGestures.append(registuredGesture)
return registeredGestures
def registerShortcutByString(self, function, name, shortcutString, profile, application, learnModeEnabled = True, contextName = None):
keybindings = self.app.getDynamicApiManager().getAPI('Keybindings')
settings = self.app.getDynamicApiManager().getAPI('Settings')
resourceManager = self.app.getResourceManager()
clickCount = 0
cthulhuKey = False
shiftKey = False
ctrlKey = False
altKey = False
key = ''
shortcutList = shortcutString.split('+')
for shortcutElement in shortcutList:
shortcutElementLower = shortcutElement.lower()
if shortcutElementLower == 'press':
clickCount += 1
elif shortcutElement == 'cthulhu':
cthulhuKey = True
elif shortcutElementLower == 'shift':
shiftKey = True
elif shortcutElementLower == 'control':
ctrlKey = True
elif shortcutElementLower == 'alt':
altKey = True
else:
key = shortcutElementLower
if clickCount == 0:
clickCount = 1
if self.cthulhuKeyBindings == None:
self.cthulhuKeyBindings = keybindings.KeyBindings()
tryFunction = resource_manager.TryFunction(function)
newInputEventHandler = self.createInputEventHandler(tryFunction.runInputEvent, name, learnModeEnabled)
currModifierMask = keybindings.NO_MODIFIER_MASK
if cthulhuKey:
currModifierMask = currModifierMask | 1 << keybindings.MODIFIER_CTHULHU
if shiftKey:
currModifierMask = currModifierMask | 1 << Atspi.ModifierType.SHIFT
if altKey:
currModifierMask = currModifierMask | 1 << Atspi.ModifierType.ALT
if ctrlKey:
currModifierMask = currModifierMask | 1 << Atspi.ModifierType.CONTROL
newKeyBinding = keybindings.KeyBinding(key, keybindings.defaultModifierMask, currModifierMask, newInputEventHandler, clickCount)
self.cthulhuKeyBindings.add(newKeyBinding)
settings.keyBindingsMap["default"] = self.cthulhuKeyBindings
if contextName:
resourceContext = resourceManager.getResourceContext(contextName)
if resourceContext:
resourceEntry = resource_manager.ResourceEntry('keyboard', newKeyBinding, function, tryFunction, shortcutString)
resourceContext.addGesture(profile, application, newKeyBinding, resourceEntry)
return newKeyBinding
def unregisterShortcut(self, KeyBindingToRemove, contextName = None):
ok = False
keybindings = self.app.getDynamicApiManager().getAPI('Keybindings')
settings = self.app.getDynamicApiManager().getAPI('Settings')
resourceManager = self.app.getResourceManager()
if self.cthulhuKeyBindings == None:
self.cthulhuKeyBindings = keybindings.KeyBindings()
try:
self.cthulhuKeyBindings.remove(KeyBindingToRemove)
settings.keyBindingsMap["default"] = self.cthulhuKeyBindings
ok = True
except KeyError:
pass
if contextName:
resourceContext = resourceManager.getResourceContext(contextName)
if resourceContext:
resourceContext.removeGesture(KeyBindingToRemove)
return ok
def importModule(self, moduleName, moduleLocation):
if version in ["3.3","3.4"]:
return SourceFileLoader(moduleName, moduleLocation).load_module()
else:
spec = importlib.util.spec_from_file_location(moduleName, moduleLocation)
driver_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(driver_mod)
return driver_mod
for pluginInfo in self.plugins:
if ForceAllPlugins or pluginInfo.loaded:
self.unloadPlugin(pluginInfo)

View File

@ -1,6 +0,0 @@
[Plugin]
Module=ByeCthulhu
Loader=python3
Name=Stop announcement for cthulhu
Description=Test plugin for cthulhu
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,52 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# 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.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
import time
class ByeCthulhu(GObject.Object, Peas.Activatable, plugin.Plugin):
#__gtype_name__ = 'ByeCthulhu'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
def do_activate(self):
API = self.object
self.connectSignal("stop-application-completed", self.process)
def do_deactivate(self):
API = self.object
def do_update_state(self):
API = self.object
def process(self, app):
messages = app.getDynamicApiManager().getAPI('Messages')
activeScript = app.getDynamicApiManager().getAPI('CthulhuState').activeScript
activeScript.presentationInterrupt()
activeScript.presentMessage(messages.STOP_CTHULHU, resetStyles=False)

View File

@ -1,7 +1,7 @@
cthulhu_python_PYTHON = \
__init__.py \
ByeCthulhu.plugin \
ByeCthulhu.py
plugin.info \
plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/ByeCthulhu

View File

@ -0,0 +1,8 @@
name = Bye Cthulhu
version = 1.0.0
description = Says goodbye when Cthulhu is shutting down
authors = Stormux <storm_dragon@stormux.org>
website = https://stormux.org
copyright = Copyright 2025
builtin = false
hidden = false

View File

@ -0,0 +1,74 @@
#!/usr/bin/env python3
#
# Copyright (c) 2025 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.
"""Bye Cthulhu plugin for Cthulhu."""
import logging
import time
from cthulhu.plugin import Plugin, cthulhu_hookimpl
logger = logging.getLogger(__name__)
class ByeCthulhu(Plugin):
"""Plugin that speaks a goodbye message when Cthulhu is shutting down."""
def __init__(self, *args, **kwargs):
"""Initialize the plugin."""
super().__init__(*args, **kwargs)
logger.info("ByeCthulhu plugin initialized")
self._signal_handler_id = None
@cthulhu_hookimpl
def activate(self, plugin=None):
"""Activate the plugin."""
# Skip if this activation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Activating ByeCthulhu plugin")
try:
# Connect to the stop-application-completed signal
signal_manager = self.app.getSignalManager()
self._signal_handler_id = signal_manager.connectSignal(
"stop-application-completed",
self.process
)
except Exception as e:
logger.error(f"Error activating ByeCthulhu plugin: {e}")
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin."""
# Skip if this deactivation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Deactivating ByeCthulhu plugin")
try:
# Disconnect signal if we have an ID
if self._signal_handler_id is not None:
signal_manager = self.app.getSignalManager()
signal_manager.disconnectSignal(
"stop-application-completed",
self._signal_handler_id
)
self._signal_handler_id = None
except Exception as e:
logger.error(f"Error deactivating ByeCthulhu plugin: {e}")
def process(self, app):
"""Process the stop-application-completed signal."""
try:
messages = app.getDynamicApiManager().getAPI('Messages')
state = app.getDynamicApiManager().getAPI('CthulhuState')
if state.activeScript:
state.activeScript.presentationInterrupt()
state.activeScript.presentMessage(messages.STOP_CTHULHU, resetStyles=False)
except Exception as e:
logger.error(f"Error in ByeCthulhu process: {e}")

View File

@ -1,6 +0,0 @@
[Plugin]
Module=DisplayVersion
Loader=python3
Name=Display Version
Description=Announce the current version of Cthulhu
Authors=Storm Dragon <storm_dragon@stormux.org>

View File

@ -1,61 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# 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.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
from cthulhu import cthulhuVersion
class DisplayVersion(GObject.Object, Peas.Activatable, plugin.Plugin):
__gtype_name__ = 'displayversion'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
self._api = None
def do_activate(self):
self._api = self.object
self.registerGestureByString(
self.speakText,
_(f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}'),
'kb:cthulhu+shift+v'
)
def do_deactivate(self):
self._api = None
def speakText(self, script=None, inputEvent=None):
if not self._api:
return False
self._api.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage(
f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}',
resetStyles=False
)
return True

View File

@ -1,7 +1,7 @@
cthulhu_python_PYTHON = \
__init__.py \
DisplayVersion.plugin \
DisplayVersion.py
plugin.info \
plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/DisplayVersion

View File

@ -0,0 +1,8 @@
name = Display Version
version = 1.0.0
description = Announces the Cthulhu version with Cthulhu+Shift+V
authors = Stormux <storm_dragon@stormux.org>
website = https://stormux.org
copyright = Copyright 2025
builtin = false
hidden = false

View File

@ -0,0 +1,68 @@
#!/usr/bin/env python3
#
# Copyright (c) 2025 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.
"""Display Version plugin for Cthulhu."""
import logging
from cthulhu.plugin import Plugin, cthulhu_hookimpl
from cthulhu import cthulhuVersion
logger = logging.getLogger(__name__)
class DisplayVersion(Plugin):
"""Plugin that announces the current Cthulhu version."""
def __init__(self, *args, **kwargs):
"""Initialize the plugin."""
super().__init__(*args, **kwargs)
logger.info("DisplayVersion plugin initialized")
self._kb_binding = None
@cthulhu_hookimpl
def activate(self, plugin=None):
"""Activate the plugin."""
# Skip if this activation call isn't for us
if plugin is not None and plugin is not self:
return
try:
logger.info("Activating DisplayVersion plugin")
# Register keyboard shortcut
self._kb_binding = self.registerGestureByString(
self.speakText,
f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}',
'kb:cthulhu+shift+v'
)
except Exception as e:
logger.error(f"Error activating DisplayVersion plugin: {e}")
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin."""
# Skip if this deactivation call isn't for us
if plugin is not None and plugin is not self:
return
logger.info("Deactivating DisplayVersion plugin")
def speakText(self, script=None, inputEvent=None):
"""Speak the Cthulhu version when shortcut is pressed."""
try:
if self.app:
state = self.app.getDynamicApiManager().getAPI('CthulhuState')
if state.activeScript:
state.activeScript.presentMessage(
f'Cthulhu screen reader version {cthulhuVersion.version}-{cthulhuVersion.codeName}',
resetStyles=False
)
return True
except Exception as e:
logger.error(f"Error in DisplayVersion speakText: {e}")
return False

View File

@ -1,6 +0,0 @@
[Plugin]
Module=HelloWorld
Loader=python3
Name=Hello World (python3)
Description=Test plugin for cthulhu
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,49 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# 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.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
class HelloWorld(GObject.Object, Peas.Activatable, plugin.Plugin):
__gtype_name__ = 'helloworld'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
def do_activate(self):
API = self.object
self.registerGestureByString(self.speakTest, _('hello world'), 'kb:cthulhu+z')
print('activate hello world plugin')
def do_deactivate(self):
API = self.object
print('deactivate hello world plugin')
def speakTest(self, script=None, inputEvent=None):
API = self.object
API.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage('hello world', resetStyles=False)
return True

View File

@ -1,7 +0,0 @@
cthulhu_python_PYTHON = \
__init__.py \
HelloWorld.plugin \
HelloWorld.py
cthulhu_pythondir=$(pkgpythondir)/plugins/HelloWorld

View File

@ -1,4 +1,4 @@
SUBDIRS = Clipboard DisplayVersion HelloWorld SelfVoice Time MouseReview Date ByeCthulhu HelloCthulhu PluginManager CapsLockHack SimplePluginSystem
SUBDIRS = Clipboard DisplayVersion hello_world self_voice Time MouseReview Date ByeCthulhu HelloCthulhu PluginManager CapsLockHack SimplePluginSystem
cthulhu_pythondir=$(pkgpythondir)/plugins

View File

@ -1,7 +0,0 @@
cthulhu_python_PYTHON = \
__init__.py \
SelfVoice.plugin \
SelfVoice.py
cthulhu_pythondir=$(pkgpythondir)/plugins/SelfVoice

View File

@ -1,6 +0,0 @@
[Plugin]
Module=SelfVoice
Loader=python3
Name=Self Voice Plugin
Description=use cthulhu text / braile from using unix sockets
Authors=Chrys chrys@linux-a11y.org

View File

@ -1,135 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# 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.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
from cthulhu import plugin
import gi
gi.require_version('Peas', '1.0')
from gi.repository import GObject
from gi.repository import Peas
import select, socket, os, os.path
from threading import Thread, Lock
APPEND_CODE = '<#APPEND#>'
PERSISTENT_CODE = '<#PERSISTENT#>'
class SelfVoice(GObject.Object, Peas.Activatable, plugin.Plugin):
__gtype_name__ = 'SelfVoice'
object = GObject.Property(type=GObject.Object)
def __init__(self):
plugin.Plugin.__init__(self)
self.lock = Lock()
self.active = False
self.voiceThread = Thread(target=self.voiceWorker)
def do_activate(self):
API = self.object
self.activateWorker()
def do_deactivate(self):
API = self.object
self.deactivateWorker()
def do_update_state(self):
API = self.object
def deactivateWorker(self):
with self.lock:
self.active = False
self.voiceThread.join()
def activateWorker(self):
with self.lock:
self.active = True
self.voiceThread.start()
def isActive(self):
with self.lock:
return self.active
def outputMessage(self, Message):
# Prepare
API = self.object
append = Message.startswith(APPEND_CODE)
if append:
Message = Message[len(APPEND_CODE):]
if Message.endswith(PERSISTENT_CODE):
Message = Message[:len(Message)-len(PERSISTENT_CODE)]
API.app.getAPIHelper().outputMessage(Message, not append)
else:
script_manager = API.app.getDynamicApiManager().getAPI('ScriptManager')
scriptManager = script_manager.getManager()
scriptManager.getDefaultScript().presentMessage(Message, resetStyles=False)
return
try:
settings = API.app.getDynamicApiManager().getAPI('Settings')
braille = API.app.getDynamicApiManager().getAPI('Braille')
speech = API.app.getDynamicApiManager().getAPI('Speech')
# Speak
if speech != None:
if (settings.enableSpeech):
if not append:
speech.cancel()
if Message != '':
speech.speak(Message)
# Braille
if braille != None:
if (settings.enableBraille):
braille.displayMessage(Message)
except e as Exception:
print(e)
def voiceWorker(self):
socketFile = '/tmp/cthulhu.sock'
# for testing purposes
#socketFile = '/tmp/cthulhu-plugin.sock'
if os.path.exists(socketFile):
os.unlink(socketFile)
cthulhuSock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
cthulhuSock.bind(socketFile)
os.chmod(socketFile, 0o222)
cthulhuSock.listen(1)
while self.isActive():
# Check if the client is still connected and if data is available:
try:
r, _, _ = select.select([cthulhuSock], [], [], 0.8)
except select.error:
break
if r == []:
continue
if cthulhuSock in r:
client_sock, client_addr = cthulhuSock.accept()
try:
rawdata = client_sock.recv(8129)
data = rawdata.decode("utf-8").rstrip().lstrip()
self.outputMessage(data)
except:
pass
try:
client_sock.close()
except:
pass
if cthulhuSock:
cthulhuSock.close()
cthulhuSock = None
if os.path.exists(socketFile):
os.unlink(socketFile)

View File

@ -0,0 +1,6 @@
cthulhu_python_PYTHON = \
__init__.py \
plugin.info \
plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/hello_world

View File

@ -0,0 +1,33 @@
# Hello World Plugin for Cthulhu
This is a simple test plugin for the Cthulhu screen reader that demonstrates how to use the pluggy-based plugin system.
## Features
- Registers a keyboard shortcut (Cthulhu+z) that speaks "hello world" when pressed
- Demonstrates how to use the Plugin base class
- Shows how to use cthulhu_hookimpl for hook implementation
## Implementation Details
The plugin uses the following key features:
- `pluggy` for hook specification and implementation
- The `Plugin` base class from cthulhu.plugin
- The `cthulhu_hookimpl` decorator to mark functions as plugin hooks
- The `registerGestureByString` method to register keyboard shortcuts
## Structure
- `plugin.py`: The main plugin implementation
- `plugin.info`: Metadata about the plugin
## Requirements
Requires the pluggy package to be installed:
```
pip install pluggy
```
## Usage
The plugin will be automatically loaded when Cthulhu starts if it's listed in the activePlugins setting.

View File

@ -0,0 +1,8 @@
name = Hello World
version = 1.0.0
description = Test plugin for Cthulhu
authors = Storm Dragon storm_dragon@stormux.org
website = https://stormux.org
copyright = Copyright 2025
builtin = false
hidden = false

View File

@ -0,0 +1,69 @@
#!/usr/bin/env python3
#
# Copyright (c) 2025 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.
"""Hello World plugin for Cthulhu."""
import logging
from cthulhu.plugin import Plugin, cthulhu_hookimpl
logger = logging.getLogger(__name__)
class HelloWorld(Plugin):
"""Hello World plugin."""
def __init__(self, *args, **kwargs):
"""Initialize the plugin."""
super().__init__(*args, **kwargs)
logger.info("HelloWorld plugin initialized")
@cthulhu_hookimpl
def activate(self, plugin=None):
"""Activate the plugin."""
# Skip if this activation call isn't for us
if plugin is not None and plugin is not self:
return
try:
logger.info("Activating Hello World plugin")
# Register our keyboard shortcut
self.registerGestureByString(
self.speakTest,
"hello world",
"kb:cthulhu+z",
learnModeEnabled=True
)
except Exception as e:
logger.error(f"Error activating Hello World plugin: {e}")
@cthulhu_hookimpl
def deactivate(self, plugin=None):
"""Deactivate the plugin."""
# Skip if this deactivation call isn't for us
if plugin is not None and plugin is not self:
return
try:
logger.info("Deactivating Hello World plugin")
except Exception as e:
logger.error(f"Error deactivating Hello World plugin: {e}")
def speakTest(self, script=None, inputEvent=None):
"""Speak a test message."""
try:
if self.app:
self.app.getDynamicApiManager().getAPI('CthulhuState').activeScript.presentMessage(
'hello world',
resetStyles=False
)
return True
except Exception as e:
logger.error(f"Error in speakTest: {e}")
return False

View File

@ -0,0 +1,7 @@
cthulhu_python_PYTHON = \
__init__.py \
plugin.info \
plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/self_voice

View File

@ -0,0 +1,12 @@
[Plugin]
name=Self Voice
module_name=self_voice
version=1.0.0
description=Use Cthulhu speech and braille from external applications via Unix sockets
authors=Stormux
copyright=Copyright (c) 2024 Stormux
website=https://stormux.org
icon_name=audio-speakers
builtin=false
hidden=false
help_uri=https://stormux.org

View File

@ -0,0 +1,194 @@
#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# 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.
#
# Fork of Orca Screen Reader (GNOME)
# Original source: https://gitlab.gnome.org/GNOME/orca
"""Self Voice plugin for Cthulhu screen reader."""
import os
import socket
import select
import logging
import threading
from threading import Thread, Lock
from cthulhu.plugin import Plugin, cthulhu_hookimpl
logger = logging.getLogger(__name__)
# Special codes for message handling
APPEND_CODE = '<#APPEND#>'
PERSISTENT_CODE = '<#PERSISTENT#>'
class SelfVoice(Plugin):
"""Plugin that provides a socket interface for external applications to send text to Cthulhu."""
def __init__(self):
"""Initialize the plugin."""
super().__init__()
self.lock = Lock()
self.active = False
self.voiceThread = Thread(target=self.voiceWorker)
self.voiceThread.daemon = True # Make thread exit when main thread exits
@cthulhu_hookimpl
def activate(self):
"""Activate the self-voice plugin."""
super().activate()
logger.info("Activating Self Voice Plugin")
self.activateWorker()
@cthulhu_hookimpl
def deactivate(self):
"""Deactivate the self-voice plugin."""
logger.info("Deactivating Self Voice Plugin")
self.deactivateWorker()
super().deactivate()
def activateWorker(self):
"""Start the voice worker thread."""
with self.lock:
self.active = True
# Only start if not already running
if not self.voiceThread.is_alive():
self.voiceThread = Thread(target=self.voiceWorker)
self.voiceThread.daemon = True
self.voiceThread.start()
def deactivateWorker(self):
"""Stop the voice worker thread."""
with self.lock:
self.active = False
# Try to join the thread if it's alive, with a timeout
if self.voiceThread.is_alive():
try:
self.voiceThread.join(timeout=2.0)
except Exception as e:
logger.warning(f"Error stopping voice worker thread: {e}")
def isActive(self):
"""Check if the worker is active."""
with self.lock:
return self.active
def outputMessage(self, message):
"""Output a message through Cthulhu's speech and braille systems.
Args:
message: The message to output. May include special codes.
"""
# Process special codes
append = message.startswith(APPEND_CODE)
if append:
message = message[len(APPEND_CODE):]
persistent = False
if message.endswith(PERSISTENT_CODE):
message = message[:len(message)-len(PERSISTENT_CODE)]
persistent = True
# Output through appropriate channel
if persistent:
# Use the APIHelper for persistent messages
self.app.getAPIHelper().outputMessage(message, not append)
else:
# Use the script manager for standard messages
script_manager = self.app.getDynamicApiManager().getAPI('ScriptManager')
scriptManager = script_manager.getManager()
scriptManager.getDefaultScript().presentMessage(message, resetStyles=False)
def voiceWorker(self):
"""Worker thread that listens on a socket for messages to speak."""
socketFile = '/tmp/cthulhu.sock'
# For testing purposes
# socketFile = '/tmp/cthulhu-plugin.sock'
# Clean up any existing socket file
if os.path.exists(socketFile):
try:
os.unlink(socketFile)
except Exception as e:
logger.error(f"Error removing existing socket file: {e}")
return
try:
# Create and set up the socket
cthulhu_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
cthulhu_sock.bind(socketFile)
os.chmod(socketFile, 0o222) # Write-only for everyone
cthulhu_sock.listen(1)
logger.info(f"Self Voice plugin listening on {socketFile}")
# Main loop - listen for connections
while self.isActive():
# Check if data is available with a timeout
try:
r, _, _ = select.select([cthulhu_sock], [], [], 0.8)
except select.error as e:
logger.error(f"Select error: {e}")
break
if not r: # No data available
continue
# Accept connection
if cthulhu_sock in r:
try:
client_sock, _ = cthulhu_sock.accept()
client_sock.settimeout(0.5) # Set a timeout for receiving data
try:
# Receive and process data
raw_data = client_sock.recv(8192)
if raw_data:
data = raw_data.decode("utf-8").strip()
if data:
self.outputMessage(data)
except socket.timeout:
pass
except Exception as e:
logger.error(f"Error receiving data: {e}")
finally:
client_sock.close()
except Exception as e:
logger.error(f"Error accepting connection: {e}")
except Exception as e:
logger.error(f"Socket error: {e}")
finally:
# Clean up
if 'cthulhu_sock' in locals():
try:
cthulhu_sock.close()
except Exception:
pass
if os.path.exists(socketFile):
try:
os.unlink(socketFile)
except Exception as e:
logger.error(f"Error removing socket file: {e}")
logger.info("Self Voice plugin socket closed")

View File

@ -413,4 +413,4 @@ presentChatRoomLast = False
presentLiveRegionFromInactiveTab = False
# Plugins
activePlugins = ['Clipboard', 'DisplayVersion', 'MouseReview', 'Date', 'ByeCthulhu', 'Time', 'HelloCthulhu', 'SelfVoice', 'PluginManager', 'SimplePluginSystem']
activePlugins = ['Clipboard', 'DisplayVersion', 'MouseReview', 'Date', 'ByeCthulhu', 'Time', 'HelloCthulhu', 'hello_world', 'self_voice', 'PluginManager', 'SimplePluginSystem']