From ae2d47b641014a31a953bd813b0af1e9f60dddf8 Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Fri, 1 Aug 2025 15:22:53 +0600 Subject: [PATCH] added gui for winehelper --- winehelper_gui.py | 1530 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1530 insertions(+) create mode 100644 winehelper_gui.py diff --git a/winehelper_gui.py b/winehelper_gui.py new file mode 100644 index 0000000..2d9132c --- /dev/null +++ b/winehelper_gui.py @@ -0,0 +1,1530 @@ +#!/usr/bin/env python3 +import os +import subprocess +import sys +import shlex +import shutil +from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,QPushButton, QLabel, QTabWidget, + QTextEdit, QFileDialog, QMessageBox, QLineEdit, QCheckBox, QStackedWidget, QScrollArea, + QGridLayout, QFrame, QDialog, QTextBrowser) +from PyQt5.QtCore import Qt, QProcess, QSize, QTimer, QProcessEnvironment +from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPixmap, QPainter + + +class Var: + # Переменные определяемые в скрипте winehelper + SCRIPT_NAME = os.environ.get("SCRIPT_NAME") + USER_WORK_PATH = os.environ.get("USER_WORK_PATH") + RUN_SCRIPT = os.environ.get("RUN_SCRIPT") + DATA_PATH = os.environ.get("DATA_PATH") + CHANGELOG_FILE = os.environ.get("CHANGELOG_FILE") + WH_ICON_PATH = os.environ.get("WH_ICON_PATH") + LICENSE_FILE = os.environ.get("LICENSE_FILE") + +class WineHelperGUI(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("WineHelper") + self.setGeometry(100, 100, 950, 500) + + if Var.WH_ICON_PATH and os.path.exists(Var.WH_ICON_PATH): + self.setWindowIcon(QIcon(Var.WH_ICON_PATH)) + + # Центрирование окна + screen = QApplication.primaryScreen() + screen_geometry = screen.availableGeometry() + self.move( + (screen_geometry.width() - self.width()) // 2, + (screen_geometry.height() - self.height()) // 2 + ) + + # Стиль для кнопок в списках + self.BUTTON_LIST_STYLE = """ + QPushButton { + text-align: left; + padding-left: 10px; + padding-right: 10px; + height: 42px; min-height: 42px; max-height: 42px; + } + QPushButton::icon { + padding-left: 10px; + } + """ + + self.INSTALLED_BUTTON_LIST_STYLE = self.BUTTON_LIST_STYLE.replace( + "padding-left: 10px;", "padding-left: 15px;" + ) + + # Стили для оберток кнопок (для рамки выделения) + self.FRAME_STYLE_DEFAULT = "QFrame { border: 2px solid transparent; border-radius: 2px; padding: 0px; }" + self.FRAME_STYLE_SELECTED = "QFrame { border: 2px solid #0078d7; border-radius: 2px; padding: 0px; }" + + # Основные переменные + self.winehelper_path = Var.RUN_SCRIPT + self.process = None + self.current_script = None + self.install_process = None + self.install_dialog = None + self.current_active_button = None + self.installed_buttons = [] + self.current_selected_app = None + self.icon_animators = {} + + # Создаем главный виджет и layout + self.main_widget = QWidget() + self.setCentralWidget(self.main_widget) + self.main_layout = QHBoxLayout() + self.main_widget.setLayout(self.main_layout) + + # Создаем табы + self.tabs = QTabWidget() + self.main_layout.addWidget(self.tabs, stretch=2) + + # Создаем панель информации о скрипте + self.create_info_panel() + + # Вкладки + self.create_auto_install_tab() + self.create_manual_install_tab() + self.create_installed_tab() + self.create_help_tab() + + # Обновляем список установленных приложений + self.update_installed_apps() + + # Соединяем сигнал смены вкладок с функцией + self.tabs.currentChanged.connect(self.on_tab_changed) + # Устанавливаем начальное состояние видимости панели + self.on_tab_changed(self.tabs.currentIndex()) + + def _reset_info_panel_to_default(self, tab_name): + """Сбрасывает правую панель в состояние по умолчанию для указанной вкладки.""" + if tab_name == "Автоматическая установка": + title = "Автоматическая установка" + html_content = ("

Автоматическая установка

" + "

Скрипты из этого списка скачают, установят и настроят приложение за вас.

" + "

Просто выберите программу и нажмите «Установить».

") + show_global = False + elif tab_name == "Ручная установка": + title = "Ручная установка" + html_content = ("

Ручная установка

" + "

Эти скрипты подготовят окружение для установки.

" + "

Вам нужно будет указать только путь к установочному файлу (.exe или .msi), который вы скачали самостоятельно и нажать «Установить».

") + show_global = False + elif tab_name == "Установленные": + title = "Установленные программы" + html_content = ("

Установленные программы

" + "

Здесь отображаются все приложения, установленные с помощью WineHelper.

" + "

Выберите программу, чтобы увидеть доступные действия.

" + "

Также на этой вкладке можно восстановить префикс из резервной копии с помощью соответствующей кнопки.

") + show_global = True + else: + return + + self.script_title.setText(title) + self.script_description.setHtml(html_content) + self.script_description.setVisible(True) + self.script_title.setPixmap(QPixmap()) + self.install_action_widget.setVisible(False) + self.installed_action_widget.setVisible(False) + self.installed_global_action_widget.setVisible(show_global) + self.manual_install_path_widget.setVisible(False) + if show_global: + self.backup_button.setVisible(False) + self.create_log_button.setVisible(False) + self.current_selected_app = None + + def on_tab_changed(self, index): + """Скрывает или показывает панель информации в зависимости от активной вкладки.""" + current_tab_text = self.tabs.tabText(index) + + # Сбрасываем растяжение к состоянию по умолчанию: + # растягивается виджет с описанием (индекс 1), а не виджет с действиями (индекс 4) + self.info_panel_layout.setStretch(1, 1) + self.info_panel_layout.setStretch(4, 0) + + if current_tab_text in ["Автоматическая установка", "Ручная установка", "Установленные"]: + self.info_panel.setVisible(True) + self._reset_info_panel_to_default(current_tab_text) + if current_tab_text == "Установленные": + self.filter_installed_buttons() + else: + self.info_panel.setVisible(False) + + def create_info_panel(self): + """Создает правую панель с информацией о скрипте""" + self.info_panel = QFrame() + self.info_panel.setFrameShape(QFrame.StyledPanel) + self.info_panel.setMinimumWidth(400) # Размер инф. панели + self.info_panel.setFont(QFont('Arial', 10)) # Шрифт и размер шрифта в инф. панели + self.info_panel_layout = QVBoxLayout() + self.info_panel.setLayout(self.info_panel_layout) + + # Заголовок + self.script_title = QLabel("Выберите программу") + self.script_title.setFont(QFont('Arial', 14, QFont.Bold)) # Шрифт и размер шрифта в заголовке инф. панели + self.script_title.setAlignment(Qt.AlignCenter) + self.info_panel_layout.addWidget(self.script_title) + + # Описание + self.script_description = QTextBrowser() + self.script_description.setReadOnly(True) + self.script_description.setOpenExternalLinks(True) + self.info_panel_layout.addWidget(self.script_description) + + # Строка для ввода пути установочного файла + self.manual_install_path_layout = QVBoxLayout() + self.install_path_label = QLabel("Путь к установочному файлу:") + self.manual_install_path_layout.addWidget(self.install_path_label) + + path_input_layout = QHBoxLayout() + self.install_path_edit = QLineEdit() + self.install_path_edit.setPlaceholderText("Укажите путь к установочному файлу...") + self.browse_button = QPushButton("Обзор...") + self.browse_button.clicked.connect(self.browse_install_file) + + path_input_layout.addWidget(self.install_path_edit) + path_input_layout.addWidget(self.browse_button) + self.manual_install_path_layout.addLayout(path_input_layout) + + self.manual_install_path_widget = QWidget() + self.manual_install_path_widget.setLayout(self.manual_install_path_layout) + self.manual_install_path_widget.setVisible(False) + self.info_panel_layout.addWidget(self.manual_install_path_widget) + + # --- ВИДЖЕТЫ ДЛЯ ДЕЙСТВИЙ --- + # Виджет для действий установщика + self.install_action_widget = QWidget() + install_action_layout = QVBoxLayout() + install_action_layout.setContentsMargins(0, 0, 0, 0) + self.install_button = QPushButton("Установить") + self.install_button.setFont(QFont('Arial', 13, QFont.Bold)) + self.install_button.setStyleSheet("background-color: #4CAF50; color: white;") + self.install_button.clicked.connect(self.install_current_script) + install_action_layout.addWidget(self.install_button) + self.install_action_widget.setLayout(install_action_layout) + self.info_panel_layout.addWidget(self.install_action_widget) + + # Виджет для действий с установленным приложением + self.installed_action_widget = QWidget() + installed_action_layout = QVBoxLayout() + installed_action_layout.setContentsMargins(0, 0, 0, 0) + installed_action_layout.setSpacing(5) + + top_buttons_layout = QHBoxLayout() + self.run_button = QPushButton("Запустить") + self.run_button.clicked.connect(self.run_installed_app) + self.uninstall_button = QPushButton("Удалить префикс") + self.uninstall_button.clicked.connect(self.uninstall_app) + top_buttons_layout.addWidget(self.run_button) + top_buttons_layout.addWidget(self.uninstall_button) + installed_action_layout.addLayout(top_buttons_layout) + + self.installed_action_widget.setLayout(installed_action_layout) + self.info_panel_layout.addWidget(self.installed_action_widget) + + self.installed_global_action_widget = QWidget() + installed_global_layout = QVBoxLayout() + installed_global_layout.setContentsMargins(0, 10, 0, 0) + + self.create_log_button = QPushButton("Создать лог запуска программы") + self.create_log_button.setIcon(QIcon.fromTheme("view-list-text")) + self.create_log_button.clicked.connect(self.run_installed_app_with_debug) + installed_global_layout.addWidget(self.create_log_button) + + self.backup_button = QPushButton("Создать резервную копию префикса") + self.backup_button.setIcon(QIcon.fromTheme("document-save")) + self.backup_button.clicked.connect(self.backup_prefix_for_app) + installed_global_layout.addWidget(self.backup_button) + + self.restore_prefix_button_panel = QPushButton("Восстановить префикс из резервной копии") + self.restore_prefix_button_panel.setIcon(QIcon.fromTheme("document-revert")) + self.restore_prefix_button_panel.clicked.connect(self.restore_prefix) + installed_global_layout.addWidget(self.restore_prefix_button_panel) + self.installed_global_action_widget.setLayout(installed_global_layout) + self.info_panel_layout.addWidget(self.installed_global_action_widget) + + # Изначально скрыть все виджеты действий + self.install_action_widget.setVisible(False) + self.installed_action_widget.setVisible(False) + self.installed_global_action_widget.setVisible(False) + + self.main_layout.addWidget(self.info_panel, stretch=1) + + def browse_install_file(self): + """Открывает диалог выбора файла для ручной установки""" + file_path, _ = QFileDialog.getOpenFileName( + self, + "Выберите установочный файл", + os.path.expanduser("~"), + "Все файлы (*);;Исполняемые файлы (*.exe *.msi)" + ) + if file_path: + self.install_path_edit.setText(file_path) + + def extract_icons_from_script(self, script_path): + """ + Извлекает иконку для скрипта. + Сначала ищет переменную 'export PROG_ICON=', если не находит, + то ищет все вызовы 'create_desktop' и берет иконки из третьего аргумента. + Возвращает список имен иконок. + """ + try: + with open(script_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # 1. Приоритет у PROG_ICON + for line in lines: + if line.strip().startswith('export PROG_ICON='): + icon_name = line.split('=', 1)[1].strip().strip('"\'') + if icon_name: + return [icon_name] + + # 2. Если PROG_ICON не найден, ищем все вызовы create_desktop + icon_names = [] + for line in lines: + line = line.strip() + # Пропускаем закомментированные строки и пустые строки + if not line or line.startswith('#'): + continue + + if 'create_desktop' in line: + try: + parts = shlex.split(line) + # Ищем все вхождения, а не только первое + for i, part in enumerate(parts): + if part == 'create_desktop': + if len(parts) > i + 3: + icon_name = parts[i + 3] + if icon_name: + icon_names.append(icon_name) + except (ValueError, IndexError): + continue + return icon_names + except Exception as e: + print(f"Ошибка чтения файла для извлечения иконки: {str(e)}") + return [] + + def extract_prog_name_from_script(self, script_path): + """Извлекает имя программы из строки PROG_NAME= в скрипте""" + try: + with open(script_path, 'r', encoding='utf-8') as f: + for line in f: + # Ищем строку, которая начинается с PROG_NAME= или export PROG_NAME= + if line.strip().startswith(('export PROG_NAME=', 'PROG_NAME=')): + # Отделяем имя переменной от значения и убираем кавычки + name = line.split('=', 1)[1].strip().strip('"\'') + if name: + return name + return None + except Exception as e: + print(f"Ошибка чтения файла для извлечения PROG_NAME: {str(e)}") + return None + + def extract_prog_url_from_script(self, script_path): + """Извлекает URL из строки export PROG_URL= в скрипте""" + try: + with open(script_path, 'r', encoding='utf-8') as f: + for line in f: + if line.startswith('export PROG_URL='): + return line.replace('export PROG_URL=', '').strip().strip('"\'') + return None + except Exception as e: + print(f"Ошибка чтения файла для извлечения PROG_URL: {str(e)}") + return None + + def _ease_in_out_quad(self, t): + """Простая квадратичная функция сглаживания (ease-in-out).""" + # t - значение от 0.0 до 1.0 + if t < 0.5: + return 2 * t * t + return 1 - pow(-2 * t + 2, 2) / 2 + + def _start_icon_fade_animation(self, button): + """Запускает анимацию плавного перехода для иконки на кнопке.""" + if button not in self.icon_animators: + return + + anim_data = self.icon_animators[button] + + # Останавливаем предыдущую анимацию, если она еще идет + if anim_data.get('fade_timer') and anim_data['fade_timer'].isActive(): + anim_data['fade_timer'].stop() + + # Определяем текущую и следующую иконки + current_icon_path = anim_data['icons'][anim_data['current_index']] + next_icon_index = (anim_data['current_index'] + 1) % len(anim_data['icons']) + next_icon_path = anim_data['icons'][next_icon_index] + + current_pixmap = QPixmap(current_icon_path) + next_pixmap = QPixmap(next_icon_path) + + # Запускаем таймер для покадровой анимации + anim_data['fade_step'] = 0 + fade_timer = QTimer(button) + anim_data['fade_timer'] = fade_timer + fade_timer.timeout.connect( + lambda b=button, p1=current_pixmap, p2=next_pixmap: self._update_fade_frame(b, p1, p2) + ) + fade_timer.start(16) # ~60 кадров в секунду + + def _update_fade_frame(self, button, old_pixmap, new_pixmap): + """Обновляет кадр анимации перехода иконок.""" + if button not in self.icon_animators: + return + + anim_data = self.icon_animators[button] + # Длительность анимации: 44 шага * 16 мс ~= 700 мс + FADE_DURATION_STEPS = 44 + + anim_data['fade_step'] += 1 + progress_linear = anim_data['fade_step'] / FADE_DURATION_STEPS + # Применяем функцию сглаживания для более плавного старта и завершения + progress = self._ease_in_out_quad(progress_linear) + + if progress_linear >= 1.0: + anim_data['fade_timer'].stop() + anim_data['current_index'] = (anim_data['current_index'] + 1) % len(anim_data['icons']) + button.setIcon(QIcon(new_pixmap)) + return + + canvas = QPixmap(button.iconSize()) + canvas.fill(Qt.transparent) + painter = QPainter(canvas) + painter.setRenderHint(QPainter.Antialiasing, True) + painter.setRenderHint(QPainter.SmoothPixmapTransform, True) + painter.setOpacity(1.0 - progress) + painter.drawPixmap(canvas.rect(), old_pixmap) + painter.setOpacity(progress) + painter.drawPixmap(canvas.rect(), new_pixmap) + painter.end() + button.setIcon(QIcon(canvas)) + + def _create_app_button(self, text, icon_paths, style_sheet): + """Создает и настраивает стандартную кнопку для списков приложений.""" + # Добавляем пробелы перед текстом для создания отступа от иконки + btn = QPushButton(" " + text) + btn.setStyleSheet(style_sheet) + + # Отфильтровываем несуществующие пути + existing_icon_paths = [path for path in icon_paths if path and os.path.exists(path)] + + if existing_icon_paths: + if len(existing_icon_paths) > 1: + # Устанавливаем первую иконку и запускаем анимацию + btn.setIcon(QIcon(existing_icon_paths[0])) + main_timer = QTimer(btn) + self.icon_animators[btn] = {'main_timer': main_timer, 'icons': existing_icon_paths, 'current_index': 0} + main_timer.timeout.connect(lambda b=btn: self._start_icon_fade_animation(b)) + main_timer.start(2500) # Интервал между сменами иконок + else: + # Устанавливаем одну иконку + btn.setIcon(QIcon(existing_icon_paths[0])) + + btn.setIconSize(QSize(32, 32)) + else: + # Устанавливаем пустую иконку, если ничего не найдено, для сохранения отступов + btn.setIcon(QIcon()) + btn.setIconSize(QSize(32, 32)) + + return btn + + def _populate_install_grid(self, grid_layout, scripts_list, script_folder, button_list): + """ + Заполняет QGridLayout кнопками установщиков. + Кнопки создаются только для скриптов, в которых найдена переменная PROG_NAME. + + :param grid_layout: QGridLayout для заполнения. + :param scripts_list: Список имен скриптов. + :param script_folder: Имя папки со скриптами ('autoinstall' или 'manualinstall'). + :param button_list: Список для хранения созданных кнопок. + """ + button_index = 0 + for script in scripts_list: + script_path = os.path.join(Var.DATA_PATH, script_folder, script) + prog_name = self.extract_prog_name_from_script(script_path) + + # Создаем кнопку, только если для скрипта указано имя программы + if not prog_name: + continue + + icon_names = self.extract_icons_from_script(script_path) + icon_paths = [os.path.join(Var.DATA_PATH, "image", f"{name}.png") for name in icon_names] + btn = self._create_app_button(prog_name, icon_paths, self.BUTTON_LIST_STYLE) + + # Обертка для рамки выделения + frame = QFrame() + frame.setStyleSheet(self.FRAME_STYLE_DEFAULT) + layout = QVBoxLayout(frame) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(btn) + + btn.clicked.connect(lambda _, s=script, b=btn: self.show_script_info(s, b)) + row, column = divmod(button_index, 2) + grid_layout.addWidget(frame, row, column) + button_list.append(btn) + button_index += 1 + + def _create_searchable_grid_tab(self, placeholder_text, filter_slot): + """ + Создает стандартную вкладку с полем поиска и сеточным макетом с прокруткой. + Возвращает кортеж (главный виджет вкладки, сеточный макет, поле поиска, область прокрутки). + """ + tab_widget = QWidget() + layout = QVBoxLayout(tab_widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + search_edit = QLineEdit() + search_edit.setPlaceholderText(placeholder_text) + search_edit.textChanged.connect(filter_slot) + layout.addWidget(search_edit) + + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setContentsMargins(0, 0, 0, 0) + layout.addWidget(scroll_area) + + scroll_content_widget = QWidget() + scroll_area.setWidget(scroll_content_widget) + + v_scroll_layout = QVBoxLayout(scroll_content_widget) + v_scroll_layout.setContentsMargins(0, 0, 0, 0) + v_scroll_layout.setSpacing(0) + + grid_layout = QGridLayout() + grid_layout.setContentsMargins(5, 5, 5, 5) + grid_layout.setSpacing(2) + grid_layout.setColumnStretch(0, 1) + grid_layout.setColumnStretch(1, 1) + + v_scroll_layout.addLayout(grid_layout) + v_scroll_layout.addStretch(1) + + return tab_widget, grid_layout, search_edit, scroll_area + + def _create_and_populate_install_tab(self, tab_title, script_folder, search_placeholder, filter_slot): + """ + Создает и заполняет вкладку для установки (автоматической или ручной). + Возвращает кортеж со скриптами, кнопками и виджетами. + """ + tab_widget, grid_layout, search_edit, scroll_area = self._create_searchable_grid_tab( + search_placeholder, filter_slot + ) + + scripts = [] + script_path = os.path.join(Var.DATA_PATH, script_folder) + if os.path.isdir(script_path): + try: + scripts = sorted(os.listdir(script_path)) + except OSError as e: + print(f"Не удалось прочитать директорию {script_path}: {e}") + + buttons_list = [] + self._populate_install_grid(grid_layout, scripts, script_folder, buttons_list) + + self.tabs.addTab(tab_widget, tab_title) + + return scripts, buttons_list, grid_layout, search_edit, scroll_area + + def create_auto_install_tab(self): + """Создает вкладку для автоматической установки программ""" + ( + self.autoinstall_scripts, self.autoinstall_buttons, self.scroll_layout, + self.search_edit, self.auto_scroll_area + ) = self._create_and_populate_install_tab( + "Автоматическая установка", "autoinstall", "Поиск скрипта автоматической установки...", self.filter_autoinstall_buttons + ) + + def create_manual_install_tab(self): + """Создает вкладку для ручной установки программ""" + ( + self.manualinstall_scripts, self.manualinstall_buttons, self.manual_scroll_layout, + self.manual_search_edit, self.manual_scroll_area + ) = self._create_and_populate_install_tab( + "Ручная установка", "manualinstall", "Поиск скрипта ручной установки...", self.filter_manual_buttons + ) + + def create_installed_tab(self): + """Создает вкладку для отображения установленных программ в виде кнопок""" + installed_tab = QWidget() + installed_layout = QVBoxLayout() + installed_layout.setContentsMargins(0, 0, 0, 0) + installed_layout.setSpacing(5) + installed_tab.setLayout(installed_layout) + + installed_tab, self.installed_scroll_layout, self.installed_search_edit, self.installed_scroll_area = self._create_searchable_grid_tab( + "Поиск установленной программы...", self.filter_installed_buttons + ) + self.tabs.addTab(installed_tab, "Установленные") + + def create_help_tab(self): + """Создает вкладку 'Справка' с подвкладками""" + help_tab = QWidget() + help_layout = QVBoxLayout(help_tab) + help_layout.setContentsMargins(5, 5, 5, 5) + + help_subtabs = QTabWidget() + help_layout.addWidget(help_subtabs) + + # Подвкладка "Руководство" + guide_tab = QWidget() + guide_layout = QVBoxLayout(guide_tab) + guide_text = QTextBrowser() + guide_text.setOpenExternalLinks(True) + guide_text.setHtml(""" +

