From 100ffa22ba891b5afdb7a48c027281de086d7f2e Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Mon, 11 Aug 2025 15:41:48 +0600 Subject: [PATCH 01/12] added winetricks control buttons --- winehelper_gui.py | 664 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 646 insertions(+), 18 deletions(-) diff --git a/winehelper_gui.py b/winehelper_gui.py index fe9d20d..b343626 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -6,9 +6,13 @@ import re import shlex import shutil import html +import time +import json +import hashlib +from functools import partial from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,QPushButton, QLabel, QTabWidget, QTextEdit, QFileDialog, QMessageBox, QLineEdit, QCheckBox, QStackedWidget, QScrollArea, - QGridLayout, QFrame, QDialog, QTextBrowser) + QListWidget, QListWidgetItem, QGridLayout, QFrame, QDialog, QTextBrowser) from PyQt5.QtCore import Qt, QProcess, QSize, QTimer, QProcessEnvironment from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPixmap, QPainter @@ -24,6 +28,516 @@ class Var: LICENSE_FILE = os.environ.get("LICENSE_FILE") LICENSE_AGREEMENT_FILE = os.environ.get("AGREEMENT") +class WinetricksManagerDialog(QDialog): + """Диалог для управления компонентами Winetricks.""" + + INFO_TEXT = ( + "Компоненты можно только установить либо переустановить.\n" + "Удаление компонентов не реализовано в Winetricks.\n" + "Для установки нового компонента: Поставьте галочку и нажмите «Применить».\n" + "Для переустановки компонента: Выделите его в списке и нажмите кнопку «Переустановить»." + ) + + def __init__(self, prefix_path, winetricks_path, parent=None): + super().__init__(parent) + self.prefix_path = prefix_path + self.winetricks_path = winetricks_path + self.initial_states = {} + self.apply_process = None + self.installation_finished = False + self.user_cancelled = False + self.processes = {} + self.category_statuses = {} + self.previous_tab_widget = None + self.cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "winehelper", "winetricks") + os.makedirs(self.cache_dir, exist_ok=True) + + self.setWindowTitle(f"Менеджер компонентов для префикса: {os.path.basename(prefix_path)}") + self.setMinimumSize(800, 500) + + # Основной layout + main_layout = QVBoxLayout(self) + + # Табы для категорий + self.tabs = QTabWidget() + main_layout.addWidget(self.tabs) + + # Создаем табы + self.categories = { + "Библиотеки": "dlls", + "Шрифты": "fonts", + "Настройки": "settings" + } + self.list_widgets = {} + self.search_edits = {} + for display_name, internal_name in self.categories.items(): + list_widget, search_edit = self._create_category_tab(display_name) + self.list_widgets[internal_name] = list_widget + self.search_edits[internal_name] = search_edit + + # Лог для вывода команд + self.log_output = QTextEdit() + self.log_output.setReadOnly(True) + self.log_output.setFont(QFont('DejaVu Sans Mono', 10)) + self.log_output.setMaximumHeight(150) + self.log_output.setText(self.INFO_TEXT) + main_layout.addWidget(self.log_output) + + # Кнопки управления + button_layout = QHBoxLayout() + self.status_label = QLabel("Загрузка компонентов...") + button_layout.addWidget(self.status_label, 1) + + self.apply_button = QPushButton("Применить") + self.apply_button.setEnabled(False) + self.apply_button.clicked.connect(self.apply_changes) + button_layout.addWidget(self.apply_button) + + self.reinstall_button = QPushButton("Переустановить") + self.reinstall_button.setEnabled(False) + self.reinstall_button.clicked.connect(self.reinstall_selected) + button_layout.addWidget(self.reinstall_button) + + self.close_button = QPushButton("Закрыть") + self.close_button.clicked.connect(self.close) + button_layout.addWidget(self.close_button) + main_layout.addLayout(button_layout) + + # Подключаем сигнал после создания всех виджетов, чтобы избежать ошибки атрибута + self.tabs.currentChanged.connect(self.on_tab_switched) + + # Загружаем данные + self.load_all_categories() + # Устанавливаем начальное состояние для отслеживания покинутой вкладки + self.previous_tab_widget = self.tabs.currentWidget() + + def on_tab_switched(self, index): + """ + Обрабатывает переключение вкладок. + Если установка только что завершилась, сбрасывает лог к информационному тексту. + """ + # Очищаем поле поиска на той вкладке, которую покинули. + if self.previous_tab_widget: + search_edit = self.previous_tab_widget.findChild(QLineEdit) + if search_edit: + search_edit.clear() + + if self.installation_finished: + self.log_output.setText(self.INFO_TEXT) + self.installation_finished = False + self._update_ui_state() + # Сохраняем текущую вкладку для следующего переключения + self.previous_tab_widget = self.tabs.widget(index) + + def _create_category_tab(self, title): + """Создает вкладку с поиском и списком.""" + tab = QWidget() + layout = QVBoxLayout(tab) + + search_edit = QLineEdit() + search_edit.setPlaceholderText("Поиск...") + layout.addWidget(search_edit) + + list_widget = QListWidget() + list_widget.itemChanged.connect(self._on_item_changed) + list_widget.currentItemChanged.connect(self._update_ui_state) + layout.addWidget(list_widget) + + search_edit.textChanged.connect(lambda text, lw=list_widget: self.filter_list(text, lw)) + + self.tabs.addTab(tab, title) + return list_widget, search_edit + + def filter_list(self, text, list_widget): + """Фильтрует элементы в списке.""" + for i in range(list_widget.count()): + item = list_widget.item(i) + item.setHidden(text.lower() not in item.text().lower()) + + def load_all_categories(self): + """Запускает загрузку всех категорий.""" + self.loading_count = len(self.categories) + self.category_statuses = {name: "загрузка..." for name in self.categories.keys()} + for internal_name in self.categories.values(): + self._start_load_process(internal_name) + + def _get_cache_path(self, category): + """Возвращает путь к файлу кэша для указанной категории.""" + return os.path.join(self.cache_dir, f"{category}.json") + + def _get_winetricks_hash(self): + """Вычисляет хэш файла winetricks для проверки его обновления.""" + try: + hasher = hashlib.sha256() + with open(self.winetricks_path, 'rb') as f: + while chunk := f.read(4096): + hasher.update(chunk) + return hasher.hexdigest() + except (IOError, OSError): + return None + + def _start_load_process(self, category): + """Запускает QProcess для получения списка компонентов, используя кэш.""" + cache_path = self._get_cache_path(category) + cache_ttl_seconds = 86400 # 24 часа + + # Попытка прочитать из кэша + if os.path.exists(cache_path): + try: + with open(cache_path, 'r', encoding='utf-8') as f: + cache_data = json.load(f) + cache_age = time.time() - cache_data.get("timestamp", 0) + winetricks_hash = self._get_winetricks_hash() + if cache_age < cache_ttl_seconds and cache_data.get("hash") == winetricks_hash: + QTimer.singleShot(0, lambda: self._on_load_finished( + category, 0, QProcess.NormalExit, from_cache=cache_data.get("output") + )) + return + except (json.JSONDecodeError, IOError, KeyError): + self._log(f"--- Кэш для '{category}' поврежден, будет выполнена перезагрузка. ---") + process = QProcess(self) + self.processes[category] = process + process.setProcessChannelMode(QProcess.MergedChannels) + + env = QProcessEnvironment.systemEnvironment() + env.insert("WINEPREFIX", self.prefix_path) + # Отключаем winemenubuilder, чтобы избежать зависаний, связанных с 'wineserver -w'. + # Это известная проблема при запуске winetricks из ГУИ. + process.setProcessEnvironment(env) + + # Используем functools.partial для надежной привязки категории к слоту. + # Это стандартный и самый надежный способ избежать проблем с замыканием в цикле. + process.finished.connect(partial(self._on_load_finished, category)) + process.start(self.winetricks_path, [category, "list"]) + + def _update_status_label(self): + """Обновляет текстовую метку состояния загрузки.""" + status_parts = [] + for name, status in self.category_statuses.items(): + status_parts.append(f"{name}: {status}") + self.status_label.setText(" | ".join(status_parts)) + + def _parse_winetricks_log(self): + """Читает winetricks.log и возвращает множество установленных компонентов.""" + installed_verbs = set() + log_path = os.path.join(self.prefix_path, "winetricks.log") + if not os.path.exists(log_path): + return installed_verbs + + try: + with open(log_path, 'r', encoding='utf-8') as f: + for line in f: + verb = line.split('#', 1)[0].strip() + if verb: + installed_verbs.add(verb) + except Exception as e: + self._log(f"--- Предупреждение: не удалось прочитать {log_path}: {e} ---") + return installed_verbs + + def _parse_winetricks_list_output(self, output, installed_verbs, list_widget): + """Парсит вывод 'winetricks list' и заполняет QListWidget.""" + # Regex, который обрабатывает строки как с префиксом статуса '[ ]', так и без него. + # 1. `(?:\[(.)]\s+)?` - опциональная группа для статуса (напр. '[x]'). + # 2. `([^\s]+)` - имя компонента (без пробелов). + # 3. `(.*)` - оставшаяся часть строки (описание). + line_re = re.compile(r"^\s*(?:\[(.)]\s+)?([^\s]+)\s*(.*)") + found_items = False + + for line in output.splitlines(): + match = line_re.match(line) + if not match: + continue + + found_items = True + _status, name, description = match.groups() + + # Удаляем из описания информацию о доступности для скачивания, так как она избыточна + description = re.sub(r'\[\s*доступно для скачивания[^]]*]', '', description) + description = re.sub(r'\[\s*в кэше\s*]', '', description) + + # Фильтруем служебные строки, которые могут быть ошибочно распознаны. + # Имена компонентов winetricks не содержат слэшей и не являются командами. + if '/' in name or '\\' in name or name.lower() in ('executing', 'using', 'warning:') or name.endswith(':'): + continue + + is_checked = name in installed_verbs + item_text = f"{name.ljust(27)}{description.strip()}" + item = QListWidgetItem(item_text) + item.setData(Qt.UserRole, name) + item.setFont(QFont("DejaVu Sans Mono", 10)) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked if is_checked else Qt.Unchecked) + list_widget.addItem(item) + self.initial_states[name] = is_checked + + return found_items + + def _on_load_finished(self, category, exit_code, exit_status, from_cache=None): + """Обрабатывает завершение загрузки списка компонентов.""" + if from_cache is not None: + output = from_cache + process = None + else: + process = self.processes[category] + output = process.readAllStandardOutput().data().decode('utf-8', 'ignore') + + list_widget = self.list_widgets[category] + category_display_name = next(k for k, v in self.categories.items() if v == category) + + # Очищаем список перед заполнением. + list_widget.clear() + + if exit_code != 0 or exit_status != QProcess.NormalExit: + error_string = process.errorString() if process else "N/A" + self._log(f"--- Ошибка загрузки категории '{category}' (код: {exit_code}) ---", "red") + self.category_statuses[category_display_name] = "ошибка" + self._update_status_label() # Показываем ошибку в статусе + if exit_status == QProcess.CrashExit: + self._log("--- Процесс winetricks завершился аварийно. ---", "red") + # По умолчанию используется "Неизвестная ошибка", которая не очень полезна. + if error_string != "Неизвестная ошибка": + self._log(f"--- Системная ошибка: {error_string} ---", "red") + self._log(output if output.strip() else "Winetricks не вернул вывод. Проверьте, что он работает корректно.") + self._log("--------------------------------------------------", "red") + else: + self.category_statuses[category_display_name] = "готово" + installed_verbs = self._parse_winetricks_log() + # Обновляем статус только если это была сетевая загрузка + if from_cache is None: + self._update_status_label() + found_items = self._parse_winetricks_list_output(output, installed_verbs, list_widget) + + if from_cache is None: # Только если мы не читали из кэша + # Сохраняем успешный результат в кэш + cache_path = self._get_cache_path(category) + winetricks_hash = self._get_winetricks_hash() + if winetricks_hash: + cache_data = { + "timestamp": time.time(), + "hash": winetricks_hash, + "output": output + } + try: + with open(cache_path, 'w', encoding='utf-8') as f: + json.dump(cache_data, f, ensure_ascii=False, indent=2) + except (IOError, OSError) as e: + self._log(f"--- Не удалось сохранить кэш для '{category}': {e} ---") + if not found_items and output.strip(): + self._log(f"--- Не удалось распознать вывод для категории '{category}' ---") + self._log(output) + self._log("--------------------------------------------------") + + self.loading_count -= 1 + if self.loading_count == 0: + self.status_label.setText("Готово.") + self._update_ui_state() + + def _on_item_changed(self, item): + """Обрабатывает изменение состояния чекбокса, предотвращая снятие галочки с установленных.""" + name = item.data(Qt.UserRole) + # Если компонент был изначально установлен и пользователь пытается его снять + if name in self.initial_states and self.initial_states.get(name) is True: + if item.checkState() == Qt.Unchecked: + # Блокируем сигналы, чтобы избежать рекурсии, и возвращаем галочку на место. + list_widget = item.listWidget() + if list_widget: + list_widget.blockSignals(True) + item.setCheckState(Qt.Checked) + if list_widget: + list_widget.blockSignals(False) + self._update_ui_state() + + def _update_ui_state(self, *args): + """Централизованно обновляет состояние кнопок 'Применить' и 'Переустановить'.""" + # 1. Проверяем, есть ли изменения в чекбоксах (установка новых или снятие галочек с новых) + has_changes = False + for list_widget in self.list_widgets.values(): + for i in range(list_widget.count()): + item = list_widget.item(i) + name = item.data(Qt.UserRole) + if name in self.initial_states: + initial_state = self.initial_states[name] + current_state = item.checkState() == Qt.Checked + if current_state != initial_state: + has_changes = True + break + if has_changes: + break + + self.apply_button.setEnabled(has_changes) + + # 2. Проверяем, можно ли переустановить выбранный компонент + is_reinstallable = False + # Переустановка возможна только если нет других изменений + if not has_changes: + current_list_widget = self.tabs.currentWidget().findChild(QListWidget) + if current_list_widget: + current_item = current_list_widget.currentItem() + if current_item: + name = current_item.data(Qt.UserRole) + # Компонент можно переустановить, если он был изначально установлен + if self.initial_states.get(name, False): + is_reinstallable = True + + self.reinstall_button.setEnabled(is_reinstallable) + + def reinstall_selected(self): + """Переустанавливает выбранный компонент.""" + current_list_widget = self.tabs.currentWidget().findChild(QListWidget) + if not current_list_widget: + return + + current_item = current_list_widget.currentItem() + if not current_item: + return + + name = current_item.data(Qt.UserRole) + if not name: + return + + self.log_output.setText(self.INFO_TEXT) + self.apply_button.setEnabled(False) + self.reinstall_button.setEnabled(False) + self.close_button.setEnabled(False) + + # Установка будет форсированной + verbs_to_reinstall = [name] + self._start_install_process(verbs_to_reinstall) + + def apply_changes(self): + """Применяет выбранные изменения.""" + # Собираем все компоненты, которые были отмечены для установки. + verbs_to_install = [] + + for list_widget in self.list_widgets.values(): + for i in range(list_widget.count()): + item = list_widget.item(i) + name = item.data(Qt.UserRole) + if name not in self.initial_states: + continue + + initial_state = self.initial_states[name] + current_state = item.checkState() == Qt.Checked + + if current_state != initial_state: + verbs_to_install.append(name) + + if not verbs_to_install: + QMessageBox.information(self, "Нет изменений", "Не выбрано ни одного компонента для установки.") + return + + self.log_output.setText(self.INFO_TEXT) + self.apply_button.setEnabled(False) + self.reinstall_button.setEnabled(False) + self.close_button.setEnabled(False) + + self._start_install_process(verbs_to_install) + + def _start_install_process(self, verbs_to_install): + """Запускает процесс установки/переустановки winetricks.""" + # Добавляем флаг --force, чтобы разрешить переустановку + self._log(f"Выполнение установки: winetricks --unattended --force {' '.join(verbs_to_install)}") + self.apply_process = QProcess(self) + self.apply_process.setProcessChannelMode(QProcess.MergedChannels) + env = QProcessEnvironment.systemEnvironment() + env.insert("WINEPREFIX", self.prefix_path) + self.apply_process.setProcessEnvironment(env) + self.apply_process.readyReadStandardOutput.connect(lambda: self.log_output.append(self.apply_process.readAllStandardOutput().data().decode('utf-8', 'ignore').strip())) + self.apply_process.finished.connect(self.on_apply_finished) + self.apply_process.start(self.winetricks_path, ["--unattended", "--force"] + verbs_to_install) + + def on_apply_finished(self, exit_code, exit_status): + """Обрабатывает завершение применения изменений.""" + # 1. Проверяем, была ли отмена пользователем + if self.user_cancelled: + self._log("\n=== Установка прервана пользователем. ===") + self._show_message_box("Отмена", "Установки компонентов прервана пользователем.", + QMessageBox.Warning, {"buttons": {"Да": QMessageBox.AcceptRole}}) + + # Сбрасываем флаг и восстанавливаем UI + self.user_cancelled = False + self.apply_button.setEnabled(True) + self.close_button.setEnabled(True) + return + + # 2. Обрабатываем реальную ошибку + if exit_code != 0 or exit_status != QProcess.NormalExit: + self._log(f"\n=== Ошибка во время выполнения операций (код: {exit_code}). ===", "red") + self._show_message_box("Ошибка", + "Произошла ошибка во время выполнения операций.\n" + "Подробности смотрите в логе.", + QMessageBox.Warning, + {"buttons": {"OK": QMessageBox.AcceptRole}}) + self.apply_button.setEnabled(True) + self.close_button.setEnabled(True) + return + + # 3. Обрабатываем успех + self._log("\n=== Все операции успешно завершены ===") + self._show_message_box("Успех", + "Операции с компонентами были успешно выполнены.", + QMessageBox.Information, + {"buttons": {"Да": QMessageBox.AcceptRole}}) + + self.apply_button.setEnabled(True) + self.reinstall_button.setEnabled(False) # Сбрасываем в неактивное состояние + self.close_button.setEnabled(True) + + # Очищаем все поля поиска. + for search_edit in self.search_edits.values(): + search_edit.clear() + + # Перезагружаем данные, чтобы обновить состояние + self.status_label.setText("Обновление данных...") + self.initial_states.clear() + self.load_all_categories() + self.installation_finished = True + + def closeEvent(self, event): + """Обрабатывает закрытие окна, чтобы предотвратить выход во время установки.""" + # Проверяем, запущен ли процесс установки/переустановки + if self.apply_process and self.apply_process.state() == QProcess.Running: + reply = self._show_message_box('Подтверждение', + "Процесс установки еще не завершен. Вы уверены, что хотите прервать его?", + QMessageBox.Question, + {"buttons": {"Да": QMessageBox.YesRole, "Нет": QMessageBox.NoRole}, "default": "Нет"}) + if reply == "Да": + self.user_cancelled = True + self.apply_process.terminate() # Попытка мягкого завершения + event.accept() # Разрешаем закрытие + else: + event.ignore() # Запрещаем закрытие + else: + event.accept() # Процесс не запущен, можно закрывать + + def _show_message_box(self, title, text, icon, config): + """Централизованный метод для создания и показа QMessageBox.""" + msg_box = QMessageBox(self) + msg_box.setWindowTitle(title) + msg_box.setText(text) + msg_box.setIcon(icon) + + buttons = {} + for btn_text, role in config.get("buttons", {}).items(): + buttons[btn_text] = msg_box.addButton(btn_text, role) + + default_btn_text = config.get("default") + if default_btn_text and default_btn_text in buttons: + msg_box.setDefaultButton(buttons[default_btn_text]) + + msg_box.exec_() + + clicked_button = msg_box.clickedButton() + return clicked_button.text() if clicked_button else None + + def _log(self, message, color=None): + """Добавляет сообщение в лог с возможностью указания цвета.""" + if color: + self.log_output.append(f'{message}') + else: + self.log_output.append(message) + self.log_output.moveCursor(QTextCursor.End) + class WineHelperGUI(QMainWindow): def __init__(self): super().__init__() @@ -144,6 +658,7 @@ class WineHelperGUI(QMainWindow): if show_global: self.backup_button.setVisible(False) self.create_log_button.setVisible(False) + self.uninstall_button.setVisible(False) self.current_selected_app = None def on_tab_changed(self, index): @@ -223,15 +738,46 @@ class WineHelperGUI(QMainWindow): 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) + # --- Сетка с утилитами --- + utils_grid_layout = QGridLayout() + utils_grid_layout.setSpacing(5) + + # Ряд 0 + self.winetricks_button = QPushButton("Менеджер компонентов") + self.winetricks_button.clicked.connect(self.open_winetricks_manager) + utils_grid_layout.addWidget(self.winetricks_button, 0, 0) + + self.winecfg_button = QPushButton("Редактор настроек") + self.winecfg_button.clicked.connect(lambda: self._run_wine_util('winecfg')) + utils_grid_layout.addWidget(self.winecfg_button, 0, 1) + + # Ряд 1 + self.regedit_button = QPushButton("Редактор реестра") + self.regedit_button.clicked.connect(lambda: self._run_wine_util('regedit')) + utils_grid_layout.addWidget(self.regedit_button, 1, 0) + + self.uninstaller_button = QPushButton("Удаление программ") + self.uninstaller_button.clicked.connect(lambda: self._run_wine_util('uninstaller')) + utils_grid_layout.addWidget(self.uninstaller_button, 1, 1) + + # Ряд 2 + self.cmd_button = QPushButton("Командная строка") + self.cmd_button.clicked.connect(lambda: self._run_wine_util('cmd')) + utils_grid_layout.addWidget(self.cmd_button, 2, 0) + + self.winefile_button = QPushButton("Файловый менеджер") + self.winefile_button.clicked.connect(lambda: self._run_wine_util('winefile')) + utils_grid_layout.addWidget(self.winefile_button, 2, 1) + + installed_action_layout.addLayout(utils_grid_layout) + self.installed_action_widget.setLayout(installed_action_layout) self.info_panel_layout.addWidget(self.installed_action_widget) @@ -249,10 +795,16 @@ class WineHelperGUI(QMainWindow): self.backup_button.clicked.connect(self.backup_prefix_for_app) installed_global_layout.addWidget(self.backup_button) + self.uninstall_button = QPushButton("Удалить префикс") + self.uninstall_button.setIcon(QIcon.fromTheme("user-trash")) + self.uninstall_button.clicked.connect(self.uninstall_app) + installed_global_layout.addWidget(self.uninstall_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) @@ -557,12 +1109,6 @@ class WineHelperGUI(QMainWindow): 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 ) @@ -817,6 +1363,7 @@ class WineHelperGUI(QMainWindow): self.installed_global_action_widget.setVisible(True) self.backup_button.setVisible(True) self.create_log_button.setVisible(True) + self.uninstall_button.setVisible(True) self.manual_install_path_widget.setVisible(False) except Exception as e: @@ -861,7 +1408,7 @@ class WineHelperGUI(QMainWindow): yes_button = QPushButton("Да") no_button = QPushButton("Нет") - msg_box = QMessageBox() + msg_box = QMessageBox(self) msg_box.setWindowTitle("Создание резервной копии") msg_box.setText( f"Будет создана резервная копия префикса '{prefix_name}'.\n" @@ -894,7 +1441,8 @@ class WineHelperGUI(QMainWindow): layout.addWidget(self.command_close_button) self.command_dialog.setLayout(layout) - self.command_process = QProcess() + # Устанавливаем родителя, чтобы избежать утечек памяти + self.command_process = QProcess(self.command_dialog) self.command_process.setProcessChannelMode(QProcess.MergedChannels) self.command_process.readyReadStandardOutput.connect(self._handle_command_output) self.command_process.finished.connect(self._handle_command_finished) @@ -937,7 +1485,8 @@ class WineHelperGUI(QMainWindow): layout.addWidget(self.command_close_button) self.command_dialog.setLayout(layout) - self.command_process = QProcess() + # Устанавливаем родителя, чтобы избежать утечек памяти + self.command_process = QProcess(self.command_dialog) self.command_process.setProcessChannelMode(QProcess.MergedChannels) self.command_process.readyReadStandardOutput.connect(self._handle_command_output) self.command_process.finished.connect(self._handle_restore_finished) @@ -955,7 +1504,7 @@ class WineHelperGUI(QMainWindow): yes_button = QPushButton("Да") no_button = QPushButton("Нет") - msg_box = QMessageBox() + msg_box = QMessageBox(self) msg_box.setWindowTitle("Создание лога") msg_box.setText( "Приложение будет запущено в режиме отладки.\n" @@ -969,6 +1518,72 @@ class WineHelperGUI(QMainWindow): if msg_box.clickedButton() == yes_button: self._run_app_launcher(debug=True) + def open_winetricks_manager(self): + """Открывает новый диалог для управления компонентами Winetricks.""" + prefix_name = self._get_prefix_name_for_selected_app() + if not prefix_name: + QMessageBox.warning(self, "Менеджер Winetricks", "Сначала выберите установленное приложение, чтобы открыть менеджер для его префикса.") + return + + prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name) + if not os.path.isdir(prefix_path): + QMessageBox.critical(self, "Ошибка", f"Каталог префикса не найден:\n{prefix_path}") + return + + winehelper_dir = os.path.dirname(self.winehelper_path) + winetricks_path = None + try: + # Ищем файл, который начинается с 'winetricks_' + for filename in os.listdir(winehelper_dir): + if filename.startswith("winetricks_"): + winetricks_path = os.path.join(winehelper_dir, filename) + break # Нашли, выходим из цикла + except OSError as e: + QMessageBox.critical(self, "Ошибка", f"Не удалось прочитать директорию {winehelper_dir}: {e}") + return + + if not winetricks_path: + QMessageBox.critical(self, "Ошибка", f"Скрипт winetricks не найден в директории:\n{winehelper_dir}") + return + + dialog = WinetricksManagerDialog(prefix_path, winetricks_path, self) + dialog.exec_() + + def _run_wine_util(self, util_name): + """Запускает стандартную утилиту Wine для выбранного префикса.""" + prefix_name = self._get_prefix_name_for_selected_app() + if not prefix_name: + QMessageBox.warning(self, "Ошибка", "Сначала выберите установленное приложение.") + return + + prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name) + if not os.path.isdir(prefix_path): + QMessageBox.critical(self, "Ошибка", f"Каталог префикса не найден:\n{prefix_path}") + return + + env = os.environ.copy() + env["WINEPREFIX"] = prefix_path + + # 'wine cmd' - особый случай, требует запуска в терминале + if util_name == 'cmd': + terminal_command = f"env WINEPREFIX='{prefix_path}' wine cmd" + try: + # x-terminal-emulator - стандартный способ вызова терминала по умолчанию + subprocess.Popen(['x-terminal-emulator', '-e', terminal_command]) + except FileNotFoundError: + QMessageBox.critical(self, "Ошибка", "Не удалось найти `x-terminal-emulator`.\nУбедитесь, что у вас установлен терминал по умолчанию (например, mate-terminal или xterm).") + except Exception as e: + QMessageBox.critical(self, "Ошибка", f"Не удалось запустить терминал: {e}") + return + + # Для остальных утилит + command = ['wine', util_name] + try: + subprocess.Popen(command, env=env) + except Exception as e: + QMessageBox.critical(self, "Ошибка запуска", + f"Не удалось запустить команду:\n{' '.join(command)}\n\nОшибка: {str(e)}") + def run_installed_app(self): """Запускает выбранное установленное приложение""" self._run_app_launcher(debug=False) @@ -1035,7 +1650,7 @@ class WineHelperGUI(QMainWindow): yes_button = QPushButton("Да") no_button = QPushButton("Нет") - msg_box = QMessageBox() + msg_box = QMessageBox(self) msg_box.setWindowTitle('Подтверждение') msg_box.setText( f'Вы действительно хотите удалить "{app_name}" и все связанные файлы (включая префикс "{prefix_name}")?' @@ -1240,7 +1855,7 @@ class WineHelperGUI(QMainWindow): 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) @@ -1384,7 +1999,8 @@ class WineHelperGUI(QMainWindow): def _start_installation(self, winehelper_path, script_path, install_file=None): """Запускает процесс установки""" - self.install_process = QProcess() + # Устанавливаем родителя для QProcess, чтобы он корректно удалялся вместе с диалогом + self.install_process = QProcess(self.install_dialog) self.install_process.setProcessChannelMode(QProcess.MergedChannels) self.install_process.setWorkingDirectory(os.path.dirname(winehelper_path)) @@ -1519,6 +2135,12 @@ class WineHelperGUI(QMainWindow): # Кнопка прервать self.btn_abort.setEnabled(False) + # Процесс завершен, можно запланировать его удаление и очистить ссылку, + # чтобы избежать утечек и висячих ссылок. + if self.install_process: + self.install_process.deleteLater() + self.install_process = None + def handle_install_dialog_close(self, event): """Обрабатывает событие закрытия диалога установки.""" # Проверяем, запущен ли еще процесс установки @@ -1555,7 +2177,7 @@ class WineHelperGUI(QMainWindow): 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 = QMessageBox(self.install_dialog) msg_box.setWindowTitle("Подтверждение") msg_box.setText("Вы действительно хотите прервать установку?") msg_box.addButton(yes_button, QMessageBox.YesRole) @@ -1580,6 +2202,9 @@ class WineHelperGUI(QMainWindow): self.command_log_output.append(f"\n=== Команда успешно завершена ===") else: self.command_log_output.append(f"\n=== Ошибка выполнения (код: {exit_code}) ===") + if self.command_process: + self.command_process.deleteLater() + self.command_process = None self.command_close_button.setEnabled(True) def _handle_restore_finished(self, exit_code, exit_status): @@ -1590,6 +2215,9 @@ class WineHelperGUI(QMainWindow): self.filter_installed_buttons() else: self.command_log_output.append(f"\n=== Ошибка выполнения (код: {exit_code}) ===") + if self.command_process: + self.command_process.deleteLater() + self.command_process = None self.command_close_button.setEnabled(True) def cleanup_process(self): From 2666ee33ad52e2ae792f18ef85a326d4b5e4e25f Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Mon, 11 Aug 2025 16:59:08 +0600 Subject: [PATCH 02/12] refactoring the filter_autoinstall_buttons and filter_manual_buttons functions --- winehelper_gui.py | 60 +++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/winehelper_gui.py b/winehelper_gui.py index b343626..7caae3c 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -585,6 +585,7 @@ class WineHelperGUI(QMainWindow): self.install_dialog = None self.current_active_button = None self.installed_buttons = [] + self.install_tabs_data = {} self.current_selected_app = None self.icon_animators = {} @@ -1092,20 +1093,28 @@ class WineHelperGUI(QMainWindow): def create_auto_install_tab(self): """Создает вкладку для автоматической установки программ""" ( - self.autoinstall_scripts, self.autoinstall_buttons, self.scroll_layout, - self.search_edit, self.auto_scroll_area + scripts, buttons, layout, + search_edit, scroll_area ) = self._create_and_populate_install_tab( - "Автоматическая установка", "autoinstall", "Поиск скрипта автоматической установки...", self.filter_autoinstall_buttons + "Автоматическая установка", "autoinstall", "Поиск скрипта автоматической установки...", partial(self.filter_buttons, 'auto') ) + self.autoinstall_scripts = scripts + self.install_tabs_data['auto'] = { + 'buttons': buttons, 'layout': layout, 'search_edit': search_edit, 'scroll_area': scroll_area + } def create_manual_install_tab(self): """Создает вкладку для ручной установки программ""" ( - self.manualinstall_scripts, self.manualinstall_buttons, self.manual_scroll_layout, - self.manual_search_edit, self.manual_scroll_area + scripts, buttons, layout, + search_edit, scroll_area ) = self._create_and_populate_install_tab( - "Ручная установка", "manualinstall", "Поиск скрипта ручной установки...", self.filter_manual_buttons + "Ручная установка", "manualinstall", "Поиск скрипта ручной установки...", partial(self.filter_buttons, 'manual') ) + self.manualinstall_scripts = scripts + self.install_tabs_data['manual'] = { + 'buttons': buttons, 'layout': layout, 'search_edit': search_edit, 'scroll_area': scroll_area + } def create_installed_tab(self): """Создает вкладку для отображения установленных программ в виде кнопок""" @@ -1764,35 +1773,36 @@ class WineHelperGUI(QMainWindow): 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): - """Общий метод для фильтрации кнопок и перестроения сетки.""" + """Общий метод для фильтрации кнопок и перестроения сетки (helper).""" 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_buttons(self, tab_type): + """Фильтрует кнопки для указанной вкладки установки ('auto' или 'manual').""" + if tab_type not in self.install_tabs_data: + return + data = self.install_tabs_data[tab_type] + self._filter_buttons_in_grid( + data['search_edit'].text(), data['buttons'], data['layout'] + ) + 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: @@ -1812,22 +1822,22 @@ class WineHelperGUI(QMainWindow): # Определяем виджеты и действия в зависимости от типа скрипта 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 + tab_type = 'auto' 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 + tab_type = 'manual' self.manual_install_path_widget.setVisible(True) + tab_data = self.install_tabs_data[tab_type] + search_edit = tab_data['search_edit'] + scroll_area = tab_data['scroll_area'] + # Общая логика: очищаем поиск, обновляем список и прокручиваем к элементу search_edit.blockSignals(True) search_edit.clear() search_edit.blockSignals(False) - filter_func() + self.filter_buttons(tab_type) frame = button_widget.parent() if isinstance(frame, QFrame): QTimer.singleShot(0, lambda: scroll_area.ensureWidgetVisible(frame)) @@ -2233,12 +2243,6 @@ class WineHelperGUI(QMainWindow): 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) From 159fc26eca23b50cb80ff0a2e15cc89e81fbf52f Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Mon, 11 Aug 2025 17:06:54 +0600 Subject: [PATCH 03/12] added clearing of the search field when switching tabs --- winehelper_gui.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/winehelper_gui.py b/winehelper_gui.py index 7caae3c..63e6435 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -588,6 +588,7 @@ class WineHelperGUI(QMainWindow): self.install_tabs_data = {} self.current_selected_app = None self.icon_animators = {} + self.previous_tab_index = 0 # Создаем главный виджет и layout self.main_widget = QWidget() @@ -664,6 +665,17 @@ class WineHelperGUI(QMainWindow): def on_tab_changed(self, index): """Скрывает или показывает панель информации в зависимости от активной вкладки.""" + # Очищаем поле поиска на вкладке, которую покинули + previous_widget = self.tabs.widget(self.previous_tab_index) + if previous_widget: + # Ищем QLineEdit в дочерних элементах + search_edit = previous_widget.findChild(QLineEdit) + if search_edit: + search_edit.clear() + + # Обновляем индекс предыдущей вкладки для следующего переключения + self.previous_tab_index = index + current_tab_text = self.tabs.tabText(index) # Сбрасываем растяжение к состоянию по умолчанию: From a27832329db11ae992cf89b3d7ea5842972e6e44 Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Mon, 11 Aug 2025 17:40:04 +0600 Subject: [PATCH 04/12] optimization of icon animation in the _start_icon_fade_animation method --- winehelper_gui.py | 81 +++++++++++++++++++++++++++-------------------- 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/winehelper_gui.py b/winehelper_gui.py index 63e6435..c01851a 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -13,7 +13,7 @@ from functools import partial from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,QPushButton, QLabel, QTabWidget, QTextEdit, QFileDialog, QMessageBox, QLineEdit, QCheckBox, QStackedWidget, QScrollArea, QListWidget, QListWidgetItem, QGridLayout, QFrame, QDialog, QTextBrowser) -from PyQt5.QtCore import Qt, QProcess, QSize, QTimer, QProcessEnvironment +from PyQt5.QtCore import Qt, QProcess, QSize, QTimer, QProcessEnvironment, QPropertyAnimation, QEasingCurve from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPixmap, QPainter @@ -908,68 +908,79 @@ class WineHelperGUI(QMainWindow): 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): - """Запускает анимацию плавного перехода для иконки на кнопке.""" + """Запускает анимацию плавного перехода для иконки на кнопке с помощью QPropertyAnimation.""" if button not in self.icon_animators: return anim_data = self.icon_animators[button] + # Получаем или создаем объект анимации один раз + animation = anim_data.get('animation') + if not animation: + # Устанавливаем динамическое свойство, чтобы избежать предупреждений + button.setProperty("fadeProgress", 0.0) + animation = QPropertyAnimation(button, b"fadeProgress", self) + animation.setDuration(700) + animation.setEasingCurve(QEasingCurve.InOutQuad) + + # Сигналы подключаются только один раз при создании + animation.valueChanged.connect( + lambda value, b=button: self._update_icon_frame(b, value) + ) + animation.finished.connect( + lambda b=button: self._on_fade_animation_finished(b) + ) + anim_data['animation'] = animation + # Останавливаем предыдущую анимацию, если она еще идет - if anim_data.get('fade_timer') and anim_data['fade_timer'].isActive(): - anim_data['fade_timer'].stop() + if animation.state() == QPropertyAnimation.Running: + animation.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) + # Сохраняем QPixmap для использования в функции обновления кадра + anim_data['pixmaps'] = (QPixmap(current_icon_path), 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 кадров в секунду + # Устанавливаем начальное и конечное значения и запускаем + animation.setStartValue(0.0) + animation.setEndValue(1.0) + animation.start() # Без DeleteWhenStopped - def _update_fade_frame(self, button, old_pixmap, new_pixmap): - """Обновляет кадр анимации перехода иконок.""" - if button not in self.icon_animators: + def _on_fade_animation_finished(self, button): + """Вызывается по завершении анимации для обновления индекса иконки.""" + if button in self.icon_animators: + anim_data = self.icon_animators[button] + anim_data['current_index'] = (anim_data['current_index'] + 1) % len(anim_data['icons']) + + def _update_icon_frame(self, button, progress): + """Обновляет кадр анимации, смешивая две иконки в зависимости от прогресса.""" + anim_data = self.icon_animators.get(button) + if not anim_data or 'pixmaps' not in anim_data: return - anim_data = self.icon_animators[button] - # Длительность анимации: 44 шага * 16 мс ~= 700 мс - FADE_DURATION_STEPS = 44 + old_pixmap, new_pixmap = anim_data['pixmaps'] - 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']) + # На последнем кадре просто устанавливаем новую иконку + if progress >= 1.0: 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() From 16b9a84191f04b82321ad1bcebeec19fa7ff6f48 Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Thu, 14 Aug 2025 13:15:40 +0600 Subject: [PATCH 05/12] added system dependency check --- winehelper_gui.py | 410 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 403 insertions(+), 7 deletions(-) diff --git a/winehelper_gui.py b/winehelper_gui.py index c01851a..2e56df6 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -28,6 +28,396 @@ class Var: LICENSE_FILE = os.environ.get("LICENSE_FILE") LICENSE_AGREEMENT_FILE = os.environ.get("AGREEMENT") +class DependencyManager: + """Класс для управления проверкой и установкой системных зависимостей.""" + + def __init__(self): + """Инициализирует менеджер, определяя необходимые пути.""" + self.dependencies_script_path = self._get_dependencies_path() + self.config_dir = os.path.join(os.path.expanduser("~"), ".config", "winehelper") + self.hash_flag_file = os.path.join(self.config_dir, "dependencies_hash.txt") + os.makedirs(self.config_dir, exist_ok=True) + self.app_icon = QIcon(Var.WH_ICON_PATH) if Var.WH_ICON_PATH and os.path.exists(Var.WH_ICON_PATH) else QIcon() + + def _get_dependencies_path(self): + """Определяет и возвращает путь к скрипту dependencies.sh.""" + winehelper_script_path = os.environ.get("RUN_SCRIPT") + if winehelper_script_path and os.path.exists(winehelper_script_path): + return os.path.join(os.path.dirname(winehelper_script_path), 'dependencies.sh') + return None + + def _calculate_file_hash(self): + """Вычисляет хэш SHA256 файла зависимостей.""" + if not self.dependencies_script_path or not os.path.exists(self.dependencies_script_path): + return None + hasher = hashlib.sha256() + try: + with open(self.dependencies_script_path, 'rb') as f: + while chunk := f.read(4096): + hasher.update(chunk) + return hasher.hexdigest() + except IOError: + return None + + def _parse_dependencies_from_script(self): + """ + Парсит скрипт dependencies.sh для извлечения списка базовых пакетов. + Возвращает список пакетов или None в случае ошибки. + """ + if not os.path.exists(self.dependencies_script_path): + return None + + base_packages = [] + try: + with open(self.dependencies_script_path, 'r', encoding='utf-8') as f: + content = f.read() + + content = content.replace('\\\n', '') + pattern = r'apt-get install\s+\{i586-,\}(\{.*?\}|[\w.-]+)' + matches = re.findall(pattern, content) + + for match in matches: + match = match.strip() + if match.startswith('{') and match.endswith('}'): + group_content = match[1:-1] + packages = [pkg.strip() for pkg in group_content.split(',')] + base_packages.extend(packages) + else: + base_packages.append(match) + + return sorted(list(set(pkg for pkg in base_packages if pkg))) or None + except Exception: + return None + + def _parse_repo_error_from_script(self): + """ + Парсит скрипт dependencies.sh для извлечения сообщения об ошибке репозитория. + Возвращает сообщение или None в случае ошибки. + """ + if not os.path.exists(self.dependencies_script_path): + return None + + try: + with open(self.dependencies_script_path, 'r', encoding='utf-8') as f: + content = f.read() + + content = content.replace('\\\n', ' ') + match = re.search(r'apt-repo.*\|\|.*fatal\s+"([^"]+)"', content) + if match: + error_message = match.group(1).strip() + return re.sub(r'\s+', ' ', error_message) + return None + except Exception: + return None + + def _show_startup_messages(self): + """ + Проверяет, является ли это первым запуском или обновлением, + и показывает соответствующие сообщения. + """ + current_hash = self._calculate_file_hash() + stored_hash = None + hash_file_exists = os.path.exists(self.hash_flag_file) + + if hash_file_exists: + try: + with open(self.hash_flag_file, 'r', encoding='utf-8') as f: + stored_hash = f.read().strip() + except IOError: + pass + + if not hash_file_exists: + msg_box = QMessageBox(QMessageBox.Information, "Первый запуск WineHelper", + "Поскольку это первый запуск, программа проверит наличие необходимых системных зависимостей.") + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + elif current_hash != stored_hash: + msg_box = QMessageBox(QMessageBox.Information, "Обновление зависимостей", + "Обнаружены изменения в системных требованиях.\n\n" + "Программа выполнит проверку, чтобы убедиться, что все компоненты на месте.") + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + + def _save_dependency_hash(self): + """Сохраняет хэш зависимостей в конфигурационный файл.""" + current_hash = self._calculate_file_hash() + if not current_hash: + return + try: + with open(self.hash_flag_file, 'w', encoding='utf-8') as f: + f.write(current_hash) + except IOError: + print("Предупреждение: не удалось записать файл с хэшем зависимостей.") + + def _perform_check_and_install(self): + """ + Выполняет основную логику проверки и установки зависимостей. + Возвращает True в случае успеха, иначе False. + """ + # Проверка наличия ключевых утилит + if not shutil.which('apt-repo') or not shutil.which('rpm'): + return True + + if not self.dependencies_script_path or not os.path.exists(self.dependencies_script_path): + msg_box = QMessageBox(QMessageBox.Critical, "Критическая ошибка", + f"Файл зависимостей не найден по пути:\n'{self.dependencies_script_path}'.\n\n" + "Программа не может продолжить работу." + ) + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + return False + + # 1. Проверка наличия репозитория x86_64-i586 + try: + result = subprocess.run(['apt-repo'], capture_output=True, text=True, check=False, encoding='utf-8') + if result.returncode != 0 or 'x86_64-i586' not in result.stdout: + error_message = self._parse_repo_error_from_script() + if not error_message: + msg_box = QMessageBox(QMessageBox.Critical, "Критическая ошибка", + f"Репозиторий x86_64-i586 не подключен, но не удалось извлечь текст ошибки из файла:\n'{self.dependencies_script_path}'.\n\n" + "Проверьте целостность скрипта. Работа программы будет прекращена." + ) + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + return False + + msg_box = QMessageBox(QMessageBox.Critical, "Ошибка репозитория", + f"{error_message}\n\nРабота программы будет прекращена.") + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + return False + except FileNotFoundError: + return True + + # 2. Определение списка пакетов из dependencies.sh + base_packages = self._parse_dependencies_from_script() + + # Если парсинг не удался или скрипт не найден, прерываем работу. + if not base_packages: + msg_box = QMessageBox(QMessageBox.Critical, "Критическая ошибка", + f"Не удалось найти или проанализировать файл зависимостей:\n'{self.dependencies_script_path}'.\n\n" + "Программа не может продолжить работу без списка необходимых пакетов." + ) + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + return False + + required_packages = [f"i586-{pkg}" for pkg in base_packages] + base_packages + + # 3. Проверка, какие пакеты отсутствуют + try: + # Запрашиваем список всех установленных пакетов один раз для эффективности + result = subprocess.run( + ['rpm', '-qa', '--queryformat', '%{NAME}\n'], + capture_output=True, text=True, check=True, encoding='utf-8' + ) + installed_packages_set = set(result.stdout.splitlines()) + required_packages_set = set(required_packages) + + # Находим разницу между требуемыми и установленными пакетами + missing_packages = sorted(list(required_packages_set - installed_packages_set)) + + except (subprocess.CalledProcessError, FileNotFoundError) as e: + # В случае ошибки (например, rpm не найден), показываем критическое сообщение + msg_box = QMessageBox(QMessageBox.Critical, "Критическая ошибка", + f"Не удалось получить список установленных пакетов с помощью rpm.\n\nОшибка: {e}\n\n" + "Программа не может проверить зависимости и будет закрыта." + ) + msg_box.setWindowIcon(self.app_icon) + msg_box.exec_() + return False + + if not missing_packages: + return True + + # 4. Запрос у пользователя на установку + msg_box = QMessageBox() + msg_box.setWindowIcon(self.app_icon) + msg_box.setIcon(QMessageBox.Warning) + msg_box.setWindowTitle("Отсутствуют зависимости") + # Устанавливаем формат текста в RichText, чтобы QMessageBox корректно обрабатывал HTML-теги. + msg_box.setTextFormat(Qt.RichText) + + # Формируем весь текст как единый HTML-блок для корректного отображения. + full_html_text = ( + "Для корректной работы WineHelper требуются дополнительные системные компоненты.

