""" Filename: plugins.py Description: Plugins window Note: Compliant with pylance strict type checking Author: Tyler de Zeeuw License: GPL-3.0 """ # Built-in imports from pathlib import Path from typing import Any, cast # External library imports from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, QTabWidget, QTextBrowser, QVBoxLayout, QWidget ) from plugin_manager import PluginManager, parse_version from src.shared.shareddata import APP_NAME, CURRENT_VERSION, PLUGINS_URL class RemoteFetchWorker(QThread): """Background thread to fetch remote repository data without lagging the main UI.""" fetched = Signal(list, bool) # (plugins_data, all_failed) def __init__(self, manager: PluginManager, urls: list[str]) -> None: super().__init__() self.manager = manager self.urls = urls def run(self) -> None: plugins, all_failed = self.manager.fetch_remote_repositories(self.urls) self.fetched.emit(plugins, all_failed) class PluginsWindow(QWidget): 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(750, 500) self.repository_urls: list[str] = [PLUGINS_URL] self.remote_plugins_data: list[dict[str, Any]] = [] self._has_fetched_remote: bool = False self._fetch_worker: RemoteFetchWorker | None = None # 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.currentChanged.connect(self._on_tab_changed) main_layout.addWidget(self.tab_widget) self.setLayout(main_layout) self.refresh_installed_plugins() 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() 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() # Re-populate browser list if remote data was already fetched to reflect newly installed/uninstalled plugins if self._has_fetched_remote and self.remote_plugins_data: self._populate_browser_list() 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 plugin_info = cast(dict[str, Any], info) name = str(plugin_info.get("name", "Unknown")) version = str(plugin_info.get("version", "1.0.0")) author = str(plugin_info.get("author", "Unknown")) desc = str(plugin_info.get("description", "No description provided.")) is_disabled = bool(plugin_info.get("is_disabled", False)) path = str(plugin_info.get("path", "")) self.lbl_plugin_title.setText(name) self.lbl_plugin_meta.setText(f"Version: {version}  |  Author: {author}") if is_disabled: self.lbl_plugin_status.setText("Status: Disabled") else: self.lbl_plugin_status.setText("Status: Active") self.txt_plugin_desc.setHtml(desc) self.lbl_plugin_path.setText(f"Path: {path}") 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 raw_info = selected.data(Qt.ItemDataRole.UserRole) if isinstance(raw_info, dict): info = cast(dict[str, Any], raw_info) plugin_path = info.get("path") if isinstance(plugin_path, str) and plugin_path: self.manager.toggle_plugin_state(Path(plugin_path)) def uninstall_plugin(self) -> None: selected = self.installed_list.currentItem() if not selected: return raw_info = selected.data(Qt.ItemDataRole.UserRole) if not isinstance(raw_info, dict): return info = cast(dict[str, Any], raw_info) plugin_path = info.get("path") plugin_name = info.get("name", "this plugin") if not isinstance(plugin_path, str) or not plugin_path: return reply = QMessageBox.question( self, "Confirm Uninstall", f"Are you sure you want to delete '{plugin_name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: self.manager.uninstall_plugin(Path(plugin_path)) def fetch_remote_plugins(self) -> None: """Asynchronously fetches remote plugins on a background thread.""" if self._fetch_worker is not None and self._fetch_worker.isRunning(): return self.browser_list.clear() self.browser_list.addItem("Fetching remote repositories...") self.btn_install.setEnabled(False) self.btn_fetch.setEnabled(False) # Defer network call to background worker to avoid UI freeze self._fetch_worker = RemoteFetchWorker(self.manager, self.repository_urls) self._fetch_worker.fetched.connect(self._on_remote_fetched) self._fetch_worker.start() def _on_remote_fetched(self, plugins: list[dict[str, Any]], all_failed: bool) -> None: """Callback executed on the main UI thread when remote fetching finishes.""" self.remote_plugins_data = plugins self._has_fetched_remote = True self.btn_fetch.setEnabled(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: """Populates the browser tab list and evaluates compatibility and installation state.""" self.browser_list.clear() installed_info = self.manager.get_installed_plugins_info() installed_ids = {p.get("id") for p in installed_info if p.get("id")} installed_names = {p.get("name") for p in installed_info if p.get("name")} for plugin in self.remote_plugins_data: name = plugin.get("name", "Unknown") p_id = plugin.get("id", "") version = plugin.get("version", "v0.0") desc = plugin.get("description", "") platforms = str(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 # Evaluate reasons for incompatibility incompat_reasons: list[str] = [] if not is_platform_ok: plat_str = ", ".join(platforms) if isinstance(platforms, list) else str(platforms) incompat_reasons.append(f"Requires platform: {plat_str}") if not is_version_ok: incompat_reasons.append(f"Requires App v{min_v_str}+") is_installed = (bool(p_id) and p_id in installed_ids) or (bool(name) and name in installed_names) display_text = f"{name} (v{version}) - {desc}" if is_installed: display_text += " [Installed]" elif incompat_reasons: reason_str = "; ".join(incompat_reasons) display_text += f" [Incompatible: {reason_str}]" item = QListWidgetItem(display_text, self.browser_list) item.setData(Qt.ItemDataRole.UserRole, plugin) item.setData(Qt.ItemDataRole.UserRole + 1, is_compatible) item.setData(Qt.ItemDataRole.UserRole + 2, is_installed) if is_installed: item.setForeground(QColor("#2e7d32")) # Green for installed elif not is_compatible: item.setForeground(QColor("#d32f2f")) # Red for incompatible 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: if index == 1 and not self._has_fetched_remote: self.fetch_remote_plugins() def _on_browser_item_changed(self, current: QListWidgetItem | None, _: Any) -> None: if current: is_compatible = bool(current.data(Qt.ItemDataRole.UserRole + 1)) is_installed = bool(current.data(Qt.ItemDataRole.UserRole + 2)) # Button lights up ONLY if plugin is compatible and NOT yet installed self.btn_install.setEnabled(is_compatible and not is_installed) else: self.btn_install.setEnabled(False) def _on_add_repo(self) -> None: """Adds a custom repository URL and re-fetches plugins.""" url = self.repo_input.text().strip() if url and url not in self.repository_urls: self.repository_urls.append(url) self.repo_input.clear() self.fetch_remote_plugins() def _create_installed_tab(self) -> QWidget: tab = QWidget() layout = QVBoxLayout(tab) path_layout = QHBoxLayout() path_label = QLabel(f"Plugins Directory: {self.manager.plugins_dir}", 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) content_layout = QHBoxLayout() self.installed_list = QListWidget(tab) self.installed_list.currentItemChanged.connect(self._on_installed_item_changed) self.details_panel = QWidget(tab) details_layout = QVBoxLayout(self.details_panel) details_layout.setContentsMargins(10, 0, 0, 0) self.lbl_plugin_title = QLabel("Select a plugin", self.details_panel) self.lbl_plugin_title.setStyleSheet("font-size: 13pt; font-weight: bold;") self.lbl_plugin_meta = QLabel("", self.details_panel) self.lbl_plugin_status = QLabel("", self.details_panel) 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("Description:", 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) 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: tab = QWidget() layout = QVBoxLayout(tab) info = QLabel( f"Platform: {self.manager.current_platform} | " f"App Version: v{CURRENT_VERSION}", tab ) repo_layout = QHBoxLayout() self.repo_input = QLineEdit(tab) self.repo_input.setPlaceholderText("Enter custom plugins.json URL...") btn_add = QPushButton("Add Repo", tab) btn_add.clicked.connect(self._on_add_repo) repo_layout.addWidget(self.repo_input) repo_layout.addWidget(btn_add) self.browser_list = QListWidget(tab) self.browser_list.currentItemChanged.connect(self._on_browser_item_changed) btn_layout = QHBoxLayout() self.btn_fetch = QPushButton("Fetch Remote Lists", tab) self.btn_install = QPushButton("Install Plugin", tab) self.btn_install.setEnabled(False) self.btn_fetch.clicked.connect(self.fetch_remote_plugins) self.btn_install.clicked.connect(self.install_selected_plugin) btn_layout.addWidget(self.btn_fetch) btn_layout.addStretch() btn_layout.addWidget(self.btn_install) layout.addWidget(info) layout.addLayout(repo_layout) layout.addWidget(self.browser_list) layout.addLayout(btn_layout) return tab