Руководство пользователя

+

Подробное и актуальное руководство по использованию WineHelper смотрите на https://www.altlinux.org/Winehelper

+ """) + guide_layout.addWidget(guide_text) + help_subtabs.addTab(guide_tab, "Руководство") + + # Подвкладка "Авторы" + authors_tab = QWidget() + authors_layout = QVBoxLayout(authors_tab) + authors_text = QTextEdit() + authors_text.setReadOnly(True) + authors_text.setHtml(""" +
+

Разработчики

+ Михаил Тергоев (fidel)
+ Сергей Пальчех (minergenon)

+

Помощники
+ Иван Мажукин (vanomj)

+

Идея и поддержка:
+ сообщество ALT Linux

+
+

Отдельная благодарность всем, кто вносит свой вклад в развитие проекта,
+ тестирует и сообщает об ошибках!

+
+ """) + authors_layout.addWidget(authors_text) + help_subtabs.addTab(authors_tab, "Авторы") + + # Подвкладка "Лицензия" + license_tab = QWidget() + license_layout = QVBoxLayout(license_tab) + import html + license_text = QTextBrowser() + license_text.setOpenExternalLinks(True) + + try: + if not Var.LICENSE_FILE or not os.path.exists(Var.LICENSE_FILE): + raise FileNotFoundError + + with open(Var.LICENSE_FILE, 'r', encoding='utf-8') as f: + license_content = f.read() + + escaped_license_content = html.escape(license_content) + + license_html = f""" +

