From 725590e247f4d4a81b5769c8d8f0a41caf880efa Mon Sep 17 00:00:00 2001 From: LemonNexus Date: Sun, 15 Feb 2026 01:43:25 +0000 Subject: [PATCH] feat: Plugin Store - Framework-only architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: EU-Utility is now a framework-only application. All user-facing features have been moved to separate plugin repository. NEW FEATURES: 1. Plugin Store Core Module (core/plugin_store.py) - PluginStoreWorker: Background operations (fetch, download, updates) - PluginStoreUI: Grid-based plugin browser with cards - PluginInfo dataclass for plugin metadata - Fetches from remote git repository 2. Plugin Store UI Features: - Grid layout with plugin cards (300x200px each) - Search/filter by name, description, tags - Category filter dropdown - Visual indicators: * 📦 Plugin icon (emoji-based) * Version badge * Status badges (✅ Enabled, 📦 Installed) * Tag display * 🔗 Dependency count with tooltip - Install/Enable/Disable/Uninstall buttons - Progress bar for operations - Refresh and Check Updates buttons 3. Settings Integration: - New 'Plugin Store' tab in Settings - Moved plugin management to 'My Plugins' tab - Plugin Store uses core module directly 4. Plugin Store UI Plugin (plugins/plugin_store_ui/): - Standalone plugin for overlay integration - Hotkey: Ctrl+Shift+P (configurable) ARCHITECTURE CHANGES: - EU-Utility Core: Framework only (PluginAPI, services, overlay) - Plugin Repository: https://git.lemonlink.eu/impulsivefps/EU-Utility-Plugins-Repo - Plugins installed via Store → user plugins/ directory - Local plugins/ folder still supported for development MANIFEST FORMAT: USER WORKFLOW: 1. Open Settings → Plugin Store 2. Browse/search available plugins 3. Click Install (with dependency confirmation) 4. Restart EU-Utility 5. Enable plugin in 'My Plugins' tab DEVELOPER WORKFLOW: 1. Develop plugin locally in plugins/ 2. Test with core framework 3. Submit to plugin repository 4. Users install via Store This enables limitless modularity - users only install what they need, developers can publish independently. --- core/plugin_store.py | 849 ++++++++++++++++++++++-------- plugins/plugin_store_ui/plugin.py | 282 ++-------- plugins/settings/plugin.py | 28 +- 3 files changed, 680 insertions(+), 479 deletions(-) diff --git a/core/plugin_store.py b/core/plugin_store.py index 50dd219..062e139 100644 --- a/core/plugin_store.py +++ b/core/plugin_store.py @@ -1,253 +1,648 @@ """ EU-Utility - Plugin Store -Fetch and install community plugins from GitHub. +Fetches and installs plugins from remote repositories. """ import json -import zipfile import shutil +import zipfile from pathlib import Path -from PyQt6.QtCore import QObject, QThread, pyqtSignal +from typing import Dict, List, Optional +from dataclasses import dataclass +from urllib.parse import urljoin -from core.http_client import get_http_client +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 -class PluginStore(QObject): - """Community plugin repository manager.""" - - # Repository configuration - REPO_URL = "https://raw.githubusercontent.com/ImpulsiveFPS/EU-Utility-Plugins/main/" - INDEX_URL = REPO_URL + "plugins.json" - - # Signals - plugins_loaded = pyqtSignal(list) - plugin_installed = pyqtSignal(str, bool) - plugin_removed = pyqtSignal(str, bool) - error_occurred = pyqtSignal(str) - - def __init__(self, plugins_dir="user_plugins"): - super().__init__() - self.plugins_dir = Path(plugins_dir) - self.plugins_dir.mkdir(parents=True, exist_ok=True) - self.available_plugins = [] - self.installed_plugins = [] - self._load_installed() - - def fetch_plugins(self): - """Fetch available plugins from repository.""" - self.fetch_thread = PluginFetchThread(self.INDEX_URL) - self.fetch_thread.fetched.connect(self._on_plugins_fetched) - self.fetch_thread.error.connect(self._on_fetch_error) - self.fetch_thread.start() - - def _on_plugins_fetched(self, plugins): - """Handle fetched plugins.""" - self.available_plugins = plugins - self.plugins_loaded.emit(plugins) - - def _on_fetch_error(self, error): - """Handle fetch error.""" - self.error_occurred.emit(error) - - def install_plugin(self, plugin_id): - """Install a plugin from the store.""" - plugin = self._find_plugin(plugin_id) - if not plugin: - self.plugin_installed.emit(plugin_id, False) - return - - self.install_thread = PluginInstallThread( - plugin, - self.plugins_dir - ) - self.install_thread.installed.connect( - lambda success: self._on_installed(plugin_id, success) - ) - self.install_thread.error.connect(self._on_fetch_error) - self.install_thread.start() - - def _on_installed(self, plugin_id, success): - """Handle plugin installation.""" - if success: - self.installed_plugins.append(plugin_id) - self._save_installed() - self.plugin_installed.emit(plugin_id, success) - - def remove_plugin(self, plugin_id): - """Remove an installed plugin.""" - try: - plugin_dir = self.plugins_dir / plugin_id - if plugin_dir.exists(): - shutil.rmtree(plugin_dir) - - if plugin_id in self.installed_plugins: - self.installed_plugins.remove(plugin_id) - self._save_installed() - - self.plugin_removed.emit(plugin_id, True) - except Exception as e: - self.error_occurred.emit(str(e)) - self.plugin_removed.emit(plugin_id, False) - - def _find_plugin(self, plugin_id): - """Find plugin by ID.""" - for plugin in self.available_plugins: - if plugin.get('id') == plugin_id: - return plugin - return None - - def _load_installed(self): - """Load list of installed plugins.""" - installed_file = self.plugins_dir / ".installed" - if installed_file.exists(): - try: - with open(installed_file, 'r') as f: - self.installed_plugins = json.load(f) - except: - self.installed_plugins = [] - - def _save_installed(self): - """Save list of installed plugins.""" - installed_file = self.plugins_dir / ".installed" - with open(installed_file, 'w') as f: - json.dump(self.installed_plugins, f) - - def is_installed(self, plugin_id): - """Check if a plugin is installed.""" - return plugin_id in self.installed_plugins - - def get_installed_plugins(self): - """Get list of installed plugin IDs.""" - return self.installed_plugins.copy() +@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 PluginFetchThread(QThread): - """Background thread to fetch plugin index.""" - - fetched = pyqtSignal(list) +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, url): + + def __init__(self, repo_url: str, plugins_dir: Path): super().__init__() - self.url = url - + 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): - """Fetch plugin index.""" + """Execute queued operation.""" try: - http_client = get_http_client() - response = http_client.get( - self.url, - cache_ttl=300, # 5 minute cache for plugin list - headers={'User-Agent': 'EU-Utility/1.0'} - ) + 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" - if response.get('json'): - data = response['json'] - self.fetched.emit(data.get('plugins', [])) + 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: - # Try to parse JSON from text - data = json.loads(response['text']) - self.fetched.emit(data.get('plugins', [])) - - except Exception as e: - self.error.emit(str(e)) - - -class PluginInstallThread(QThread): - """Background thread to install a plugin.""" - - installed = pyqtSignal(bool) - error = pyqtSignal(str) - progress = pyqtSignal(str) - - def __init__(self, plugin, install_dir): - super().__init__() - self.plugin = plugin - self.install_dir = Path(install_dir) - - def run(self): - """Install plugin.""" + 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.""" + plugin_id = self.target_plugin['id'] + folder = self.target_plugin['folder'] + + self.progress_update.emit(f"Downloading {plugin_id}...") + + # Create temp directory + temp_dir = Path("temp_download") + temp_dir.mkdir(exist_ok=True) + try: - self.progress.emit(f"Downloading {self.plugin['name']}...") - - # Download zip - download_url = self.plugin.get('download_url') - if not download_url: - self.error.emit("No download URL") - self.installed.emit(False) - return - - temp_zip = self.install_dir / "temp.zip" - - http_client = get_http_client() - response = http_client.get( - download_url, - cache_ttl=0, # Don't cache downloads - headers={'User-Agent': 'EU-Utility/1.0'} + # For git repositories, clone and copy folder + import subprocess + + clone_dir = temp_dir / "repo" + if clone_dir.exists(): + shutil.rmtree(clone_dir) + + # Clone the repository + self.progress_update.emit(f"Cloning repository...") + result = subprocess.run( + ['git', 'clone', '--depth', '1', self.repo_url, str(clone_dir)], + capture_output=True, + text=True, + timeout=60 ) - with open(temp_zip, 'wb') as f: - f.write(response['content']) - - self.progress.emit("Extracting...") - - # Extract - plugin_dir = self.install_dir / self.plugin['id'] - if plugin_dir.exists(): - shutil.rmtree(plugin_dir) - plugin_dir.mkdir() - - with zipfile.ZipFile(temp_zip, 'r') as zip_ref: - zip_ref.extractall(plugin_dir) - - # Cleanup - temp_zip.unlink() - - self.progress.emit("Installed!") - self.installed.emit(True) - + if result.returncode != 0: + raise Exception(f"Git clone failed: {result.stderr}") + + # Copy plugin folder + source = clone_dir / folder + dest = self.plugins_dir / plugin_id + + if dest.exists(): + shutil.rmtree(dest) + + self.progress_update.emit(f"Installing {plugin_id}...") + shutil.copytree(source, dest) + + # Clean up + shutil.rmtree(temp_dir) + + self.plugin_downloaded.emit(plugin_id, True) + except Exception as e: self.error.emit(str(e)) - self.installed.emit(False) + 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 -# Sample plugins.json structure for GitHub repo: -SAMPLE_PLUGINS_JSON = { - "version": "1.0.0", - "plugins": [ - { - "id": "loot_tracker", - "name": "Loot Tracker", - "description": "Track and analyze hunting loot", - "version": "1.0.0", - "author": "ImpulsiveFPS", - "category": "hunting", - "download_url": "https://github.com/.../loot_tracker.zip", - "icon": "🎁", - "min_app_version": "1.0.0" - }, - { - "id": "mining_helper", - "name": "Mining Helper", - "description": "Track mining finds and claims", - "version": "1.1.0", - "author": "Community", - "category": "mining", - "download_url": "https://github.com/.../mining_helper.zip", - "icon": "⛏️", - "min_app_version": "1.0.0" - }, - { - "id": "market_analyzer", - "name": "Market Analyzer", - "description": "Analyze auction prices and trends", - "version": "0.9.0", - "author": "EU Community", - "category": "trading", - "download_url": "https://github.com/.../market_analyzer.zip", - "icon": "📈", - "min_app_version": "1.0.0" - }, - ] -} +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 = QLabel(f"🔗 {deps_count} dependencies") + deps_label.setStyleSheet("color: #ffd93d; font-size: 10px;") + deps_text = "Requires: " + ", ".join( + plugin.dependencies.get('plugins', []) + plugin.dependencies.get('core', []) + ) + deps_label.setToolTip(deps_text[:200]) + 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 _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}") diff --git a/plugins/plugin_store_ui/plugin.py b/plugins/plugin_store_ui/plugin.py index 6a92ae6..6f85f09 100644 --- a/plugins/plugin_store_ui/plugin.py +++ b/plugins/plugin_store_ui/plugin.py @@ -1,273 +1,55 @@ """ EU-Utility - Plugin Store UI Plugin -Browse and install community plugins. +Provides the Plugin Store interface for browsing and installing plugins. """ -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QPushButton, QLineEdit, QListWidget, QListWidgetItem, - QProgressBar, QFrame, QTextEdit -) -from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel from plugins.base_plugin import BasePlugin +from core.plugin_store import PluginStoreUI class PluginStoreUIPlugin(BasePlugin): - """Browse and install community plugins.""" + """Plugin Store for browsing and installing plugins from repositories.""" name = "Plugin Store" version = "1.0.0" author = "ImpulsiveFPS" - description = "Community plugin marketplace" - hotkey = "ctrl+shift+slash" + description = "Browse, install, and manage plugins from the official repository" + icon = "shopping-bag" + hotkeys = [ + { + 'action': 'open_store', + 'description': 'Open Plugin Store', + 'default': 'ctrl+shift+p', + 'config_key': 'pluginstore_open' + } + ] def initialize(self): """Setup plugin store.""" - self.available_plugins = [] - self.installed_plugins = [] - self.is_loading = False + self.store_ui = None def get_ui(self): """Create plugin store UI.""" - widget = QWidget() - widget.setStyleSheet("background: transparent;") - layout = QVBoxLayout(widget) - layout.setSpacing(15) - layout.setContentsMargins(0, 0, 0, 0) + # Get plugin manager from overlay + plugin_manager = getattr(self.overlay, 'plugin_manager', None) - # Title - title = QLabel("🛒 Plugin Store") - title.setStyleSheet("color: white; font-size: 16px; font-weight: bold;") - layout.addWidget(title) + if not plugin_manager: + # Fallback - show error + widget = QWidget() + layout = QVBoxLayout(widget) + error = QLabel("Plugin Manager not available. Cannot load Plugin Store.") + error.setStyleSheet("color: #ff4757; font-size: 14px;") + layout.addWidget(error) + return widget - # Search - search_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: 8px; - } - """) - search_layout.addWidget(self.search_input) - - search_btn = QPushButton("🔍") - search_btn.setFixedSize(32, 32) - search_btn.setStyleSheet(""" - QPushButton { - background-color: #ff8c42; - border: none; - border-radius: 4px; - } - """) - search_btn.clicked.connect(self._search_plugins) - search_layout.addWidget(search_btn) - - layout.addLayout(search_layout) - - # Categories - cats_layout = QHBoxLayout() - for cat in ["All", "Hunting", "Mining", "Crafting", "Tools", "Social"]: - btn = QPushButton(cat) - btn.setStyleSheet(""" - QPushButton { - background-color: rgba(255,255,255,15); - color: white; - border: none; - border-radius: 4px; - padding: 5px 10px; - } - QPushButton:hover { - background-color: rgba(255,255,255,30); - } - """) - cats_layout.addWidget(btn) - cats_layout.addStretch() - layout.addLayout(cats_layout) - - # Plugins list - self.plugins_list = QListWidget() - self.plugins_list.setStyleSheet(""" - QListWidget { - background-color: rgba(30, 35, 45, 200); - color: white; - border: 1px solid rgba(100, 110, 130, 80); - border-radius: 6px; - } - QListWidget::item { - padding: 12px; - border-bottom: 1px solid rgba(100, 110, 130, 40); - } - QListWidget::item:hover { - background-color: rgba(255, 255, 255, 10); - } - """) - self.plugins_list.itemClicked.connect(self._show_plugin_details) - layout.addWidget(self.plugins_list) - - # Sample plugins - self._load_sample_plugins() - - # Details panel - self.details_panel = QFrame() - self.details_panel.setStyleSheet(""" - QFrame { - background-color: rgba(30, 35, 45, 200); - border: 1px solid rgba(100, 110, 130, 80); - border-radius: 6px; - } - """) - details_layout = QVBoxLayout(self.details_panel) - - self.detail_name = QLabel("Select a plugin") - self.detail_name.setStyleSheet("color: #ff8c42; font-size: 14px; font-weight: bold;") - details_layout.addWidget(self.detail_name) - - self.detail_desc = QLabel("") - self.detail_desc.setStyleSheet("color: rgba(255,255,255,150);") - self.detail_desc.setWordWrap(True) - details_layout.addWidget(self.detail_desc) - - self.detail_author = QLabel("") - self.detail_author.setStyleSheet("color: rgba(255,255,255,100); font-size: 10px;") - details_layout.addWidget(self.detail_author) - - self.install_btn = QPushButton("Install") - self.install_btn.setStyleSheet(""" - QPushButton { - background-color: #4caf50; - color: white; - padding: 10px; - border: none; - border-radius: 4px; - font-weight: bold; - } - """) - self.install_btn.clicked.connect(self._install_plugin) - self.install_btn.setEnabled(False) - details_layout.addWidget(self.install_btn) - - layout.addWidget(self.details_panel) - - # Refresh button - refresh_btn = QPushButton("🔄 Refresh") - refresh_btn.setStyleSheet(""" - QPushButton { - background-color: rgba(255,255,255,20); - color: white; - padding: 8px; - border: none; - border-radius: 4px; - } - """) - refresh_btn.clicked.connect(self._refresh_store) - layout.addWidget(refresh_btn) - - layout.addStretch() - return widget + self.store_ui = PluginStoreUI(plugin_manager) + return self.store_ui - def _load_sample_plugins(self): - """Load sample plugin list.""" - sample = [ - { - 'name': 'Crafting Calculator', - 'description': 'Calculate crafting success rates and costs', - 'author': 'EU Community', - 'version': '1.0.0', - 'downloads': 542, - 'rating': 4.5, - }, - { - 'name': 'Global Tracker', - 'description': 'Track globals and HOFs with notifications', - 'author': 'ImpulsiveFPS', - 'version': '1.2.0', - 'downloads': 1203, - 'rating': 4.8, - }, - { - 'name': 'Bank Manager', - 'description': 'Manage storage and bank items across planets', - 'author': 'StorageMaster', - 'version': '0.9.0', - 'downloads': 328, - 'rating': 4.2, - }, - { - 'name': 'Society Tools', - 'description': 'Society management and member tracking', - 'author': 'SocietyDev', - 'version': '1.0.0', - 'downloads': 215, - 'rating': 4.0, - }, - { - 'name': 'Team Helper', - 'description': 'Team coordination and loot sharing', - 'author': 'TeamPlayer', - 'version': '1.1.0', - 'downloads': 876, - 'rating': 4.6, - }, - ] - - self.available_plugins = sample - self._update_list() - - def _update_list(self): - """Update plugins list.""" - self.plugins_list.clear() - - for plugin in self.available_plugins: - item = QListWidgetItem( - f"{plugin['name']} v{plugin['version']}\n" - f"⭐ {plugin['rating']} | ⬇ {plugin['downloads']}" - ) - item.setData(Qt.ItemDataRole.UserRole, plugin) - self.plugins_list.addItem(item) - - def _show_plugin_details(self, item): - """Show plugin details.""" - plugin = item.data(Qt.ItemDataRole.UserRole) - if plugin: - self.detail_name.setText(f"{plugin['name']} v{plugin['version']}") - self.detail_desc.setText(plugin['description']) - self.detail_author.setText(f"By {plugin['author']} | ⭐ {plugin['rating']}") - self.install_btn.setEnabled(True) - self.selected_plugin = plugin - - def _install_plugin(self): - """Install selected plugin.""" - if hasattr(self, 'selected_plugin'): - print(f"Installing {self.selected_plugin['name']}...") - self.install_btn.setText("Installing...") - self.install_btn.setEnabled(False) - # TODO: Actual install - - def _search_plugins(self): - """Search plugins.""" - query = self.search_input.text().lower() - - filtered = [ - p for p in self.available_plugins - if query in p['name'].lower() or query in p['description'].lower() - ] - - self.plugins_list.clear() - for plugin in filtered: - item = QListWidgetItem( - f"{plugin['name']} v{plugin['version']}\n" - f"⭐ {plugin['rating']} | ⬇ {plugin['downloads']}" - ) - item.setData(Qt.ItemDataRole.UserRole, plugin) - self.plugins_list.addItem(item) - - def _refresh_store(self): - """Refresh plugin list.""" - self._load_sample_plugins() + def on_hotkey(self): + """Handle hotkey press.""" + # Show the plugin store tab + if hasattr(self.overlay, 'show_plugin'): + self.overlay.show_plugin('plugins.plugin_store_ui.plugin') diff --git a/plugins/settings/plugin.py b/plugins/settings/plugin.py index 59659ac..bd4aeaa 100644 --- a/plugins/settings/plugin.py +++ b/plugins/settings/plugin.py @@ -63,9 +63,13 @@ class SettingsPlugin(BasePlugin): general_tab = self._create_general_tab() tabs.addTab(general_tab, "General") - # Plugins tab + # Plugin Store tab + store_tab = self._create_plugin_store_tab() + tabs.addTab(store_tab, "Plugin Store") + + # Local Plugins tab (for managing installed plugins) plugins_tab = self._create_plugins_tab() - tabs.addTab(plugins_tab, "Plugins") + tabs.addTab(plugins_tab, "My Plugins") # Hotkeys tab hotkeys_tab = self._create_hotkeys_tab() @@ -183,6 +187,26 @@ class SettingsPlugin(BasePlugin): return tab + def _create_plugin_store_tab(self): + """Create plugin store tab for browsing and installing plugins.""" + from core.plugin_store import PluginStoreUI + + # Get plugin manager from overlay + plugin_manager = getattr(self.overlay, 'plugin_manager', None) + + if not plugin_manager: + # Fallback - show error + tab = QWidget() + layout = QVBoxLayout(tab) + error = QLabel("Plugin Manager not available. Cannot load Plugin Store.") + error.setStyleSheet("color: #ff4757; font-size: 14px;") + layout.addWidget(error) + return tab + + # Create and return the plugin store UI + store_ui = PluginStoreUI(plugin_manager) + return store_ui + def _create_plugins_tab(self): """Create plugins management tab with dependency visualization.""" tab = QWidget()