Intial redesign of plugins manager. This is likely to be buggy.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user