Лицензия

+
{escaped_license_content}
+
+

Сторонние компоненты

+

Некоторые компоненты, используемые или устанавливаемые данным ПО, могут иметь собственные лицензии. Пользователь несет полную ответственность за соблюдение этих лицензионных соглашений.

+

Ниже приведен список основных сторонних компонентов и ссылки на их исходный код:

+ """ + + # Читаем и парсим файл THIRD-PARTY + third_party_html = "" + third_party_file_path = os.path.join(Var.DATA_PATH, "THIRD-PARTY") + if os.path.exists(third_party_file_path): + with open(third_party_file_path, 'r', encoding='utf-8') as f_tp: + third_party_content = f_tp.read() + + # Преобразуем контент в HTML + third_party_html += '
' + for line in third_party_content.splitlines(): + line = line.strip() + if not line: + third_party_html += '
' + continue + escaped_line = html.escape(line) + if line.startswith('http'): + third_party_html += f'    {escaped_line}
' + else: + third_party_html += f'{escaped_line}
' + third_party_html += '
' + + license_text.setHtml(license_html + third_party_html) + except (FileNotFoundError, TypeError): + license_text.setHtml(f'

Лицензия

Не удалось загрузить файл лицензии по пути:
{Var.LICENSE_FILE}

') + except Exception as e: + license_text.setHtml(f'

Лицензия

Произошла ошибка при чтении файла лицензии:
{str(e)}

') + + license_layout.addWidget(license_text) + help_subtabs.addTab(license_tab, "Лицензия") + + # Подвкладка "История изменений" + changelog_tab = QWidget() + changelog_layout = QVBoxLayout(changelog_tab) + changelog_text = QTextEdit() + changelog_text.setReadOnly(True) + changelog_text.setFont(QFont('DejaVu Sans Mono', 9)) + + try: + if not Var.CHANGELOG_FILE or not os.path.exists(Var.CHANGELOG_FILE): + raise FileNotFoundError + with open(Var.CHANGELOG_FILE, 'r', encoding='utf-8') as f: + changelog_content = f.read() + changelog_text.setText(changelog_content) + except (FileNotFoundError, TypeError): + changelog_text.setText(f"Файл истории изменений не найден по пути:\n{Var.CHANGELOG_FILE}") + except Exception as e: + changelog_text.setText(f"Не удалось прочитать файл истории изменений:\n{str(e)}") + + changelog_layout.addWidget(changelog_text) + help_subtabs.addTab(changelog_tab, "История изменений") + + self.tabs.addTab(help_tab, "Справка") + + def update_installed_apps(self): + """Обновляет список установленных приложений в виде кнопок""" + # Если активная кнопка находится в списке удаляемых, сбрасываем ее + if self.current_active_button in self.installed_buttons: + self.current_active_button = None + + # Очистить существующие кнопки + for btn in self.installed_buttons: + btn.deleteLater() + self.installed_buttons.clear() + + if not os.path.exists(Var.USER_WORK_PATH): + os.makedirs(Var.USER_WORK_PATH, exist_ok=True) + return + + desktop_files = [] + for entry in os.scandir(Var.USER_WORK_PATH): + if entry.is_file() and entry.name.endswith('.desktop'): + desktop_files.append(entry.path) + + desktop_files.sort() + + for i, desktop_path in enumerate(desktop_files): + display_name = os.path.splitext(os.path.basename(desktop_path))[0] + icon_path = None + try: + with open(desktop_path, 'r', encoding='utf-8') as f: + for line in f: + if line.startswith('Name='): + display_name = line.split('=', 1)[1].strip() + elif line.startswith('Icon='): + icon_file = line.split('=', 1)[1].strip() + if os.path.exists(icon_file): + icon_path = icon_file + except Exception as e: + print(f"Error reading {desktop_path}: {str(e)}") + + btn = self._create_app_button(display_name, [icon_path], self.INSTALLED_BUTTON_LIST_STYLE) + # Обертка для рамки выделения + frame = QFrame() + frame.setStyleSheet(self.FRAME_STYLE_DEFAULT) + layout = QVBoxLayout(frame) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(btn) + + # Клик по кнопке показывает информацию о приложении + btn.clicked.connect(lambda _, p=desktop_path, b=btn: self.show_installed_app_info(p, b)) + + row = i // 2 + column = i % 2 + self.installed_scroll_layout.addWidget(frame, row, column) + self.installed_buttons.append(btn) + + def _set_active_button(self, button_widget): + """Устанавливает активную кнопку и обновляет стили ее обертки (QFrame).""" + # Сброс стиля предыдущей активной кнопки + if self.current_active_button and self.current_active_button != button_widget: + parent_frame = self.current_active_button.parent() + if isinstance(parent_frame, QFrame): + parent_frame.setStyleSheet(self.FRAME_STYLE_DEFAULT) + + # Применение стиля к новой кнопке + parent_frame = button_widget.parent() + if isinstance(parent_frame, QFrame): + parent_frame.setStyleSheet(self.FRAME_STYLE_SELECTED) + + self.current_active_button = button_widget + + def show_installed_app_info(self, desktop_path, button_widget): + """Показывает информацию об установленном приложении в правой панели.""" + self._set_active_button(button_widget) + # Очищаем поле поиска и принудительно обновляем список, чтобы показать все приложения + self.installed_search_edit.blockSignals(True) + self.installed_search_edit.clear() + self.installed_search_edit.blockSignals(False) + self.filter_installed_buttons() + + # Прокручиваем к выбранному элементу + frame = button_widget.parent() + if isinstance(frame, QFrame): + # Даем циклу событий обработать перерисовку перед прокруткой + QTimer.singleShot(0, lambda: self.installed_scroll_area.ensureWidgetVisible(frame)) + + self.current_selected_app = {'desktop_path': desktop_path} + + # Меняем растяжение: убираем у описания (индекс 1) и добавляем + # виджету с действиями для приложения (индекс 4), чтобы он оттолкнул кнопку "Восстановить" вниз. + self.info_panel_layout.setStretch(1, 0) + self.info_panel_layout.setStretch(4, 1) + + try: + with open(desktop_path, 'r', encoding='utf-8') as f: + content = f.read() + # Парсим .desktop файл для получения информации + name = "" + comment = "" + exec_cmd = "" + icon = "" + + for line in content.split('\n'): + if line.startswith('Name='): + name = line.split('=', 1)[1].strip() + elif line.startswith('Comment='): + comment = line.split('=', 1)[1].strip() + elif line.startswith('Exec='): + exec_cmd = line.split('=', 1)[1].strip() + elif line.startswith('Icon='): + icon = line.split('=', 1)[1].strip() + + self.current_selected_app['name'] = name + self.current_selected_app['exec'] = exec_cmd + + # Показываем панель информации + self.info_panel.setVisible(True) + + # Показываем информацию в правой панели + self.script_title.setPixmap(QPixmap()) # Сначала очищаем иконку, чтобы отобразился текст + self.script_title.setText(name) + self.script_description.setVisible(False) + + # Управляем видимостью кнопок + self.install_action_widget.setVisible(False) + self.installed_action_widget.setVisible(True) + self.installed_global_action_widget.setVisible(True) + self.backup_button.setVisible(True) + self.create_log_button.setVisible(True) + self.manual_install_path_widget.setVisible(False) + + except Exception as e: + QMessageBox.warning(self, "Ошибка", f"Не удалось прочитать информацию о приложении: {str(e)}") + self.current_selected_app = None + self.info_panel.setVisible(False) + + def _get_prefix_name_for_selected_app(self): + """Извлекает имя префикса для выбранного приложения.""" + if not self.current_selected_app or 'desktop_path' not in self.current_selected_app: + return None + + desktop_file = self.current_selected_app['desktop_path'] + if os.path.exists(desktop_file): + try: + with open(desktop_file, 'r', encoding='utf-8') as f: + for line in f: + if line.startswith('Exec='): + exec_line = line.strip() + # Ищем часть пути, например: .../prefixes/some_prefix_name/... + if "prefixes/" in exec_line: + prefix_part = exec_line.split("prefixes/")[1].split("/")[0] + if prefix_part: + return prefix_part + except Exception as e: + print(f"Error getting prefix name from {desktop_file}: {e}") + return None + + def backup_prefix_for_app(self): + """Создает резервную копию префикса для выбранного приложения.""" + prefix_name = self._get_prefix_name_for_selected_app() + if not prefix_name: + QMessageBox.warning(self, "Ошибка", "Не удалось определить префикс для выбранного приложения.") + return + + # Создаем кастомные кнопки + yes_button = QPushButton("Да") + no_button = QPushButton("Нет") + + msg_box = QMessageBox() + msg_box.setWindowTitle("Создание резервной копии") + msg_box.setText( + f"Будет создана резервная копия префикса '{prefix_name}'.\n" + f"Файл будет сохранен на вашем Рабочем столе в формате .whpack.\n\nПродолжить?" + ) + msg_box.addButton(yes_button, QMessageBox.YesRole) + msg_box.addButton(no_button, QMessageBox.NoRole) + msg_box.setDefaultButton(no_button) + + msg_box.exec_() + + if msg_box.clickedButton() != yes_button: + return # Отмена, если нажато "Нет" или крестик + # Используем модальный диалог для отображения процесса резервного копирования (бэкап) + self.command_dialog = QDialog(self) + self.command_dialog.setWindowTitle(f"Резервное копирование: {prefix_name}") + self.command_dialog.setMinimumSize(750, 400) + self.command_dialog.setModal(True) + self.command_dialog.setWindowFlags(self.command_dialog.windowFlags() & ~Qt.WindowCloseButtonHint) + + layout = QVBoxLayout() + self.command_log_output = QTextEdit() + self.command_log_output.setReadOnly(True) + self.command_log_output.setFont(QFont('DejaVu Sans Mono', 10)) + layout.addWidget(self.command_log_output) + + self.command_close_button = QPushButton("Закрыть") + self.command_close_button.setEnabled(False) + self.command_close_button.clicked.connect(self.command_dialog.close) + layout.addWidget(self.command_close_button) + self.command_dialog.setLayout(layout) + + self.command_process = QProcess() + self.command_process.setProcessChannelMode(QProcess.MergedChannels) + self.command_process.readyReadStandardOutput.connect(self._handle_command_output) + self.command_process.finished.connect(self._handle_command_finished) + + winehelper_path = self.winehelper_path + args = ["backup-prefix", prefix_name] + + self.command_log_output.append(f"Выполнение: {shlex.quote(winehelper_path)} {' '.join(shlex.quote(a) for a in args)}") + self.command_process.start(winehelper_path, args) + self.command_dialog.exec_() + + def restore_prefix(self): + """Восстанавливает префикс из резервной копии.""" + backup_path, _ = QFileDialog.getOpenFileName( + self, + "Выберите файл резервной копии для восстановления", + os.path.expanduser("~"), + "WineHelper Backups (*.whpack);;Все файлы (*)" + ) + + if not backup_path: + return + + # Используем модальный диалог для отображения процесса восстановления из бэкапа + self.command_dialog = QDialog(self) + self.command_dialog.setWindowTitle(f"Восстановление из: {os.path.basename(backup_path)}") + self.command_dialog.setMinimumSize(600, 400) + self.command_dialog.setModal(True) + self.command_dialog.setWindowFlags(self.command_dialog.windowFlags() & ~Qt.WindowCloseButtonHint) + + layout = QVBoxLayout() + self.command_log_output = QTextEdit() + self.command_log_output.setReadOnly(True) + self.command_log_output.setFont(QFont('DejaVu Sans Mono', 10)) + layout.addWidget(self.command_log_output) + + self.command_close_button = QPushButton("Закрыть") + self.command_close_button.setEnabled(False) + self.command_close_button.clicked.connect(self.command_dialog.close) + layout.addWidget(self.command_close_button) + self.command_dialog.setLayout(layout) + + self.command_process = QProcess() + self.command_process.setProcessChannelMode(QProcess.MergedChannels) + self.command_process.readyReadStandardOutput.connect(self._handle_command_output) + self.command_process.finished.connect(self._handle_restore_finished) + + winehelper_path = self.winehelper_path + args = ["restore-prefix", backup_path] + + self.command_log_output.append(f"Выполнение: {shlex.quote(winehelper_path)} {' '.join(shlex.quote(a) for a in args)}") + self.command_process.start(winehelper_path, args) + self.command_dialog.exec_() + + def run_installed_app_with_debug(self): + """Запускает выбранное установленное приложение с созданием лога""" + # Создаем кастомные кнопки + yes_button = QPushButton("Да") + no_button = QPushButton("Нет") + + msg_box = QMessageBox() + msg_box.setWindowTitle("Создание лога") + msg_box.setText( + "Приложение будет запущено в режиме отладки.\n" + "После закрытия приложения лог будет сохранен в вашем домашнем каталоге " + "под именем 'winehelper.log'." + ) + msg_box.addButton(yes_button, QMessageBox.YesRole) + msg_box.addButton(no_button, QMessageBox.NoRole) + msg_box.exec_() + + if msg_box.clickedButton() == yes_button: + self._run_app_launcher(debug=True) + + def run_installed_app(self): + """Запускает выбранное установленное приложение""" + self._run_app_launcher(debug=False) + + def _run_app_launcher(self, debug=False): + """Внутренний метод для запуска приложения (с отладкой или без)""" + if not self.current_selected_app or 'exec' not in self.current_selected_app: + QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.") + return + + command_str = self.current_selected_app['exec'] + + try: + # Используем shlex для безопасного разбора командной строки + command_parts = shlex.split(command_str) + + # Удаляем параметры (%F и подобные) + clean_command = [part for part in command_parts if not part.startswith('%')] + + if debug: + # Команда имеет вид: ['env', '/path/to/winehelper', '/path/to/app.exe'] + # Нужно вставить '--debug' после скрипта winehelper + try: + script_index = -1 + for i, part in enumerate(clean_command): + if 'winehelper' in os.path.basename(part): + script_index = i + break + + if script_index != -1: + clean_command.insert(script_index + 1, '--debug') + else: + QMessageBox.critical(self, "Ошибка", "Не удалось найти скрипт winehelper в команде запуска.") + return + except Exception as e: + QMessageBox.critical(self, "Ошибка", f"Не удалось модифицировать команду для отладки: {e}") + return + + # Удаление префикса + try: + subprocess.Popen(clean_command) + print(f"Запущено: {' '.join(clean_command)}") + except Exception as e: + QMessageBox.critical(self, "Ошибка запуска", + f"Не удалось запустить команду:\n{' '.join(clean_command)}\n\nОшибка: {str(e)}") + except Exception as e: + QMessageBox.critical(self, "Ошибка", + f"Не удалось обработать команду запуска:\n{command_str}\n\nОшибка: {str(e)}") + + def uninstall_app(self): + """Удаляет выбранное установленное приложение и его префикс""" + if not self.current_selected_app or 'desktop_path' not in self.current_selected_app: + QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.") + return + + app_name = self.current_selected_app.get('name', 'это приложение') + prefix_name = self._get_prefix_name_for_selected_app() + + if not prefix_name: + QMessageBox.warning(self, "Ошибка", "Не удалось определить префикс для выбранного приложения.") + return + + # Создаем кастомные кнопки + yes_button = QPushButton("Да") + no_button = QPushButton("Нет") + + msg_box = QMessageBox() + msg_box.setWindowTitle('Подтверждение') + msg_box.setText( + f'Вы действительно хотите удалить "{app_name}" и все связанные файлы (включая префикс "{prefix_name}")?' + ) + msg_box.addButton(yes_button, QMessageBox.YesRole) + msg_box.addButton(no_button, QMessageBox.NoRole) + msg_box.setDefaultButton(no_button) + + msg_box.exec_() + + if msg_box.clickedButton() == yes_button: + try: + # Полный путь к префиксу + prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name) + + # 1. Сначала собираем ВСЕ .desktop файлы, связанные с этим префиксом + all_desktop_files = set() + # Добавляем основной .desktop файл + if 'desktop_path' in self.current_selected_app: + all_desktop_files.add(self.current_selected_app['desktop_path']) + + desktop_locations = [ + Var.USER_WORK_PATH, + os.path.join(os.path.expanduser("~"), ".local/share/applications/WineHelper"), + os.path.join(os.path.expanduser("~"), "Desktop"), + os.path.join(os.path.expanduser("~"), "Рабочий стол"), + ] + + # Проверяем все .desktop файлы в стандартных местах + for location in desktop_locations: + if not os.path.exists(location): + continue + + for file in os.listdir(location): + if file.endswith('.desktop'): + file_path = os.path.join(location, file) + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + if f"prefixes/{prefix_name}/" in content: + all_desktop_files.add(file_path) + except: + continue + + # 2. Удаляем сам префикс + try: + if os.path.exists(prefix_path): + shutil.rmtree(prefix_path) + print(f"Удален префикс: {prefix_path}") + else: + print(f"Префикс не найден: {prefix_path}") + except Exception as e: + raise RuntimeError(f"Ошибка удаления префикса: {str(e)}") + + # 3. Удаляем ВСЕ найденные .desktop файлы, связанные с этим префиксом + removed_files = [] + for file_path in all_desktop_files: + try: + os.remove(file_path) + removed_files.append(file_path) + except Exception as e: + print(f"Ошибка удаления {file_path}: {str(e)}") + + # 4. Удаляем категорию меню, если она пуста + menu_dir = os.path.join(os.path.expanduser("~"), ".local/share/applications/WineHelper") + if os.path.exists(menu_dir) and not os.listdir(menu_dir): + try: + os.rmdir(menu_dir) + # Удаляем связанные файлы меню + menu_files = [ + os.path.join(os.path.expanduser("~"), + ".local/share/desktop-directories/WineHelper.directory"), + os.path.join(os.path.expanduser("~"), ".config/menus/applications-merged/WineHelper.menu") + ] + for f in menu_files: + if os.path.exists(f): + os.remove(f) + except Exception as e: + print(f"Ошибка удаления пустой категории меню: {str(e)}") + + # Обновляем кэш desktop файлов + try: + subprocess.run( + ["update-desktop-database", os.path.join(os.path.expanduser("~"), ".local/share/applications")]) + except Exception as e: + print(f"Ошибка обновления кэша desktop файлов: {str(e)}") + + # Обновляем список установленных приложений + self.update_installed_apps() + + # Формируем отчет об удалении + report = f"Удаление завершено:\n" + report += f"- Префикс: {prefix_path}\n" + report += f"- Удаленные .desktop файлы ({len(removed_files)}):\n" + report += "\n".join(f" - {f}" for f in removed_files) if removed_files else " (не найдены)" + + # Создаем кастомный диалог, чтобы кнопка была на русском + success_box = QMessageBox(self) + success_box.setWindowTitle("Успех") + success_box.setText(report) + success_box.setIcon(QMessageBox.Information) + success_box.addButton("Готово", QMessageBox.AcceptRole) + success_box.exec_() + + self._reset_info_panel_to_default("Установленные") + + except Exception as e: + QMessageBox.critical(self, "Ошибка", + f"Не удалось удалить приложение: {str(e)}\n\n" + f"Desktop файл: {self.current_selected_app.get('desktop_path', 'не определен')}\n" + f"Префикс: {prefix_name}\n" + f"Путь к префиксу: {prefix_path if 'prefix_path' in locals() else 'не определен'}") + def _filter_buttons_in_grid(self, search_text, button_list, grid_layout): + """Общий метод для фильтрации кнопок и перестроения сетки.""" + search_text_lower = search_text.lower() + visible_frames = [] + for btn in button_list: + frame = btn.parent() + if isinstance(frame, QFrame): + # Сначала скрываем все, чтобы правильно перестроить сетку + frame.setVisible(False) + if search_text_lower in btn.text().lower(): + visible_frames.append(frame) + + # Перестраиваем сетку только с видимыми элементами + for i, frame in enumerate(visible_frames): + row, column = divmod(i, 2) + grid_layout.addWidget(frame, row, column) + frame.setVisible(True) + + def filter_installed_buttons(self): + """Фильтрует кнопки установленных программ.""" + self._filter_buttons_in_grid( + self.installed_search_edit.text(), self.installed_buttons, self.installed_scroll_layout + ) + + def filter_manual_buttons(self): + """Фильтрует кнопки ручной установки.""" + self._filter_buttons_in_grid( + self.manual_search_edit.text(), self.manualinstall_buttons, self.manual_scroll_layout + ) + + def extract_info_ru(self, script_path): + """Извлекает информацию из строки # info_ru: в скрипте""" + try: + with open(script_path, 'r', encoding='utf-8') as f: + for line in f: + if line.startswith('# info_ru:'): + return line.replace('# info_ru:', '').strip() + return "Описание отсутствует" + except Exception as e: + return f"Ошибка чтения файла: {str(e)}" + + def show_script_info(self, script_name, button_widget): + """Показывает информацию о выбранном скрипте""" + self._set_active_button(button_widget) + self.current_script = script_name + + # Определяем виджеты и действия в зависимости от типа скрипта + if script_name in self.autoinstall_scripts: + script_path = os.path.join(Var.DATA_PATH, "autoinstall", script_name) + search_edit = self.search_edit + filter_func = self.filter_autoinstall_buttons + scroll_area = self.auto_scroll_area + self.manual_install_path_widget.setVisible(False) + else: + script_path = os.path.join(Var.DATA_PATH, "manualinstall", script_name) + search_edit = self.manual_search_edit + filter_func = self.filter_manual_buttons + scroll_area = self.manual_scroll_area + self.manual_install_path_widget.setVisible(True) + + # Общая логика: очищаем поиск, обновляем список и прокручиваем к элементу + search_edit.blockSignals(True) + search_edit.clear() + search_edit.blockSignals(False) + filter_func() + frame = button_widget.parent() + if isinstance(frame, QFrame): + QTimer.singleShot(0, lambda: scroll_area.ensureWidgetVisible(frame)) + + # Обновляем информацию в правой панели + description = self.extract_info_ru(script_path) + icon_names = self.extract_icons_from_script(script_path) + prog_name = self.extract_prog_name_from_script(script_path) + prog_url = self.extract_prog_url_from_script(script_path) + display_name = prog_name if prog_name else script_name + + if icon_names: + # Для заголовка используем первую иконку из списка + icon_path = os.path.join(Var.DATA_PATH, "image", f"{icon_names[0]}.png") + if os.path.exists(icon_path): + self.script_title.setPixmap(QPixmap(icon_path).scaled(64, 64, Qt.KeepAspectRatio)) + else: + self.script_title.setPixmap(QPixmap()) + else: + self.script_title.setPixmap(QPixmap()) + + self.script_title.setText(display_name) + + html_description = f"

{description}

" + if prog_url: + html_description += f'

Официальный сайт: {prog_url}

' + + self.script_description.setHtml(html_description) + self.script_description.setVisible(True) + self.install_action_widget.setVisible(True) + self.installed_action_widget.setVisible(False) + self.installed_global_action_widget.setVisible(False) + self.install_button.setText(f"Установить {display_name}") + + def install_current_script(self): + """Устанавливает текущий выбранный скрипт""" + if not self.current_script: + QMessageBox.warning(self, "Ошибка", "Не выбрана программа для установки") + return + + if self.current_script in self.manualinstall_scripts and not self.install_path_edit.text().strip(): + QMessageBox.warning(self, "Ошибка", "Укажите путь к установочному файлу") + return + + # Создаем диалоговое окно установки + self.install_dialog = QDialog(self) + self.install_dialog.setWindowTitle(f"Установка {self.current_script}") + self.install_dialog.setMinimumSize(750, 400) + self.install_dialog.setWindowModality(Qt.WindowModal) + + self.stacked_widget = QStackedWidget() + layout = QVBoxLayout() + layout.addWidget(self.stacked_widget) + self.install_dialog.setLayout(layout) + + # Первая страница - лицензионное соглашение + license_page = QWidget() + license_layout = QVBoxLayout(license_page) + + license_text = QTextEdit() + license_text.setReadOnly(True) + + # Получаем текст лицензии из скрипта winehelper + script_path = os.path.join(Var.DATA_PATH, "winehelper") + license_content = "" + try: + with open(script_path, 'r', encoding='utf-8') as f: + capturing = False + for line in f: + if 'print_warning "Лицензионные соглашения использования сторонних компонентов:' in line: + capturing = True + continue + + if capturing: + if 'Подтверждая продолжение установки' in line: + break + # Очищаем строку от лишних символов + clean_line = line.strip() + clean_line = clean_line.replace('print_warning "', '').replace('\\n', '\n') + clean_line = clean_line.rstrip('"') + license_content += clean_line + '\n' + + license_text.setHtml(f""" +

Лицензионные соглашения использования сторонних компонентов:

+

{license_content}

+ """) + except Exception as e: + print(f"Ошибка чтения файла для извлечения лицензии: {str(e)}") + license_text.setHtml(""" +

Лицензионные соглашения

+

Не удалось загрузить текст лицензионного соглашения.

+ """) + + license_layout.addWidget(license_text) + + self.license_checkbox = QCheckBox("Я принимаю условия лицензионного соглашения") + license_layout.addWidget(self.license_checkbox) + + self.btn_continue = QPushButton("Продолжить установку") + self.btn_continue.setEnabled(False) + self.btn_continue.clicked.connect(self._prepare_installation) + license_layout.addWidget(self.btn_continue) + + # Вторая страница - лог установки + log_page = QWidget() + log_layout = QVBoxLayout(log_page) + + self.log_output = QTextEdit() + self.log_output.setReadOnly(True) + self.log_output.setFont(QFont('DejaVu Sans Mono', 10)) + log_layout.addWidget(self.log_output) + + self.control_buttons = QWidget() + btn_layout = QHBoxLayout(self.control_buttons) + self.btn_abort = QPushButton("Прервать") + self.btn_abort.clicked.connect(self.abort_installation) + btn_layout.addWidget(self.btn_abort) + + self.btn_close = QPushButton("Закрыть") + self.btn_close.setEnabled(False) + self.btn_close.clicked.connect(self.install_dialog.close) + btn_layout.addWidget(self.btn_close) + + log_layout.addWidget(self.control_buttons) + + self.stacked_widget.addWidget(license_page) + self.stacked_widget.addWidget(log_page) + + # Назначение кастомного обработчика закрытия окна + def dialog_close_handler(event): + self.handle_install_dialog_close(event) + self.install_dialog.closeEvent = dialog_close_handler + + self.license_checkbox.stateChanged.connect( + lambda state: self.btn_continue.setEnabled(state == Qt.Checked) + ) + + self.install_dialog.show() + + def _prepare_installation(self): + """Подготавливает и запускает процесс установки""" + self.stacked_widget.setCurrentIndex(1) + + winehelper_path = self.winehelper_path + script_path = os.path.join(Var.DATA_PATH, + "autoinstall" if self.current_script in self.autoinstall_scripts else "manualinstall", + self.current_script) + + if not os.path.exists(winehelper_path): + QMessageBox.critical(self.install_dialog, "Ошибка", f"winehelper не найден по пути:\n{winehelper_path}") + return + if not os.path.exists(script_path): + QMessageBox.critical(self.install_dialog, "Ошибка", f"Скрипт установки не найден:\n{script_path}") + return + + if self.current_script in self.manualinstall_scripts: + install_file = self.install_path_edit.text().strip() + if not install_file: + QMessageBox.critical(self.install_dialog, "Ошибка", "Не указан путь к установочному файлу") + return + QTimer.singleShot(100, lambda: self._start_installation(winehelper_path, script_path, install_file)) + else: + QTimer.singleShot(100, lambda: self._start_installation(winehelper_path, script_path)) + + def _start_installation(self, winehelper_path, script_path, install_file=None): + """Запускает процесс установки""" + self.install_process = QProcess() + self.install_process.setProcessChannelMode(QProcess.MergedChannels) + self.install_process.setWorkingDirectory(os.path.dirname(winehelper_path)) + + env = QProcessEnvironment.systemEnvironment() + env.insert("GUI_MODE", "1") + self.install_process.setProcessEnvironment(env) + + self.install_process.readyReadStandardOutput.connect(self.handle_process_output) + self.install_process.finished.connect(self.handle_process_finished) + + args = ["install", os.path.basename(script_path)] + if install_file: + args.append(install_file) + + self.append_log(f"=== Начало установки {self.current_script} ===") + self.append_log(f"Исполняемый файл: {winehelper_path}") + self.append_log(f"Аргументы: {' '.join(shlex.quote(a) for a in args)}") + + try: + self.install_process.start(winehelper_path, args) + if not self.install_process.waitForStarted(3000): + raise RuntimeError("Не удалось запустить процесс установки") + self.append_log("Процесс установки успешно запущен...") + except Exception as e: + self.append_log(f"\n=== ОШИБКА: {str(e)} ===", is_error=True) + QMessageBox.critical(self.install_dialog, "Ошибка", f"Не удалось запустить установку:\n{str(e)}") + self.cleanup_process() + + def append_log(self, text, is_error=False): + """Добавляет сообщение в лог""" + if not hasattr(self, 'log_output'): return + cursor = self.log_output.textCursor() + cursor.movePosition(QTextCursor.End) + + if is_error: + cursor.insertHtml(f'{text}
') + else: + cursor.insertText(f"{text}\n") + + self.log_output.ensureCursorVisible() + QApplication.processEvents() + + def handle_process_output(self): + """Обрабатывает вывод процесса""" + output = self.install_process.readAllStandardOutput().data().decode('utf-8', errors='ignore').strip() + if output: + self.append_log(output) + + def handle_process_finished(self, exit_code, exit_status): + """Обрабатывает завершение процесса""" + if exit_code == 0 and exit_status == QProcess.NormalExit: + self.append_log("\n=== Установка успешно завершена ===") + # Создаем кастомный диалог, чтобы кнопка была на русском + success_box = QMessageBox(self.install_dialog) + success_box.setWindowTitle("Успех") + success_box.setText(f"Программа {self.current_script} установлена успешно!") + success_box.setIcon(QMessageBox.Information) + success_box.addButton("Готово", QMessageBox.AcceptRole) + success_box.exec_() + + self.update_installed_apps() + + # Кнопка закрыть + self.btn_close.setEnabled(True) + # Кнопка прервать + self.btn_abort.setEnabled(False) + + def handle_install_dialog_close(self, event): + """Обрабатывает событие закрытия диалога установки.""" + # Проверяем, запущен ли еще процесс установки + if hasattr(self, 'install_process') and self.install_process and self.install_process.state() == QProcess.Running: + yes_button = QPushButton("Да, прервать") + no_button = QPushButton("Нет") + + msg_box = QMessageBox(self.install_dialog) + msg_box.setWindowTitle("Прервать установку?") + msg_box.setText("Установка еще не завершена.\nВы действительно хотите прервать процесс?") + msg_box.setIcon(QMessageBox.Question) + msg_box.addButton(yes_button, QMessageBox.YesRole) + msg_box.addButton(no_button, QMessageBox.NoRole) + msg_box.setDefaultButton(no_button) + + msg_box.exec_() + + if msg_box.clickedButton() == yes_button: + self.append_log("\n=== Пользователь прервал установку через закрытие окна ===", is_error=True) + # Завершаем процесс. Сигнал finished вызовет handle_process_finished, + # который обновит состояние кнопок. + self.install_process.terminate() + event.accept() # Разрешаем закрытие окна + else: + # Пользователь нажал "Нет", поэтому игнорируем событие закрытия + event.ignore() + else: + # Процесс не запущен (установка завершена или еще не началась), + # поэтому просто закрываем окно + event.accept() + + def abort_installation(self): + """Прерывает текущую установку""" + if hasattr(self, 'install_process') and self.install_process and self.install_process.state() == QProcess.Running: + yes_button = QPushButton("Да") + no_button = QPushButton("Нет") + msg_box = QMessageBox() + msg_box.setWindowTitle("Подтверждение") + msg_box.setText("Вы действительно хотите прервать установку?") + msg_box.addButton(yes_button, QMessageBox.YesRole) + msg_box.addButton(no_button, QMessageBox.NoRole) + msg_box.setDefaultButton(no_button) + msg_box.exec_() + if msg_box.clickedButton() == yes_button: + self.append_log("\n=== Пользователь прервал установку ===", is_error=True) + self.cleanup_process() + + def _handle_command_output(self): + """Обрабатывает вывод для диалога команды""" + if hasattr(self, 'command_process') and self.command_process: + output = self.command_process.readAllStandardOutput().data().decode('utf-8', errors='ignore').strip() + if output and hasattr(self, 'command_log_output'): + self.command_log_output.append(output) + QApplication.processEvents() + + def _handle_command_finished(self, exit_code, exit_status): + """Обрабатывает завершение для диалога команды""" + if exit_code == 0: + self.command_log_output.append(f"\n=== Команда успешно завершена ===") + else: + self.command_log_output.append(f"\n=== Ошибка выполнения (код: {exit_code}) ===") + self.command_close_button.setEnabled(True) + + def _handle_restore_finished(self, exit_code, exit_status): + """Обрабатывает завершение для диалога команды восстановления.""" + if exit_code == 0: + self.command_log_output.append(f"\n=== Восстановление успешно завершено ===") + self.update_installed_apps() + self.filter_installed_buttons() + else: + self.command_log_output.append(f"\n=== Ошибка выполнения (код: {exit_code}) ===") + self.command_close_button.setEnabled(True) + + def cleanup_process(self): + """Очищает ресурсы процесса, принудительно завершая его, если он активен.""" + if hasattr(self, 'install_process') and self.install_process: + if self.install_process.state() == QProcess.Running: + self.install_process.terminate() + # Даем процессу 3 секунды на завершение + if not self.install_process.waitForFinished(3000): + self.append_log("Процесс не ответил на terminate, отправляется kill...", is_error=True) + self.install_process.kill() + self.install_process.waitForFinished() # Ждем завершения после kill + self.install_process.deleteLater() + self.install_process = None + + def filter_autoinstall_buttons(self): + """Фильтрует кнопки автоматической установки.""" + self._filter_buttons_in_grid( + self.search_edit.text(), self.autoinstall_buttons, self.scroll_layout + ) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = WineHelperGUI() + window.show() + sys.exit(app.exec_())