# Description: Auto-updater plugin for EU-Utility # Checks for updates and installs them automatically """ EU-Utility Auto-Updater Features: - Check for updates from GitHub - Download and install updates - Changelog display - Automatic rollback on failure - Scheduled update checks """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QProgressBar, QTextEdit, QMessageBox, QCheckBox, QGroupBox, QComboBox ) from PyQt6.QtCore import QThread, pyqtSignal, QTimer, Qt from plugins.base_plugin import BasePlugin import requests import json import os import shutil import zipfile import subprocess import sys from datetime import datetime class UpdateWorker(QThread): """Background worker for update operations.""" progress = pyqtSignal(int) status = pyqtSignal(str) finished_signal = pyqtSignal(bool, str) def __init__(self, download_url, install_path, backup_path): super().__init__() self.download_url = download_url self.install_path = install_path self.backup_path = backup_path self.temp_download = None def run(self): try: # Step 1: Download self.status.emit("Downloading update...") self._download() self.progress.emit(33) # Step 2: Backup self.status.emit("Creating backup...") self._create_backup() self.progress.emit(66) # Step 3: Install self.status.emit("Installing update...") self._install() self.progress.emit(100) self.finished_signal.emit(True, "Update installed successfully. Please restart EU-Utility.") except Exception as e: self.status.emit(f"Error: {str(e)}") self._rollback() self.finished_signal.emit(False, str(e)) finally: # Cleanup temp file if self.temp_download and os.path.exists(self.temp_download): try: os.remove(self.temp_download) except: pass def _download(self): """Download update package.""" self.temp_download = os.path.join(os.path.expanduser('~/.eu-utility'), 'update.zip') os.makedirs(os.path.dirname(self.temp_download), exist_ok=True) response = requests.get(self.download_url, stream=True, timeout=120) response.raise_for_status() with open(self.temp_download, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): if chunk: f.write(chunk) def _create_backup(self): """Create backup of current installation.""" if os.path.exists(self.install_path): os.makedirs(self.backup_path, exist_ok=True) backup_name = f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}" backup_full_path = os.path.join(self.backup_path, backup_name) shutil.copytree(self.install_path, backup_full_path, ignore_dangling_symlinks=True) def _install(self): """Install the update.""" # Extract update with zipfile.ZipFile(self.temp_download, 'r') as zip_ref: # Extract to temp location first temp_extract = os.path.join(os.path.expanduser('~/.eu-utility'), 'update_extract') if os.path.exists(temp_extract): shutil.rmtree(temp_extract) zip_ref.extractall(temp_extract) # Find the actual content (might be in a subdirectory) contents = os.listdir(temp_extract) if len(contents) == 1 and os.path.isdir(os.path.join(temp_extract, contents[0])): source = os.path.join(temp_extract, contents[0]) else: source = temp_extract # Copy files to install path for item in os.listdir(source): s = os.path.join(source, item) d = os.path.join(self.install_path, item) if os.path.isdir(s): if os.path.exists(d): shutil.rmtree(d) shutil.copytree(s, d) else: shutil.copy2(s, d) # Cleanup shutil.rmtree(temp_extract) def _rollback(self): """Rollback to backup on failure.""" try: # Find most recent backup if os.path.exists(self.backup_path): backups = sorted(os.listdir(self.backup_path)) if backups: latest_backup = os.path.join(self.backup_path, backups[-1]) # Restore from backup if os.path.exists(self.install_path): shutil.rmtree(self.install_path) shutil.copytree(latest_backup, self.install_path) except: pass class AutoUpdaterPlugin(BasePlugin): """ Auto-updater for EU-Utility. Checks for updates from GitHub and installs them automatically. """ name = "Auto Updater" version = "1.0.0" author = "LemonNexus" description = "Automatic update checker and installer" icon = "refresh" # GitHub repository info GITHUB_REPO = "ImpulsiveFPS/EU-Utility" GITHUB_API_URL = "https://api.github.com/repos/{}/releases/latest" def initialize(self): """Initialize auto-updater.""" self.current_version = "2.0.0" # Should be read from version file self.latest_version = None self.latest_release = None self.worker = None # Settings self.check_on_startup = self.load_data("check_on_startup", True) self.auto_install = self.load_data("auto_install", False) self.check_interval_hours = self.load_data("check_interval", 24) # Check for updates if enabled if self.check_on_startup: QTimer.singleShot(5000, self._check_for_updates) # Check after 5 seconds def get_ui(self): """Create updater UI.""" widget = QWidget() layout = QVBoxLayout(widget) layout.setSpacing(15) # Title title = QLabel("Auto Updater") title.setStyleSheet("font-size: 20px; font-weight: bold; color: #4a9eff;") layout.addWidget(title) # Current version version_group = QGroupBox("Version Information") version_layout = QVBoxLayout(version_group) self.current_version_label = QLabel(f"Current Version: {self.current_version}") version_layout.addWidget(self.current_version_label) self.latest_version_label = QLabel("Latest Version: Checking...") version_layout.addWidget(self.latest_version_label) self.status_label = QLabel("Status: Ready") self.status_label.setStyleSheet("color: #4caf50;") version_layout.addWidget(self.status_label) layout.addWidget(version_group) # Check for updates button check_btn = QPushButton("Check for Updates") check_btn.setStyleSheet(""" QPushButton { background-color: #4a9eff; color: white; padding: 12px; font-weight: bold; border-radius: 4px; } QPushButton:hover { background-color: #5aafff; } """) check_btn.clicked.connect(self._check_for_updates) layout.addWidget(check_btn) # Changelog changelog_group = QGroupBox("Changelog") changelog_layout = QVBoxLayout(changelog_group) self.changelog_text = QTextEdit() self.changelog_text.setReadOnly(True) self.changelog_text.setPlaceholderText("Check for updates to see changelog...") changelog_layout.addWidget(self.changelog_text) layout.addWidget(changelog_group) # Progress self.progress_bar = QProgressBar() self.progress_bar.setVisible(False) layout.addWidget(self.progress_bar) self.progress_status = QLabel("") layout.addWidget(self.progress_status) # Update button self.update_btn = QPushButton("Download and Install Update") self.update_btn.setStyleSheet(""" QPushButton { background-color: #4caf50; color: white; padding: 12px; font-weight: bold; border-radius: 4px; } QPushButton:hover { background-color: #5cbf60; } QPushButton:disabled { background-color: #555; } """) self.update_btn.setEnabled(False) self.update_btn.clicked.connect(self._start_update) layout.addWidget(self.update_btn) # Settings settings_group = QGroupBox("Settings") settings_layout = QVBoxLayout(settings_group) self.startup_checkbox = QCheckBox("Check for updates on startup") self.startup_checkbox.setChecked(self.check_on_startup) self.startup_checkbox.toggled.connect(self._on_startup_changed) settings_layout.addWidget(self.startup_checkbox) self.auto_checkbox = QCheckBox("Auto-install updates (not recommended)") self.auto_checkbox.setChecked(self.auto_install) self.auto_checkbox.toggled.connect(self._on_auto_changed) settings_layout.addWidget(self.auto_checkbox) interval_layout = QHBoxLayout() interval_layout.addWidget(QLabel("Check interval:")) self.interval_combo = QComboBox() self.interval_combo.addItems(["Every hour", "Every 6 hours", "Every 12 hours", "Daily", "Weekly"]) self.interval_combo.setCurrentIndex(3) # Daily self.interval_combo.currentIndexChanged.connect(self._on_interval_changed) interval_layout.addWidget(self.interval_combo) settings_layout.addLayout(interval_layout) layout.addWidget(settings_group) # Manual rollback rollback_btn = QPushButton("Rollback to Previous Version") rollback_btn.setStyleSheet("color: #ff9800;") rollback_btn.clicked.connect(self._rollback_dialog) layout.addWidget(rollback_btn) layout.addStretch() return widget def _check_for_updates(self): """Check GitHub for updates.""" self.status_label.setText("Status: Checking...") self.status_label.setStyleSheet("color: #ff9800;") try: # Query GitHub API url = self.GITHUB_API_URL.format(self.GITHUB_REPO) response = requests.get(url, timeout=30) response.raise_for_status() self.latest_release = response.json() self.latest_version = self.latest_release['tag_name'].lstrip('v') self.latest_version_label.setText(f"Latest Version: {self.latest_version}") # Parse changelog changelog = self.latest_release.get('body', 'No changelog available.') self.changelog_text.setText(changelog) # Compare versions if self._version_compare(self.latest_version, self.current_version) > 0: self.status_label.setText("Status: Update available!") self.status_label.setStyleSheet("color: #4caf50; font-weight: bold;") self.update_btn.setEnabled(True) self.notify_info( "Update Available", f"Version {self.latest_version} is available. Check the Auto Updater to install." ) else: self.status_label.setText("Status: Up to date") self.status_label.setStyleSheet("color: #4caf50;") self.update_btn.setEnabled(False) except Exception as e: self.status_label.setText(f"Status: Check failed") self.status_label.setStyleSheet("color: #f44336;") self.log_error(f"Update check failed: {e}") def _version_compare(self, v1, v2): """Compare two version strings. Returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal.""" def normalize(v): return [int(x) for x in v.split('.')] n1 = normalize(v1) n2 = normalize(v2) for i in range(max(len(n1), len(n2))): x1 = n1[i] if i < len(n1) else 0 x2 = n2[i] if i < len(n2) else 0 if x1 > x2: return 1 elif x1 < x2: return -1 return 0 def _start_update(self): """Start the update process.""" if not self.latest_release: QMessageBox.warning(self.get_ui(), "No Update", "Please check for updates first.") return # Get download URL assets = self.latest_release.get('assets', []) if not assets: QMessageBox.critical(self.get_ui(), "Error", "No update package found.") return download_url = assets[0]['browser_download_url'] # Confirm update reply = QMessageBox.question( self.get_ui(), "Confirm Update", f"This will update EU-Utility to version {self.latest_version}.\n\n" "The application will need to restart after installation.\n\n" "Continue?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return # Start update worker install_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) backup_path = os.path.expanduser('~/.eu-utility/backups') self.worker = UpdateWorker(download_url, install_path, backup_path) self.worker.progress.connect(self.progress_bar.setValue) self.worker.status.connect(self.progress_status.setText) self.worker.finished_signal.connect(self._on_update_finished) self.progress_bar.setVisible(True) self.progress_bar.setValue(0) self.update_btn.setEnabled(False) self.worker.start() def _on_update_finished(self, success, message): """Handle update completion.""" self.progress_bar.setVisible(False) if success: QMessageBox.information( self.get_ui(), "Update Complete", f"{message}\n\nClick OK to restart EU-Utility." ) self._restart_application() else: QMessageBox.critical( self.get_ui(), "Update Failed", f"Update failed: {message}\n\nRollback was attempted." ) self.update_btn.setEnabled(True) def _restart_application(self): """Restart the application.""" python = sys.executable script = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'core', 'main.py') subprocess.Popen([python, script]) sys.exit(0) def _rollback_dialog(self): """Show rollback dialog.""" backup_path = os.path.expanduser('~/.eu-utility/backups') if not os.path.exists(backup_path): QMessageBox.information(self.get_ui(), "No Backups", "No backups found.") return backups = sorted(os.listdir(backup_path)) if not backups: QMessageBox.information(self.get_ui(), "No Backups", "No backups found.") return # Show simple rollback for now reply = QMessageBox.question( self.get_ui(), "Confirm Rollback", f"This will restore the most recent backup:\n{backups[-1]}\n\n" "The application will restart. Continue?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: try: backup = os.path.join(backup_path, backups[-1]) install_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Restore if os.path.exists(install_path): shutil.rmtree(install_path) shutil.copytree(backup, install_path) QMessageBox.information( self.get_ui(), "Rollback Complete", "Rollback successful. Click OK to restart." ) self._restart_application() except Exception as e: QMessageBox.critical(self.get_ui(), "Rollback Failed", str(e)) def _on_startup_changed(self, checked): """Handle startup check toggle.""" self.check_on_startup = checked self.save_data("check_on_startup", checked) def _on_auto_changed(self, checked): """Handle auto-install toggle.""" self.auto_install = checked self.save_data("auto_install", checked) def _on_interval_changed(self, index): """Handle check interval change.""" intervals = [1, 6, 12, 24, 168] # hours self.check_interval_hours = intervals[index] self.save_data("check_interval", self.check_interval_hours)