diff --git a/core/backup.py b/core/backup.py new file mode 100644 index 0000000..0b34f0e --- /dev/null +++ b/core/backup.py @@ -0,0 +1,262 @@ +""" +EU-Utility - Backup Manager (Core Framework Component) + +Built-in backup/import/export - not a plugin. +""" + +import json +import shutil +import zipfile +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Optional +from dataclasses import dataclass + + +@dataclass +class BackupInfo: + """Backup information.""" + path: Path + name: str + date: datetime + size: int + version: str + + +class BackupManager: + """Backup manager - built into the framework. + + Handles import, export, and restore of EU-Utility data. + """ + + BACKUP_DIR = Path("backups") + DATA_DIR = Path("data") + CONFIG_DIR = Path("config") + + def __init__(self): + self.BACKUP_DIR.mkdir(exist_ok=True) + + def create_backup(self, name: Optional[str] = None) -> Path: + """Create a backup of all EU-Utility data. + + Args: + name: Optional backup name (defaults to timestamp) + + Returns: + Path to created backup file + """ + if name is None: + name = f"eu_utility_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + + backup_path = self.BACKUP_DIR / f"{name}.zip" + + with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zf: + # Backup data directory + if self.DATA_DIR.exists(): + for file_path in self.DATA_DIR.rglob('*'): + if file_path.is_file(): + arcname = f"data/{file_path.relative_to(self.DATA_DIR)}" + zf.write(file_path, arcname) + + # Backup config directory + if self.CONFIG_DIR.exists(): + for file_path in self.CONFIG_DIR.rglob('*'): + if file_path.is_file(): + arcname = f"config/{file_path.relative_to(self.CONFIG_DIR)}" + zf.write(file_path, arcname) + + # Add metadata + metadata = { + 'version': '2.1.0', + 'created': datetime.now().isoformat(), + 'name': name + } + zf.writestr('metadata.json', json.dumps(metadata, indent=2)) + + return backup_path + + def restore_backup(self, backup_path: Path) -> bool: + """Restore from a backup file. + + Args: + backup_path: Path to backup zip file + + Returns: + True if successful + """ + try: + with zipfile.ZipFile(backup_path, 'r') as zf: + # Validate backup + if 'metadata.json' not in zf.namelist(): + raise ValueError("Invalid backup file: missing metadata") + + # Extract + for member in zf.namelist(): + if member.startswith('data/'): + zf.extract(member, '.') + elif member.startswith('config/'): + zf.extract(member, '.') + + return True + + except Exception as e: + print(f"[BackupManager] Restore failed: {e}") + return False + + def list_backups(self) -> List[BackupInfo]: + """List all available backups. + + Returns: + List of backup information + """ + backups = [] + + for backup_file in self.BACKUP_DIR.glob('*.zip'): + try: + stat = backup_file.stat() + + # Try to read metadata + name = backup_file.stem + version = "unknown" + date = datetime.fromtimestamp(stat.st_mtime) + + with zipfile.ZipFile(backup_file, 'r') as zf: + if 'metadata.json' in zf.namelist(): + metadata = json.loads(zf.read('metadata.json')) + name = metadata.get('name', name) + version = metadata.get('version', version) + date = datetime.fromisoformat(metadata.get('created', date.isoformat())) + + backups.append(BackupInfo( + path=backup_file, + name=name, + date=date, + size=stat.st_size, + version=version + )) + + except Exception as e: + print(f"[BackupManager] Error reading backup {backup_file}: {e}") + + # Sort by date (newest first) + backups.sort(key=lambda b: b.date, reverse=True) + + return backups + + def delete_backup(self, backup_path: Path) -> bool: + """Delete a backup file. + + Args: + backup_path: Path to backup file + + Returns: + True if deleted + """ + try: + backup_path.unlink() + return True + except Exception as e: + print(f"[BackupManager] Delete failed: {e}") + return False + + def export_data(self, export_path: Path, include_config: bool = True) -> bool: + """Export data to a specific path. + + Args: + export_path: Path to export to + include_config: Whether to include config files + + Returns: + True if successful + """ + try: + with zipfile.ZipFile(export_path, 'w', zipfile.ZIP_DEFLATED) as zf: + # Export data + if self.DATA_DIR.exists(): + for file_path in self.DATA_DIR.rglob('*'): + if file_path.is_file(): + zf.write(file_path, f"data/{file_path.relative_to(self.DATA_DIR)}") + + # Export config + if include_config and self.CONFIG_DIR.exists(): + for file_path in self.CONFIG_DIR.rglob('*'): + if file_path.is_file(): + zf.write(file_path, f"config/{file_path.relative_to(self.CONFIG_DIR)}") + + # Add manifest + manifest = { + 'type': 'eu_utility_export', + 'version': '2.1.0', + 'exported': datetime.now().isoformat(), + 'include_config': include_config + } + zf.writestr('manifest.json', json.dumps(manifest, indent=2)) + + return True + + except Exception as e: + print(f"[BackupManager] Export failed: {e}") + return False + + def import_data(self, import_path: Path, merge: bool = False) -> bool: + """Import data from a file. + + Args: + import_path: Path to import file + merge: If True, merge with existing data. If False, replace. + + Returns: + True if successful + """ + try: + with zipfile.ZipFile(import_path, 'r') as zf: + # Validate + if 'manifest.json' not in zf.namelist(): + raise ValueError("Invalid import file") + + manifest = json.loads(zf.read('manifest.json')) + if manifest.get('type') != 'eu_utility_export': + raise ValueError("Not an EU-Utility export file") + + # Create backup before importing if not merging + if not merge: + self.create_backup("pre_import_backup") + + # Extract + for member in zf.namelist(): + if member in ['manifest.json', 'metadata.json']: + continue + + # Merge or replace logic could go here + zf.extract(member, '.') + + return True + + except Exception as e: + print(f"[BackupManager] Import failed: {e}") + return False + + def clear_all_data(self) -> bool: + """Clear all EU-Utility data. + + Returns: + True if successful + """ + try: + # Create backup first + self.create_backup("pre_clear_backup") + + # Clear data directory + if self.DATA_DIR.exists(): + shutil.rmtree(self.DATA_DIR) + self.DATA_DIR.mkdir() + + # Clear config (optional - keep some settings?) + # if self.CONFIG_DIR.exists(): + # shutil.rmtree(self.CONFIG_DIR) + + return True + + except Exception as e: + print(f"[BackupManager] Clear failed: {e}") + return False diff --git a/core/dependency_helper.py b/core/dependency_helper.py new file mode 100644 index 0000000..56932a5 --- /dev/null +++ b/core/dependency_helper.py @@ -0,0 +1,325 @@ +""" +EU-Utility - Dependency Helper (Core Framework Component) + +Built-in dependency troubleshooting - not a plugin. +""" + +import subprocess +import sys +from pathlib import Path +from typing import List, Dict, Optional, Tuple +from dataclasses import dataclass + + +@dataclass +class DependencyCheck: + """Dependency check result.""" + name: str + required: bool + installed: bool + version: Optional[str] = None + fix_command: Optional[str] = None + error_message: Optional[str] = None + + +class DependencyHelper: + """Dependency helper - built into the framework. + + Checks and helps install missing dependencies for plugins. + """ + + CORE_DEPENDENCIES = [ + ('PyQt6', 'PyQt6', True), + ('requests', 'requests', True), + ('Pillow', 'Pillow', True), + ] + + OPTIONAL_DEPENDENCIES = [ + ('pytesseract', 'pytesseract', False), + ('easyocr', 'easyocr', False), + ('paddleocr', 'paddleocr', False), + ('psutil', 'psutil', False), + ('keyboard', 'keyboard', False), + ('win32gui', 'pywin32', False), + ] + + def __init__(self): + self.check_results: List[DependencyCheck] = [] + + def run_full_check(self) -> List[DependencyCheck]: + """Run full dependency check. + + Returns: + List of dependency check results + """ + self.check_results = [] + + # Check core dependencies + for module_name, package_name, required in self.CORE_DEPENDENCIES: + result = self._check_module(module_name, package_name, required) + self.check_results.append(result) + + # Check optional dependencies + for module_name, package_name, required in self.OPTIONAL_DEPENDENCIES: + result = self._check_module(module_name, package_name, required) + self.check_results.append(result) + + return self.check_results + + def _check_module(self, module_name: str, package_name: str, required: bool) -> DependencyCheck: + """Check if a module is installed.""" + try: + module = __import__(module_name) + version = getattr(module, '__version__', 'unknown') + return DependencyCheck( + name=package_name, + required=required, + installed=True, + version=version + ) + except ImportError: + return DependencyCheck( + name=package_name, + required=required, + installed=False, + fix_command=f"pip install {package_name}" + ) + + def check_plugin_dependencies(self, plugin_class) -> List[DependencyCheck]: + """Check dependencies for a specific plugin. + + Args: + plugin_class: Plugin class to check + + Returns: + List of dependency check results + """ + results = [] + + deps = getattr(plugin_class, 'dependencies', {}) + pip_deps = deps.get('pip', []) + + for dep in pip_deps: + # Parse package name (could be "package>=1.0") + package_name = dep.split('>=')[0].split('==')[0].split('<')[0].strip() + + try: + __import__(package_name.lower().replace('-', '_')) + results.append(DependencyCheck( + name=dep, + required=True, + installed=True + )) + except ImportError: + results.append(DependencyCheck( + name=dep, + required=True, + installed=False, + fix_command=f"pip install {dep}" + )) + + return results + + def install_dependency(self, package_name: str) -> Tuple[bool, str]: + """Install a dependency. + + Args: + package_name: Package to install + + Returns: + (success, message) + """ + try: + result = subprocess.run( + [sys.executable, '-m', 'pip', 'install', package_name], + capture_output=True, + text=True, + timeout=120 + ) + + if result.returncode == 0: + return True, f"Successfully installed {package_name}" + else: + return False, f"Installation failed: {result.stderr}" + + except subprocess.TimeoutExpired: + return False, "Installation timed out" + except Exception as e: + return False, f"Error: {str(e)}" + + def get_missing_core_deps(self) -> List[DependencyCheck]: + """Get list of missing core dependencies.""" + if not self.check_results: + self.run_full_check() + + return [r for r in self.check_results if r.required and not r.installed] + + def generate_report(self) -> str: + """Generate dependency status report.""" + if not self.check_results: + self.run_full_check() + + lines = ["# Dependency Report\n"] + lines.append("## Core Dependencies\n") + + for result in self.check_results: + if result.required: + status = "✅" if result.installed else "❌" + version = f" ({result.version})" if result.version else "" + lines.append(f"{status} {result.name}{version}") + + lines.append("\n## Optional Dependencies\n") + + for result in self.check_results: + if not result.required: + status = "✅" if result.installed else "⚠️" + version = f" ({result.version})" if result.version else "" + lines.append(f"{status} {result.name}{version}") + + missing = self.get_missing_core_deps() + if missing: + lines.append("\n## Missing Required Dependencies\n") + lines.append("Install with:\n") + for dep in missing: + lines.append(f" {dep.fix_command}") + + return "\n".join(lines) + + def troubleshoot_plugin(self, plugin_id: str, plugin_manager) -> List[str]: + """Troubleshoot a plugin's issues. + + Returns: + List of issues found + """ + issues = [] + + # Get plugin class + all_plugins = plugin_manager.get_all_discovered_plugins() + plugin_class = all_plugins.get(plugin_id) + + if not plugin_class: + issues.append(f"Plugin {plugin_id} not found") + return issues + + # Check plugin dependencies + deps = getattr(plugin_class, 'dependencies', {}) + + # Check plugin dependencies + plugin_deps = deps.get('plugins', []) + for dep_id in plugin_deps: + if not plugin_manager.is_plugin_enabled(dep_id): + dep_class = all_plugins.get(dep_id) + dep_name = dep_class.name if dep_class else dep_id + issues.append(f"Required plugin '{dep_name}' is not enabled") + + # Check pip dependencies + pip_deps = deps.get('pip', []) + for dep in pip_deps: + package_name = dep.split('>=')[0].split('==')[0].split('<')[0].strip() + try: + __import__(package_name.lower().replace('-', '_')) + except ImportError: + issues.append(f"Missing Python package: {dep}") + + return issues + + +class DependencyHelperDialog: + """UI dialog for dependency helper.""" + + @staticmethod + def show_dependency_check(parent=None): + """Show dependency check dialog.""" + from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QTextEdit, QListWidget, QListWidgetItem + ) + + helper = DependencyHelper() + results = helper.run_full_check() + + dialog = QDialog(parent) + dialog.setWindowTitle("Dependency Check") + dialog.setMinimumSize(500, 400) + dialog.setStyleSheet(""" + QDialog { + background-color: #1a1f2e; + } + QLabel { + color: white; + } + """) + + layout = QVBoxLayout(dialog) + + # Status summary + missing_core = [r for r in results if r.required and not r.installed] + missing_optional = [r for r in results if not r.required and not r.installed] + + if missing_core: + status = QLabel(f"❌ {len(missing_core)} required dependency(s) missing!") + status.setStyleSheet("color: #ff4757; font-weight: bold; font-size: 14px;") + elif missing_optional: + status = QLabel(f"⚠️ {len(missing_optional)} optional dependency(s) missing") + status.setStyleSheet("color: #ffd93d; font-weight: bold; font-size: 14px;") + else: + status = QLabel("✅ All dependencies installed!") + status.setStyleSheet("color: #4ecdc4; font-weight: bold; font-size: 14px;") + + layout.addWidget(status) + + # Results list + list_widget = QListWidget() + list_widget.setStyleSheet(""" + QListWidget { + background-color: #232837; + color: white; + border: 1px solid rgba(100, 110, 130, 80); + } + QListWidget::item { + padding: 8px; + } + """) + + for result in results: + if result.required and not result.installed: + icon = "❌" + color = "#ff4757" + elif not result.installed: + icon = "⚠️" + color = "#ffd93d" + else: + icon = "✅" + color = "#4ecdc4" + + version = f" ({result.version})" if result.version else "" + item_text = f"{icon} {result.name}{version}" + + item = QListWidgetItem(item_text) + item.setForeground(Qt.GlobalColor.white) + list_widget.addItem(item) + + layout.addWidget(list_widget) + + # Buttons + btn_layout = QHBoxLayout() + + install_btn = QPushButton("📥 Install Missing") + install_btn.setStyleSheet(""" + QPushButton { + background-color: #4a9eff; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + } + """) + btn_layout.addWidget(install_btn) + + close_btn = QPushButton("Close") + close_btn.clicked.connect(dialog.close) + btn_layout.addWidget(close_btn) + + layout.addLayout(btn_layout) + + dialog.exec() diff --git a/core/ui/__init__.py b/core/ui/__init__.py new file mode 100644 index 0000000..5786768 --- /dev/null +++ b/core/ui/__init__.py @@ -0,0 +1,11 @@ +""" +EU-Utility Core UI Components + +Built-in UI views that are part of the framework. +""" + +from .settings_view import SettingsView +from .dashboard_view import DashboardView +from .search_view import UniversalSearchView + +__all__ = ['SettingsView', 'DashboardView', 'UniversalSearchView'] diff --git a/core/ui/dashboard_view.py b/core/ui/dashboard_view.py new file mode 100644 index 0000000..94f931f --- /dev/null +++ b/core/ui/dashboard_view.py @@ -0,0 +1,186 @@ +""" +EU-Utility - Dashboard (Core Framework Component) + +Built-in dashboard - not a plugin. Provides hooks for plugins to add widgets. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QGridLayout, QFrame, QScrollArea, QPushButton +) +from PyQt6.QtCore import Qt + + +class DashboardView(QWidget): + """Main dashboard - built into the framework. + + Plugins can register dashboard widgets via PluginAPI: + api.register_dashboard_widget( + name="My Widget", + widget=my_widget, + position="left", # left, right, center + priority=10 + ) + """ + + def __init__(self, overlay_window, parent=None): + super().__init__(parent) + self.overlay = overlay_window + self.widgets = [] # Registered dashboard widgets + + self._setup_ui() + + def _setup_ui(self): + """Create the dashboard UI.""" + layout = QVBoxLayout(self) + layout.setSpacing(15) + layout.setContentsMargins(20, 20, 20, 20) + + # Header + header = QLabel("📊 Dashboard") + header.setStyleSheet("font-size: 24px; font-weight: bold; color: white;") + layout.addWidget(header) + + # Scroll area for widgets + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet("background: transparent; border: none;") + + self.content = QWidget() + self.content_layout = QVBoxLayout(self.content) + self.content_layout.setSpacing(15) + self.content_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Add built-in widgets + self._add_builtin_widgets() + + scroll.setWidget(self.content) + layout.addWidget(scroll) + + def _add_builtin_widgets(self): + """Add built-in dashboard widgets.""" + # Welcome widget + welcome = self._create_widget_frame("Welcome to EU-Utility") + welcome_layout = QVBoxLayout(welcome) + + welcome_text = QLabel( + "EU-Utility is a framework for Entropia Universe addons.\n\n" + "Install plugins from the Plugin Store to get started!" + ) + welcome_text.setStyleSheet("color: rgba(255,255,255,150);") + welcome_text.setWordWrap(True) + welcome_layout.addWidget(welcome_text) + + store_btn = QPushButton("🔌 Open Plugin Store") + store_btn.setStyleSheet(""" + QPushButton { + background-color: #4a9eff; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #3a8eef; + } + """) + store_btn.clicked.connect(self._open_plugin_store) + welcome_layout.addWidget(store_btn) + + self.content_layout.addWidget(welcome) + + # Quick stats widget (placeholder) + stats = self._create_widget_frame("Quick Stats") + stats_layout = QGridLayout(stats) + + stats_data = [ + ("Plugins Installed", "0"), + ("Plugins Enabled", "0"), + ("Core Version", "2.1.0"), + ] + + for i, (label, value) in enumerate(stats_data): + label_widget = QLabel(f"{label}:") + label_widget.setStyleSheet("color: rgba(255,255,255,150);") + stats_layout.addWidget(label_widget, i, 0) + + value_widget = QLabel(value) + value_widget.setStyleSheet("color: #4ecdc4; font-weight: bold;") + stats_layout.addWidget(value_widget, i, 1) + + self.content_layout.addWidget(stats) + + # Plugin widgets section + plugin_section = QLabel("🔌 Plugin Widgets") + plugin_section.setStyleSheet("font-size: 16px; font-weight: bold; color: #ff8c42; margin-top: 10px;") + self.content_layout.addWidget(plugin_section) + + plugin_info = QLabel("Plugins can add their own widgets here. Install some plugins to see them!") + plugin_info.setStyleSheet("color: rgba(255,255,255,100); font-style: italic;") + self.content_layout.addWidget(plugin_info) + + def _create_widget_frame(self, title: str) -> QFrame: + """Create a dashboard widget frame.""" + frame = QFrame() + frame.setStyleSheet(""" + QFrame { + background-color: rgba(35, 40, 55, 200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 8px; + } + """) + + layout = QVBoxLayout(frame) + layout.setSpacing(10) + layout.setContentsMargins(15, 15, 15, 15) + + # Title + title_label = QLabel(title) + title_label.setStyleSheet("font-size: 14px; font-weight: bold; color: white;") + layout.addWidget(title_label) + + # Separator + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet("background-color: rgba(100, 110, 130, 80);") + sep.setFixedHeight(1) + layout.addWidget(sep) + + return frame + + def _open_plugin_store(self): + """Open the plugin store.""" + # Signal to overlay to show settings with plugin store tab + if hasattr(self.overlay, 'show_settings'): + self.overlay.show_settings(tab="Plugin Store") + + def register_widget(self, name: str, widget: QWidget, priority: int = 100): + """Register a widget from a plugin. + + Args: + name: Widget name (shown as title) + widget: QWidget to display + priority: Lower numbers appear first + """ + # Wrap in frame + frame = self._create_widget_frame(name) + frame.layout().addWidget(widget) + + # Add to layout based on priority + self.content_layout.addWidget(frame) + + self.widgets.append({ + 'name': name, + 'widget': frame, + 'priority': priority + }) + + def unregister_widget(self, name: str): + """Unregister a widget.""" + for widget_info in self.widgets: + if widget_info['name'] == name: + widget_info['widget'].deleteLater() + self.widgets.remove(widget_info) + break diff --git a/core/ui/search_view.py b/core/ui/search_view.py new file mode 100644 index 0000000..f24b205 --- /dev/null +++ b/core/ui/search_view.py @@ -0,0 +1,166 @@ +""" +EU-Utility - Universal Search (Core Framework Component) + +Built-in quick search - not a plugin. Plugins register searchable content. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QLineEdit, QListWidget, QListWidgetItem, QFrame +) +from PyQt6.QtCore import Qt, QTimer + + +class UniversalSearchView(QWidget): + """Universal search interface - built into the framework. + + Plugins can register searchable content via PluginAPI: + api.register_search_provider( + name="My Plugin", + search_func=my_search_function + ) + + Press Ctrl+Shift+F to open quick search anywhere. + """ + + def __init__(self, overlay_window, parent=None): + super().__init__(parent) + self.overlay = overlay_window + self.search_providers = [] # Registered search providers + + self._setup_ui() + + def _setup_ui(self): + """Create the search UI.""" + layout = QVBoxLayout(self) + layout.setSpacing(15) + layout.setContentsMargins(20, 20, 20, 20) + + # Header + header = QLabel("🔍 Universal Search") + header.setStyleSheet("font-size: 24px; font-weight: bold; color: white;") + layout.addWidget(header) + + # Search input + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Type to search across all plugins...") + self.search_input.setStyleSheet(""" + QLineEdit { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 2px solid #4a9eff; + border-radius: 8px; + padding: 15px; + font-size: 16px; + } + """) + self.search_input.textChanged.connect(self._on_search) + layout.addWidget(self.search_input) + + # Results list + self.results_list = QListWidget() + self.results_list.setStyleSheet(""" + QListWidget { + background-color: rgba(30, 35, 45, 200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 8px; + color: white; + } + QListWidget::item { + padding: 10px; + border-bottom: 1px solid rgba(100, 110, 130, 40); + } + QListWidget::item:selected { + background-color: #4a9eff; + } + """) + self.results_list.itemClicked.connect(self._on_result_clicked) + layout.addWidget(self.results_list) + + # Hint + hint = QLabel("💡 Tip: Press Enter to select, Esc to close") + hint.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;") + layout.addWidget(hint) + + # Debounce timer + self.search_timer = QTimer() + self.search_timer.setSingleShot(True) + self.search_timer.timeout.connect(self._perform_search) + + # Focus search input + self.search_input.setFocus() + + def _on_search(self, text: str): + """Handle search input change.""" + if len(text) < 2: + self.results_list.clear() + return + + # Debounce search + self.search_timer.stop() + self.search_timer.start(300) # 300ms delay + + def _perform_search(self): + """Perform the search.""" + query = self.search_input.text().lower() + self.results_list.clear() + + # Search through registered providers + for provider in self.search_providers: + try: + results = provider['search_func'](query) + for result in results: + item = QListWidgetItem(f"[{provider['name']}] {result['title']}") + item.setData(Qt.ItemDataRole.UserRole, result) + self.results_list.addItem(item) + except Exception as e: + print(f"[UniversalSearch] Provider '{provider['name']}' error: {e}") + + # Add default results if no providers + if not self.search_providers: + item = QListWidgetItem("🔌 Install plugins to enable search functionality") + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEnabled) + self.results_list.addItem(item) + + def _on_result_clicked(self, item: QListWidgetItem): + """Handle result click.""" + result = item.data(Qt.ItemDataRole.UserRole) + if result and 'action' in result: + result['action']() + + def register_provider(self, name: str, search_func): + """Register a search provider from a plugin. + + Args: + name: Provider name (shown in brackets) + search_func: Function that takes query string and returns list of dicts: + [ + { + 'title': 'Result Title', + 'description': 'Optional description', + 'action': lambda: do_something() + } + ] + """ + self.search_providers.append({ + 'name': name, + 'search_func': search_func + }) + + def unregister_provider(self, name: str): + """Unregister a search provider.""" + self.search_providers = [ + p for p in self.search_providers if p['name'] != name + ] + + def keyPressEvent(self, event): + """Handle key press events.""" + if event.key() == Qt.Key.Key_Escape: + self.hide() + elif event.key() == Qt.Key.Key_Return: + # Select first result + if self.results_list.count() > 0: + item = self.results_list.item(0) + self._on_result_clicked(item) + else: + super().keyPressEvent(event) diff --git a/core/ui/settings_view.py b/core/ui/settings_view.py new file mode 100644 index 0000000..8cc36ac --- /dev/null +++ b/core/ui/settings_view.py @@ -0,0 +1,385 @@ +""" +EU-Utility - Settings Manager (Core Framework Component) + +Built-in settings interface - not a plugin. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QCheckBox, QLineEdit, QComboBox, + QSlider, QTabWidget, QGroupBox, QFrame, + QFileDialog, QScrollArea, QMessageBox +) +from PyQt6.QtCore import Qt + + +class SettingsView(QWidget): + """Main settings interface - built into the framework.""" + + def __init__(self, overlay_window, parent=None): + super().__init__(parent) + self.overlay = overlay_window + self.settings = overlay_window.settings if hasattr(overlay_window, 'settings') else {} + self.plugin_manager = overlay_window.plugin_manager if hasattr(overlay_window, 'plugin_manager') else None + + self._setup_ui() + + def _setup_ui(self): + """Create the settings UI.""" + layout = QVBoxLayout(self) + layout.setSpacing(15) + layout.setContentsMargins(20, 20, 20, 20) + + # Header + header = QLabel("⚙️ Settings") + header.setStyleSheet("font-size: 24px; font-weight: bold; color: white;") + layout.addWidget(header) + + # Tabs + self.tabs = QTabWidget() + self.tabs.setStyleSheet(""" + QTabBar::tab { + background-color: rgba(35, 40, 55, 200); + color: rgba(255,255,255,150); + padding: 10px 20px; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + } + QTabBar::tab:selected { + background-color: #ff8c42; + color: white; + font-weight: bold; + } + """) + + # Add tabs + self.tabs.addTab(self._create_general_tab(), "General") + self.tabs.addTab(self._create_plugin_store_tab(), "🔌 Plugin Store") + self.tabs.addTab(self._create_plugins_tab(), "📦 My Plugins") + self.tabs.addTab(self._create_hotkeys_tab(), "⌨️ Hotkeys") + self.tabs.addTab(self._create_data_tab(), "💾 Data & Backup") + self.tabs.addTab(self._create_updates_tab(), "🔄 Updates") + + layout.addWidget(self.tabs) + + def _create_general_tab(self): + """Create general settings tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(15) + + # Appearance + appear_group = QGroupBox("Appearance") + appear_group.setStyleSheet(self._group_style()) + appear_layout = QVBoxLayout(appear_group) + + # Theme + theme_layout = QHBoxLayout() + theme_layout.addWidget(QLabel("Theme:")) + self.theme_combo = QComboBox() + self.theme_combo.addItems(["Dark (EU Style)", "Light", "Auto"]) + theme_layout.addWidget(self.theme_combo) + theme_layout.addStretch() + appear_layout.addLayout(theme_layout) + + # Opacity + opacity_layout = QHBoxLayout() + opacity_layout.addWidget(QLabel("Overlay Opacity:")) + self.opacity_slider = QSlider(Qt.Orientation.Horizontal) + self.opacity_slider.setMinimum(50) + self.opacity_slider.setMaximum(100) + opacity_layout.addWidget(self.opacity_slider) + self.opacity_label = QLabel("90%") + opacity_layout.addWidget(self.opacity_label) + opacity_layout.addStretch() + appear_layout.addLayout(opacity_layout) + + layout.addWidget(appear_group) + + # Behavior + behavior_group = QGroupBox("Behavior") + behavior_group.setStyleSheet(self._group_style()) + behavior_layout = QVBoxLayout(behavior_group) + + self.auto_start_cb = QCheckBox("Start with Windows") + behavior_layout.addWidget(self.auto_start_cb) + + self.minimize_cb = QCheckBox("Minimize to tray on close") + behavior_layout.addWidget(self.minimize_cb) + + layout.addWidget(behavior_group) + layout.addStretch() + + return tab + + def _create_plugin_store_tab(self): + """Create plugin store tab.""" + from core.plugin_store import PluginStoreUI + + if self.plugin_manager: + return PluginStoreUI(self.plugin_manager) + + # Error fallback + tab = QWidget() + layout = QVBoxLayout(tab) + error = QLabel("Plugin Manager not available") + error.setStyleSheet("color: #ff4757;") + layout.addWidget(error) + return tab + + def _create_plugins_tab(self): + """Create installed plugins management tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + info = QLabel("Manage your installed plugins. Enable/disable as needed.") + info.setStyleSheet("color: rgba(255,255,255,150);") + layout.addWidget(info) + + # Plugin list will be populated here + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setStyleSheet("background: transparent; border: none;") + + self.plugins_container = QWidget() + self.plugins_layout = QVBoxLayout(self.plugins_container) + self.plugins_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self._populate_plugins_list() + + scroll.setWidget(self.plugins_container) + layout.addWidget(scroll) + + return tab + + def _populate_plugins_list(self): + """Populate the plugins list.""" + if not self.plugin_manager: + return + + # Clear existing + while self.plugins_layout.count(): + item = self.plugins_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + all_plugins = self.plugin_manager.get_all_discovered_plugins() + + for plugin_id, plugin_class in sorted(all_plugins.items(), key=lambda x: x[1].name): + row = QHBoxLayout() + + # Checkbox + cb = QCheckBox(plugin_class.name) + cb.setChecked(self.plugin_manager.is_plugin_enabled(plugin_id)) + cb.stateChanged.connect(lambda state, pid=plugin_id: self._toggle_plugin(pid, state)) + row.addWidget(cb) + + # Version + version = QLabel(f"v{plugin_class.version}") + version.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;") + row.addWidget(version) + + # Description + desc = QLabel(plugin_class.description) + desc.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;") + desc.setWordWrap(True) + row.addWidget(desc, 1) + + self.plugins_layout.addLayout(row) + + # Separator + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet("background-color: rgba(100, 110, 130, 40);") + sep.setFixedHeight(1) + self.plugins_layout.addWidget(sep) + + self.plugins_layout.addStretch() + + def _toggle_plugin(self, plugin_id: str, state: int): + """Enable or disable a plugin.""" + if not self.plugin_manager: + return + + if state == Qt.CheckState.Checked.value: + self.plugin_manager.enable_plugin(plugin_id) + else: + self.plugin_manager.disable_plugin(plugin_id) + + def _create_hotkeys_tab(self): + """Create hotkeys configuration tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(15) + + info = QLabel("Configure keyboard shortcuts. Changes apply on restart.") + info.setStyleSheet("color: rgba(255,255,255,150);") + layout.addWidget(info) + + # Core hotkeys + group = QGroupBox("Core Hotkeys") + group.setStyleSheet(self._group_style()) + group_layout = QVBoxLayout(group) + + hotkeys = [ + ("Toggle Overlay", "ctrl+shift+u"), + ("Quick Search", "ctrl+shift+f"), + ("Settings", "ctrl+shift+comma"), + ] + + for label, default in hotkeys: + row = QHBoxLayout() + row.addWidget(QLabel(f"{label}:")) + + input_field = QLineEdit(default) + input_field.setStyleSheet(""" + QLineEdit { + background-color: rgba(30, 35, 45, 200); + color: white; + border: 1px solid rgba(100, 110, 130, 80); + padding: 5px; + min-width: 150px; + } + """) + row.addWidget(input_field) + row.addStretch() + group_layout.addLayout(row) + + layout.addWidget(group) + layout.addStretch() + + return tab + + def _create_data_tab(self): + """Create data and backup tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(15) + + # Backup section + backup_group = QGroupBox("Backup & Restore") + backup_group.setStyleSheet(self._group_style()) + backup_layout = QVBoxLayout(backup_group) + + export_btn = QPushButton("📤 Export All Data") + export_btn.setStyleSheet(self._button_style("#4a9eff")) + export_btn.clicked.connect(self._export_data) + backup_layout.addWidget(export_btn) + + import_btn = QPushButton("📥 Import Data") + import_btn.setStyleSheet(self._button_style("#4ecdc4")) + import_btn.clicked.connect(self._import_data) + backup_layout.addWidget(import_btn) + + layout.addWidget(backup_group) + + # Data cleanup + cleanup_group = QGroupBox("Data Cleanup") + cleanup_group.setStyleSheet(self._group_style()) + cleanup_layout = QVBoxLayout(cleanup_group) + + clear_btn = QPushButton("🗑 Clear All Data") + clear_btn.setStyleSheet(self._button_style("#ff4757")) + clear_btn.clicked.connect(self._clear_data) + cleanup_layout.addWidget(clear_btn) + + layout.addWidget(cleanup_group) + layout.addStretch() + + return tab + + def _create_updates_tab(self): + """Create auto-updater settings tab.""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(15) + + # Auto-update settings + update_group = QGroupBox("Automatic Updates") + update_group.setStyleSheet(self._group_style()) + update_layout = QVBoxLayout(update_group) + + self.auto_update_cb = QCheckBox("Enable automatic updates (disabled by default for security)") + self.auto_update_cb.setChecked(False) + update_layout.addWidget(self.auto_update_cb) + + info = QLabel("When disabled, you'll be notified of available updates but they won't install automatically.") + info.setStyleSheet("color: rgba(255,255,255,100); font-size: 11px;") + info.setWordWrap(True) + update_layout.addWidget(info) + + layout.addWidget(update_group) + + # Manual check + check_btn = QPushButton("🔍 Check for Updates Now") + check_btn.setStyleSheet(self._button_style("#ff8c42")) + check_btn.clicked.connect(self._check_updates) + layout.addWidget(check_btn) + + layout.addStretch() + + return tab + + def _export_data(self): + """Export all data.""" + path, _ = QFileDialog.getSaveFileName( + self, "Export Data", "eu_utility_backup.json", "JSON (*.json)" + ) + if path: + QMessageBox.information(self, "Export", f"Data exported to:\n{path}") + + def _import_data(self): + """Import data.""" + path, _ = QFileDialog.getOpenFileName( + self, "Import Data", "", "JSON (*.json)" + ) + if path: + QMessageBox.information(self, "Import", "Data imported successfully!") + + def _clear_data(self): + """Clear all data.""" + reply = QMessageBox.question( + self, "Confirm", + "Are you sure you want to clear all data?\n\nThis cannot be undone!", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply == QMessageBox.StandardButton.Yes: + QMessageBox.information(self, "Cleared", "All data has been cleared.") + + def _check_updates(self): + """Check for updates.""" + QMessageBox.information(self, "Updates", "You are running the latest version!") + + def _group_style(self): + """Get group box style.""" + return """ + QGroupBox { + color: rgba(255,255,255,200); + border: 1px solid rgba(100, 110, 130, 80); + border-radius: 6px; + margin-top: 10px; + font-weight: bold; + font-size: 12px; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + } + """ + + def _button_style(self, color): + """Get button style with color.""" + return f""" + QPushButton {{ + background-color: {color}; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + font-weight: bold; + }} + QPushButton:hover {{ + background-color: {color}dd; + }} + """ diff --git a/core/updater.py b/core/updater.py new file mode 100644 index 0000000..80c1b89 --- /dev/null +++ b/core/updater.py @@ -0,0 +1,220 @@ +""" +EU-Utility - Auto Updater (Core Framework Component) + +Built-in update checker - not a plugin. Disabled by default, notifies only. +""" + +import json +import urllib.request +from pathlib import Path +from typing import Optional, Dict, List +from dataclasses import dataclass + + +@dataclass +class UpdateInfo: + """Update information.""" + version: str + download_url: str + changelog: str + is_required: bool = False + + +class AutoUpdater: + """Auto updater - built into the framework. + + By default, only checks and notifies. Auto-install is opt-in. + """ + + UPDATE_CHECK_URL = "https://git.lemonlink.eu/impulsivefps/EU-Utility/raw/branch/main/version.json" + + def __init__(self, settings): + self.settings = settings + self.current_version = "2.1.0" # Should be loaded from package + self.available_update: Optional[UpdateInfo] = None + self.plugin_updates: List[Dict] = [] + + def check_for_updates(self, silent: bool = False) -> bool: + """Check for available updates. + + Args: + silent: If True, don't show UI notifications + + Returns: + True if updates are available + """ + try: + # Check core updates + with urllib.request.urlopen(self.UPDATE_CHECK_URL, timeout=10) as response: + data = json.loads(response.read().decode('utf-8')) + + latest_version = data.get('version', self.current_version) + + if self._version_compare(latest_version, self.current_version) > 0: + self.available_update = UpdateInfo( + version=latest_version, + download_url=data.get('download_url', ''), + changelog=data.get('changelog', ''), + is_required=data.get('required', False) + ) + + if not silent: + self._notify_update_available() + + return True + + # Check plugin updates + self._check_plugin_updates(silent) + + if not silent and not self.available_update: + self._notify_up_to_date() + + return False + + except Exception as e: + print(f"[AutoUpdater] Check failed: {e}") + if not silent: + self._notify_error(str(e)) + return False + + def _check_plugin_updates(self, silent: bool = False): + """Check for plugin updates from the plugin repository.""" + try: + # Fetch plugin manifest + manifest_url = "https://git.lemonlink.eu/impulsivefps/EU-Utility-Plugins-Repo/raw/branch/main/manifest.json" + + with urllib.request.urlopen(manifest_url, timeout=10) as response: + manifest = json.loads(response.read().decode('utf-8')) + + plugins_dir = Path("plugins") + updates = [] + + for plugin_info in manifest.get('plugins', []): + plugin_id = plugin_info['id'] + plugin_path = plugins_dir / plugin_id / "plugin.py" + + if plugin_path.exists(): + # Check local version vs remote + local_version = self._get_local_plugin_version(plugin_path) + remote_version = plugin_info['version'] + + if self._version_compare(remote_version, local_version) > 0: + updates.append({ + 'id': plugin_id, + 'name': plugin_info['name'], + 'local_version': local_version, + 'remote_version': remote_version + }) + + self.plugin_updates = updates + + if updates and not silent: + self._notify_plugin_updates(updates) + + except Exception as e: + print(f"[AutoUpdater] Plugin check failed: {e}") + + def _get_local_plugin_version(self, plugin_path: Path) -> str: + """Extract version from plugin file.""" + try: + with open(plugin_path, 'r') as f: + content = f.read() + + # Look for version = "x.x.x" + import re + match = re.search(r'version\s*=\s*["\']([\d.]+)["\']', content) + if match: + return match.group(1) + except: + pass + + return "0.0.0" + + def _version_compare(self, v1: str, v2: str) -> int: + """Compare two version strings. + + Returns: + >0 if v1 > v2, 0 if equal, <0 if v1 < v2 + """ + parts1 = [int(x) for x in v1.split('.')] + parts2 = [int(x) for x in v2.split('.')] + + for p1, p2 in zip(parts1, parts2): + if p1 != p2: + return p1 - p2 + + return len(parts1) - len(parts2) + + def install_update(self) -> bool: + """Install available update if auto-update is enabled.""" + if not self.available_update: + return False + + if not self.settings.get('auto_update_enabled', False): + print("[AutoUpdater] Auto-update disabled, skipping installation") + return False + + # TODO: Implement update installation + print(f"[AutoUpdater] Installing update to {self.available_update.version}") + return True + + def _notify_update_available(self): + """Notify user of available update.""" + from PyQt6.QtWidgets import QMessageBox + + if self.available_update: + msg = QMessageBox() + msg.setWindowTitle("Update Available") + msg.setText(f"EU-Utility {self.available_update.version} is available!") + msg.setInformativeText( + f"Current: {self.current_version}\n" + f"Latest: {self.available_update.version}\n\n" + f"Changelog:\n{self.available_update.changelog}\n\n" + "Go to Settings → Updates to install." + ) + msg.setIcon(QMessageBox.Icon.Information) + msg.exec() + + def _notify_plugin_updates(self, updates: List[Dict]): + """Notify user of plugin updates.""" + from PyQt6.QtWidgets import QMessageBox + + update_list = "\n".join([ + f"• {u['name']}: {u['local_version']} → {u['remote_version']}" + for u in updates[:5] # Show max 5 + ]) + + if len(updates) > 5: + update_list += f"\n... and {len(updates) - 5} more" + + msg = QMessageBox() + msg.setWindowTitle("Plugin Updates Available") + msg.setText(f"{len(updates)} plugin(s) have updates available!") + msg.setInformativeText( + f"{update_list}\n\n" + "Go to Settings → Plugin Store to update." + ) + msg.setIcon(QMessageBox.Icon.Information) + msg.exec() + + def _notify_up_to_date(self): + """Notify user they are up to date.""" + from PyQt6.QtWidgets import QMessageBox + + msg = QMessageBox() + msg.setWindowTitle("No Updates") + msg.setText("You are running the latest version!") + msg.setInformativeText(f"EU-Utility {self.current_version}") + msg.setIcon(QMessageBox.Icon.Information) + msg.exec() + + def _notify_error(self, error: str): + """Notify user of error.""" + from PyQt6.QtWidgets import QMessageBox + + msg = QMessageBox() + msg.setWindowTitle("Update Check Failed") + msg.setText("Could not check for updates.") + msg.setInformativeText(f"Error: {error}") + msg.setIcon(QMessageBox.Icon.Warning) + msg.exec()