Danil Iarosh 35c9693740 fix: stabilize OTA updates in v2.0.2
- Исправлен перезапуск приложения после замены файлов и добавлено подтверждение запуска новой версии.
- Обеспечены откат при ошибке и корректное завершение фоновых задач.
- Исключены посторонние DLL из Windows-сборки, обновлены тесты и инструкция ручного перехода с v2.0.1.
2026-09-17 10:30:18 +03:00

292 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import QThread, QTimer, Qt, Signal
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QFrame,
QGraphicsDropShadowEffect,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from .. import __version__
from ..services.update_service import (
UpdateCancelled,
UpdateInfo,
check_for_update,
current_executable,
download_update,
launch_update,
)
from ..ui.icons import ICON_COLOR_PRIMARY, get_icon_color, icon
from .dialogs.commands_dialog import CommandsDialog
from .dialogs.settings_dialog import SettingsDialog
class StartTab(QWidget):
def __init__(self, on_new_connection) -> None:
super().__init__()
self._open_dialog_windows: dict[str, QWidget] = {}
self._available_update: UpdateInfo | None = None
self._update_check_thread: _UpdateCheckThread | None = None
self._update_download_thread: _UpdateDownloadThread | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(26, 26, 26, 26)
layout.addStretch(1)
hero_card = QFrame()
hero_card.setObjectName("HeroCard")
hero_card.setMaximumWidth(560)
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(28)
shadow.setOffset(0, 8)
shadow.setColor(QColor(20, 30, 50, 45))
hero_card.setGraphicsEffect(shadow)
hero_layout = QVBoxLayout(hero_card)
hero_layout.setContentsMargins(28, 28, 28, 28)
hero_layout.setSpacing(14)
badge = QLabel("Plate Config")
badge.setObjectName("HeroBadge")
badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
hero_layout.addWidget(badge, alignment=Qt.AlignmentFlag.AlignHCenter)
title = QLabel("Подключение к COM-плате")
title.setObjectName("HeroTitle")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
hero_layout.addWidget(title)
hint = QLabel("Откройте новое соединение и настройте параметры порта для работы с платой.")
hint.setObjectName("HeroHint")
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint.setWordWrap(True)
hero_layout.addWidget(hint)
btn = QPushButton("Новое соединение")
btn.setProperty("role", "primary")
btn.setMinimumWidth(210)
btn.setMinimumHeight(40)
btn.setIcon(icon("fa5s.plus-circle", color=ICON_COLOR_PRIMARY))
btn.clicked.connect(on_new_connection)
hero_layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignHCenter)
card_wrap = QHBoxLayout()
card_wrap.addStretch(1)
card_wrap.addWidget(hero_card)
card_wrap.addStretch(1)
layout.addLayout(card_wrap)
# ── Кнопки «Команды платы» и «Настройки» ───────────────────
bottom_row = QHBoxLayout()
bottom_row.setSpacing(12)
bottom_row.addStretch(1)
self._commands_btn = QPushButton("Команды платы")
self._commands_btn.setMinimumHeight(38)
self._commands_btn.setMinimumWidth(180)
self._commands_btn.setIcon(icon("fa5s.book", color=get_icon_color()))
self._commands_btn.clicked.connect(self._open_commands)
bottom_row.addWidget(self._commands_btn)
settings_column = QWidget()
self._settings_column_layout = QVBoxLayout(settings_column)
self._settings_column_layout.setContentsMargins(0, 0, 0, 0)
self._settings_column_layout.setSpacing(8)
self._settings_btn = QPushButton("Настройки приложения")
self._settings_btn.setMinimumHeight(38)
self._settings_btn.setMinimumWidth(180)
self._settings_btn.setIcon(icon("fa5s.cog", color=get_icon_color()))
self._settings_btn.clicked.connect(self._open_settings)
self._settings_column_layout.addWidget(self._settings_btn)
self._update_btn = QPushButton("Скачать обновление")
self._update_btn.setProperty("role", "primary")
self._update_btn.setMinimumHeight(38)
self._update_btn.setMinimumWidth(180)
self._update_btn.setIcon(icon("fa5s.download", color=ICON_COLOR_PRIMARY))
self._update_btn.clicked.connect(self._start_update_download)
self._update_btn.hide()
self._settings_column_layout.addWidget(self._update_btn)
bottom_row.addWidget(settings_column, alignment=Qt.AlignmentFlag.AlignTop)
bottom_row.addStretch(1)
layout.addLayout(bottom_row)
layout.addStretch(2)
QTimer.singleShot(500, self._start_update_check)
# ── Public ────────────────────────────────────────────────────
def refresh_icons(self) -> None:
"""Re-create icons with the current theme colour."""
clr = get_icon_color()
self._commands_btn.setIcon(icon("fa5s.book", color=clr))
self._settings_btn.setIcon(icon("fa5s.cog", color=clr))
self._update_btn.setIcon(icon("fa5s.download", color=ICON_COLOR_PRIMARY))
def shutdown(self) -> None:
"""Остановить фоновые задачи перед закрытием приложения."""
for thread in (self._update_check_thread, self._update_download_thread):
if thread is not None and thread.isRunning():
thread.requestInterruption()
thread.wait(35_000)
# ── Handlers ──────────────────────────────────────────────────
def _on_dialog_destroyed(self, key: str) -> None:
self._open_dialog_windows.pop(key, None)
def _open_single_dialog(self, key: str, factory) -> None:
existing = self._open_dialog_windows.get(key)
if existing is not None:
try:
existing.show()
existing.raise_()
existing.activateWindow()
return
except RuntimeError:
self._open_dialog_windows.pop(key, None)
dlg = factory()
dlg.setModal(False)
dlg.setWindowModality(Qt.WindowModality.NonModal)
dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
dlg.destroyed.connect(lambda _obj=None, k=key: self._on_dialog_destroyed(k))
self._open_dialog_windows[key] = dlg
dlg.show()
dlg.raise_()
dlg.activateWindow()
def _open_commands(self) -> None:
self._open_single_dialog("commands", lambda: CommandsDialog(self))
def _open_settings(self) -> None:
self._open_single_dialog("settings", lambda: SettingsDialog(self))
# ── Обновление приложения ─────────────────────────────────────
def _start_update_check(self) -> None:
if self._update_check_thread is not None:
return
thread = _UpdateCheckThread(parent=self)
thread.result_ready.connect(self._on_update_check_done)
thread.finished.connect(self._cleanup_update_check)
self._update_check_thread = thread
thread.start()
def _on_update_check_done(self, info: UpdateInfo | None) -> None:
self._available_update = info
if info is None:
self._update_btn.hide()
return
self._update_btn.setText("Скачать обновление")
self._update_btn.setToolTip(f"Доступна версия {info.tag}")
self._update_btn.setEnabled(True)
self._update_btn.show()
def _cleanup_update_check(self) -> None:
if self._update_check_thread is not None:
self._update_check_thread.deleteLater()
self._update_check_thread = None
def _start_update_download(self) -> None:
info = self._available_update
if info is None or self._update_download_thread is not None:
return
try:
target_directory = current_executable().parent
except Exception as exc:
QMessageBox.information(self, "Обновление приложения", str(exc))
return
self._update_btn.setEnabled(False)
self._update_btn.setText("Скачивание 0%")
thread = _UpdateDownloadThread(info, target_directory, parent=self)
thread.progress_changed.connect(self._on_update_progress)
thread.result_ready.connect(self._on_update_downloaded)
thread.failed.connect(self._on_update_failed)
thread.finished.connect(self._cleanup_update_download)
self._update_download_thread = thread
thread.start()
def _on_update_progress(self, percent: int) -> None:
self._update_btn.setText(f"Скачивание {percent}%")
def _on_update_downloaded(self, path: Path) -> None:
self._update_btn.setText("Перезапуск…")
try:
launch_update(path)
except Exception as exc:
path.unlink(missing_ok=True)
self._on_update_failed(str(exc))
return
self.window().close()
def _on_update_failed(self, detail: str) -> None:
self._update_btn.setText("Скачать обновление")
self._update_btn.setEnabled(True)
QMessageBox.warning(
self,
"Обновление приложения",
f"Не удалось установить обновление:\n{detail}",
)
def _cleanup_update_download(self) -> None:
if self._update_download_thread is not None:
self._update_download_thread.deleteLater()
self._update_download_thread = None
class _UpdateCheckThread(QThread):
result_ready = Signal(object)
def run(self) -> None:
try:
info = check_for_update(__version__)
except Exception:
info = None
if not self.isInterruptionRequested():
self.result_ready.emit(info)
class _UpdateDownloadThread(QThread):
progress_changed = Signal(int)
result_ready = Signal(object)
failed = Signal(str)
def __init__(
self,
info: UpdateInfo,
target_directory: Path,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self._info = info
self._target_directory = target_directory
def run(self) -> None:
try:
path = download_update(
self._info,
self._target_directory,
progress=self.progress_changed.emit,
cancelled=self.isInterruptionRequested,
)
except UpdateCancelled:
return
except Exception as exc:
if not self.isInterruptionRequested():
self.failed.emit(str(exc))
return
if not self.isInterruptionRequested():
self.result_ready.emit(path)