continuation of plugin support
This commit is contained in:
@@ -34,6 +34,7 @@ from PySide6.QtGui import QAction, QActionGroup, QFontMetrics, QKeySequence, QIc
|
||||
from PySide6.QtSvgWidgets import QSvgWidget # needed to show svgs when app is not frozen
|
||||
|
||||
from file_ext_registration import register_file_association, ELEVATION_FLAG
|
||||
from plugin_manager import PluginManager
|
||||
from project_manager import ProjectManager
|
||||
from src.window.about import AboutWindow
|
||||
from src.window.plugins import PluginsWindow
|
||||
@@ -579,6 +580,7 @@ class MainApplication(QMainWindow):
|
||||
self.files_are_dirty = False
|
||||
|
||||
self.project_manager = ProjectManager(self, file_cfg=file_cfg, cfg_path=cfg_path)
|
||||
self.plugin_manager = PluginManager(self)
|
||||
|
||||
# Initialization to ensure that saving can occur
|
||||
for item in DATA_SCHEMA:
|
||||
@@ -620,7 +622,9 @@ class MainApplication(QMainWindow):
|
||||
self.local_check_thread.pending_update_found.connect(self.updater.on_pending_update_found)
|
||||
self.local_check_thread.no_pending_update.connect(self.updater.on_no_pending_update)
|
||||
self.local_check_thread.start()
|
||||
|
||||
|
||||
self.plugin_manager.reload_plugins()
|
||||
|
||||
self.show()
|
||||
|
||||
# Check if we should pop up the welcome screen
|
||||
@@ -1327,7 +1331,7 @@ class MainApplication(QMainWindow):
|
||||
|
||||
def plugins_gui(self):
|
||||
if self.plugins is None or not self.plugins.isVisible():
|
||||
self.plugins = PluginsWindow(self)
|
||||
self.plugins = PluginsWindow(self, self.plugin_manager)
|
||||
self.plugins.show()
|
||||
|
||||
def terminal_gui(self):
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Filename: plugin_manager.py
|
||||
Description: Manager file for anything plugin related
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
import io
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
import platform
|
||||
import urllib.request
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
# External library imports
|
||||
from PySide6.QtCore import QObject, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import QMainWindow, QMenu, QMessageBox
|
||||
|
||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLATFORM_NAME
|
||||
|
||||
|
||||
def get_current_platform_id() -> str:
|
||||
"""Returns standardized platform identifier (win_x64, darwin_arm64, etc.)."""
|
||||
sys_name = sys.platform
|
||||
arch = platform.machine().lower()
|
||||
|
||||
if sys_name == "win32":
|
||||
return "win_x64" if "64" in arch or "amd64" in arch else "win_x86"
|
||||
elif sys_name == "darwin":
|
||||
return "darwin_arm64" if "arm" in arch or "aarch64" in arch else "darwin_x64"
|
||||
elif sys_name.startswith("linux"):
|
||||
return "linux_x64" if "64" in arch else "linux_x86"
|
||||
return sys_name
|
||||
|
||||
|
||||
def parse_version(v_str: str) -> tuple[int, ...]:
|
||||
"""Parses a version string into a comparable integer tuple."""
|
||||
try:
|
||||
return tuple(int(x) for x in v_str.strip().lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
class PluginManager(QObject):
|
||||
"""
|
||||
Handles plugin discovery, dynamic loading, menu building,
|
||||
and plugin lifecycle (install, toggle, uninstall).
|
||||
"""
|
||||
|
||||
plugins_changed = Signal()
|
||||
|
||||
def __init__(self, main_window: QMainWindow) -> None:
|
||||
super().__init__()
|
||||
self.main_window: QMainWindow = main_window
|
||||
self.plugins_dir: Path = self._resolve_plugins_dir()
|
||||
self.loaded_plugins: list[Any] = []
|
||||
self.current_platform: str = get_current_platform_id()
|
||||
self.current_app_version: tuple[int, ...] = parse_version(str(CURRENT_VERSION))
|
||||
|
||||
def _resolve_plugins_dir(self) -> Path:
|
||||
"""Determines the local plugins directory based on execution context."""
|
||||
if PLATFORM_NAME == "darwin":
|
||||
base_dir = Path(sys.executable).parent / "../../.."
|
||||
else:
|
||||
base_dir = Path.cwd()
|
||||
|
||||
plugins_path = (base_dir / "plugins").resolve()
|
||||
plugins_path.mkdir(parents=True, exist_ok=True)
|
||||
return plugins_path
|
||||
|
||||
|
||||
def reload_plugins(self) -> list[Any]:
|
||||
"""Scans directory, imports enabled plugins, and updates the main application menu."""
|
||||
self.unload_plugins()
|
||||
|
||||
plugins_dir_str = str(self.plugins_dir)
|
||||
if plugins_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugins_dir_str)
|
||||
|
||||
failed_plugins: list[tuple[str, str]] = []
|
||||
|
||||
for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()):
|
||||
if entry.name.startswith((".", "__")) or entry.name.endswith(".disabled"):
|
||||
continue
|
||||
|
||||
module_name = ""
|
||||
init_file: Path | None = None
|
||||
|
||||
if entry.is_dir():
|
||||
init_file = entry / "__init__.py"
|
||||
module_name = entry.name
|
||||
elif entry.is_file() and entry.suffix == ".py":
|
||||
init_file = entry
|
||||
module_name = entry.stem
|
||||
|
||||
if not init_file or not init_file.exists():
|
||||
continue
|
||||
|
||||
try:
|
||||
instance = self._import_and_instantiate(module_name, init_file)
|
||||
if instance is not None:
|
||||
self.loaded_plugins.append(instance)
|
||||
except Exception as e:
|
||||
failed_plugins.append((module_name, str(e)))
|
||||
|
||||
# Report failures after all plugins have attempted to load
|
||||
if failed_plugins:
|
||||
error_details = "\n".join(f"• {name}: {err}" for name, err in failed_plugins)
|
||||
summary_msg = f"One or more plugins failed to load:\n\n{error_details}"
|
||||
|
||||
print(f"[PluginManager] {summary_msg}")
|
||||
|
||||
parent_widget = getattr(self, "main_window", None) or self
|
||||
QMessageBox.warning(
|
||||
parent_widget,
|
||||
"Plugin Load Failures",
|
||||
summary_msg
|
||||
)
|
||||
|
||||
self.build_plugins_menu()
|
||||
self.plugins_changed.emit()
|
||||
return self.loaded_plugins
|
||||
|
||||
def _import_and_instantiate(self, module_name: str, file_path: Path) -> Any | None:
|
||||
"""Dynamically imports a plugin module, attaches its metadata, and returns the Plugin instance."""
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Invalid or missing spec for file: {file_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
if hasattr(module, "Plugin") and isinstance(module.Plugin, type):
|
||||
instance = module.Plugin(self.main_window)
|
||||
|
||||
# Attach manifest metadata directly to the plugin instance if plugin.json exists
|
||||
manifest_path = file_path.parent / "plugin.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
instance.metadata = json.load(f)
|
||||
# Override class name property if specified in manifest
|
||||
if "name" in instance.metadata:
|
||||
instance.name = instance.metadata["name"]
|
||||
except Exception as e:
|
||||
print(f"[PluginManager] Could not attach metadata to '{module_name}': {e}")
|
||||
|
||||
return instance
|
||||
|
||||
return None
|
||||
|
||||
def build_plugins_menu(self) -> None:
|
||||
"""Constructs or refreshes submenus under the top-level 'Plugins' menu item."""
|
||||
menubar = self.main_window.menuBar()
|
||||
|
||||
# Dynamically locate the existing 'Plugins' menu on the menubar
|
||||
plugins_menu: QMenu | None = None
|
||||
for action in menubar.actions():
|
||||
clean_text = action.text().replace("&", "").strip().lower()
|
||||
if clean_text == "plugins":
|
||||
plugins_menu = action.menu()
|
||||
break
|
||||
|
||||
# If it doesn't exist yet, create it
|
||||
if plugins_menu is None:
|
||||
plugins_menu = menubar.addMenu("&Plugins")
|
||||
|
||||
plugins_menu.clear()
|
||||
|
||||
# 1. Populate loaded plugin submenus
|
||||
if not self.loaded_plugins:
|
||||
disabled_action = plugins_menu.addAction("No active plugins")
|
||||
disabled_action.setEnabled(False)
|
||||
else:
|
||||
for plugin in self.loaded_plugins:
|
||||
plugin_name = getattr(plugin, "name", "Unnamed Plugin")
|
||||
plugin_submenu = plugins_menu.addMenu(plugin_name)
|
||||
|
||||
if hasattr(plugin, "register_menu") and callable(plugin.register_menu):
|
||||
plugin.register_menu(plugin_submenu)
|
||||
|
||||
# 2. Append entry to open Plugin Manager GUI
|
||||
plugins_menu.addSeparator()
|
||||
manager_action = plugins_menu.addAction("Manage Plugins...")
|
||||
|
||||
if hasattr(self.main_window, "plugins_gui") and callable(self.main_window.plugins_gui):
|
||||
manager_action.triggered.connect(self.main_window.plugins_gui)
|
||||
|
||||
def unload_plugins(self) -> None:
|
||||
"""Clears current active plugin instances."""
|
||||
self.loaded_plugins.clear()
|
||||
|
||||
def get_installed_plugins_info(self) -> list[dict[str, Any]]:
|
||||
"""Returns metadata for all local plugins in the plugins folder by reading their plugin.json."""
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for entry in sorted(self.plugins_dir.iterdir(), key=lambda p: p.name.lower()):
|
||||
if entry.name.startswith((".", "__")):
|
||||
continue
|
||||
|
||||
is_disabled = entry.name.endswith(".disabled")
|
||||
clean_name = entry.name[:-9] if is_disabled else entry.name
|
||||
|
||||
if entry.is_dir() or (entry.is_file() and (entry.suffix == ".py" or entry.name.endswith(".py.disabled"))):
|
||||
if entry.is_file() and entry.stem in ("__init__", "__init__.py"):
|
||||
continue
|
||||
|
||||
# Load local plugin.json if present
|
||||
manifest: dict[str, Any] = {}
|
||||
manifest_path = (entry / "plugin.json") if entry.is_dir() else None
|
||||
|
||||
if manifest_path and manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[PluginManager] Warning: Failed to read manifest for '{entry.name}': {e}")
|
||||
|
||||
display_name = manifest.get("name", clean_name.removesuffix(".py"))
|
||||
version = manifest.get("version", "1.0.0")
|
||||
description = manifest.get("description", "")
|
||||
author = manifest.get("author", "Unknown")
|
||||
|
||||
results.append({
|
||||
"id": manifest.get("id", clean_name),
|
||||
"name": display_name,
|
||||
"version": version,
|
||||
"description": description,
|
||||
"author": author,
|
||||
"path": entry,
|
||||
"is_disabled": is_disabled,
|
||||
"manifest": manifest,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
def toggle_plugin_state(self, plugin_path: Path) -> None:
|
||||
"""Swaps a plugin between enabled and disabled by renaming with/without '.disabled'."""
|
||||
if not plugin_path.exists():
|
||||
return
|
||||
|
||||
if plugin_path.name.endswith(".disabled"):
|
||||
new_path = plugin_path.with_name(plugin_path.name.removesuffix(".disabled"))
|
||||
else:
|
||||
new_path = plugin_path.with_name(f"{plugin_path.name}.disabled")
|
||||
|
||||
plugin_path.rename(new_path)
|
||||
self.reload_plugins()
|
||||
|
||||
def uninstall_plugin(self, plugin_path: Path) -> None:
|
||||
"""Deletes a plugin directory or file from disk."""
|
||||
if not plugin_path.exists():
|
||||
return
|
||||
|
||||
if plugin_path.is_dir():
|
||||
shutil.rmtree(plugin_path)
|
||||
else:
|
||||
plugin_path.unlink()
|
||||
|
||||
self.reload_plugins()
|
||||
|
||||
def fetch_remote_repositories(self, repo_urls: list[str]) -> tuple[list[dict[str, Any]], bool]:
|
||||
"""Fetches remote plugin metadata from repository URLs."""
|
||||
aggregated: list[dict[str, Any]] = []
|
||||
failed_count = 0
|
||||
|
||||
for url in repo_urls:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
if response.status == 200:
|
||||
raw_data = response.read().decode("utf-8")
|
||||
data = json.loads(raw_data)
|
||||
if isinstance(data, list):
|
||||
aggregated.extend(cast(list[Any], data))
|
||||
else:
|
||||
failed_count += 1
|
||||
except Exception:
|
||||
failed_count += 1
|
||||
|
||||
all_failed = failed_count == len(repo_urls) and len(repo_urls) > 0
|
||||
return aggregated, all_failed
|
||||
|
||||
def install_plugin_from_url(self, download_url: str, plugin_id: str = "") -> Path:
|
||||
"""
|
||||
Downloads a single-folder ZIP archive and extracts it directly into plugins/.
|
||||
"""
|
||||
req = urllib.request.Request(
|
||||
download_url,
|
||||
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"Download failed with HTTP status code {response.status}")
|
||||
|
||||
zip_bytes = response.read()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zip_ref:
|
||||
zip_ref.extractall(self.plugins_dir)
|
||||
|
||||
self.reload_plugins()
|
||||
return self.plugins_dir
|
||||
|
||||
def open_plugins_directory(self) -> bool:
|
||||
"""Opens the local plugins folder in the operating system's file manager."""
|
||||
if self.plugins_dir.exists():
|
||||
return QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.plugins_dir)))
|
||||
return False
|
||||
+235
-149
@@ -1,54 +1,52 @@
|
||||
"""
|
||||
Filename: plugins.py
|
||||
Description: Plugins window
|
||||
Note: Compliant with pylance strict type checking
|
||||
|
||||
Author: Tyler de Zeeuw
|
||||
License: GPL-3.0
|
||||
"""
|
||||
|
||||
# Built-in imports
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
# External library imports
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QPushButton, QTabWidget, QVBoxLayout, QWidget
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
||||
QMessageBox, QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget
|
||||
)
|
||||
|
||||
from src.shared.shareddata import APP_NAME, PLATFORM_NAME, PLUGINS_URL
|
||||
from plugin_manager import PluginManager, parse_version
|
||||
from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL
|
||||
|
||||
|
||||
class PluginsWindow(QWidget):
|
||||
"""
|
||||
Plugins window containing two tabs: Installed Plugins and Plugin Browser.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None, optional): Parent widget of this window. Defaults to None.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
def __init__(self, parent: QWidget | None, plugin_manager: PluginManager) -> None:
|
||||
super().__init__(None, Qt.WindowType.Window)
|
||||
|
||||
self.main_app_window = parent
|
||||
self.manager: PluginManager = plugin_manager
|
||||
|
||||
self.setWindowTitle(f"{APP_NAME.upper()} - Plugins")
|
||||
self.resize(650, 500)
|
||||
self.resize(750, 500)
|
||||
|
||||
self.plugins_dir: Path = self._resolve_plugins_dir()
|
||||
self.repository_urls: list[str] = [PLUGINS_URL]
|
||||
self.remote_plugins_data: list[dict[str, Any]] = []
|
||||
self._has_fetched_remote: bool = False
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
# Refresh UI automatically if manager updates state
|
||||
self.manager.plugins_changed.connect(self.refresh_installed_plugins)
|
||||
|
||||
main_layout = QVBoxLayout(self)
|
||||
self.tab_widget = QTabWidget(self)
|
||||
|
||||
self.installed_tab = self._create_installed_tab()
|
||||
self.browser_tab = self._create_browser_tab()
|
||||
|
||||
self.tab_widget.addTab(self.installed_tab, "Installed Plugins")
|
||||
self.tab_widget.addTab(self.browser_tab, "Plugin Browser")
|
||||
|
||||
self.tab_widget.setCurrentIndex(0)
|
||||
self.tab_widget.currentChanged.connect(self._on_tab_changed)
|
||||
|
||||
main_layout.addWidget(self.tab_widget)
|
||||
@@ -56,169 +54,257 @@ class PluginsWindow(QWidget):
|
||||
|
||||
self.refresh_installed_plugins()
|
||||
|
||||
def _resolve_plugins_dir(self) -> Path:
|
||||
"""Determines the local plugins directory based on execution context."""
|
||||
if PLATFORM_NAME == "darwin":
|
||||
base_dir = Path(sys.executable).parent / "../../.."
|
||||
else:
|
||||
base_dir = Path.cwd()
|
||||
def refresh_installed_plugins(self) -> None:
|
||||
"""Refreshes installed list using metadata from PluginManager."""
|
||||
self.installed_list.clear()
|
||||
plugins_info = self.manager.get_installed_plugins_info()
|
||||
|
||||
return (base_dir / "plugins").resolve()
|
||||
for info in plugins_info:
|
||||
display_text = f"{info['name']} (v{info.get('version', '1.0.0')})"
|
||||
if info["is_disabled"]:
|
||||
display_text += " [Disabled]"
|
||||
|
||||
item = QListWidgetItem(display_text, self.installed_list)
|
||||
item.setData(Qt.ItemDataRole.UserRole, info)
|
||||
|
||||
if info["is_disabled"]:
|
||||
item.setForeground(QColor("#757575"))
|
||||
|
||||
if self.installed_list.count() == 0:
|
||||
self.installed_list.addItem("No plugins installed.")
|
||||
self._clear_details_panel()
|
||||
|
||||
def _on_installed_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
|
||||
"""Updates the right-hand details panel when a plugin is selected."""
|
||||
if not current:
|
||||
self._clear_details_panel()
|
||||
return
|
||||
|
||||
info = current.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(info, dict):
|
||||
self._clear_details_panel()
|
||||
return
|
||||
|
||||
name = info.get("name", "Unknown")
|
||||
version = info.get("version", "1.0.0")
|
||||
author = info.get("author", "Unknown")
|
||||
desc = info.get("description", "No description provided.")
|
||||
is_disabled = info.get("is_disabled", False)
|
||||
path = info.get("path", "")
|
||||
|
||||
self.lbl_plugin_title.setText(name)
|
||||
self.lbl_plugin_meta.setText(f"<b>Version:</b> {version} | <b>Author:</b> {author}")
|
||||
|
||||
if is_disabled:
|
||||
self.lbl_plugin_status.setText("<font color='#d32f2f'><b>Status: Disabled</b></font>")
|
||||
else:
|
||||
self.lbl_plugin_status.setText("<font color='#2e7d32'><b>Status: Active</b></font>")
|
||||
|
||||
self.txt_plugin_desc.setHtml(desc)
|
||||
self.lbl_plugin_path.setText(f"<b>Path:</b> <code>{path}</code>")
|
||||
|
||||
def _clear_details_panel(self) -> None:
|
||||
"""Clears details display when nothing is selected."""
|
||||
self.lbl_plugin_title.setText("Select a plugin")
|
||||
self.lbl_plugin_meta.setText("")
|
||||
self.lbl_plugin_status.setText("")
|
||||
self.txt_plugin_desc.clear()
|
||||
self.lbl_plugin_path.setText("")
|
||||
|
||||
def toggle_enable_disable(self) -> None:
|
||||
selected = self.installed_list.currentItem()
|
||||
if not selected:
|
||||
return
|
||||
info = selected.data(Qt.ItemDataRole.UserRole)
|
||||
if isinstance(info, dict) and "path" in info:
|
||||
self.manager.toggle_plugin_state(info["path"])
|
||||
|
||||
def uninstall_plugin(self) -> None:
|
||||
selected = self.installed_list.currentItem()
|
||||
if not selected:
|
||||
return
|
||||
info = selected.data(Qt.ItemDataRole.UserRole)
|
||||
if not isinstance(info, dict) or "path" not in info:
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self, "Confirm Uninstall",
|
||||
f"Are you sure you want to delete '{info['name']}'?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
self.manager.uninstall_plugin(info["path"])
|
||||
|
||||
def fetch_remote_plugins(self) -> None:
|
||||
self.browser_list.clear()
|
||||
self.browser_list.addItem("Fetching remote repositories...")
|
||||
self.btn_install.setEnabled(False)
|
||||
|
||||
plugins, all_failed = self.manager.fetch_remote_repositories(self.repository_urls)
|
||||
self.remote_plugins_data = plugins
|
||||
self._has_fetched_remote = True
|
||||
|
||||
if plugins:
|
||||
self._populate_browser_list()
|
||||
elif all_failed:
|
||||
self.browser_list.clear()
|
||||
self.browser_list.addItem("Unable to load plugins from configured repositories.")
|
||||
else:
|
||||
self.browser_list.clear()
|
||||
self.browser_list.addItem("No plugins found across configured repositories.")
|
||||
|
||||
def _populate_browser_list(self) -> None:
|
||||
self.browser_list.clear()
|
||||
for plugin in self.remote_plugins_data:
|
||||
name = plugin.get("name", "Unknown")
|
||||
version = plugin.get("version", "v0.0")
|
||||
desc = plugin.get("description", "")
|
||||
platforms = plugin.get("platforms", [])
|
||||
min_v_str = plugin.get("min_app_version", "0.0.0")
|
||||
|
||||
is_platform_ok = not platforms or self.manager.current_platform in platforms
|
||||
is_version_ok = self.manager.current_app_version >= parse_version(min_v_str)
|
||||
is_compatible = is_platform_ok and is_version_ok
|
||||
|
||||
display_text = f"{name} (v{version}) - {desc}"
|
||||
if not is_compatible:
|
||||
display_text += " [Incompatible]"
|
||||
|
||||
item = QListWidgetItem(display_text, self.browser_list)
|
||||
item.setData(Qt.ItemDataRole.UserRole, plugin)
|
||||
item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible)
|
||||
|
||||
if not is_compatible:
|
||||
item.setForeground(QColor("#d32f2f"))
|
||||
|
||||
def install_selected_plugin(self) -> None:
|
||||
selected = self.browser_list.currentItem()
|
||||
if not selected:
|
||||
return
|
||||
data = selected.data(Qt.ItemDataRole.UserRole)
|
||||
if not data:
|
||||
return
|
||||
|
||||
try:
|
||||
target_path = self.manager.install_plugin_from_url(
|
||||
data.get("download_url", ""),
|
||||
data.get("id", "unnamed")
|
||||
)
|
||||
QMessageBox.information(self, "Success", f"Installed plugin to:\n{target_path}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Installation Failed", f"Could not install plugin:\n{e}")
|
||||
|
||||
def _on_tab_changed(self, index: int) -> None:
|
||||
"""Triggers remote fetch only when switching specifically to the Plugin Browser tab."""
|
||||
if index == 1 and not self._has_fetched_remote:
|
||||
self.fetch_remote_plugins()
|
||||
|
||||
def refresh_installed_plugins(self) -> None:
|
||||
"""Scans local plugins directory silently without UI popups."""
|
||||
self.installed_list.clear()
|
||||
self.plugins_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
installed_plugins: list[str] = []
|
||||
|
||||
for entry in self.plugins_dir.iterdir():
|
||||
if entry.is_dir() and not entry.name.startswith((".", "__")):
|
||||
installed_plugins.append(entry.name)
|
||||
elif entry.is_file() and entry.suffix == ".py" and entry.stem != "__init__":
|
||||
installed_plugins.append(entry.stem)
|
||||
|
||||
if installed_plugins:
|
||||
self.installed_list.addItems(sorted(installed_plugins))
|
||||
else:
|
||||
self.installed_list.addItem("No plugins installed.")
|
||||
|
||||
def add_custom_repository(self) -> None:
|
||||
"""Adds a custom repository URL from the input field and refreshes the browser list."""
|
||||
url = self.repo_input.text().strip()
|
||||
if not url:
|
||||
return
|
||||
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
self._show_browser_error("Invalid URL format. Must start with http:// or https://")
|
||||
return
|
||||
|
||||
if url not in self.repository_urls:
|
||||
self.repository_urls.append(url)
|
||||
|
||||
self.repo_input.clear()
|
||||
self.fetch_remote_plugins()
|
||||
|
||||
def fetch_remote_plugins(self) -> None:
|
||||
"""
|
||||
Fetches plugins.json from all configured repository URLs directly into memory.
|
||||
Aggregates results and handles network failures gracefully in-line.
|
||||
"""
|
||||
self.browser_list.clear()
|
||||
self.browser_list.addItem("Fetching remote repositories...")
|
||||
|
||||
aggregated_plugins: list[dict[str, Any]] = []
|
||||
failed_count: int = 0
|
||||
|
||||
for url in self.repository_urls:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
headers={"User-Agent": f"{APP_NAME}-PluginManager"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
if response.status == 200:
|
||||
raw_data = response.read().decode("utf-8")
|
||||
plugins = json.loads(raw_data)
|
||||
if isinstance(plugins, list):
|
||||
aggregated_plugins.extend(cast(list[Any], plugins))
|
||||
else:
|
||||
failed_count += 1
|
||||
except Exception:
|
||||
failed_count += 1
|
||||
|
||||
self.remote_plugins_data = aggregated_plugins
|
||||
self._has_fetched_remote = True
|
||||
|
||||
if aggregated_plugins:
|
||||
self._populate_browser_list()
|
||||
elif failed_count == len(self.repository_urls):
|
||||
self._show_browser_error(
|
||||
"Unable to load plugins from any repository. Check your connection or URLs."
|
||||
)
|
||||
else:
|
||||
self._show_browser_error("No plugins found across configured repositories.")
|
||||
|
||||
def _show_browser_error(self, message: str) -> None:
|
||||
"""Renders error text directly in the list widget."""
|
||||
self.browser_list.clear()
|
||||
self.browser_list.addItem(message)
|
||||
|
||||
def _populate_browser_list(self) -> None:
|
||||
"""Populates list widget with parsed remote plugin data."""
|
||||
self.browser_list.clear()
|
||||
|
||||
for plugin in self.remote_plugins_data:
|
||||
name = plugin.get("name", "Unknown Plugin")
|
||||
version = plugin.get("version", "v0.0")
|
||||
desc = plugin.get("description", "No description provided.")
|
||||
|
||||
display_text = f"{name} ({version}) - {desc}"
|
||||
item = QListWidgetItem(display_text, self.browser_list)
|
||||
item.setData(Qt.ItemDataRole.UserRole, plugin)
|
||||
def _on_browser_item_changed(self, current: QListWidgetItem | None, _: Any) -> None:
|
||||
if current:
|
||||
self.btn_install.setEnabled(bool(current.data(Qt.ItemDataRole.UserRole + 1)))
|
||||
|
||||
def _create_installed_tab(self) -> QWidget:
|
||||
"""Creates 'Installed Plugins' tab UI."""
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
path_label = QLabel(f"Directory: {self.plugins_dir}", tab)
|
||||
# Header bar with directory path and Open Folder button
|
||||
path_layout = QHBoxLayout()
|
||||
path_label = QLabel(f"<b>Plugins Directory:</b> <code>{self.manager.plugins_dir}</code>", tab)
|
||||
btn_open_folder = QPushButton("Open Folder", tab)
|
||||
btn_open_folder.setToolTip("Open this directory in Explorer / Finder")
|
||||
btn_open_folder.clicked.connect(self.manager.open_plugins_directory)
|
||||
|
||||
path_layout.addWidget(path_label)
|
||||
path_layout.addStretch()
|
||||
path_layout.addWidget(btn_open_folder)
|
||||
|
||||
# Splitter Layout: Left side List, Right side Detail Panel
|
||||
content_layout = QHBoxLayout()
|
||||
|
||||
self.installed_list = QListWidget(tab)
|
||||
self.installed_list.currentItemChanged.connect(self._on_installed_item_changed)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self.btn_refresh_local = QPushButton("Refresh List", tab)
|
||||
self.btn_enable_disable = QPushButton("Enable/Disable", tab)
|
||||
self.btn_uninstall = QPushButton("Uninstall", tab)
|
||||
# Right-side Details Panel
|
||||
self.details_panel = QWidget(tab)
|
||||
details_layout = QVBoxLayout(self.details_panel)
|
||||
details_layout.setContentsMargins(10, 0, 0, 0)
|
||||
|
||||
self.btn_refresh_local.clicked.connect(self.refresh_installed_plugins)
|
||||
self.lbl_plugin_title = QLabel("Select a plugin", self.details_panel)
|
||||
self.lbl_plugin_title.setStyleSheet("font-size: 13pt; font-weight: bold;")
|
||||
|
||||
button_layout.addWidget(self.btn_refresh_local)
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(self.btn_enable_disable)
|
||||
button_layout.addWidget(self.btn_uninstall)
|
||||
self.lbl_plugin_meta = QLabel("", self.details_panel)
|
||||
self.lbl_plugin_status = QLabel("", self.details_panel)
|
||||
|
||||
layout.addWidget(path_label)
|
||||
layout.addWidget(self.installed_list)
|
||||
layout.addLayout(button_layout)
|
||||
self.txt_plugin_desc = QTextBrowser(self.details_panel)
|
||||
self.txt_plugin_desc.setPlaceholderText("Select an installed plugin to view details...")
|
||||
|
||||
self.lbl_plugin_path = QLabel("", self.details_panel)
|
||||
self.lbl_plugin_path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self.lbl_plugin_path.setWordWrap(True)
|
||||
|
||||
details_layout.addWidget(self.lbl_plugin_title)
|
||||
details_layout.addWidget(self.lbl_plugin_meta)
|
||||
details_layout.addWidget(self.lbl_plugin_status)
|
||||
details_layout.addWidget(QLabel("<b>Description:</b>", self.details_panel))
|
||||
details_layout.addWidget(self.txt_plugin_desc)
|
||||
details_layout.addWidget(self.lbl_plugin_path)
|
||||
|
||||
content_layout.addWidget(self.installed_list, stretch=1)
|
||||
content_layout.addWidget(self.details_panel, stretch=1)
|
||||
|
||||
# Bottom Action Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_refresh = QPushButton("Refresh List", tab)
|
||||
btn_enable = QPushButton("Enable / Disable", tab)
|
||||
btn_uninstall = QPushButton("Uninstall", tab)
|
||||
|
||||
btn_refresh.clicked.connect(self.refresh_installed_plugins)
|
||||
btn_enable.clicked.connect(self.toggle_enable_disable)
|
||||
btn_uninstall.clicked.connect(self.uninstall_plugin)
|
||||
|
||||
btn_layout.addWidget(btn_refresh)
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(btn_enable)
|
||||
btn_layout.addWidget(btn_uninstall)
|
||||
|
||||
layout.addLayout(path_layout)
|
||||
layout.addLayout(content_layout)
|
||||
layout.addLayout(btn_layout)
|
||||
return tab
|
||||
|
||||
def _create_browser_tab(self) -> QWidget:
|
||||
"""Creates 'Plugin Browser' tab UI."""
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
|
||||
# Custom Repository Input Controls
|
||||
info = QLabel(
|
||||
f"<b>Platform:</b> <code>{self.manager.current_platform}</code> | "
|
||||
f"<b>App Version:</b> <code>v{CURRENT_VERSION}</code>", tab
|
||||
)
|
||||
|
||||
repo_layout = QHBoxLayout()
|
||||
self.repo_input = QLineEdit(tab)
|
||||
self.repo_input.setPlaceholderText("Enter custom plugins.json URL...")
|
||||
self.btn_add_repo = QPushButton("Add Repo", tab)
|
||||
self.btn_add_repo.clicked.connect(self.add_custom_repository)
|
||||
btn_add = QPushButton("Add Repo", tab)
|
||||
|
||||
repo_layout.addWidget(self.repo_input)
|
||||
repo_layout.addWidget(self.btn_add_repo)
|
||||
repo_layout.addWidget(btn_add)
|
||||
|
||||
label = QLabel("Available Plugins from Repositories:", tab)
|
||||
self.browser_list = QListWidget(tab)
|
||||
self.browser_list.currentItemChanged.connect(self._on_browser_item_changed)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self.btn_fetch_remote = QPushButton("Fetch Remote Lists", tab)
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_fetch = QPushButton("Fetch Remote Lists", tab)
|
||||
self.btn_install = QPushButton("Install Plugin", tab)
|
||||
self.btn_install.setEnabled(False)
|
||||
|
||||
self.btn_fetch_remote.clicked.connect(self.fetch_remote_plugins)
|
||||
btn_fetch.clicked.connect(self.fetch_remote_plugins)
|
||||
self.btn_install.clicked.connect(self.install_selected_plugin)
|
||||
|
||||
button_layout.addWidget(self.btn_fetch_remote)
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(self.btn_install)
|
||||
btn_layout.addWidget(btn_fetch)
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(self.btn_install)
|
||||
|
||||
layout.addWidget(info)
|
||||
layout.addLayout(repo_layout)
|
||||
layout.addWidget(label)
|
||||
layout.addWidget(self.browser_list)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
return tab
|
||||
Reference in New Issue
Block a user