" + "Отсутствуют следующие пакеты:
" + # Ограничиваем высоту блока с пакетами и добавляем прокрутку, если список длинный. + f"""
+ {'
'.join(sorted(missing_packages))} +
""" + "Нажмите 'Установить', чтобы запустить установку с правами администратора. " + "Вам потребуется ввести пароль. Этот процесс может занять некоторое время." + ) + msg_box.setText(full_html_text) + install_button = msg_box.addButton("Установить", QMessageBox.AcceptRole) + cancel_button = msg_box.addButton("Отмена", QMessageBox.RejectRole) + msg_box.setDefaultButton(install_button) + + msg_box.exec_() + + if msg_box.clickedButton() != install_button: + cancel_box = QMessageBox(QMessageBox.Warning, "Отмена", + "Установка отменена.\n\n" + "WineHelper не может продолжить работу\nбез необходимых зависимостей.") + cancel_box.setWindowIcon(self.app_icon) + cancel_box.exec_() + return False + + # 5. Запуск установки с отображением лога в диалоговом окне + if not shutil.which('pkexec'): + error_box = QMessageBox(QMessageBox.Critical, "Ошибка", + "Не найден компонент 'pkexec' для повышения прав.\n" + "Пожалуйста, установите пакет 'polkit-pkexec', " + f"а затем установите зависимости вручную:\n\n sudo apt-get install {' '.join(missing_packages)}" + ) + error_box.setWindowIcon(self.app_icon) + error_box.exec_() + return False + + install_cmd_str = f"apt-get update && apt-get install -y {' '.join(missing_packages)}" + + # Этот флаг будет установлен в True, если установка и проверка пройдут успешно. + installation_successful = False + + # Создаем диалог для вывода лога установки + dialog = QDialog() + dialog.setWindowIcon(self.app_icon) + dialog.setWindowTitle("Установка зависимостей") + dialog.setMinimumSize(680, 400) + dialog.setModal(True) + + # Центрирование окна по центру экрана + screen_geometry = QApplication.primaryScreen().availableGeometry() + dialog.move( + (screen_geometry.width() - dialog.width()) // 2, + (screen_geometry.height() - dialog.height()) // 2 + ) + + layout = QVBoxLayout(dialog) + log_output = QTextEdit() + log_output.setReadOnly(True) + log_output.setFont(QFont("DejaVu Sans Mono", 10)) + layout.addWidget(log_output) + + close_button = QPushButton("Закрыть") + close_button.setEnabled(False) + close_button.clicked.connect(dialog.accept) + layout.addWidget(close_button) + + process = QProcess(dialog) + process.setProcessChannelMode(QProcess.MergedChannels) + + def handle_output(): + # Используем insertPlainText для корректного отображения потокового вывода (например, прогресс-баров) + log_output.insertPlainText(process.readAll().data().decode('utf-8', 'ignore')) + log_output.moveCursor(QTextCursor.End) + + def handle_finish(exit_code, exit_status): + nonlocal installation_successful + log_output.moveCursor(QTextCursor.End) + if exit_code == 0 and exit_status == QProcess.NormalExit: + log_output.append("\n=== Установка успешно завершена ===") + log_output.ensureCursorVisible() + + # Повторная проверка зависимостей сразу после установки + try: + result = subprocess.run( + ['rpm', '-qa', '--queryformat', '%{NAME}\n'], + capture_output=True, text=True, check=True, encoding='utf-8' + ) + installed_packages_set = set(result.stdout.splitlines()) + missing_packages_set = set(missing_packages) + still_missing = sorted(list(missing_packages_set - installed_packages_set)) + except (subprocess.CalledProcessError, FileNotFoundError): + warn_box = QMessageBox(dialog) + warn_box.setWindowIcon(self.app_icon) + warn_box.setIcon(QMessageBox.Warning) + warn_box.setWindowTitle("Проверка не удалась") + warn_box.setText("Не удалось повторно проверить зависимости после установки.") + warn_box.exec_() + still_missing = missing_packages + + if not still_missing: + info_box = QMessageBox(dialog) + info_box.setWindowIcon(self.app_icon) + info_box.setIcon(QMessageBox.Information) + info_box.setWindowTitle("Успех") + info_box.setText("Все необходимые зависимости были успешно установлены.") + info_box.exec_() + installation_successful = True + else: + warn_box = QMessageBox(dialog) + warn_box.setWindowIcon(self.app_icon) + warn_box.setIcon(QMessageBox.Warning) + warn_box.setWindowTitle("Установка не завершена") + warn_box.setText( + "Не все зависимости были установлены. WineHelper может работать некорректно.\n\n" + f"Отсутствуют: {', '.join(sorted(still_missing))}\n\n" + "Попробуйте запустить установку снова или установите пакеты вручную." + ) + warn_box.exec_() + else: + log_tag = "ПРЕРВАНО" if exit_status == QProcess.CrashExit else "ОШИБКА" + log_output.append(f"\n=== {log_tag} (код: {exit_code}) ===") + log_output.ensureCursorVisible() + + close_button.setEnabled(True) + + def dialog_close_handler(event): + """Обрабатывает закрытие окна во время установки зависимостей.""" + if process.state() == QProcess.Running: + msg_box = QMessageBox(dialog) + msg_box.setMinimumWidth(900) + msg_box.setIcon(QMessageBox.Question) + msg_box.setWindowTitle("Прервать установку?") + msg_box.setText( + "

Установка зависимостей еще не завершена.

" + "

Вы действительно хотите прервать процесс?

" + "

Это закроет только окно программы.
" + "Процесс установки зависимостей будет продолжен.

" + ) + + yes_button = msg_box.addButton("Да, прервать", QMessageBox.YesRole) + no_button = msg_box.addButton("Нет", QMessageBox.NoRole) + msg_box.setDefaultButton(no_button) + + msg_box.exec_() + + if msg_box.clickedButton() == yes_button: + process.readyRead.disconnect() + process.finished.disconnect() + process.terminate() + event.accept() + else: + event.ignore() + else: + event.accept() + + process.readyRead.connect(handle_output) + process.finished.connect(handle_finish) + + log_output.append(f"Выполнение команды:\npkexec sh -c \"{install_cmd_str}\"\n") + log_output.append("Пожалуйста, введите пароль в появившемся окне аутентификации...") + log_output.append("-" * 40 + "\n") + + dialog.closeEvent = dialog_close_handler + process.start('pkexec', ['sh', '-c', install_cmd_str]) + dialog.exec_() + + return installation_successful + + def run(self): + """ + Основной публичный метод для запуска полной проверки зависимостей. + Возвращает True, если все зависимости удовлетворены, иначе False. + """ + self._show_startup_messages() + if self._perform_check_and_install(): + self._save_dependency_hash() + return True + return False + class WinetricksManagerDialog(QDialog): """Диалог для управления компонентами Winetricks.""" @@ -542,7 +932,7 @@ class WineHelperGUI(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("WineHelper") - self.setGeometry(100, 100, 950, 500) + self.setMinimumSize(950, 500) if Var.WH_ICON_PATH and os.path.exists(Var.WH_ICON_PATH): self.setWindowIcon(QIcon(Var.WH_ICON_PATH)) @@ -2258,17 +2648,23 @@ class WineHelperGUI(QMainWindow): 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.waitForFinished() self.install_process.deleteLater() self.install_process = None +def main(): + """Основная точка входа в приложение.""" + app = QApplication(sys.argv) + + dependency_manager = DependencyManager() + if dependency_manager.run(): + window = WineHelperGUI() + window.show() + return app.exec_() + return 1 if __name__ == "__main__": - app = QApplication(sys.argv) - window = WineHelperGUI() - window.show() - sys.exit(app.exec_()) + sys.exit(main()) From 95dd7ae598e0e93637db1e944510c7218278e38e Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Thu, 14 Aug 2025 13:55:16 +0600 Subject: [PATCH 06/12] added tooltips for winetricks buttons --- winehelper_gui.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/winehelper_gui.py b/winehelper_gui.py index 2e56df6..6ecd75f 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -1155,28 +1155,34 @@ class WineHelperGUI(QMainWindow): # Ряд 0 self.winetricks_button = QPushButton("Менеджер компонентов") self.winetricks_button.clicked.connect(self.open_winetricks_manager) + self.winetricks_button.setToolTip("Установка компонентов, библиотек и шрифтов в префикс с помощью Winetricks.") utils_grid_layout.addWidget(self.winetricks_button, 0, 0) self.winecfg_button = QPushButton("Редактор настроек") self.winecfg_button.clicked.connect(lambda: self._run_wine_util('winecfg')) + self.winecfg_button.setToolTip("Запуск утилиты winecfg для настройки параметров Wine (версия Windows, диски, аудио и т.д.).") utils_grid_layout.addWidget(self.winecfg_button, 0, 1) # Ряд 1 self.regedit_button = QPushButton("Редактор реестра") self.regedit_button.clicked.connect(lambda: self._run_wine_util('regedit')) + self.regedit_button.setToolTip("Запуск редактора реестра Wine (regedit) для просмотра и изменения ключей реестра в префиксе.") utils_grid_layout.addWidget(self.regedit_button, 1, 0) self.uninstaller_button = QPushButton("Удаление программ") self.uninstaller_button.clicked.connect(lambda: self._run_wine_util('uninstaller')) + self.uninstaller_button.setToolTip("Запуск стандартного деинсталлятора Wine для удаления установленных в префикс Windows-программ.") utils_grid_layout.addWidget(self.uninstaller_button, 1, 1) # Ряд 2 self.cmd_button = QPushButton("Командная строка") self.cmd_button.clicked.connect(lambda: self._run_wine_util('cmd')) + self.cmd_button.setToolTip("Запуск командной строки (cmd) в окружении выбранного префикса.") utils_grid_layout.addWidget(self.cmd_button, 2, 0) self.winefile_button = QPushButton("Файловый менеджер") self.winefile_button.clicked.connect(lambda: self._run_wine_util('winefile')) + self.winefile_button.setToolTip("Запуск файлового менеджера Wine (winefile) для просмотра файлов внутри префикса.") utils_grid_layout.addWidget(self.winefile_button, 2, 1) installed_action_layout.addLayout(utils_grid_layout) From 3e13cdf8f072d966c7ac5aa4a2c9c4a85c4f24d6 Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Thu, 14 Aug 2025 16:00:58 +0600 Subject: [PATCH 07/12] re-launching the program is disabled --- winehelper_gui.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/winehelper_gui.py b/winehelper_gui.py index 6ecd75f..64a3287 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -15,6 +15,7 @@ from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QH QListWidget, QListWidgetItem, QGridLayout, QFrame, QDialog, QTextBrowser) from PyQt5.QtCore import Qt, QProcess, QSize, QTimer, QProcessEnvironment, QPropertyAnimation, QEasingCurve from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPixmap, QPainter +from PyQt5.QtNetwork import QLocalServer, QLocalSocket class Var: @@ -1015,6 +1016,17 @@ class WineHelperGUI(QMainWindow): # Устанавливаем начальное состояние видимости панели self.on_tab_changed(self.tabs.currentIndex()) + def activate(self): + """ + Активирует и показывает окно приложения, поднимая его из свернутого состояния + и перемещая на передний план. + """ + # Убеждаемся, что окно не свернуто + self.setWindowState(self.windowState() & ~Qt.WindowMinimized | Qt.WindowActive) + self.show() + self.raise_() + self.activateWindow() + def _reset_info_panel_to_default(self, tab_name): """Сбрасывает правую панель в состояние по умолчанию для указанной вкладки.""" if tab_name == "Автоматическая установка": @@ -2663,13 +2675,59 @@ class WineHelperGUI(QMainWindow): def main(): """Основная точка входа в приложение.""" + # QApplication должен быть создан до использования любых других частей Qt app = QApplication(sys.argv) + # Создаем уникальное имя для сокета на основе ID пользователя, + # чтобы у каждого пользователя был свой экземпляр. + socket_name = f"winehelper_{os.getuid()}" + + # Пытаемся подключиться к существующему экземпляру + socket = QLocalSocket() + socket.connectToServer(socket_name) + + # Если подключение успешно в течение 500 мс, значит, другой экземпляр уже запущен. + if socket.waitForConnected(500): + # Отправляем сообщение (любое, сам факт подключения - это сигнал) + socket.write(b"activate") + socket.waitForBytesWritten(500) + socket.disconnectFromServer() + # Успешно выходим, не запуская новый экземпляр + return 0 + + # Если подключиться не удалось, это первый экземпляр. + # На всякий случай удаляем старый файл сокета, если он остался от сбоя. + QLocalServer.removeServer(socket_name) + dependency_manager = DependencyManager() if dependency_manager.run(): window = WineHelperGUI() + + # Создаем локальный сервер для приема "сигналов" от последующих запусков + server = QLocalServer(window) + + # Функция для обработки входящих подключений + def handle_new_connection(): + client_socket = server.nextPendingConnection() + if client_socket: + # Ждем данные (не обязательно, но для надежности) + client_socket.waitForReadyRead(100) + client_socket.readAll() # Очищаем буфер + window.activate() + client_socket.close() + + server.newConnection.connect(handle_new_connection) + + # Начинаем слушать. Если не удалось, программа все равно будет работать, + # но без функции активации существующего окна. + if not server.listen(socket_name): + print(f"Предупреждение: не удалось запустить сервер {socket_name}: {server.errorString()}") + + # Сохраняем ссылку на сервер, чтобы он не был удален сборщиком мусора + window.server = server window.show() return app.exec_() + return 1 if __name__ == "__main__": From 658816a84df32cde6661638b34546e0f4142a41b Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Thu, 14 Aug 2025 16:22:32 +0600 Subject: [PATCH 08/12] correction of typos --- winehelper_gui.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/winehelper_gui.py b/winehelper_gui.py index 64a3287..9962e76 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -509,9 +509,9 @@ class WinetricksManagerDialog(QDialog): """ # Очищаем поле поиска на той вкладке, которую покинули. if self.previous_tab_widget: - search_edit = self.previous_tab_widget.findChild(QLineEdit) - if search_edit: - search_edit.clear() + search_edit = self.previous_tab_widget.findChild(QLineEdit) + if search_edit: + search_edit.clear() if self.installation_finished: self.log_output.setText(self.INFO_TEXT) @@ -2712,7 +2712,7 @@ def main(): if client_socket: # Ждем данные (не обязательно, но для надежности) client_socket.waitForReadyRead(100) - client_socket.readAll() # Очищаем буфер + client_socket.readAll() # Очищаем буфер window.activate() client_socket.close() From c1e58bb8a13d1298e79670b2f59adc00dceb3398 Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Fri, 15 Aug 2025 09:58:22 +0600 Subject: [PATCH 09/12] the notification window for canceling the installation of dependencies has been changed --- winehelper_gui.py | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/winehelper_gui.py b/winehelper_gui.py index 9962e76..174fb84 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -368,30 +368,25 @@ class DependencyManager: def dialog_close_handler(event): """Обрабатывает закрытие окна во время установки зависимостей.""" if process.state() == QProcess.Running: - msg_box = QMessageBox(dialog) - msg_box.setMinimumWidth(900) - msg_box.setIcon(QMessageBox.Question) - msg_box.setWindowTitle("Прервать установку?") - msg_box.setText( - "

Установка зависимостей еще не завершена.

" - "

Вы действительно хотите прервать процесс?

" - "

Это закроет только окно программы.
" - "Процесс установки зависимостей будет продолжен.

" + # QMessageBox без кнопок может некорректно обрабатывать закрытие. + # Используем простой QDialog для надежности. + info_dialog = QDialog(dialog) + info_dialog.setWindowTitle("Идет установка") + info_dialog.setModal(True) + info_dialog.setFixedSize(450, 150) + + layout = QVBoxLayout(info_dialog) + label = QLabel( + "

Установка зависимостей еще не завершена.

" + "

Пожалуйста, дождитесь окончания процесса.

" + "

Закрыть основное окно можно будет после завершения установки.

" ) + label.setTextFormat(Qt.RichText) + label.setAlignment(Qt.AlignCenter) + layout.addWidget(label) - yes_button = msg_box.addButton("Да, прервать", QMessageBox.YesRole) - no_button = msg_box.addButton("Нет", QMessageBox.NoRole) - msg_box.setDefaultButton(no_button) - - msg_box.exec_() - - if msg_box.clickedButton() == yes_button: - process.readyRead.disconnect() - process.finished.disconnect() - process.terminate() - event.accept() - else: - event.ignore() + info_dialog.exec_() + event.ignore() else: event.accept() From 4e1c3e787dda0b276e7d6faa33c0e0c8107f20bb Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Fri, 15 Aug 2025 10:36:11 +0600 Subject: [PATCH 10/12] simplifying logic in WinetricksManagerDialog --- winehelper_gui.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/winehelper_gui.py b/winehelper_gui.py index 174fb84..39b2c2d 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -525,7 +525,7 @@ class WinetricksManagerDialog(QDialog): layout.addWidget(search_edit) list_widget = QListWidget() - list_widget.itemChanged.connect(self._on_item_changed) + list_widget.itemChanged.connect(self._update_ui_state) list_widget.currentItemChanged.connect(self._update_ui_state) layout.addWidget(list_widget) @@ -651,8 +651,18 @@ class WinetricksManagerDialog(QDialog): item = QListWidgetItem(item_text) item.setData(Qt.UserRole, name) item.setFont(QFont("DejaVu Sans Mono", 10)) - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Checked if is_checked else Qt.Unchecked) + + if is_checked: + # Если компонент уже установлен, делаем его неинтерактивным, + # так как удаление не поддерживается. Переустановка - через отдельную кнопку. + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked) + item.setToolTip("Этот компонент уже установлен. Для переустановки выделите его и нажмите кнопку 'Переустановить'.") + else: + # Для неустановленных компонентов разрешаем установку через чекбокс. + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Unchecked) + list_widget.addItem(item) self.initial_states[name] = is_checked @@ -718,21 +728,6 @@ class WinetricksManagerDialog(QDialog): self.status_label.setText("Готово.") self._update_ui_state() - def _on_item_changed(self, item): - """Обрабатывает изменение состояния чекбокса, предотвращая снятие галочки с установленных.""" - name = item.data(Qt.UserRole) - # Если компонент был изначально установлен и пользователь пытается его снять - if name in self.initial_states and self.initial_states.get(name) is True: - if item.checkState() == Qt.Unchecked: - # Блокируем сигналы, чтобы избежать рекурсии, и возвращаем галочку на место. - list_widget = item.listWidget() - if list_widget: - list_widget.blockSignals(True) - item.setCheckState(Qt.Checked) - if list_widget: - list_widget.blockSignals(False) - self._update_ui_state() - def _update_ui_state(self, *args): """Централизованно обновляет состояние кнопок 'Применить' и 'Переустановить'.""" # 1. Проверяем, есть ли изменения в чекбоксах (установка новых или снятие галочек с новых) From 3bfdf5c01ac866f025d4631e6f0980b52cbcbfe2 Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Fri, 15 Aug 2025 10:48:43 +0600 Subject: [PATCH 11/12] encapsulation of script parsing logic --- winehelper_gui.py | 212 ++++++++++++++++++++++++---------------------- 1 file changed, 111 insertions(+), 101 deletions(-) diff --git a/winehelper_gui.py b/winehelper_gui.py index 39b2c2d..330e267 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -525,7 +525,7 @@ class WinetricksManagerDialog(QDialog): layout.addWidget(search_edit) list_widget = QListWidget() - list_widget.itemChanged.connect(self._update_ui_state) + list_widget.itemChanged.connect(self._on_item_changed) list_widget.currentItemChanged.connect(self._update_ui_state) layout.addWidget(list_widget) @@ -651,18 +651,8 @@ class WinetricksManagerDialog(QDialog): item = QListWidgetItem(item_text) item.setData(Qt.UserRole, name) item.setFont(QFont("DejaVu Sans Mono", 10)) - - if is_checked: - # Если компонент уже установлен, делаем его неинтерактивным, - # так как удаление не поддерживается. Переустановка - через отдельную кнопку. - item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Checked) - item.setToolTip("Этот компонент уже установлен. Для переустановки выделите его и нажмите кнопку 'Переустановить'.") - else: - # Для неустановленных компонентов разрешаем установку через чекбокс. - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(Qt.Unchecked) - + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked if is_checked else Qt.Unchecked) list_widget.addItem(item) self.initial_states[name] = is_checked @@ -728,6 +718,21 @@ class WinetricksManagerDialog(QDialog): self.status_label.setText("Готово.") self._update_ui_state() + def _on_item_changed(self, item): + """Обрабатывает изменение состояния чекбокса, предотвращая снятие галочки с установленных.""" + name = item.data(Qt.UserRole) + # Если компонент был изначально установлен и пользователь пытается его снять + if name in self.initial_states and self.initial_states.get(name) is True: + if item.checkState() == Qt.Unchecked: + # Блокируем сигналы, чтобы избежать рекурсии, и возвращаем галочку на место. + list_widget = item.listWidget() + if list_widget: + list_widget.blockSignals(True) + item.setCheckState(Qt.Checked) + if list_widget: + list_widget.blockSignals(False) + self._update_ui_state() + def _update_ui_state(self, *args): """Централизованно обновляет состояние кнопок 'Применить' и 'Переустановить'.""" # 1. Проверяем, есть ли изменения в чекбоксах (установка новых или снятие галочек с новых) @@ -919,6 +924,93 @@ class WinetricksManagerDialog(QDialog): self.log_output.append(message) self.log_output.moveCursor(QTextCursor.End) +class ScriptParser: + """Утилитарный класс для парсинга информации из скриптов установки.""" + + @staticmethod + def extract_icons_from_script(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 [] + + @staticmethod + def extract_prog_name_from_script(script_path): + """Извлекает имя программы из строки PROG_NAME= в скрипте""" + try: + with open(script_path, 'r', encoding='utf-8') as f: + for line in f: + 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 + + @staticmethod + def extract_prog_url_from_script(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 + + @staticmethod + def extract_info_ru(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)}" + class WineHelperGUI(QMainWindow): def __init__(self): super().__init__() @@ -1235,77 +1327,6 @@ class WineHelperGUI(QMainWindow): 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 _start_icon_fade_animation(self, button): """Запускает анимацию плавного перехода для иконки на кнопке с помощью QPropertyAnimation.""" if button not in self.icon_animators: @@ -1426,13 +1447,13 @@ class WineHelperGUI(QMainWindow): 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) + prog_name = ScriptParser.extract_prog_name_from_script(script_path) # Создаем кнопку, только если для скрипта указано имя программы if not prog_name: continue - icon_names = self.extract_icons_from_script(script_path) + icon_names = ScriptParser.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) @@ -2224,17 +2245,6 @@ class WineHelperGUI(QMainWindow): self.installed_search_edit.text(), self.installed_buttons, self.installed_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) @@ -2264,10 +2274,10 @@ class WineHelperGUI(QMainWindow): 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) + description = ScriptParser.extract_info_ru(script_path) + icon_names = ScriptParser.extract_icons_from_script(script_path) + prog_name = ScriptParser.extract_prog_name_from_script(script_path) + prog_url = ScriptParser.extract_prog_url_from_script(script_path) display_name = prog_name if prog_name else script_name self.current_display_name = display_name From 6b8909902c3966b16c21de3d275ce262d70b8331 Mon Sep 17 00:00:00 2001 From: Sergey Palcheh Date: Wed, 20 Aug 2025 11:55:33 +0600 Subject: [PATCH 12/12] determining the version of Wine used --- winehelper_gui.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/winehelper_gui.py b/winehelper_gui.py index 330e267..2895c8a 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -2012,12 +2012,40 @@ class WineHelperGUI(QMainWindow): QMessageBox.critical(self, "Ошибка", f"Каталог префикса не найден:\n{prefix_path}") return + # --- Определение используемой версии Wine --- + wine_executable = 'wine' # По умолчанию системный wine + last_conf_path = os.path.join(prefix_path, "last.conf") + wh_wine_use = None + + if os.path.exists(last_conf_path): + try: + with open(last_conf_path, 'r', encoding='utf-8') as f: + for line in f: + if 'WH_WINE_USE=' in line: + # Извлекаем значение из строки вида: export WH_WINE_USE="wine_ver" + value = line.split('=', 1)[1].strip().strip('"\'') + if value: + wh_wine_use = value + break + except Exception as e: + print(f"Предупреждение: не удалось прочитать или обработать {last_conf_path}: {e}") + + if wh_wine_use and not wh_wine_use.startswith('system'): + local_wine_path = os.path.join(Var.USER_WORK_PATH, "dist", wh_wine_use, "bin", "wine") + if os.path.exists(local_wine_path): + wine_executable = local_wine_path + else: + QMessageBox.warning(self, "Предупреждение", + f"Локальная версия Wine '{wh_wine_use}' не найдена по пути:\n{local_wine_path}\n\n" + "Будет использована системная версия Wine.") + # --- Конец определения версии Wine --- + env = os.environ.copy() env["WINEPREFIX"] = prefix_path # 'wine cmd' - особый случай, требует запуска в терминале if util_name == 'cmd': - terminal_command = f"env WINEPREFIX='{prefix_path}' wine cmd" + terminal_command = f"env WINEPREFIX='{prefix_path}' {shlex.quote(wine_executable)} cmd" try: # x-terminal-emulator - стандартный способ вызова терминала по умолчанию subprocess.Popen(['x-terminal-emulator', '-e', terminal_command]) @@ -2028,7 +2056,7 @@ class WineHelperGUI(QMainWindow): return # Для остальных утилит - command = ['wine', util_name] + command = [wine_executable, util_name] try: subprocess.Popen(command, env=env) except Exception as e: