diff --git a/winehelper_gui.py b/winehelper_gui.py index fe9d20d..2895c8a 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -6,11 +6,16 @@ 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) -from PyQt5.QtCore import Qt, QProcess, QSize, QTimer, QProcessEnvironment + 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: @@ -24,11 +29,993 @@ 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: + # 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) + + info_dialog.exec_() + 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.""" + + 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 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__() 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)) @@ -71,8 +1058,10 @@ 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 = {} + self.previous_tab_index = 0 # Создаем главный виджет и layout self.main_widget = QWidget() @@ -109,6 +1098,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 == "Автоматическая установка": @@ -144,10 +1144,22 @@ 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): """Скрывает или показывает панель информации в зависимости от активной вкладки.""" + # Очищаем поле поиска на вкладке, которую покинули + 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) # Сбрасываем растяжение к состоянию по умолчанию: @@ -223,15 +1235,52 @@ 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) + 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) + self.installed_action_widget.setLayout(installed_action_layout) self.info_panel_layout.addWidget(self.installed_action_widget) @@ -249,10 +1298,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) @@ -272,139 +1327,79 @@ 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 _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() @@ -452,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) @@ -540,29 +1535,31 @@ 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): """Создает вкладку для отображения установленных программ в виде кнопок""" - 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 +1814,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 +1859,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 +1892,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 +1936,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 +1955,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 +1969,100 @@ 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 + + # --- Определение используемой версии 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}' {shlex.quote(wine_executable)} 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_executable, 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 +2129,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}")?' @@ -1149,46 +2243,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: - 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) @@ -1197,31 +2281,31 @@ 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)) # Обновляем информацию в правой панели - 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 @@ -1240,7 +2324,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 +2468,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 +2604,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 +2646,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 +2671,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 +2684,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): @@ -1597,23 +2694,69 @@ 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 filter_autoinstall_buttons(self): - """Фильтрует кнопки автоматической установки.""" - self._filter_buttons_in_grid( - self.search_edit.text(), self.autoinstall_buttons, self.scroll_layout - ) +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__": - app = QApplication(sys.argv) - window = WineHelperGUI() - window.show() - sys.exit(app.exec_()) + sys.exit(main())