From 28652e24f4e7dd35b18cc0a9438c5ffea41cfe68 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 3 Jan 2026 16:22:33 -0500 Subject: [PATCH] Intial redesign of plugins manager. This is likely to be buggy. --- src/cthulhu/cthulhu_gui_prefs.py | 361 ++++++++++++++++++++ src/cthulhu/plugin_system_manager.py | 344 ++++++++++++++++--- src/cthulhu/plugins/PluginManager/plugin.py | 3 - src/cthulhu/settings.py | 2 + 4 files changed, 663 insertions(+), 47 deletions(-) diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index 7195c9c..27c0987 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -38,6 +38,7 @@ gi.require_version("Gtk", "3.0") from gi.repository import Atspi import os +import threading from gi.repository import Gdk from gi.repository import GLib from gi.repository import Gtk @@ -134,6 +135,19 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.planeCellRendererText = None self.pronunciationModel = None self.pronunciationView = None + self._plugin_checkboxes = {} + self._plugin_listbox = None + self._plugin_sources = [] + self._plugin_sources_entry = None + self._plugin_sources_listbox = None + self._plugin_sources_original = [] + self._available_plugins = set() + self._plugin_canonical_map = {} + self._plugin_group_map = {} + self._plugin_update_button = None + self._plugin_update_progress = None + self._plugin_update_status = None + self._plugin_update_in_progress = False self.screenHeight = None self.screenWidth = None self.speechFamiliesChoice = None @@ -365,6 +379,11 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._isInitialSetup = \ not os.path.exists(_settingsManager.getPrefsDir()) + try: + self._initPluginsPage() + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin page init failed: {e}", True) + appPage = self.script.getAppPreferencesGUI() if appPage: label = Gtk.Label(label=AXObject.get_name(self.script.app)) @@ -373,6 +392,347 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self._initGUIState() self._initSoundThemeState() + def _initPluginsPage(self): + self._plugin_sources = list(self.prefsDict.get("pluginSources", settings.pluginSources) or []) + self._plugin_sources_original = list(self._plugin_sources) + + pluginsPage = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + pluginsPage.set_border_width(12) + + infoLabel = Gtk.Label(label="Enable or disable plugins and manage plugin sources.") + infoLabel.set_line_wrap(True) + infoLabel.set_halign(Gtk.Align.START) + pluginsPage.pack_start(infoLabel, False, False, 0) + + pluginsFrame = Gtk.Frame(label="Plugins") + try: + pluginsFrame.set_label_align(0.0, 0.5) + except Exception: + try: + pluginsFrame.set_label_xalign(0.0) + except Exception: + pass + pluginsFrame.set_shadow_type(Gtk.ShadowType.NONE) + pluginsFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + pluginsFrameBox.set_border_width(6) + pluginsFrame.add(pluginsFrameBox) + + pluginsScrolled = Gtk.ScrolledWindow() + pluginsScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + pluginsScrolled.set_size_request(-1, 200) + self._plugin_listbox = Gtk.ListBox() + self._plugin_listbox.set_selection_mode(Gtk.SelectionMode.NONE) + pluginsScrolled.add(self._plugin_listbox) + pluginsFrameBox.pack_start(pluginsScrolled, True, True, 0) + + pluginsPage.pack_start(pluginsFrame, True, True, 0) + + sourcesFrame = Gtk.Frame(label="Plugin Sources") + try: + sourcesFrame.set_label_align(0.0, 0.5) + except Exception: + try: + sourcesFrame.set_label_xalign(0.0) + except Exception: + pass + sourcesFrame.set_shadow_type(Gtk.ShadowType.NONE) + sourcesFrameBox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + sourcesFrameBox.set_border_width(6) + sourcesFrame.add(sourcesFrameBox) + + sourcesGrid = Gtk.Grid(row_spacing=6, column_spacing=6) + sourcesLabel = Gtk.Label(label="_Source URL:") + sourcesLabel.set_use_underline(True) + sourcesLabel.set_halign(Gtk.Align.START) + self._plugin_sources_entry = Gtk.Entry() + self._plugin_sources_entry.set_placeholder_text("https://example.com/repo.git") + sourcesLabel.set_mnemonic_widget(self._plugin_sources_entry) + addButton = Gtk.Button(label="Add Source") + addButton.connect("clicked", self._on_add_plugin_source) + sourcesGrid.attach(sourcesLabel, 0, 0, 1, 1) + sourcesGrid.attach(self._plugin_sources_entry, 1, 0, 1, 1) + sourcesGrid.attach(addButton, 2, 0, 1, 1) + sourcesFrameBox.pack_start(sourcesGrid, False, False, 0) + + updateRow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self._plugin_update_button = Gtk.Button(label="Update Plugins") + self._plugin_update_button.connect("clicked", self._on_update_plugins_clicked) + self._plugin_update_progress = Gtk.ProgressBar() + self._plugin_update_progress.set_show_text(True) + self._plugin_update_progress.set_text("Idle") + updateRow.pack_start(self._plugin_update_button, False, False, 0) + updateRow.pack_start(self._plugin_update_progress, True, True, 0) + sourcesFrameBox.pack_start(updateRow, False, False, 0) + + sourcesScrolled = Gtk.ScrolledWindow() + sourcesScrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + sourcesScrolled.set_size_request(-1, 120) + self._plugin_sources_listbox = Gtk.ListBox() + self._plugin_sources_listbox.set_selection_mode(Gtk.SelectionMode.NONE) + sourcesScrolled.add(self._plugin_sources_listbox) + sourcesFrameBox.pack_start(sourcesScrolled, True, True, 0) + + sourcesNote = Gtk.Label(label="Use Update Plugins to install or update sources, then Apply or OK to save settings.") + sourcesNote.set_line_wrap(True) + sourcesNote.set_halign(Gtk.Align.START) + sourcesFrameBox.pack_start(sourcesNote, False, False, 0) + + self._plugin_update_status = Gtk.Label(label="") + self._plugin_update_status.set_line_wrap(True) + self._plugin_update_status.set_halign(Gtk.Align.START) + sourcesFrameBox.pack_start(self._plugin_update_status, False, False, 0) + + pluginsPage.pack_start(sourcesFrame, True, True, 0) + + notebook = self.get_widget("notebook") + notebook.append_page(pluginsPage, Gtk.Label(label="Plugins")) + + self._populate_plugin_list() + self._populate_plugin_sources_list() + pluginsPage.show_all() + + def _clear_listbox(self, listbox): + for child in listbox.get_children(): + listbox.remove(child) + + def _populate_plugin_list(self): + if not self._plugin_listbox: + return + + try: + self._clear_listbox(self._plugin_listbox) + self._plugin_checkboxes.clear() + self._available_plugins = set() + self._plugin_canonical_map = {} + self._plugin_group_map = {} + + manager = cthulhu.cthulhuApp.getPluginSystemManager() + if manager: + try: + manager.rescanPlugins() + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin rescan failed: {e}", True) + manager = None + + active_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) + active_plugins_lower = {name.lower() for name in active_plugins} + + plugin_infos = manager.plugins if manager else [] + self._available_plugins = {info.get_module_name() for info in plugin_infos} + canonical_counts = {} + canonical_builtins = {} + for info in plugin_infos: + canonical = info.get_canonical_name() + canonical_counts[canonical] = canonical_counts.get(canonical, 0) + 1 + if info.builtin: + canonical_builtins[canonical] = True + for plugin_info in sorted(plugin_infos, key=lambda item: (item.get_name() or item.get_module_name()).lower()): + plugin_name = plugin_info.get_module_name() + canonical_name = plugin_info.get_canonical_name() + if plugin_info.hidden or canonical_name == "PluginManager": + continue + + row = Gtk.ListBoxRow() + row.set_activatable(False) + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + hbox.set_border_width(5) + + checkbox = Gtk.CheckButton() + is_active = ( + plugin_name in active_plugins + or plugin_name.lower() in active_plugins_lower + or (plugin_info.preferred_alias and (canonical_name in active_plugins or canonical_name.lower() in active_plugins_lower)) + or plugin_info.builtin + ) + if canonical_builtins.get(canonical_name) and not plugin_info.builtin: + is_active = False + checkbox.set_active(is_active) + if plugin_info.builtin: + checkbox.set_sensitive(False) + elif canonical_builtins.get(canonical_name): + checkbox.set_sensitive(False) + checkbox.connect("toggled", self._on_plugin_checkbox_toggled, plugin_name) + + display_name = GLib.markup_escape_text(plugin_info.get_name() or plugin_name) + info_text = f"{display_name}" + description = plugin_info.get_description() + if description: + info_text += f"\n{GLib.markup_escape_text(description)}" + version = plugin_info.get_version() + if version: + info_text += f" (v{GLib.markup_escape_text(version)})" + if canonical_counts.get(canonical_name, 0) > 1: + info_text += f"\nSource: {GLib.markup_escape_text(plugin_info.get_source_label())}" + if canonical_builtins.get(canonical_name) and not plugin_info.builtin: + info_text += "\nDisabled because a builtin plugin uses this name." + + label = Gtk.Label() + label.set_markup(info_text) + label.set_halign(Gtk.Align.START) + label.set_line_wrap(True) + + hbox.pack_start(checkbox, False, False, 0) + hbox.pack_start(label, True, True, 0) + row.add(hbox) + self._plugin_listbox.add(row) + self._plugin_checkboxes[plugin_name] = checkbox + self._plugin_canonical_map[plugin_name] = canonical_name + self._plugin_group_map.setdefault(canonical_name, []).append(plugin_name) + + self._plugin_listbox.show_all() + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin list build failed: {e}", True) + + def _populate_plugin_sources_list(self): + if not self._plugin_sources_listbox: + return + + self._clear_listbox(self._plugin_sources_listbox) + + for source in self._plugin_sources: + row = Gtk.ListBoxRow() + row.set_activatable(False) + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + hbox.set_border_width(5) + + label = Gtk.Label(label=source) + label.set_halign(Gtk.Align.START) + label.set_line_wrap(True) + + remove_button = Gtk.Button(label="Remove") + remove_button.connect("clicked", self._on_remove_plugin_source, source, row) + + hbox.pack_start(label, True, True, 0) + hbox.pack_start(remove_button, False, False, 0) + row.add(hbox) + self._plugin_sources_listbox.add(row) + + self._plugin_sources_listbox.show_all() + + def _on_add_plugin_source(self, widget): + if not self._plugin_sources_entry: + return + source = self._plugin_sources_entry.get_text().strip() + if not source: + return + if source in self._plugin_sources: + return + self._plugin_sources.append(source) + self._plugin_sources_entry.set_text("") + self._populate_plugin_sources_list() + + def _on_remove_plugin_source(self, widget, source, row): + if source in self._plugin_sources: + self._plugin_sources.remove(source) + if self._plugin_sources_listbox and row: + self._plugin_sources_listbox.remove(row) + + def _on_update_plugins_clicked(self, widget): + if self._plugin_update_in_progress: + return + + sources = list(self._plugin_sources) + if not sources: + self._set_plugin_update_status("No sources to update.", done=True) + return + + self._plugin_update_in_progress = True + if self._plugin_update_button: + self._plugin_update_button.set_sensitive(False) + self._set_plugin_update_progress(0, len(sources), "Starting updates...") + + def _run_updates(): + manager = cthulhu.cthulhuApp.getPluginSystemManager() + if not manager: + GLib.idle_add(self._finish_plugin_updates, "Plugin manager unavailable.") + return + + def _progress_callback(index, total, message): + GLib.idle_add(self._set_plugin_update_progress, index, total, message) + + manager.syncPluginSources(sources, progress_callback=_progress_callback) + try: + manager.rescanPlugins() + except Exception as e: + GLib.idle_add(self._finish_plugin_updates, f"Update finished with errors: {e}") + return + + GLib.idle_add(self._finish_plugin_updates, "Update complete.") + + thread = threading.Thread(target=_run_updates, daemon=True) + thread.start() + + def _set_plugin_update_progress(self, index, total, message): + if not self._plugin_update_progress: + return + fraction = 0.0 if total <= 0 else min(1.0, float(index) / float(total)) + self._plugin_update_progress.set_fraction(fraction) + self._plugin_update_progress.set_text(message) + self._set_plugin_update_status(message) + + def _set_plugin_update_status(self, message, done=False): + if self._plugin_update_status: + self._plugin_update_status.set_text(message) + if done and self._plugin_update_progress: + self._plugin_update_progress.set_fraction(0.0) + self._plugin_update_progress.set_text("Idle") + + def _finish_plugin_updates(self, message): + self._plugin_update_in_progress = False + if self._plugin_update_button: + self._plugin_update_button.set_sensitive(True) + if message: + self._set_plugin_update_status(message) + self._populate_plugin_list() + + def _on_plugin_checkbox_toggled(self, checkbox, plugin_name): + if not checkbox.get_active(): + return + canonical_name = self._plugin_canonical_map.get(plugin_name) + if not canonical_name: + return + for other_name in self._plugin_group_map.get(canonical_name, []): + if other_name == plugin_name: + continue + other_checkbox = self._plugin_checkboxes.get(other_name) + if other_checkbox and other_checkbox.get_active(): + other_checkbox.set_active(False) + + def _get_active_plugins_from_ui(self): + existing_plugins = list(self.prefsDict.get("activePlugins", settings.activePlugins) or []) + preserved_plugins = [ + name for name in existing_plugins + if name not in self._plugin_checkboxes and name in self._available_plugins + ] + selected_plugins = [] + for canonical_name, plugin_names in self._plugin_group_map.items(): + active_in_group = [ + name for name in plugin_names + if self._plugin_checkboxes.get(name) and self._plugin_checkboxes[name].get_active() + ] + if active_in_group: + selected_plugins.append(active_in_group[-1]) + return preserved_plugins + selected_plugins + + def _apply_plugin_changes(self): + active_plugins = self._get_active_plugins_from_ui() + plugin_sources = list(self._plugin_sources) + + self.prefsDict["activePlugins"] = active_plugins + self.prefsDict["pluginSources"] = plugin_sources + + removed_sources = [source for source in self._plugin_sources_original if source not in plugin_sources] + manager = cthulhu.cthulhuApp.getPluginSystemManager() + if manager: + try: + manager.removePluginSources(removed_sources) + manager.rescanPlugins() + except Exception as e: + debug.printMessage(debug.LEVEL_WARNING, f"PREFERENCES DIALOG: Plugin sync failed: {e}", True) + + self._plugin_sources_original = list(plugin_sources) + self._populate_plugin_list() + def _getACSSForVoiceType(self, voiceType): """Return the ACSS value for the given voice type. @@ -3683,6 +4043,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): self.prefsDict['startingProfile'] = startingProfile _settingsManager.setStartingProfile(startingProfile) + self._apply_plugin_changes() self.writeUserPreferences() cthulhu.loadUserSettings(self.script) braille.checkBrailleSetting() diff --git a/src/cthulhu/plugin_system_manager.py b/src/cthulhu/plugin_system_manager.py index c749054..1f751c5 100644 --- a/src/cthulhu/plugin_system_manager.py +++ b/src/cthulhu/plugin_system_manager.py @@ -8,11 +8,15 @@ """Plugin System Manager for Cthulhu using pluggy.""" -import os -import inspect -import importlib.util -import logging import configparser +import hashlib +import importlib.util +import inspect +import logging +import os +import re +import shutil +import subprocess from enum import IntEnum # Import pluggy if available @@ -54,11 +58,15 @@ class PluginType(IntEnum): class PluginInfo: """Information about a plugin.""" - def __init__(self, name, module_name, module_dir, metadata=None): + def __init__(self, name, module_name, module_dir, metadata=None, canonical_name=None, source_id=None, origin=None): self.name = name self.module_name = module_name self.module_dir = module_dir self.metadata = metadata or {} + self.canonical_name = canonical_name or module_name + self.source_id = source_id or "unknown" + self.origin = origin or "unknown" + self.preferred_alias = False self.builtin = False self.hidden = False self.module = None @@ -68,6 +76,9 @@ class PluginInfo: def get_module_name(self): return self.module_name + def get_canonical_name(self): + return self.canonical_name + def get_name(self): return self.metadata.get('name', self.name) @@ -77,6 +88,17 @@ class PluginInfo: def get_description(self): return self.metadata.get('description', '') + def get_source_id(self): + return self.source_id + + def get_origin(self): + return self.origin + + def get_source_label(self): + if self.origin == "sources": + return self.source_id + return self.origin + def get_module_dir(self): return self.module_dir @@ -117,6 +139,7 @@ class PluginSystemManager: # Plugin storage self._plugins = {} # module_name -> PluginInfo + self._plugin_name_index = {} # canonical_name -> [module_name] self._active_plugins = [] # Create plugin directories @@ -310,6 +333,53 @@ class PluginSystemManager: """Ensure plugin directories exist.""" os.makedirs(PluginType.SYSTEM.get_root_dir(), exist_ok=True) os.makedirs(PluginType.USER.get_root_dir(), exist_ok=True) + os.makedirs(self._get_plugin_sources_root(), exist_ok=True) + + def _get_plugin_sources_root(self): + return os.path.expanduser('~/.local/share/cthulhu/plugin-sources') + + def _get_additional_plugin_dirs(self): + return [os.path.expanduser('~/.local/share/plugins')] + + def _path_under_root(self, path, root): + try: + return os.path.commonpath([os.path.abspath(path), os.path.abspath(root)]) == os.path.abspath(root) + except ValueError: + return False + + def _sanitize_source_id(self, source_id): + return re.sub(r'[^a-zA-Z0-9._-]+', '-', source_id).strip('-') or "source" + + def _get_origin_info(self, plugin_dir): + system_root = PluginType.SYSTEM.get_root_dir() + user_root = PluginType.USER.get_root_dir() + local_root = os.path.expanduser('~/.local/share/plugins') + sources_root = self._get_plugin_sources_root() + + if self._path_under_root(plugin_dir, system_root): + return ("system", "system") + if self._path_under_root(plugin_dir, user_root): + return ("user", "user") + if self._path_under_root(plugin_dir, local_root): + return ("local", "local") + if self._path_under_root(plugin_dir, sources_root): + rel_path = os.path.relpath(plugin_dir, sources_root) + source_id = rel_path.split(os.sep, 1)[0] + return ("sources", self._sanitize_source_id(source_id)) + + return ("unknown", "unknown") + + def _make_unique_plugin_id(self, canonical_name, source_id): + base_id = canonical_name + if canonical_name in self._plugin_name_index: + base_id = f"{canonical_name}@{self._sanitize_source_id(source_id)}" + + unique_id = base_id + suffix = 2 + while unique_id in self._plugins: + unique_id = f"{base_id}-{suffix}" + suffix += 1 + return unique_id @property def plugins(self): @@ -323,10 +393,15 @@ class PluginSystemManager: """Scan for plugins in the plugin directories.""" old_plugins = self._plugins.copy() self._plugins = {} + self._plugin_name_index = {} # 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()) + self._scan_plugins_in_directory(PluginType.USER.get_root_dir(), max_depth=1) + for plugin_dir in self._get_additional_plugin_dirs(): + if os.path.isdir(plugin_dir): + self._scan_plugins_in_directory(plugin_dir, max_depth=1) + self._scan_plugins_in_directory(self._get_plugin_sources_root(), max_depth=1) # Preserve state for already loaded plugins for name, old_info in old_plugins.items(): @@ -335,52 +410,157 @@ class PluginSystemManager: self._plugins[name].instance = old_info.instance self._plugins[name].module = old_info.module - def _scan_plugins_in_directory(self, directory): + def _scan_plugins_in_directory(self, directory, max_depth=0, _depth=0): """Scan for plugins in a directory.""" if not os.path.exists(directory) or not os.path.isdir(directory): - logger.warning(f"Plugin directory not found or not a directory: {directory}") + if _depth == 0: + logger.warning(f"Plugin directory not found or not a directory: {directory}") return - logger.info(f"Scanning for plugins in directory: {directory}") - for item in os.listdir(directory): + if _depth == 0: + logger.info(f"Scanning for plugins in directory: {directory}") + + for item in sorted(os.listdir(directory)): + if item.startswith('.'): + continue plugin_dir = os.path.join(directory, item) if not os.path.isdir(plugin_dir): continue - # Check for the traditional structure first (plugin.py & plugin.info) - plugin_file = os.path.join(plugin_dir, "plugin.py") - metadata_file = os.path.join(plugin_dir, "plugin.info") + if self._register_plugin_from_directory(plugin_dir): + continue - # Fall back to [PluginName].py if plugin.py doesn't exist - if not os.path.isfile(plugin_file): - alternative_plugin_file = os.path.join(plugin_dir, f"{item}.py") - if os.path.isfile(alternative_plugin_file): - plugin_file = alternative_plugin_file - logger.info(f"Using alternative plugin file: {alternative_plugin_file}") - - # Check if we have any valid plugin file - if os.path.isfile(plugin_file): - # Extract plugin info - module_name = os.path.basename(plugin_dir) - logger.info(f"Found plugin: {module_name} in {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' - - logger.info(f"Adding plugin to registry: {module_name}") - self._plugins[module_name] = plugin_info - else: + if _depth < max_depth: + self._scan_plugins_in_directory(plugin_dir, max_depth=max_depth, _depth=_depth + 1) + elif max_depth == 0: logger.warning(f"No plugin file found in directory: {plugin_dir}") + def _register_plugin_from_directory(self, plugin_dir): + item = os.path.basename(plugin_dir) + plugin_file = os.path.join(plugin_dir, "plugin.py") + metadata_file = os.path.join(plugin_dir, "plugin.info") + + if not os.path.isfile(plugin_file): + alternative_plugin_file = os.path.join(plugin_dir, f"{item}.py") + if os.path.isfile(alternative_plugin_file): + plugin_file = alternative_plugin_file + logger.info(f"Using alternative plugin file: {alternative_plugin_file}") + + if not os.path.isfile(plugin_file): + return False + + canonical_name = os.path.basename(plugin_dir) + origin, source_id = self._get_origin_info(plugin_dir) + module_name = self._make_unique_plugin_id(canonical_name, source_id) + if canonical_name != module_name: + logger.warning( + "Duplicate plugin name detected: " + f"{canonical_name} (origin={origin}, source={source_id}). " + f"Registering as {module_name}." + ) + logger.info(f"Found plugin: {canonical_name} in {plugin_dir}") + metadata = self._load_plugin_metadata(metadata_file) + + plugin_info = PluginInfo( + metadata.get('name', canonical_name), + module_name, + plugin_dir, + metadata, + canonical_name=canonical_name, + source_id=source_id, + origin=origin, + ) + + plugin_info.builtin = metadata.get('builtin', 'false').lower() == 'true' + plugin_info.hidden = metadata.get('hidden', 'false').lower() == 'true' + + if canonical_name not in self._plugin_name_index: + plugin_info.preferred_alias = True + self._plugin_name_index[canonical_name] = [] + self._plugin_name_index[canonical_name].append(module_name) + + logger.info(f"Adding plugin to registry: {module_name}") + self._plugins[module_name] = plugin_info + return True + + def _plugin_source_dir(self, source_url): + base_name = source_url.rstrip('/').split('/')[-1] or 'plugin-source' + if base_name.endswith('.git'): + base_name = base_name[:-4] + base_name = re.sub(r'[^a-zA-Z0-9._-]+', '-', base_name).strip('-') or 'plugin-source' + digest = hashlib.sha1(source_url.encode('utf-8')).hexdigest()[:8] + return os.path.join(self._get_plugin_sources_root(), f"{base_name}-{digest}") + + def syncPluginSources(self, sources, progress_callback=None): + """Clone or update plugin source repositories.""" + if not sources: + return + + git_path = shutil.which("git") + if not git_path: + logger.error("Git not available; cannot sync plugin sources.") + return + + os.makedirs(self._get_plugin_sources_root(), exist_ok=True) + total = len(sources) + for index, source in enumerate(sources, start=1): + source = source.strip() + if not source: + continue + dest_dir = self._plugin_source_dir(source) + if os.path.isdir(dest_dir): + try: + subprocess.run( + [git_path, "-C", dest_dir, "pull", "--ff-only"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + logger.info(f"Updated plugin source: {source}") + if progress_callback: + progress_callback(index, total, f"Updated {source}") + except subprocess.CalledProcessError as exc: + logger.error(f"Failed to update plugin source {source}: {exc.stderr.decode('utf-8', 'ignore')}") + if progress_callback: + progress_callback(index, total, f"Failed to update {source}") + else: + try: + subprocess.run( + [git_path, "clone", source, dest_dir], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + logger.info(f"Installed plugin source: {source}") + if progress_callback: + progress_callback(index, total, f"Installed {source}") + except subprocess.CalledProcessError as exc: + logger.error(f"Failed to clone plugin source {source}: {exc.stderr.decode('utf-8', 'ignore')}") + if progress_callback: + progress_callback(index, total, f"Failed to clone {source}") + + def removePluginSources(self, sources): + """Remove plugin source repositories.""" + if not sources: + return + + sources_root = self._get_plugin_sources_root() + for source in sources: + source = source.strip() + if not source: + continue + dest_dir = self._plugin_source_dir(source) + if not os.path.isdir(dest_dir): + continue + if os.path.commonpath([dest_dir, sources_root]) != sources_root: + logger.warning(f"Skipping removal outside plugin sources root: {dest_dir}") + continue + try: + shutil.rmtree(dest_dir) + logger.info(f"Removed plugin source: {source}") + except Exception as exc: + logger.error(f"Failed to remove plugin source {source}: {exc}") + def _load_plugin_metadata(self, metadata_file): """Load plugin metadata from a file.""" metadata = {} @@ -448,10 +628,71 @@ class PluginSystemManager: logger.info("No plugins found, rescanning...") self.rescanPlugins() - self._active_plugins = activePlugins + plugin_by_id = {info.get_module_name(): info for info in self.plugins} + plugin_by_id_lower = {name.lower(): info for name, info in plugin_by_id.items()} + preferred_by_canonical = { + info.get_canonical_name().lower(): info.get_module_name() + for info in self.plugins if info.preferred_alias + } + canonical_with_builtin = { + info.get_canonical_name().lower() + for info in self.plugins if info.builtin + } + + normalized_active = [] + seen_canonicals = set() + unknown_active = [] + + for name in activePlugins: + if not name: + continue + name_lower = name.lower() + plugin_info = plugin_by_id.get(name) or plugin_by_id_lower.get(name_lower) + if plugin_info: + canonical_lower = plugin_info.get_canonical_name().lower() + if canonical_lower in canonical_with_builtin and not plugin_info.builtin: + logger.warning( + f"Skipping plugin {plugin_info.get_module_name()} because builtin plugin " + f"uses name {plugin_info.get_canonical_name()}" + ) + continue + if canonical_lower in seen_canonicals: + logger.warning( + f"Skipping plugin {plugin_info.get_module_name()} because another plugin " + f"with name {plugin_info.get_canonical_name()} is already active" + ) + continue + normalized_active.append(plugin_info.get_module_name()) + seen_canonicals.add(canonical_lower) + continue + + preferred_id = preferred_by_canonical.get(name_lower) + if preferred_id: + preferred_info = plugin_by_id.get(preferred_id) + canonical_lower = name_lower + if preferred_info and canonical_lower in canonical_with_builtin and not preferred_info.builtin: + logger.warning( + f"Skipping plugin {preferred_id} because builtin plugin uses name {preferred_info.get_canonical_name()}" + ) + continue + if canonical_lower in seen_canonicals: + logger.warning( + f"Skipping plugin {preferred_id} because another plugin with name {name} is already active" + ) + continue + normalized_active.append(preferred_id) + seen_canonicals.add(canonical_lower) + continue + + unknown_active.append(name) + + self._active_plugins = normalized_active + unknown_active # Log active vs available plugins available_plugins = [p.get_module_name() for p in self.plugins] + available_aliases = [p.get_canonical_name() for p in self.plugins if p.preferred_alias] + available_plugins_lower = {p.lower() for p in available_plugins} + available_aliases_lower = {p.lower() for p in available_aliases} logger.info(f"Available plugins: {available_plugins}") logger.info(f"Active plugins: {self._active_plugins}") @@ -462,7 +703,13 @@ class PluginSystemManager: logger.warning("DisplayVersion is NOT in active plugins list!") # Find missing plugins - missing_plugins = [p for p in self._active_plugins if p not in available_plugins] + missing_plugins = [ + p for p in self._active_plugins + if p not in available_plugins + and p not in available_aliases + and p.lower() not in available_plugins_lower + and p.lower() not in available_aliases_lower + ] if missing_plugins: logger.warning(f"Active plugins not found: {missing_plugins}") @@ -488,6 +735,7 @@ class PluginSystemManager: def isPluginActive(self, pluginInfo): """Check if a plugin is active.""" module_name = pluginInfo.get_module_name() + canonical_name = pluginInfo.get_canonical_name() # Builtin plugins are always active if pluginInfo.builtin: @@ -507,9 +755,16 @@ class PluginSystemManager: logger.debug(f"Plugin {module_name} found in active plugins list") return True + if pluginInfo.preferred_alias and canonical_name in active_plugins: + logger.debug(f"Plugin {module_name} matched canonical name {canonical_name}") + return True + # Try case-insensitive match module_name_lower = module_name.lower() + canonical_lower = canonical_name.lower() is_active = any(plugin.lower() == module_name_lower for plugin in active_plugins) + if not is_active and pluginInfo.preferred_alias: + is_active = any(plugin.lower() == canonical_lower for plugin in active_plugins) if is_active: logger.debug(f"Plugin {module_name} found in active plugins list (case-insensitive match)") @@ -573,7 +828,8 @@ class PluginSystemManager: # Fall back to [PluginName].py if plugin.py doesn't exist if not os.path.exists(plugin_file): - alternative_plugin_file = os.path.join(plugin_dir, f"{module_name}.py") + canonical_name = pluginInfo.get_canonical_name() + alternative_plugin_file = os.path.join(plugin_dir, f"{canonical_name}.py") if os.path.exists(alternative_plugin_file): plugin_file = alternative_plugin_file logger.info(f"Using alternative plugin file: {alternative_plugin_file}") diff --git a/src/cthulhu/plugins/PluginManager/plugin.py b/src/cthulhu/plugins/PluginManager/plugin.py index 493e172..f10ce4a 100644 --- a/src/cthulhu/plugins/PluginManager/plugin.py +++ b/src/cthulhu/plugins/PluginManager/plugin.py @@ -53,9 +53,6 @@ class PluginManager(Plugin): try: debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin activation starting", True) - # Register keybinding for opening plugin manager (Cthulhu+Shift+P) - self._register_keybinding() - self._activated = True debug.printMessage(debug.LEVEL_INFO, "PluginManager: Plugin activated successfully", True) return True diff --git a/src/cthulhu/settings.py b/src/cthulhu/settings.py index 7dbb6ef..65916e6 100644 --- a/src/cthulhu/settings.py +++ b/src/cthulhu/settings.py @@ -136,6 +136,7 @@ userCustomizableSettings = [ "presentTimeFormat", "activeProfile", "activePlugins", + "pluginSources", "startingProfile", "spellcheckSpellError", "spellcheckSpellSuggestion", @@ -490,6 +491,7 @@ presentLiveRegionFromInactiveTab = False # Plugins activePlugins = ['AIAssistant', 'DisplayVersion', 'OCR', 'PluginManager', 'HelloCthulhu', 'ByeCthulhu', 'IndentationAudio'] +pluginSources = [] # AI Assistant settings (disabled by default for opt-in behavior) aiAssistantEnabled = True