Intial redesign of plugins manager. This is likely to be buggy.

This commit is contained in:
Storm Dragon
2026-01-03 16:22:33 -05:00
parent c1ede7728e
commit 28652e24f4
4 changed files with 663 additions and 47 deletions
+361
View File
@@ -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"<b>{display_name}</b>"
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()
+278 -22
View File
@@ -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,51 +410,156 @@ 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):
if _depth == 0:
logger.warning(f"Plugin directory not found or not a directory: {directory}")
return
if _depth == 0:
logger.info(f"Scanning for plugins in directory: {directory}")
for item in os.listdir(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)
if self._register_plugin_from_directory(plugin_dir):
continue
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")
# 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}")
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', module_name),
metadata.get('name', canonical_name),
module_name,
plugin_dir,
metadata
metadata,
canonical_name=canonical_name,
source_id=source_id,
origin=origin,
)
# 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'
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:
logger.warning(f"No plugin file found in directory: {plugin_dir}")
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."""
@@ -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}")
@@ -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
+2
View File
@@ -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