Skip to content

Реализовать механизм автоматического обновления приложения #50

@chelslava

Description

@chelslava

Описание

Отсутствует механизм обновления приложения. Пользователи должны вручную скачивать и устанавливать новые версии, что создаёт friction и риск использования устаревших версий с известными уязвимостями.

Цели

  1. Автоматическая проверка обновлений
  2. Фоновое скачивание обновлений
  3. Уведомление пользователя о доступных обновлениях
  4. Безопасное применение обновлений с rollback

Технические требования

1. Модель данных обновлений

from dataclasses import dataclass
from datetime import datetime

@dataclass
class ReleaseInfo:
    """Информация о релизе."""
    version: str
    release_date: datetime
    download_url: str
    changelog: str
    checksum: str  # SHA256
    size_bytes: int
    is_prerelease: bool = False
    is_minimum_required: bool = False  # Обязательное обновление

@dataclass
class UpdateStatus:
    """Статус обновления."""
    current_version: str
    latest_version: str | None
    update_available: bool
    release_info: ReleaseInfo | None
    download_progress: float | None  # 0.0 - 1.0
    is_downloaded: bool
    error: str | None

2. Менеджер обновлений

import hashlib
import subprocess
from urllib.request import urlopen, Request
import json

class UpdateManager:
    """Менеджер обновлений приложения."""
    
    UPDATE_CHECK_URL = "https://api.github.com/repos/{owner}/{repo}/releases"
    UPDATE_CHANNEL_STABLE = "stable"
    UPDATE_CHANNEL_BETA = "beta"
    
    def __init__(self, owner: str, repo: str, current_version: str):
        self._owner = owner
        self._repo = repo
        self._current_version = current_version
        self._channel = self.UPDATE_CHANNEL_STABLE
    
    def check_for_updates(self) -> UpdateStatus:
        """Проверяет наличие обновлений."""
        try:
            url = self.UPDATE_CHECK_URL.format(owner=self._owner, repo=self._repo)
            request = Request(url, headers={"Accept": "application/vnd.github.v3+json"})
            
            with urlopen(request, timeout=10) as response:
                releases = json.loads(response.read().decode("utf-8"))
            
            # Найти последний стабильный релиз
            latest = self._find_latest_stable(releases)
            
            if latest is None:
                return UpdateStatus(
                    current_version=self._current_version,
                    latest_version=None,
                    update_available=False,
                    release_info=None,
                    download_progress=None,
                    is_downloaded=False,
                    error=None
                )
            
            update_available = self._compare_versions(
                latest["tag_name"], 
                self._current_version
            ) > 0
            
            return UpdateStatus(
                current_version=self._current_version,
                latest_version=latest["tag_name"],
                update_available=update_available,
                release_info=self._parse_release(latest),
                download_progress=None,
                is_downloaded=False,
                error=None
            )
            
        except Exception as e:
            return UpdateStatus(
                current_version=self._current_version,
                latest_version=None,
                update_available=False,
                release_info=None,
                download_progress=None,
                is_downloaded=False,
                error=str(e)
            )
    
    def download_update(self, release: ReleaseInfo, 
                       progress_callback: Callable[[float], None] = None
                       ) -> Path | None:
        """Скачивает обновление с прогрессом."""
        try:
            request = Request(release.download_url)
            response = urlopen(request, timeout=300)
            
            total_size = int(response.headers.get("Content-Length", 0))
            downloaded = 0
            chunk_size = 8192
            
            temp_dir = Path(tempfile.gettempdir()) / "mia_update"
            temp_dir.mkdir(exist_ok=True)
            output_path = temp_dir / f"mia_setup_{release.version}.exe"
            
            with open(output_path, "wb") as f:
                while True:
                    chunk = response.read(chunk_size)
                    if not chunk:
                        break
                    
                    f.write(chunk)
                    downloaded += len(chunk)
                    
                    if progress_callback and total_size > 0:
                        progress_callback(downloaded / total_size)
            
            # Проверить checksum
            if not self._verify_checksum(output_path, release.checksum):
                output_path.unlink()
                return None
            
            return output_path
            
        except Exception as e:
            logger.error(f"Download failed: {e}")
            return None
    
    def apply_update(self, installer_path: Path) -> bool:
        """Применяет обновление (запускает инсталлятор)."""
        try:
            # Создать backup текущей версии
            self._create_backup()
            
            # Запустить инсталлятор
            subprocess.Popen(
                [str(installer_path), "/SILENT"],
                creationflags=get_subprocess_creationflags()
            )
            
            # Завершить текущее приложение
            QApplication.quit()
            return True
            
        except Exception as e:
            logger.error(f"Update apply failed: {e}")
            return False
    
    def rollback(self) -> bool:
        """Откатывает обновление."""
        backup_dir = self._get_backup_dir()
        if not backup_dir.exists():
            return False
        
        # Восстановить файлы из backup
        # ...
        return True

