""" 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(0, 0, 0, 0) # Remove margins to fill parent # 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.setMinimumWidth(300) 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.setMinimumWidth(150) 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.setMinimumWidth(100) 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.setMinimumWidth(120) 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 - make it expand to fill space self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) self.scroll.setFrameShape(QFrame.Shape.NoFrame) self.scroll.setStyleSheet("background: transparent; border: none;") self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.grid_widget = QWidget() self.grid_layout = QGridLayout(self.grid_widget) self.grid_layout.setSpacing(20) self.grid_layout.setContentsMargins(10, 10, 10, 10) self.grid_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) self.scroll.setWidget(self.grid_widget) layout.addWidget(self.scroll, 1) # Add stretch factor to fill space 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 - use minimum 2 columns, expand to fill # Calculate columns based on available width available_width = self.scroll.width() - 40 # Account for margins card_width = 340 # Card width + spacing columns = max(2, available_width // card_width) 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) # Make columns stretch evenly self.grid_layout.setColumnStretch(col, 1) def _create_plugin_card(self, plugin: PluginInfo) -> QFrame: """Create a plugin card widget.""" card = QFrame() card.setMinimumSize(320, 220) card.setMaximumWidth(400) 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"
These will be automatically installed when you install this plugin.
" if not core_deps and not plugin_deps: msg += "This plugin has no dependencies.
" 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}")