391 lines
15 KiB
Python
391 lines
15 KiB
Python
"""
|
|
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"<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
|
|
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"<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)
|
|
|
|
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("<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)
|
|
|
|
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"<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...")
|
|
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 |