EU-Utility/plugins/auto_updater/plugin.py

482 lines
17 KiB
Python

# 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)