482 lines
17 KiB
Python
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)
|