3. API endpoints

# Проверка обновлений
GET /api/v1/system/check-update
Response: {
    "current_version": "1.2.3",
    "latest_version": "1.3.0",
    "update_available": true,
    "release": {
        "version": "1.3.0",
        "changelog": "...",
        "download_url": "...",
        "size_bytes": 52428800
    }
}

# Скачивание обновления
POST /api/v1/system/download-update
Response: {
    "downloaded": true,
    "path": "C:\\Users\\...\\mia_setup_1.3.0.exe"
}

# Применение обновления
POST /api/v1/system/apply-update
Response: {
    "started": true,
    "message": "Приложение будет обновлено и перезапущено"
}

4. UI компонент

class UpdateNotificationWidget(QWidget):
    """Виджет уведомления об обновлении."""
    
    update_available = pyqtSignal()
    
    def __init__(self, update_manager: UpdateManager):
        super().__init__()
        self._manager = update_manager
        self._setup_ui()
    
    def check_and_notify(self) -> None:
        """Проверяет обновления и показывает уведомление."""
        status = self._manager.check_for_updates()
        
        if status.update_available:
            self._show_update_dialog(status)
    
    def _show_update_dialog(self, status: UpdateStatus) -> None:
        """Показывает диалог обновления."""
        msg = QMessageBox()
        msg.setIcon(QMessageBox.Icon.Information)
        msg.setWindowTitle("Доступно обновление")
        msg.setText(f"MIA-ScreenCapture {status.latest_version} доступно!")
        msg.setDetailedText(status.release_info.changelog)
        
        msg.addButton("Скачать", QMessageBox.ButtonRole.AcceptRole)
        msg.addButton("Позже", QMessageBox.ButtonRole.RejectRole)
        
        if msg.exec() == QMessageBox.Accepted:
            self._start_download(status.release_info)

Конфигурация

# config.py
class AppSettingsSchema(BaseModel):
    # ... existing fields ...
    auto_check_updates: bool = Field(default=True)
    update_channel: Literal["stable", "beta"] = Field(default="stable")
    update_check_interval_hours: int = Field(default=24, ge=1, le=168)

Критерии приёмки

  • Проверка обновлений при старте приложения
  • Фоновая проверка с заданным интервалом
  • Уведомление пользователя о доступных обновлениях
  • Скачивание обновления с прогрессом
  • Применение обновления с подтверждением
  • API endpoints для управления обновлениями
  • Backup перед обновлением
  • Rollback при неудаче

Файлы для изменения

  • core/update_manager.py — новый модуль
  • config.py — настройки обновлений
  • api/routes_system.py — API endpoints
  • gui/views/settings_view.py — UI настроек
  • main.py — проверка при старте

Приоритет: P2

Тэги: enhancement, devops

Metadata

Metadata

Assignees

No one assigned

    Labels

    devopsDevOps и CI/CDenhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions