EU-Utility/core/plugin_store.py

676 lines
24 KiB
Python

"""
EU-Utility - Plugin Store
Fetches and installs plugins from remote repositories.
"""
import json
import shutil
import zipfile
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass
from urllib.parse import urljoin
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QScrollArea, QLineEdit, QComboBox, QFrame, QGridLayout,
QProgressBar, QMessageBox, QTextEdit
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt6.QtGui import QFont
@dataclass
class PluginInfo:
"""Plugin metadata from repository."""
id: str
name: str
version: str
author: str
description: str
folder: str
icon: str
tags: List[str]
dependencies: Dict
min_core_version: str
category: str
installed: bool = False
update_available: bool = False
enabled: bool = False
class PluginStoreWorker(QThread):
"""Background worker for plugin store operations."""
manifest_loaded = pyqtSignal(list) # List[PluginInfo]
plugin_downloaded = pyqtSignal(str, bool) # plugin_id, success
progress_update = pyqtSignal(str)
error = pyqtSignal(str)
def __init__(self, repo_url: str, plugins_dir: Path):
super().__init__()
self.repo_url = repo_url
self.plugins_dir = plugins_dir
self.operation = None
self.target_plugin = None
def fetch_manifest(self):
"""Queue fetch manifest operation."""
self.operation = 'fetch_manifest'
self.start()
def download_plugin(self, plugin_id: str, folder: str):
"""Queue download operation."""
self.operation = 'download'
self.target_plugin = {'id': plugin_id, 'folder': folder}
self.start()
def check_updates(self, installed_plugins: Dict):
"""Queue update check operation."""
self.operation = 'check_updates'
self.installed_plugins = installed_plugins
self.start()
def run(self):
"""Execute queued operation."""
try:
if self.operation == 'fetch_manifest':
self._do_fetch_manifest()
elif self.operation == 'download':
self._do_download()
elif self.operation == 'check_updates':
self._do_check_updates()
except Exception as e:
self.error.emit(str(e))
def _do_fetch_manifest(self):
"""Fetch plugin manifest from repository."""
self.progress_update.emit("Fetching plugin list...")
try:
# Try HTTP first
import urllib.request
manifest_url = f"{self.repo_url}/raw/branch/main/manifest.json"
with urllib.request.urlopen(manifest_url, timeout=10) as response:
data = json.loads(response.read().decode('utf-8'))
except Exception as e:
# Fall back to local file for development
self.progress_update.emit("Using local manifest...")
manifest_path = Path(__file__).parent.parent.parent / "manifest.json"
if manifest_path.exists():
with open(manifest_path, 'r') as f:
data = json.load(f)
else:
raise Exception(f"Could not fetch manifest: {e}")
plugins = []
for p in data.get('plugins', []):
plugins.append(PluginInfo(
id=p['id'],
name=p['name'],
version=p['version'],
author=p['author'],
description=p['description'],
folder=p['folder'],
icon=p.get('icon', 'box'),
tags=p.get('tags', []),
dependencies=p.get('dependencies', {}),
min_core_version=p.get('min_core_version', '2.0.0'),
category=p.get('category', 'Other')
))
self.manifest_loaded.emit(plugins)
def _do_download(self):
"""Download and install a plugin using raw files (no git clone)."""
plugin_id = self.target_plugin['id']
folder = self.target_plugin['folder']
self.progress_update.emit(f"Downloading {plugin_id}...")
try:
import urllib.request
# Download files directly from raw git
dest = self.plugins_dir / plugin_id
dest.mkdir(parents=True, exist_ok=True)
# Create __init__.py
init_url = f"{self.repo_url}/raw/branch/main/{folder}/__init__.py"
try:
urllib.request.urlretrieve(init_url, dest / "__init__.py")
except:
(dest / "__init__.py").touch()
# Download plugin.py
plugin_url = f"{self.repo_url}/raw/branch/main/{folder}/plugin.py"
self.progress_update.emit(f"Downloading {plugin_id}/plugin.py...")
urllib.request.urlretrieve(plugin_url, dest / "plugin.py")
self.plugin_downloaded.emit(plugin_id, True)
except Exception as e:
self.error.emit(str(e))
self.plugin_downloaded.emit(plugin_id, False)
def _do_check_updates(self):
"""Check for available plugin updates."""
self.progress_update.emit("Checking for updates...")
# Fetch manifest
self._do_fetch_manifest()
# Compare versions
updates_available = []
for plugin_id, local_info in self.installed_plugins.items():
# Compare with remote
pass
class PluginStoreUI(QWidget):
"""Plugin Store user interface."""
DEFAULT_REPO = "https://git.lemonlink.eu/impulsivefps/EU-Utility-Plugins-Repo"
def __init__(self, plugin_manager, parent=None):
super().__init__(parent)
self.plugin_manager = plugin_manager
self.plugins_dir = Path("plugins")
self.available_plugins: List[PluginInfo] = []
self.filtered_plugins: List[PluginInfo] = []
self._setup_ui()
self._setup_worker()
self._load_plugins()
def _setup_worker(self):
"""Setup background worker."""
self.worker = PluginStoreWorker(self.DEFAULT_REPO, self.plugins_dir)
self.worker.manifest_loaded.connect(self._on_manifest_loaded)
self.worker.plugin_downloaded.connect(self._on_plugin_downloaded)
self.worker.progress_update.connect(self._on_progress)
self.worker.error.connect(self._on_error)
def _setup_ui(self):
"""Create the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(15)
layout.setContentsMargins(20, 20, 20, 20)
# Header
header = QLabel("🔌 Plugin Store")
header.setStyleSheet("font-size: 24px; font-weight: bold; color: white;")
layout.addWidget(header)
# Info
info = QLabel("Browse and install plugins to extend EU-Utility functionality.")
info.setStyleSheet("color: rgba(255,255,255,150); font-size: 12px;")
layout.addWidget(info)
# Progress bar
self.progress = QProgressBar()
self.progress.setTextVisible(True)
self.progress.setStyleSheet("""
QProgressBar {
background-color: rgba(30, 35, 45, 200);
border: 1px solid rgba(100, 110, 130, 80);
border-radius: 4px;
color: white;
}
QProgressBar::chunk {
background-color: #4ecdc4;
border-radius: 4px;
}
""")
self.progress.hide()
layout.addWidget(self.progress)
# Search and filter bar
filter_layout = QHBoxLayout()
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("🔍 Search plugins...")
self.search_input.setStyleSheet("""
QLineEdit {
background-color: rgba(30, 35, 45, 200);
color: white;
border: 1px solid rgba(100, 110, 130, 80);
border-radius: 4px;
padding: 10px;
}
""")
self.search_input.textChanged.connect(self._filter_plugins)
filter_layout.addWidget(self.search_input, 2)
self.category_combo = QComboBox()
self.category_combo.addItem("All Categories")
self.category_combo.setStyleSheet("""
QComboBox {
background-color: rgba(30, 35, 45, 200);
color: white;
border: 1px solid rgba(100, 110, 130, 80);
border-radius: 4px;
padding: 10px;
}
QComboBox::drop-down {
border: none;
}
QComboBox QAbstractItemView {
background-color: #1a1f2e;
color: white;
selection-background-color: #4a9eff;
}
""")
self.category_combo.currentTextChanged.connect(self._filter_plugins)
filter_layout.addWidget(self.category_combo, 1)
self.refresh_btn = QPushButton("🔄 Refresh")
self.refresh_btn.setStyleSheet("""
QPushButton {
background-color: #4a9eff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #3a8eef;
}
""")
self.refresh_btn.clicked.connect(self._load_plugins)
filter_layout.addWidget(self.refresh_btn)
self.check_updates_btn = QPushButton("📥 Check Updates")
self.check_updates_btn.setStyleSheet("""
QPushButton {
background-color: #ff8c42;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #e67e3a;
}
""")
self.check_updates_btn.clicked.connect(self._check_updates)
filter_layout.addWidget(self.check_updates_btn)
layout.addLayout(filter_layout)
# Plugin grid
self.scroll = QScrollArea()
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QFrame.Shape.NoFrame)
self.scroll.setStyleSheet("background: transparent; border: none;")
self.grid_widget = QWidget()
self.grid_layout = QGridLayout(self.grid_widget)
self.grid_layout.setSpacing(15)
self.grid_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
self.scroll.setWidget(self.grid_widget)
layout.addWidget(self.scroll)
def _load_plugins(self):
"""Load available plugins from repository."""
self.progress.show()
self.progress.setRange(0, 0) # Indeterminate
self.progress.setFormat("Loading plugins...")
self.worker.fetch_manifest()
def _on_manifest_loaded(self, plugins: List[PluginInfo]):
"""Handle loaded plugin manifest."""
self.progress.hide()
# Check installed status
for p in plugins:
p.installed = (self.plugins_dir / p.id).exists()
if p.installed and hasattr(self.plugin_manager, 'is_plugin_enabled'):
p.enabled = self.plugin_manager.is_plugin_enabled(f"plugins.{p.id}.plugin")
self.available_plugins = plugins
self.filtered_plugins = plugins
# Update category filter
categories = sorted(set(p.category for p in plugins))
current = self.category_combo.currentText()
self.category_combo.clear()
self.category_combo.addItem("All Categories")
self.category_combo.addItems(categories)
if current in categories:
self.category_combo.setCurrentText(current)
self._render_grid()
def _filter_plugins(self):
"""Filter plugins based on search and category."""
search = self.search_input.text().lower()
category = self.category_combo.currentText()
self.filtered_plugins = []
for p in self.available_plugins:
# Category filter
if category != "All Categories" and p.category != category:
continue
# Search filter
if search:
match = (
search in p.name.lower() or
search in p.description.lower() or
search in p.author.lower() or
any(search in t.lower() for t in p.tags)
)
if not match:
continue
self.filtered_plugins.append(p)
self._render_grid()
def _render_grid(self):
"""Render the plugin grid."""
# Clear existing
while self.grid_layout.count():
item = self.grid_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
if not self.filtered_plugins:
no_results = QLabel("No plugins found matching your criteria.")
no_results.setStyleSheet("color: rgba(255,255,255,100); font-size: 14px; padding: 50px;")
no_results.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.grid_layout.addWidget(no_results, 0, 0)
return
# Create cards
columns = 3
for i, plugin in enumerate(self.filtered_plugins):
card = self._create_plugin_card(plugin)
row = i // columns
col = i % columns
self.grid_layout.addWidget(card, row, col)
def _create_plugin_card(self, plugin: PluginInfo) -> QFrame:
"""Create a plugin card widget."""
card = QFrame()
card.setFixedSize(300, 200)
card.setStyleSheet("""
QFrame {
background-color: rgba(35, 40, 55, 200);
border: 1px solid rgba(100, 110, 130, 80);
border-radius: 8px;
}
QFrame:hover {
border: 1px solid #4a9eff;
}
""")
layout = QVBoxLayout(card)
layout.setSpacing(8)
layout.setContentsMargins(15, 15, 15, 15)
# Header with icon and name
header = QHBoxLayout()
icon = QLabel(f"📦")
icon.setStyleSheet("font-size: 24px;")
header.addWidget(icon)
name_layout = QVBoxLayout()
name = QLabel(plugin.name)
name.setStyleSheet("font-size: 14px; font-weight: bold; color: white;")
name_layout.addWidget(name)
version = QLabel(f"v{plugin.version}")
version.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;")
name_layout.addWidget(version)
header.addLayout(name_layout, 1)
# Status badge
if plugin.installed:
if plugin.enabled:
status = QLabel("✅ Enabled")
status.setStyleSheet("color: #4ecdc4; font-size: 10px; font-weight: bold;")
else:
status = QLabel("📦 Installed")
status.setStyleSheet("color: #ff8c42; font-size: 10px; font-weight: bold;")
header.addWidget(status)
layout.addLayout(header)
# Description
desc = QLabel(plugin.description[:100] + "..." if len(plugin.description) > 100 else plugin.description)
desc.setStyleSheet("color: rgba(255,255,255,150); font-size: 11px;")
desc.setWordWrap(True)
layout.addWidget(desc)
# Tags
tags = QLabel("".join(plugin.tags[:3]))
tags.setStyleSheet("color: #4a9eff; font-size: 10px;")
layout.addWidget(tags)
# Dependencies indicator
if plugin.dependencies.get('plugins') or plugin.dependencies.get('core'):
deps_count = len(plugin.dependencies.get('plugins', [])) + len(plugin.dependencies.get('core', []))
deps_label = QPushButton(f"🔗 {deps_count} dependencies")
deps_label.setStyleSheet("""
QPushButton {
color: #ffd93d;
font-size: 10px;
background-color: transparent;
border: none;
text-align: left;
padding: 0px;
}
QPushButton:hover {
color: #ffed8a;
text-decoration: underline;
}
""")
deps_label.setCursor(Qt.CursorShape.PointingHandCursor)
deps_label.clicked.connect(lambda checked, p=plugin: self._show_dependencies_dialog(p))
layout.addWidget(deps_label)
layout.addStretch()
# Action button
btn_layout = QHBoxLayout()
if plugin.installed:
if plugin.enabled:
btn = QPushButton("Disable")
btn.setStyleSheet("""
QPushButton {
background-color: rgba(255,255,255,20);
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
}
QPushButton:hover {
background-color: rgba(255,255,255,40);
}
""")
btn.clicked.connect(lambda checked, p=plugin: self._disable_plugin(p))
else:
btn = QPushButton("Enable")
btn.setStyleSheet("""
QPushButton {
background-color: #4ecdc4;
color: #141f23;
padding: 8px 16px;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #3dbdb4;
}
""")
btn.clicked.connect(lambda checked, p=plugin: self._enable_plugin(p))
# Uninstall button
uninstall_btn = QPushButton("🗑")
uninstall_btn.setFixedSize(32, 32)
uninstall_btn.setStyleSheet("""
QPushButton {
background-color: rgba(255,71,71,100);
color: white;
border: none;
border-radius: 4px;
}
QPushButton:hover {
background-color: rgba(255,71,71,150);
}
""")
uninstall_btn.setToolTip("Uninstall plugin")
uninstall_btn.clicked.connect(lambda checked, p=plugin: self._uninstall_plugin(p))
btn_layout.addWidget(uninstall_btn)
else:
btn = QPushButton("Install")
btn.setStyleSheet("""
QPushButton {
background-color: #4a9eff;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background-color: #3a8eef;
}
""")
btn.clicked.connect(lambda checked, p=plugin: self._install_plugin(p))
btn_layout.addWidget(btn)
btn_layout.addStretch()
layout.addLayout(btn_layout)
return card
def _install_plugin(self, plugin: PluginInfo):
"""Install a plugin."""
# Check dependencies
deps = plugin.dependencies.get('plugins', [])
if deps:
msg = f"This plugin requires:\n"
for dep in deps:
msg += f"{dep}\n"
msg += "\nThese will be installed automatically.\n\nContinue?"
reply = QMessageBox.question(
self, "Install Dependencies", msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
self.progress.show()
self.progress.setRange(0, 0)
self.progress.setFormat(f"Installing {plugin.name}...")
self.worker.download_plugin(plugin.id, plugin.folder)
def _on_plugin_downloaded(self, plugin_id: str, success: bool):
"""Handle plugin download completion."""
self.progress.hide()
if success:
QMessageBox.information(self, "Success", f"Plugin '{plugin_id}' installed successfully!\n\nRestart EU-Utility to use the plugin.")
self._load_plugins() # Refresh
else:
QMessageBox.critical(self, "Error", f"Failed to install plugin '{plugin_id}'.")
def _enable_plugin(self, plugin: PluginInfo):
"""Enable an installed plugin."""
plugin_id = f"plugins.{plugin.id}.plugin"
if hasattr(self.plugin_manager, 'enable_plugin'):
self.plugin_manager.enable_plugin(plugin_id)
plugin.enabled = True
self._render_grid()
def _disable_plugin(self, plugin: PluginInfo):
"""Disable an installed plugin."""
plugin_id = f"plugins.{plugin.id}.plugin"
if hasattr(self.plugin_manager, 'enable_plugin'):
self.plugin_manager.disable_plugin(plugin_id)
plugin.enabled = False
self._render_grid()
def _uninstall_plugin(self, plugin: PluginInfo):
"""Uninstall a plugin."""
reply = QMessageBox.question(
self, "Confirm Uninstall",
f"Are you sure you want to uninstall '{plugin.name}'?\n\nThis will delete all plugin data.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
plugin_path = self.plugins_dir / plugin.id
if plugin_path.exists():
shutil.rmtree(plugin_path)
QMessageBox.information(self, "Uninstalled", f"'{plugin.name}' has been uninstalled.")
self._load_plugins()
def _show_dependencies_dialog(self, plugin: PluginInfo):
"""Show dialog with plugin dependencies."""
deps = plugin.dependencies
msg = f"<h3>🔌 {plugin.name} Dependencies</h3>"
# Core dependencies
core_deps = deps.get('core', [])
if core_deps:
msg += "<h4>Core Services Required:</h4><ul>"
for dep in core_deps:
msg += f"<li>✅ {dep} (built-in)</li>"
msg += "</ul>"
# Plugin dependencies
plugin_deps = deps.get('plugins', [])
if plugin_deps:
msg += "<h4>Plugins Required:</h4><ul>"
for dep in plugin_deps:
dep_name = dep.split('.')[-1].replace('_', ' ').title()
msg += f"<li>📦 {dep_name}</li>"
msg += "</ul>"
msg += "<p><i>These will be automatically installed when you install this plugin.</i></p>"
if not core_deps and not plugin_deps:
msg += "<p>This plugin has no dependencies.</p>"
dialog = QMessageBox(self)
dialog.setWindowTitle(f"Dependencies - {plugin.name}")
dialog.setTextFormat(Qt.TextFormat.RichText)
dialog.setText(msg)
dialog.setIcon(QMessageBox.Icon.Information)
dialog.exec()
def _check_updates(self):
"""Check for plugin updates."""
self.progress.show()
self.progress.setRange(0, 0)
self.progress.setFormat("Checking for updates...")
# TODO: Implement update checking
QTimer.singleShot(1000, self.progress.hide)
QMessageBox.information(self, "Updates", "All plugins are up to date!")
def _on_progress(self, message: str):
"""Handle progress updates."""
self.progress.setFormat(message)
def _on_error(self, error: str):
"""Handle errors."""
self.progress.hide()
QMessageBox.critical(self, "Error", f"Plugin Store Error:\n{error}")