diff --git a/configure.ac b/configure.ac
index c303ac0..740db46 100644
--- a/configure.ac
+++ b/configure.ac
@@ -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
diff --git a/contrib/toggle.screenreader.gschema.xml b/contrib/toggle.screenreader.gschema.xml
new file mode 100644
index 0000000..7aa86ec
--- /dev/null
+++ b/contrib/toggle.screenreader.gschema.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ '<Shift><Alt>v'
+ Keybinding
+ Keybinding associated to toggle screen reader.
+
+
+ '/opt/I38/scripts/toggle_screenreader.sh 1>/dev/null'
+ Command
+ Command to toggle the screen reader between orca and cthulhu.
+
+
+ 'Toggle screenreader'
+ Name
+ Description associated to toggle screen reader.
+
+
+
diff --git a/src/cthulhu/cthulhu.py b/src/cthulhu/cthulhu.py
index ba0a3b4..6e85089 100644
--- a/src/cthulhu/cthulhu.py
+++ b/src/cthulhu/cthulhu.py
@@ -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()
diff --git a/src/cthulhu/cthulhuVersion.py b/src/cthulhu/cthulhuVersion.py
index 6ab45f7..ff8827e 100644
--- a/src/cthulhu/cthulhuVersion.py
+++ b/src/cthulhu/cthulhuVersion.py
@@ -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"
diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py
index 183d5a5..dd7c613 100644
--- a/src/cthulhu/cthulhu_gui_prefs.py
+++ b/src/cthulhu/cthulhu_gui_prefs.py
@@ -193,7 +193,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
# ***** Key Bindings treeview initialization *****
self.keyBindView = self.get_widget("keyBindingsTreeview")
-
+
if self.keyBindView.get_columns():
for column in self.keyBindView.get_columns():
self.keyBindView.remove_column(column)
@@ -337,7 +337,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
column.set_resizable(True)
column.set_sort_column_id(EDITABLE)
self.keyBindView.append_column(column)
-
+
# Populates the treeview with all the keybindings:
#
self._populateKeyBindings()
@@ -582,7 +582,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.get_widget("rateScale").set_value(rate)
else:
self.get_widget("rateScale").set_value(50.0)
-
+
pitch = self._getPitchForVoiceType(voiceType)
if pitch is not None:
self.get_widget("pitchScale").set_value(pitch)
@@ -1150,7 +1150,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
grid = self.get_widget('flashMessageDurationGrid')
grid.set_sensitive(not checkbox.get_active())
self.prefsDict["flashIsPersistent"] = checkbox.get_active()
-
+
def textAttributeSpokenToggled(self, cell, path, model):
"""The user has toggled the state of one of the text attribute
checkboxes to be spoken. Update our model to reflect this, then
@@ -1596,7 +1596,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
elif dateFormat == messages.DATE_FORMAT_ABBREVIATED_YMD:
indexdate = DATE_FORMAT_ABBREVIATED_YMD
combobox2.set_active (indexdate)
-
+
combobox3 = self.get_widget("timeFormatCombo")
self.populateComboBox(combobox3,
[sdtime(messages.TIME_FORMAT_LOCALE, ltime()),
@@ -1757,7 +1757,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
prefs["enableEchoByWord"])
self.get_widget("enableEchoBySentenceCheckButton").set_active( \
prefs["enableEchoBySentence"])
-
+
# Text attributes pane.
#
self._createTextAttributesTreeView()
@@ -1785,7 +1785,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.get_widget("generalDesktopButton").set_active(True)
else:
self.get_widget("generalLaptopButton").set_active(True)
-
+
combobox = self.get_widget("sayAllStyle")
self.populateComboBox(combobox, [guilabels.SAY_ALL_STYLE_LINE,
guilabels.SAY_ALL_STYLE_SENTENCE])
@@ -2748,7 +2748,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
elif dateFormatCombo == DATE_FORMAT_ABBREVIATED_YMD:
newFormat = messages.DATE_FORMAT_ABBREVIATED_YMD
self.prefsDict["presentDateFormat"] = newFormat
-
+
def timeFormatChanged(self, widget):
"""Signal handler for the "changed" signal for the timeFormat
GtkComboBox widget. Set the 'timeFormat' preference to the
diff --git a/src/cthulhu/dynamic_api_manager.py b/src/cthulhu/dynamic_api_manager.py
index fa5da8d..e377f75 100644
--- a/src/cthulhu/dynamic_api_manager.py
+++ b/src/cthulhu/dynamic_api_manager.py
@@ -66,7 +66,7 @@ class DynamicApiManager():
def getAPI(self, key, application = '', fallback = True):
# get dynamic API
api = None
-
+
try:
api = self.api[application][key]
return api
@@ -83,5 +83,5 @@ class DynamicApiManager():
api = self.api[application]['']
except:
print('API Key: "{}/{}" not found,'.format(application, key))
-
+
return api
diff --git a/src/cthulhu/laptop_keyboardmap.py b/src/cthulhu/laptop_keyboardmap.py
index 3aa3967..c4bcfda 100644
--- a/src/cthulhu/laptop_keyboardmap.py
+++ b/src/cthulhu/laptop_keyboardmap.py
@@ -44,7 +44,7 @@ CTHULHU_SHIFT_MODIFIER_MASK = keybindings.CTHULHU_SHIFT_MODIFIER_MASK
CTHULHU_CTRL_MODIFIER_MASK = keybindings.CTHULHU_CTRL_MODIFIER_MASK
keymap = (
-
+
("9", defaultModifierMask, CTHULHU_MODIFIER_MASK,
"routePointerToItemHandler"),
diff --git a/src/cthulhu/plugin.py b/src/cthulhu/plugin.py
index b5d127b..100e8f0 100644
--- a/src/cthulhu/plugin.py
+++ b/src/cthulhu/plugin.py
@@ -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())
diff --git a/src/cthulhu/plugin_system_manager.py b/src/cthulhu/plugin_system_manager.py
index 48af6da..8a7f13b 100644
--- a/src/cthulhu/plugin_system_manager.py
+++ b/src/cthulhu/plugin_system_manager.py
@@ -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")
+
+ # 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)
- self._setupPluginsDir()
- self._setupExtensionSet()
-
- 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 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):
- return self._activePlugins
+ """Get the list of active plugin names."""
+ return self._active_plugins
+
def setActivePlugins(self, activePlugins):
- self._activePlugins = activePlugins
+ """Set active plugins and sync their state."""
+ self._active_plugins = 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 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()
- 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):
+ if pluginInfo.builtin:
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')
-
- try:
- shutil.rmtree(pluginFolder, ignore_errors=True)
- except Exception as e:
- print(e)
- return False
- self.setIgnoredPlugins(pluginFolder, True)
- self.rescanPlugins()
- return True
- def callPackageTriggers(self, pluginPath, trigger):
- if not os.path.exists(pluginPath):
+ module_name = pluginInfo.get_module_name()
+
+ try:
+ # Not loaded?
+ if not pluginInfo.loaded:
+ return True
+
+ # 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)
diff --git a/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.plugin b/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.plugin
deleted file mode 100644
index 6c63a12..0000000
--- a/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.plugin
+++ /dev/null
@@ -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
diff --git a/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.py b/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.py
deleted file mode 100644
index b82ec18..0000000
--- a/src/cthulhu/plugins/ByeCthulhu/ByeCthulhu.py
+++ /dev/null
@@ -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)
diff --git a/src/cthulhu/plugins/ByeCthulhu/Makefile.am b/src/cthulhu/plugins/ByeCthulhu/Makefile.am
index 38d3489..2fbd68b 100644
--- a/src/cthulhu/plugins/ByeCthulhu/Makefile.am
+++ b/src/cthulhu/plugins/ByeCthulhu/Makefile.am
@@ -1,7 +1,7 @@
cthulhu_python_PYTHON = \
__init__.py \
- ByeCthulhu.plugin \
- ByeCthulhu.py
+ plugin.info \
+ plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/ByeCthulhu
diff --git a/src/cthulhu/plugins/ByeCthulhu/plugin.info b/src/cthulhu/plugins/ByeCthulhu/plugin.info
new file mode 100644
index 0000000..dc61076
--- /dev/null
+++ b/src/cthulhu/plugins/ByeCthulhu/plugin.info
@@ -0,0 +1,8 @@
+name = Bye Cthulhu
+version = 1.0.0
+description = Says goodbye when Cthulhu is shutting down
+authors = Stormux
+website = https://stormux.org
+copyright = Copyright 2025
+builtin = false
+hidden = false
\ No newline at end of file
diff --git a/src/cthulhu/plugins/ByeCthulhu/plugin.py b/src/cthulhu/plugins/ByeCthulhu/plugin.py
new file mode 100644
index 0000000..4720e87
--- /dev/null
+++ b/src/cthulhu/plugins/ByeCthulhu/plugin.py
@@ -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}")
\ No newline at end of file
diff --git a/src/cthulhu/plugins/DisplayVersion/DisplayVersion.plugin b/src/cthulhu/plugins/DisplayVersion/DisplayVersion.plugin
deleted file mode 100644
index 4529805..0000000
--- a/src/cthulhu/plugins/DisplayVersion/DisplayVersion.plugin
+++ /dev/null
@@ -1,6 +0,0 @@
-[Plugin]
-Module=DisplayVersion
-Loader=python3
-Name=Display Version
-Description=Announce the current version of Cthulhu
-Authors=Storm Dragon
diff --git a/src/cthulhu/plugins/DisplayVersion/DisplayVersion.py b/src/cthulhu/plugins/DisplayVersion/DisplayVersion.py
deleted file mode 100644
index 406dff2..0000000
--- a/src/cthulhu/plugins/DisplayVersion/DisplayVersion.py
+++ /dev/null
@@ -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
diff --git a/src/cthulhu/plugins/DisplayVersion/Makefile.am b/src/cthulhu/plugins/DisplayVersion/Makefile.am
index 0cf43a4..57097d4 100644
--- a/src/cthulhu/plugins/DisplayVersion/Makefile.am
+++ b/src/cthulhu/plugins/DisplayVersion/Makefile.am
@@ -1,7 +1,7 @@
cthulhu_python_PYTHON = \
__init__.py \
- DisplayVersion.plugin \
- DisplayVersion.py
+ plugin.info \
+ plugin.py
cthulhu_pythondir=$(pkgpythondir)/plugins/DisplayVersion
diff --git a/src/cthulhu/plugins/DisplayVersion/plugin.info b/src/cthulhu/plugins/DisplayVersion/plugin.info
new file mode 100644
index 0000000..1ed4e67
--- /dev/null
+++ b/src/cthulhu/plugins/DisplayVersion/plugin.info
@@ -0,0 +1,8 @@
+name = Display Version
+version = 1.0.0
+description = Announces the Cthulhu version with Cthulhu+Shift+V
+authors = Stormux
+website = https://stormux.org
+copyright = Copyright 2025
+builtin = false
+hidden = false
\ No newline at end of file
diff --git a/src/cthulhu/plugins/DisplayVersion/plugin.py b/src/cthulhu/plugins/DisplayVersion/plugin.py
new file mode 100644
index 0000000..9f7017b
--- /dev/null
+++ b/src/cthulhu/plugins/DisplayVersion/plugin.py
@@ -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
\ No newline at end of file
diff --git a/src/cthulhu/plugins/HelloWorld/HelloWorld.plugin b/src/cthulhu/plugins/HelloWorld/HelloWorld.plugin
deleted file mode 100644
index 949d8b8..0000000
--- a/src/cthulhu/plugins/HelloWorld/HelloWorld.plugin
+++ /dev/null
@@ -1,6 +0,0 @@
-[Plugin]
-Module=HelloWorld
-Loader=python3
-Name=Hello World (python3)
-Description=Test plugin for cthulhu
-Authors=Chrys chrys@linux-a11y.org
diff --git a/src/cthulhu/plugins/HelloWorld/HelloWorld.py b/src/cthulhu/plugins/HelloWorld/HelloWorld.py
deleted file mode 100644
index 6a2cc57..0000000
--- a/src/cthulhu/plugins/HelloWorld/HelloWorld.py
+++ /dev/null
@@ -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
diff --git a/src/cthulhu/plugins/HelloWorld/Makefile.am b/src/cthulhu/plugins/HelloWorld/Makefile.am
deleted file mode 100644
index 8793036..0000000
--- a/src/cthulhu/plugins/HelloWorld/Makefile.am
+++ /dev/null
@@ -1,7 +0,0 @@
-cthulhu_python_PYTHON = \
- __init__.py \
- HelloWorld.plugin \
- HelloWorld.py
-
-cthulhu_pythondir=$(pkgpythondir)/plugins/HelloWorld
-
diff --git a/src/cthulhu/plugins/Makefile.am b/src/cthulhu/plugins/Makefile.am
index fc36845..89e438a 100644
--- a/src/cthulhu/plugins/Makefile.am
+++ b/src/cthulhu/plugins/Makefile.am
@@ -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
diff --git a/src/cthulhu/plugins/SelfVoice/Makefile.am b/src/cthulhu/plugins/SelfVoice/Makefile.am
deleted file mode 100644
index 2acc355..0000000
--- a/src/cthulhu/plugins/SelfVoice/Makefile.am
+++ /dev/null
@@ -1,7 +0,0 @@
-cthulhu_python_PYTHON = \
- __init__.py \
- SelfVoice.plugin \
- SelfVoice.py
-
-cthulhu_pythondir=$(pkgpythondir)/plugins/SelfVoice
-
diff --git a/src/cthulhu/plugins/SelfVoice/SelfVoice.plugin b/src/cthulhu/plugins/SelfVoice/SelfVoice.plugin
deleted file mode 100644
index 8cf18d2..0000000
--- a/src/cthulhu/plugins/SelfVoice/SelfVoice.plugin
+++ /dev/null
@@ -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
diff --git a/src/cthulhu/plugins/SelfVoice/SelfVoice.py b/src/cthulhu/plugins/SelfVoice/SelfVoice.py
deleted file mode 100644
index cc656ab..0000000
--- a/src/cthulhu/plugins/SelfVoice/SelfVoice.py
+++ /dev/null
@@ -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)
-
-
diff --git a/src/cthulhu/plugins/hello_world/Makefile.am b/src/cthulhu/plugins/hello_world/Makefile.am
new file mode 100644
index 0000000..95853eb
--- /dev/null
+++ b/src/cthulhu/plugins/hello_world/Makefile.am
@@ -0,0 +1,6 @@
+cthulhu_python_PYTHON = \
+ __init__.py \
+ plugin.info \
+ plugin.py
+
+cthulhu_pythondir=$(pkgpythondir)/plugins/hello_world
diff --git a/src/cthulhu/plugins/hello_world/README.md b/src/cthulhu/plugins/hello_world/README.md
new file mode 100644
index 0000000..2bd1a89
--- /dev/null
+++ b/src/cthulhu/plugins/hello_world/README.md
@@ -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.
\ No newline at end of file
diff --git a/src/cthulhu/plugins/HelloWorld/__init__.py b/src/cthulhu/plugins/hello_world/__init__.py
similarity index 100%
rename from src/cthulhu/plugins/HelloWorld/__init__.py
rename to src/cthulhu/plugins/hello_world/__init__.py
diff --git a/src/cthulhu/plugins/hello_world/plugin.info b/src/cthulhu/plugins/hello_world/plugin.info
new file mode 100644
index 0000000..50e52cc
--- /dev/null
+++ b/src/cthulhu/plugins/hello_world/plugin.info
@@ -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
diff --git a/src/cthulhu/plugins/hello_world/plugin.py b/src/cthulhu/plugins/hello_world/plugin.py
new file mode 100644
index 0000000..874861c
--- /dev/null
+++ b/src/cthulhu/plugins/hello_world/plugin.py
@@ -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
diff --git a/src/cthulhu/plugins/self_voice/Makefile.am b/src/cthulhu/plugins/self_voice/Makefile.am
new file mode 100644
index 0000000..cafee1e
--- /dev/null
+++ b/src/cthulhu/plugins/self_voice/Makefile.am
@@ -0,0 +1,7 @@
+cthulhu_python_PYTHON = \
+ __init__.py \
+ plugin.info \
+ plugin.py
+
+cthulhu_pythondir=$(pkgpythondir)/plugins/self_voice
+
diff --git a/src/cthulhu/plugins/SelfVoice/__init__.py b/src/cthulhu/plugins/self_voice/__init__.py
similarity index 100%
rename from src/cthulhu/plugins/SelfVoice/__init__.py
rename to src/cthulhu/plugins/self_voice/__init__.py
diff --git a/src/cthulhu/plugins/self_voice/plugin.info b/src/cthulhu/plugins/self_voice/plugin.info
new file mode 100644
index 0000000..9ebe67d
--- /dev/null
+++ b/src/cthulhu/plugins/self_voice/plugin.info
@@ -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
diff --git a/src/cthulhu/plugins/self_voice/plugin.py b/src/cthulhu/plugins/self_voice/plugin.py
new file mode 100644
index 0000000..324f15b
--- /dev/null
+++ b/src/cthulhu/plugins/self_voice/plugin.py
@@ -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")
diff --git a/src/cthulhu/resource_manager.py b/src/cthulhu/resource_manager.py
index def0bd9..d06705f 100644
--- a/src/cthulhu/resource_manager.py
+++ b/src/cthulhu/resource_manager.py
@@ -113,7 +113,7 @@ class ResourceContext():
self.settings[profile][application] = {}
# add entry
self.settings[profile][application][sub_setting_name] = entry
-
+
print('add', 'settings', self.getName(), profile, application, entry.getResourceText())
@@ -214,7 +214,7 @@ class ResourceContext():
self.unregisterAllAPI()
except Exception as e:
print(e)
-
+
def unregisterAllAPI(self):
dynamicApiManager = self.app.getDynamicApiManager()
for application, value in self.getAPIs().copy().items():
@@ -226,7 +226,7 @@ class ResourceContext():
print('unregister api ', self.getName(), entry.getEntryType(), entry.getResourceText())
def unregisterAllGestures(self):
APIHelper = self.app.getAPIHelper()
-
+
for profile, profileValue in self.getGestures().copy().items():
for application, applicationValue in profileValue.copy().items():
for gesture, entry in applicationValue.copy().items():
@@ -272,12 +272,12 @@ class ResourceManager():
def removeResourceContext(self, contextName):
if not contextName:
return
-
+
try:
self.resourceContextDict[contextName].unregisterAllResources()
except:
pass
-
+
# temp
try:
print('_________', 'summery', self.resourceContextDict[contextName].getName(), '_________')
@@ -302,7 +302,7 @@ class ResourceManager():
return self.resourceContextDict[contextName]
except KeyError:
return None
-
+
def addAPI(self, application, api, contextName = None):
if not contextName:
return
diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py
index 1969cff..68708a7 100644
--- a/src/cthulhu/script_utilities.py
+++ b/src/cthulhu/script_utilities.py
@@ -3558,10 +3558,10 @@ class Utilities:
@staticmethod
def unicodeValueString(character):
""" Returns a four hex digit representation of the given character
-
+
Arguments:
- The character to return representation
-
+
Returns a string representaition of the given character unicode vlue
"""
diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py
index 5952280..1c05ff9 100644
--- a/src/cthulhu/settings.py
+++ b/src/cthulhu/settings.py
@@ -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']
diff --git a/src/cthulhu/speechdispatcherfactory.py b/src/cthulhu/speechdispatcherfactory.py
index 9f9f7eb..efb6297 100644
--- a/src/cthulhu/speechdispatcherfactory.py
+++ b/src/cthulhu/speechdispatcherfactory.py
@@ -69,7 +69,7 @@ class SpeechServer(speechserver.SpeechServer):
# See the parent class for documentation.
_active_servers = {}
-
+
DEFAULT_SERVER_ID = 'default'
_SERVER_NAMES = {DEFAULT_SERVER_ID: guilabels.DEFAULT_SYNTHESIZER}
@@ -93,7 +93,7 @@ class SpeechServer(speechserver.SpeechServer):
Attempt to create the server if it doesn't exist yet. Returns None
when it is not possible to create the server.
-
+
"""
if serverId not in cls._active_servers:
cls(serverId)
@@ -781,16 +781,16 @@ class SpeechServer(speechserver.SpeechServer):
def reset(self, text=None, acss=None):
self._client.close()
self._init()
-
+
def list_output_modules(self):
"""Return names of available output modules as a tuple of strings.
This method is not a part of Cthulhu speech API, but is used internally
by the Speech Dispatcher backend.
-
+
The returned tuple can be empty if the information can not be
obtained (e.g. with an older Speech Dispatcher version).
-
+
"""
try:
return self._send_command(self._client.list_output_modules)