continuation of plugin support

This commit is contained in:
2026-09-01 17:20:16 -07:00
parent ca203fcb56
commit e9ef78c872
3 changed files with 561 additions and 151 deletions
+235 -149
View File
@@ -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} &nbsp;|&nbsp; <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