- Исправлен перезапуск приложения после замены файлов и добавлено подтверждение запуска новой версии. - Обеспечены откат при ошибке и корректное завершение фоновых задач. - Исключены посторонние DLL из Windows-сборки, обновлены тесты и инструкция ручного перехода с v2.0.1.
292 lines
11 KiB
Python
292 lines
11 KiB
Python
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)
|