forked from CastroFidel/winehelper
		
	the function of extracting an icon from an exe file is combined with the creation of a desktop file added the function of automatic prefix detection if it is not specified
		
			
				
	
	
		
			3746 lines
		
	
	
		
			194 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			3746 lines
		
	
	
		
			194 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| #!/usr/bin/env python3
 | ||
| import os
 | ||
| import subprocess
 | ||
| import sys
 | ||
| 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, QTabBar,
 | ||
|                              QTextEdit, QFileDialog, QMessageBox, QLineEdit, QCheckBox, QStackedWidget, QScrollArea, QFormLayout, QGroupBox, QRadioButton, QComboBox,
 | ||
|                              QListWidget, QListWidgetItem, QGridLayout, QFrame, QDialog, QTextBrowser, QInputDialog)
 | ||
| from PyQt5.QtCore import Qt, QProcess, QSize, QTimer, QProcessEnvironment, QPropertyAnimation, QEasingCurve
 | ||
| from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPixmap, QPainter, QDesktopServices
 | ||
| from PyQt5.QtNetwork import QLocalServer, QLocalSocket
 | ||
| 
 | ||
| 
 | ||
| class Var:
 | ||
|     # Переменные определяемые в скрипте winehelper
 | ||
|     SCRIPT_NAME = os.environ.get("SCRIPT_NAME")
 | ||
|     USER_WORK_PATH = os.environ.get("USER_WORK_PATH")
 | ||
|     RUN_SCRIPT = os.environ.get("RUN_SCRIPT")
 | ||
|     DATA_PATH = os.environ.get("DATA_PATH")
 | ||
|     CHANGELOG_FILE = os.environ.get("CHANGELOG_FILE")
 | ||
|     WH_ICON_PATH = os.environ.get("WH_ICON_PATH")
 | ||
|     LICENSE_FILE = os.environ.get("LICENSE_FILE")
 | ||
|     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 требуются дополнительные системные компоненты.<br><br>"
 | ||
|             "Отсутствуют следующие пакеты:<br>"
 | ||
|             # Ограничиваем высоту блока с пакетами и добавляем прокрутку, если список длинный.
 | ||
|             f"""<div style="font-family: monospace; max-height: 150px; overflow-y: auto; margin-top: 5px; margin-bottom: 10px;">
 | ||
|             {'<br>'.join(sorted(missing_packages))}
 | ||
|             </div>"""
 | ||
|             "Нажмите <b>'Установить'</b>, чтобы запустить установку с правами администратора. "
 | ||
|             "Вам потребуется ввести пароль. Этот процесс может занять некоторое время."
 | ||
|         )
 | ||
|         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<b><font color='green'>=== Установка успешно завершена ===</font></b>")
 | ||
|                 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:
 | ||
|                     installation_successful = True
 | ||
|                     close_button.setText("Запустить WineHelper")
 | ||
|                 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:
 | ||
|                 if exit_code == 127:  # pkexec: пользователь отменил аутентификацию
 | ||
|                     log_output.append("\n<b><font color='orange'>=== УСТАНОВКА ОТМЕНЕНА ПОЛЬЗОВАТЕЛЕМ ===</font></b>")
 | ||
|                     log_output.append("Вы отменили ввод пароля. Установка зависимостей не была выполнена.")
 | ||
|                 elif exit_code == 126:  # pkexec: у пользователя нет прав
 | ||
|                     log_output.append("\n<b><font color='red'>=== ОШИБКА: НЕДОСТАТОЧНО ПРАВ ===</font></b>")
 | ||
|                     log_output.append("У вашего пользователя нет прав для выполнения этой операции.")
 | ||
|                 else:
 | ||
|                     log_tag = "ПРЕРВАНО" if exit_status == QProcess.CrashExit else "ОШИБКА"
 | ||
|                     log_output.append(f"\n<b><font color='red'>=== {log_tag} (код: {exit_code}) ===</font></b>")
 | ||
|                     log_output.append("Произошла непредвиденная ошибка во время установки.")
 | ||
|                 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(
 | ||
|                     "<h3>Установка зависимостей еще не завершена.</h3>"
 | ||
|                     "<p>Пожалуйста, дождитесь окончания процесса.</p>"
 | ||
|                     "<p>Закрыть основное окно можно будет после завершения установки.</p>"
 | ||
|                 )
 | ||
|                 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, wine_executable=None):
 | ||
|         super().__init__(parent)
 | ||
|         self.prefix_path = prefix_path
 | ||
|         self.winetricks_path = winetricks_path
 | ||
|         self.wine_executable = wine_executable or 'wine'
 | ||
|         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)
 | ||
|         env.insert("WINE", self.wine_executable)
 | ||
|         # Отключаем 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)
 | ||
|         env.insert("WINE", self.wine_executable)
 | ||
|         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.close()
 | ||
|             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.log_output.append("\n=== Прерывание установки... Ожидание завершения процесса... ===")
 | ||
|                 self.apply_process.terminate()  # Попытка мягкого завершения
 | ||
|                 # Запрещаем закрытие. on_apply_finished обработает его после завершения процесса.
 | ||
|                 event.ignore()
 | ||
|             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'<span style="color:{color};">{message}</span>')
 | ||
|         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 and icon_name.lower() not in ('auto', 'nocopy'):
 | ||
|                                         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 WineVersionSelectionDialog(QDialog):
 | ||
|     """Диалог для выбора версии Wine/Proton с группировкой."""
 | ||
| 
 | ||
|     def __init__(self, architecture, parent=None):
 | ||
|         super().__init__(parent)
 | ||
|         self.architecture = architecture
 | ||
|         self.selected_version = None
 | ||
|         self.wine_versions_data = {}
 | ||
|         self.system_wine_display_name = "Системная версия"
 | ||
|         self.selected_display_text = None
 | ||
| 
 | ||
|         self.setWindowTitle(f"Выбор версии Wine/Proton для {architecture} префикса")
 | ||
|         self.setMinimumSize(900, 500)
 | ||
|         self.setModal(True)
 | ||
| 
 | ||
|         main_layout = QVBoxLayout(self)
 | ||
| 
 | ||
|         self.search_edit = QLineEdit()
 | ||
|         self.search_edit.setPlaceholderText("Поиск версии...")
 | ||
|         self.search_edit.textChanged.connect(self.filter_versions)
 | ||
|         main_layout.addWidget(self.search_edit)
 | ||
| 
 | ||
|         self.version_tabs = QTabWidget()
 | ||
|         main_layout.addWidget(self.version_tabs)
 | ||
| 
 | ||
|         self.load_versions()
 | ||
| 
 | ||
|     def load_versions(self):
 | ||
|         """Запускает процесс получения списка версий Wine."""
 | ||
|         self.version_tabs.clear()
 | ||
|         loading_widget = QWidget()
 | ||
|         loading_layout = QVBoxLayout(loading_widget)
 | ||
|         status_label = QLabel("Загрузка, пожалуйста, подождите...")
 | ||
|         status_label.setAlignment(Qt.AlignCenter)
 | ||
|         loading_layout.addWidget(status_label)
 | ||
|         self.version_tabs.addTab(loading_widget, "Загрузка...")
 | ||
|         self.version_tabs.setEnabled(False)
 | ||
| 
 | ||
|         QApplication.processEvents()
 | ||
| 
 | ||
|         self._parse_sha256_list()
 | ||
|         self.populate_ui()
 | ||
| 
 | ||
|         self.version_tabs.setEnabled(True)
 | ||
| 
 | ||
|     def _parse_sha256_list(self):
 | ||
|         """Парсит sha256sum.list для получения списка версий."""
 | ||
|         sha256_path = os.path.join(Var.DATA_PATH, "sha256sum.list")
 | ||
|         if not os.path.exists(sha256_path):
 | ||
|             QMessageBox.warning(self, "Ошибка", f"Файл с версиями не найден:\n{sha256_path}")
 | ||
|             self.wine_versions_data = {}
 | ||
|             return
 | ||
| 
 | ||
|         self.wine_versions_data = {}
 | ||
|         current_group = None
 | ||
| 
 | ||
|         try:
 | ||
|             with open(sha256_path, 'r', encoding='utf-8') as f:
 | ||
|                 for line in f:
 | ||
|                     line = line.strip()
 | ||
|                     if not line:
 | ||
|                         continue
 | ||
| 
 | ||
|                     match = re.match(r'^#+\s+([A-Z_]+)\s+#*$', line)
 | ||
|                     if match:
 | ||
|                         group_name = match.group(1)
 | ||
|                         allowed_groups = {"WINE", "WINE_LG", "PROTON_LG", "PROTON_STEAM"}
 | ||
|                         # Отображаем только группы, которые являются сборками WINE или PROTON
 | ||
|                         if group_name in allowed_groups:
 | ||
|                             current_group = group_name
 | ||
|                             if current_group not in self.wine_versions_data:
 | ||
|                                 self.wine_versions_data[current_group] = []
 | ||
|                         else:
 | ||
|                             current_group = None
 | ||
|                         continue
 | ||
| 
 | ||
|                     if current_group and re.match(r'^[a-f0-9]{64}', line):
 | ||
|                         parts = line.split(maxsplit=1)
 | ||
|                         if len(parts) == 2:
 | ||
|                             filename = parts[1]
 | ||
|                             if filename.endswith('.tar.xz'):
 | ||
|                                 version_name = filename[:-7]
 | ||
|                                 self.wine_versions_data[current_group].append(version_name)
 | ||
|         except IOError as e:
 | ||
|             QMessageBox.warning(self, "Ошибка", f"Не удалось прочитать файл версий:\n{e}")
 | ||
|             self.wine_versions_data = {}
 | ||
| 
 | ||
|     def populate_ui(self):
 | ||
|         """Заполняет UI отфильтрованными версиями."""
 | ||
|         self.version_tabs.clear()
 | ||
| 
 | ||
|         if not self.wine_versions_data:
 | ||
|             error_widget = QWidget()
 | ||
|             error_layout = QVBoxLayout(error_widget)
 | ||
|             error_label = QLabel("Список версий пуст или не удалось его загрузить.")
 | ||
|             error_label.setAlignment(Qt.AlignCenter)
 | ||
|             error_layout.addWidget(error_label)
 | ||
|             self.version_tabs.addTab(error_widget, "Ошибка")
 | ||
|             return
 | ||
| 
 | ||
|         is_win64 = self.architecture == "win64"
 | ||
|         re_32bit = re.compile(r'i[3-6]86|x86(?!_64)')
 | ||
|         re_64bit = re.compile(r'amd64|x86_64|wow64')
 | ||
| 
 | ||
|         # --- System Tab ---
 | ||
|         if shutil.which('wine'):
 | ||
|             self.system_wine_display_name = "Системная версия"
 | ||
|             try:
 | ||
|                 # Пытаемся получить версию системного wine
 | ||
|                 result = subprocess.run(['wine', '--version'], capture_output=True, text=True, check=True, encoding='utf-8')
 | ||
|                 version_line = result.stdout.strip()
 | ||
|                 # Вывод обычно "wine-X.Y.Z"
 | ||
|                 self.system_wine_display_name = version_line
 | ||
|             except (FileNotFoundError, subprocess.CalledProcessError) as e:
 | ||
|                 print(f"Не удалось получить версию системного wine: {e}")
 | ||
|                 # Если wine возвращает ошибку, используем имя по умолчанию "Системная версия"
 | ||
| 
 | ||
|             self._create_version_tab("Системный", [(self.system_wine_display_name, "system")])
 | ||
| 
 | ||
|         # --- Other versions from JSON ---
 | ||
|         group_keys = sorted(self.wine_versions_data.keys())
 | ||
| 
 | ||
|         for key in group_keys:
 | ||
|             versions = self.wine_versions_data.get(key, [])
 | ||
| 
 | ||
|             filtered_versions = []
 | ||
|             for name in sorted(versions, reverse=True):
 | ||
|                 if is_win64:
 | ||
|                     if re_64bit.search(name) or not re_32bit.search(name):
 | ||
|                         filtered_versions.append(name)
 | ||
|                 else:
 | ||
|                     filtered_versions.append(name)
 | ||
| 
 | ||
|             if not filtered_versions:
 | ||
|                 continue
 | ||
| 
 | ||
|             pretty_key = key.replace('_', ' ').title()
 | ||
|             if key.endswith('_LG'):
 | ||
|                 pretty_key = pretty_key.replace(' Lg', ' LG')
 | ||
| 
 | ||
|             self._create_version_tab(pretty_key, filtered_versions)
 | ||
| 
 | ||
|         self.filter_versions()
 | ||
| 
 | ||
|     def _create_version_tab(self, title, versions_list):
 | ||
|         """Создает вкладку с сеткой кнопок для переданного списка версий."""
 | ||
|         tab_page = QWidget()
 | ||
|         tab_layout = QVBoxLayout(tab_page)
 | ||
|         tab_layout.setContentsMargins(5, 5, 5, 5)
 | ||
| 
 | ||
|         scroll_area = QScrollArea()
 | ||
|         scroll_area.setWidgetResizable(True)
 | ||
|         tab_layout.addWidget(scroll_area)
 | ||
| 
 | ||
|         scroll_content = QWidget()
 | ||
|         scroll_area.setWidget(scroll_content)
 | ||
| 
 | ||
|         grid_layout = QGridLayout(scroll_content)
 | ||
|         grid_layout.setAlignment(Qt.AlignTop)
 | ||
| 
 | ||
|         num_columns = 3
 | ||
|         row, col = 0, 0
 | ||
|         for version_data in versions_list:
 | ||
|             if isinstance(version_data, tuple):
 | ||
|                 display_name, value_name = version_data
 | ||
|             else:
 | ||
|                 display_name = value_name = version_data
 | ||
| 
 | ||
|             btn = QPushButton(display_name)
 | ||
|             btn.clicked.connect(partial(self.on_version_selected, value_name))
 | ||
|             grid_layout.addWidget(btn, row, col)
 | ||
|             col += 1
 | ||
|             if col >= num_columns:
 | ||
|                 col = 0
 | ||
|                 row += 1
 | ||
| 
 | ||
|         self.version_tabs.addTab(tab_page, title)
 | ||
| 
 | ||
|     def filter_versions(self):
 | ||
|         """Фильтрует видимость кнопок версий на основе текста поиска."""
 | ||
|         search_text = self.search_edit.text().lower()
 | ||
| 
 | ||
|         for i in range(self.version_tabs.count()):
 | ||
|             tab_widget = self.version_tabs.widget(i)
 | ||
|             # The grid layout is inside a scroll area content widget
 | ||
|             grid_layout = tab_widget.findChild(QGridLayout)
 | ||
|             if not grid_layout:
 | ||
|                 continue
 | ||
| 
 | ||
|             any_visible_in_tab = False
 | ||
|             for j in range(grid_layout.count()):
 | ||
|                 btn_widget = grid_layout.itemAt(j).widget()
 | ||
|                 if isinstance(btn_widget, QPushButton):
 | ||
|                     is_match = search_text in btn_widget.text().lower()
 | ||
|                     btn_widget.setVisible(is_match)
 | ||
|                     if is_match:
 | ||
|                         any_visible_in_tab = True
 | ||
| 
 | ||
|             # Enable/disable tab based on content
 | ||
|             self.version_tabs.setTabEnabled(i, any_visible_in_tab)
 | ||
| 
 | ||
|     def on_version_selected(self, version_name):
 | ||
|         """Обрабатывает выбор версии."""
 | ||
|         self.selected_version = version_name
 | ||
|         if version_name == 'system':
 | ||
|             self.selected_display_text = self.system_wine_display_name
 | ||
|         else:
 | ||
|             self.selected_display_text = version_name
 | ||
|         self.accept()
 | ||
| 
 | ||
| class WineHelperGUI(QMainWindow):
 | ||
|     def __init__(self):
 | ||
|         super().__init__()
 | ||
|         self.setWindowTitle("WineHelper")
 | ||
|         self.setMinimumSize(950, 550)
 | ||
| 
 | ||
|         if Var.WH_ICON_PATH and os.path.exists(Var.WH_ICON_PATH):
 | ||
|             self.setWindowIcon(QIcon(Var.WH_ICON_PATH))
 | ||
| 
 | ||
|         # Центрирование окна
 | ||
|         screen = QApplication.primaryScreen()
 | ||
|         screen_geometry = screen.availableGeometry()
 | ||
|         self.move(
 | ||
|             (screen_geometry.width() - self.width()) // 2,
 | ||
|             (screen_geometry.height() - self.height()) // 2
 | ||
|         )
 | ||
| 
 | ||
|         # Стиль для кнопок в списках
 | ||
|         self.BUTTON_LIST_STYLE = """
 | ||
|             QPushButton {
 | ||
|                 text-align: left;
 | ||
|                 padding-left: 10px;
 | ||
|                 padding-right: 10px;
 | ||
|                 height: 42px; min-height: 42px; max-height: 42px;
 | ||
|             }
 | ||
|             QPushButton::icon {
 | ||
|                 padding-left: 10px;
 | ||
|             }
 | ||
|         """
 | ||
| 
 | ||
|         self.INSTALLED_BUTTON_LIST_STYLE = self.BUTTON_LIST_STYLE.replace(
 | ||
|             "padding-left: 10px;", "padding-left: 15px;"
 | ||
|         )
 | ||
| 
 | ||
|         # Стили для оберток кнопок (для рамки выделения)
 | ||
|         self.FRAME_STYLE_DEFAULT = "QFrame { border: 2px solid transparent; border-radius: 8px; padding: 0px; }"
 | ||
|         self.FRAME_STYLE_SELECTED = "QFrame { border: 2px solid #0078d7; border-radius: 8px; padding: 0px; }"
 | ||
| 
 | ||
|         # Основные переменные
 | ||
|         self.winehelper_path = Var.RUN_SCRIPT
 | ||
|         self.process = None
 | ||
|         self.current_script = None
 | ||
|         self.install_process = None
 | ||
|         self.current_display_name = None
 | ||
|         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
 | ||
|         self.selected_wine_version_value = None
 | ||
|         self.created_prefixes_info = {}  # Хранит информацию о префиксах, созданных на вкладке
 | ||
|         self.current_managed_prefix_name = None  # Имя префикса, выбранного в выпадающем списке
 | ||
|         self.pending_prefix_info = None  # Временное хранилище информации о создаваемом префиксе
 | ||
| 
 | ||
|         # Добавим путь к файлу состояния
 | ||
|         self.config_dir = os.path.join(os.path.expanduser("~"), ".config", "winehelper")
 | ||
|         os.makedirs(self.config_dir, exist_ok=True)
 | ||
|         self.state_file = os.path.join(self.config_dir, "gui_state.json")
 | ||
| 
 | ||
|         # State for command dialog log processing (specifically for prefix creation)
 | ||
|         self.command_output_buffer = ""
 | ||
|         self.command_last_line_was_progress = False
 | ||
|         # Создаем главный виджет и layout
 | ||
|         self.main_widget = QWidget()
 | ||
|         self.setCentralWidget(self.main_widget)
 | ||
|         self.main_layout = QVBoxLayout()
 | ||
|         self.main_widget.setLayout(self.main_layout)
 | ||
| 
 | ||
|         # Создаем кастомную панель вкладок и виджет со страницами
 | ||
|         self.tab_bar = QTabBar()
 | ||
|         self.stacked_widget = QStackedWidget()
 | ||
|         self.main_layout.addWidget(self.tab_bar)
 | ||
| 
 | ||
|         # Горизонтальный layout для страниц и инфо-панели
 | ||
|         content_layout = QHBoxLayout()
 | ||
|         content_layout.addWidget(self.stacked_widget, stretch=1)
 | ||
| 
 | ||
|         # Создаем панель информации о скрипте
 | ||
|         self.create_info_panel()
 | ||
|         content_layout.addWidget(self.info_panel, stretch=1)
 | ||
| 
 | ||
|         self.main_layout.addLayout(content_layout)
 | ||
| 
 | ||
|         # Фиксируем минимальные размеры
 | ||
|         self.stacked_widget.setMinimumWidth(520)
 | ||
|         self.info_panel.setMinimumWidth(415)
 | ||
| 
 | ||
|         # Вкладки
 | ||
|         self.create_auto_install_tab()
 | ||
|         self.create_manual_install_tab()
 | ||
|         self.create_installed_tab()
 | ||
|         self.create_prefix_tab()
 | ||
|         self.create_help_tab()
 | ||
| 
 | ||
|         # Загружаем состояние после создания всех виджетов
 | ||
|         self._load_state()
 | ||
| 
 | ||
|         # Инициализируем состояние, которое будет использоваться для логов
 | ||
|         self._reset_log_state()
 | ||
| 
 | ||
|         # Обновляем список установленных приложений
 | ||
|         self.update_installed_apps()
 | ||
| 
 | ||
|         # Соединяем сигнал смены вкладок с функцией
 | ||
|         self.tab_bar.currentChanged.connect(self.stacked_widget.setCurrentIndex)
 | ||
|         self.tab_bar.currentChanged.connect(self.on_tab_changed)
 | ||
|         # Устанавливаем начальное состояние видимости панели
 | ||
|         self.on_tab_changed(self.tab_bar.currentIndex())
 | ||
| 
 | ||
|     def activate(self):
 | ||
|         """
 | ||
|         Активирует и показывает окно приложения, поднимая его из свернутого состояния
 | ||
|         и перемещая на передний план.
 | ||
|         """
 | ||
|         # Убеждаемся, что окно не свернуто
 | ||
|         self.setWindowState(self.windowState() & ~Qt.WindowMinimized | Qt.WindowActive)
 | ||
|         self.show()
 | ||
|         self.raise_()
 | ||
|         self.activateWindow()
 | ||
| 
 | ||
|     def add_tab(self, widget, title):
 | ||
|         """Добавляет вкладку в кастомный TabBar и страницу в StackedWidget."""
 | ||
|         self.tab_bar.addTab(title)
 | ||
|         self.stacked_widget.addWidget(widget)
 | ||
| 
 | ||
|     def _reset_info_panel_to_default(self, tab_name):
 | ||
|         """Сбрасывает правую панель в состояние по умолчанию для указанной вкладки."""
 | ||
|         if tab_name == "Автоматическая установка":
 | ||
|             title = "Автоматическая установка"
 | ||
|             html_content = ("<h3>Автоматическая установка</h3>"
 | ||
|                             "<p>Скрипты из этого списка скачают, установят и настроят приложение за вас.</p>"
 | ||
|                             "<p>Просто выберите программу и нажмите «Установить».</p>")
 | ||
|             show_global = False
 | ||
|         elif tab_name == "Ручная установка":
 | ||
|             title = "Ручная установка"
 | ||
|             html_content = ("<h3>Ручная установка</h3>"
 | ||
|                             "<p>Эти скрипты подготовят окружение для установки.</p>"
 | ||
|                             "<p>Вам нужно будет указать только путь к установочному файлу (<code>.exe</code> или <code>.msi</code>), который вы скачали самостоятельно и нажать «Установить».</p>")
 | ||
|             show_global = False
 | ||
|         elif tab_name == "Установленные":
 | ||
|             title = "Установленные программы"
 | ||
|             html_content = ("<h3>Установленные программы</h3>"
 | ||
|                             "<p>Здесь отображаются все приложения, установленные с помощью WineHelper.</p>"
 | ||
|                             "<p>Выберите программу, чтобы увидеть доступные действия.</p>"
 | ||
|                             "<p>Также на этой вкладке можно восстановить префикс из резервной копии с помощью соответствующей кнопки.</p>")
 | ||
|             show_global = True
 | ||
|         else:
 | ||
|             return
 | ||
| 
 | ||
|         self.script_title.setText(title)
 | ||
|         self.script_description.setHtml(html_content)
 | ||
|         self.script_description.setVisible(True)
 | ||
|         self.script_title.setPixmap(QPixmap())
 | ||
|         self.install_action_widget.setVisible(False)
 | ||
|         self.installed_action_widget.setVisible(False)
 | ||
|         self.installed_global_action_widget.setVisible(show_global)
 | ||
|         self.manual_install_path_widget.setVisible(False)
 | ||
|         if show_global:
 | ||
|             self.backup_button.setVisible(False)
 | ||
|             self.create_log_button.setVisible(False)
 | ||
|             self.uninstall_button.setVisible(False)
 | ||
|         self.current_selected_app = None
 | ||
| 
 | ||
|     def on_tab_changed(self, index):
 | ||
|         """Скрывает или показывает панель информации в зависимости от активной вкладки."""
 | ||
|         # Очищаем поле поиска на вкладке, которую покинули
 | ||
|         previous_widget = self.stacked_widget.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.tab_bar.tabText(index)
 | ||
| 
 | ||
|         # Сбрасываем растяжение к состоянию по умолчанию:
 | ||
|         # растягивается виджет с описанием (индекс 1), а не виджет с действиями (индекс 4)
 | ||
|         self.info_panel_layout.setStretch(1, 1)
 | ||
|         self.info_panel_layout.setStretch(4, 0)
 | ||
| 
 | ||
|         if current_tab_text in ["Справка", "Создать префикс"]:
 | ||
|             self.info_panel.setVisible(False)
 | ||
|         else:
 | ||
|             self.info_panel.setVisible(True)
 | ||
|             self._reset_info_panel_to_default(current_tab_text)
 | ||
|             if current_tab_text == "Установленные":
 | ||
|                 self.filter_installed_buttons()
 | ||
| 
 | ||
|     def create_info_panel(self):
 | ||
|         """Создает правую панель с информацией о скрипте"""
 | ||
|         self.info_panel = QFrame()
 | ||
|         self.info_panel.setFrameShape(QFrame.StyledPanel)
 | ||
|         self.info_panel.setMinimumWidth(400)  # Размер инф. панели
 | ||
|         self.info_panel.setFont(QFont('Arial', 10))  # Шрифт и размер шрифта в инф. панели
 | ||
|         self.info_panel_layout = QVBoxLayout()
 | ||
|         self.info_panel.setLayout(self.info_panel_layout)
 | ||
| 
 | ||
|         # Заголовок
 | ||
|         self.script_title = QLabel("Выберите программу")
 | ||
|         self.script_title.setFont(QFont('Arial', 12, QFont.Bold))  # Шрифт и размер шрифта в заголовке инф. панели
 | ||
|         self.script_title.setAlignment(Qt.AlignCenter)
 | ||
|         self.info_panel_layout.addWidget(self.script_title)
 | ||
| 
 | ||
|         # Описание
 | ||
|         self.script_description = QTextBrowser()
 | ||
|         self.script_description.setReadOnly(True)
 | ||
|         self.script_description.setOpenExternalLinks(True)
 | ||
|         self.info_panel_layout.addWidget(self.script_description)
 | ||
| 
 | ||
|         # Строка для ввода пути установочного файла
 | ||
|         self.manual_install_path_layout = QVBoxLayout()
 | ||
|         self.install_path_label = QLabel("Путь к установочному файлу:")
 | ||
|         self.manual_install_path_layout.addWidget(self.install_path_label)
 | ||
| 
 | ||
|         path_input_layout = QHBoxLayout()
 | ||
|         self.install_path_edit = QLineEdit()
 | ||
|         self.install_path_edit.setPlaceholderText("Укажите путь к установочному файлу...")
 | ||
|         self.browse_button = QPushButton("Обзор...")
 | ||
|         self.browse_button.clicked.connect(self.browse_install_file)
 | ||
| 
 | ||
|         path_input_layout.addWidget(self.install_path_edit)
 | ||
|         path_input_layout.addWidget(self.browse_button)
 | ||
|         self.manual_install_path_layout.addLayout(path_input_layout)
 | ||
| 
 | ||
|         self.manual_install_path_widget = QWidget()
 | ||
|         self.manual_install_path_widget.setLayout(self.manual_install_path_layout)
 | ||
|         self.manual_install_path_widget.setVisible(False)
 | ||
|         self.info_panel_layout.addWidget(self.manual_install_path_widget)
 | ||
| 
 | ||
|         # --- ВИДЖЕТЫ ДЛЯ ДЕЙСТВИЙ ---
 | ||
|         # Виджет для действий установщика
 | ||
|         self.install_action_widget = QWidget()
 | ||
|         install_action_layout = QVBoxLayout()
 | ||
|         install_action_layout.setContentsMargins(0, 0, 0, 0)
 | ||
|         self.install_button = QPushButton("Установить")
 | ||
|         self.install_button.setFont(QFont('Arial', 12, QFont.Bold))  # Шрифт и размер шрифта в кнопке Установить
 | ||
|         self.install_button.setStyleSheet("background-color: #4CAF50; color: white;")
 | ||
|         self.install_button.clicked.connect(self.install_current_script)
 | ||
|         install_action_layout.addWidget(self.install_button)
 | ||
|         self.install_action_widget.setLayout(install_action_layout)
 | ||
|         self.info_panel_layout.addWidget(self.install_action_widget)
 | ||
| 
 | ||
|         # Виджет для действий с установленным приложением
 | ||
|         self.installed_action_widget = QWidget()
 | ||
|         installed_action_layout = QVBoxLayout()
 | ||
|         installed_action_layout.setContentsMargins(0, 0, 0, 0)
 | ||
|         installed_action_layout.setSpacing(5)
 | ||
| 
 | ||
|         # --- Верхний ряд кнопок ---
 | ||
|         top_buttons_layout = QHBoxLayout()
 | ||
|         self.run_button = QPushButton("Запустить")
 | ||
|         self.run_button.clicked.connect(self.run_installed_app)
 | ||
|         top_buttons_layout.addWidget(self.run_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)
 | ||
| 
 | ||
|         self.installed_global_action_widget = QWidget()
 | ||
|         installed_global_layout = QVBoxLayout()
 | ||
|         installed_global_layout.setContentsMargins(0, 10, 0, 0)
 | ||
| 
 | ||
|         self.create_log_button = QPushButton("Создать лог запуска программы")
 | ||
|         self.create_log_button.setIcon(QIcon.fromTheme("view-list-text"))
 | ||
|         self.create_log_button.clicked.connect(self.run_installed_app_with_debug)
 | ||
|         installed_global_layout.addWidget(self.create_log_button)
 | ||
| 
 | ||
|         self.backup_button = QPushButton("Создать резервную копию префикса")
 | ||
|         self.backup_button.setIcon(QIcon.fromTheme("document-save"))
 | ||
|         self.backup_button.clicked.connect(self.backup_prefix_for_app)
 | ||
|         installed_global_layout.addWidget(self.backup_button)
 | ||
| 
 | ||
|         self.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)
 | ||
| 
 | ||
|         # Изначально скрыть все виджеты действий
 | ||
|         self.install_action_widget.setVisible(False)
 | ||
|         self.installed_action_widget.setVisible(False)
 | ||
|         self.installed_global_action_widget.setVisible(False)
 | ||
| 
 | ||
|     def browse_install_file(self):
 | ||
|         """Открывает диалог выбора файла для ручной установки"""
 | ||
|         file_path, _ = QFileDialog.getOpenFileName(
 | ||
|             self,
 | ||
|             "Выберите установочный файл",
 | ||
|             os.path.expanduser("~"),
 | ||
|             "Все файлы (*);;Исполняемые файлы (*.exe *.msi)"
 | ||
|         )
 | ||
|         if file_path:
 | ||
|             self.install_path_edit.setText(file_path)
 | ||
| 
 | ||
|     def _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 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]
 | ||
| 
 | ||
|         # Сохраняем QPixmap для использования в функции обновления кадра
 | ||
|         anim_data['pixmaps'] = (QPixmap(current_icon_path), QPixmap(next_icon_path))
 | ||
| 
 | ||
|         # Устанавливаем начальное и конечное значения и запускаем
 | ||
|         animation.setStartValue(0.0)
 | ||
|         animation.setEndValue(1.0)
 | ||
|         animation.start()  # Без DeleteWhenStopped
 | ||
| 
 | ||
|     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
 | ||
| 
 | ||
|         old_pixmap, new_pixmap = anim_data['pixmaps']
 | ||
| 
 | ||
|         # На последнем кадре просто устанавливаем новую иконку
 | ||
|         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()
 | ||
|         button.setIcon(QIcon(canvas))
 | ||
| 
 | ||
|     def _create_app_button(self, text, icon_paths, style_sheet):
 | ||
|         """Создает и настраивает стандартную кнопку для списков приложений."""
 | ||
|         # Добавляем пробелы перед текстом для создания отступа от иконки
 | ||
|         btn = QPushButton("  " + text)
 | ||
|         btn.setStyleSheet(style_sheet)
 | ||
| 
 | ||
|         # Отфильтровываем несуществующие пути
 | ||
|         existing_icon_paths = [path for path in icon_paths if path and os.path.exists(path)]
 | ||
| 
 | ||
|         if existing_icon_paths:
 | ||
|             if len(existing_icon_paths) > 1:
 | ||
|                 # Устанавливаем первую иконку и запускаем анимацию
 | ||
|                 btn.setIcon(QIcon(existing_icon_paths[0]))
 | ||
|                 main_timer = QTimer(btn)
 | ||
|                 self.icon_animators[btn] = {'main_timer': main_timer, 'icons': existing_icon_paths, 'current_index': 0}
 | ||
|                 main_timer.timeout.connect(lambda b=btn: self._start_icon_fade_animation(b))
 | ||
|                 main_timer.start(2500)  # Интервал между сменами иконок
 | ||
|             else:
 | ||
|                 # Устанавливаем одну иконку
 | ||
|                 btn.setIcon(QIcon(existing_icon_paths[0]))
 | ||
| 
 | ||
|             btn.setIconSize(QSize(32, 32))
 | ||
|         else:
 | ||
|             # Устанавливаем пустую иконку, если ничего не найдено, для сохранения отступов
 | ||
|             btn.setIcon(QIcon())
 | ||
|             btn.setIconSize(QSize(32, 32))
 | ||
| 
 | ||
|         return btn
 | ||
| 
 | ||
|     def _populate_install_grid(self, grid_layout, scripts_list, script_folder, button_list):
 | ||
|         """
 | ||
|         Заполняет QGridLayout кнопками установщиков.
 | ||
|         Кнопки создаются только для скриптов, в которых найдена переменная PROG_NAME.
 | ||
| 
 | ||
|         :param grid_layout: QGridLayout для заполнения.
 | ||
|         :param scripts_list: Список имен скриптов.
 | ||
|         :param script_folder: Имя папки со скриптами ('autoinstall' или 'manualinstall').
 | ||
|         :param button_list: Список для хранения созданных кнопок.
 | ||
|         """
 | ||
|         button_index = 0
 | ||
|         for script in scripts_list:
 | ||
|             script_path = os.path.join(Var.DATA_PATH, script_folder, script)
 | ||
|             prog_name = ScriptParser.extract_prog_name_from_script(script_path)
 | ||
| 
 | ||
|             # Создаем кнопку, только если для скрипта указано имя программы
 | ||
|             if not prog_name:
 | ||
|                 continue
 | ||
| 
 | ||
|             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)
 | ||
| 
 | ||
|             # Обертка для рамки выделения
 | ||
|             frame = QFrame()
 | ||
|             frame.setStyleSheet(self.FRAME_STYLE_DEFAULT)
 | ||
|             layout = QVBoxLayout(frame)
 | ||
|             layout.setContentsMargins(0, 0, 0, 0)
 | ||
|             layout.addWidget(btn)
 | ||
| 
 | ||
|             btn.clicked.connect(lambda _, s=script, b=btn: self.show_script_info(s, b))
 | ||
|             row, column = divmod(button_index, 2)
 | ||
|             grid_layout.addWidget(frame, row, column)
 | ||
|             button_list.append(btn)
 | ||
|             button_index += 1
 | ||
| 
 | ||
|     def _create_searchable_grid_tab(self, placeholder_text, filter_slot):
 | ||
|         """
 | ||
|         Создает стандартную вкладку с полем поиска и сеточным макетом с прокруткой.
 | ||
|         Возвращает кортеж (главный виджет вкладки, сеточный макет, поле поиска, область прокрутки).
 | ||
|         """
 | ||
|         tab_widget = QWidget()
 | ||
|         layout = QVBoxLayout(tab_widget)
 | ||
|         layout.setContentsMargins(0, 0, 0, 0)
 | ||
|         layout.setSpacing(5)
 | ||
| 
 | ||
|         search_edit = QLineEdit()
 | ||
|         search_edit.setPlaceholderText(placeholder_text)
 | ||
|         search_edit.textChanged.connect(filter_slot)
 | ||
|         layout.addWidget(search_edit)
 | ||
| 
 | ||
|         scroll_area = QScrollArea()
 | ||
|         scroll_area.setWidgetResizable(True)
 | ||
|         scroll_area.setContentsMargins(0, 0, 0, 0)
 | ||
|         layout.addWidget(scroll_area)
 | ||
| 
 | ||
|         scroll_content_widget = QWidget()
 | ||
|         scroll_area.setWidget(scroll_content_widget)
 | ||
| 
 | ||
|         v_scroll_layout = QVBoxLayout(scroll_content_widget)
 | ||
|         v_scroll_layout.setContentsMargins(0, 0, 0, 0)
 | ||
|         v_scroll_layout.setSpacing(0)
 | ||
| 
 | ||
|         grid_layout = QGridLayout()
 | ||
|         grid_layout.setContentsMargins(5, 5, 5, 5)
 | ||
|         grid_layout.setSpacing(2)
 | ||
|         grid_layout.setColumnStretch(0, 1)
 | ||
|         grid_layout.setColumnStretch(1, 1)
 | ||
| 
 | ||
|         v_scroll_layout.addLayout(grid_layout)
 | ||
|         v_scroll_layout.addStretch(1)
 | ||
| 
 | ||
|         return tab_widget, grid_layout, search_edit, scroll_area
 | ||
| 
 | ||
|     def _create_and_populate_install_tab(self, tab_title, script_folder, search_placeholder, filter_slot):
 | ||
|         """
 | ||
|         Создает и заполняет вкладку для установки (автоматической или ручной).
 | ||
|         Возвращает кортеж со скриптами, кнопками и виджетами.
 | ||
|         """
 | ||
|         tab_widget, grid_layout, search_edit, scroll_area = self._create_searchable_grid_tab(
 | ||
|             search_placeholder, filter_slot
 | ||
|         )
 | ||
| 
 | ||
|         scripts = []
 | ||
|         script_path = os.path.join(Var.DATA_PATH, script_folder)
 | ||
|         if os.path.isdir(script_path):
 | ||
|             try:
 | ||
|                 scripts = sorted(os.listdir(script_path))
 | ||
|             except OSError as e:
 | ||
|                 print(f"Не удалось прочитать директорию {script_path}: {e}")
 | ||
| 
 | ||
|         buttons_list = []
 | ||
|         self._populate_install_grid(grid_layout, scripts, script_folder, buttons_list)
 | ||
| 
 | ||
|         self.add_tab(tab_widget, tab_title)
 | ||
| 
 | ||
|         return scripts, buttons_list, grid_layout, search_edit, scroll_area
 | ||
| 
 | ||
|     def create_auto_install_tab(self):
 | ||
|         """Создает вкладку для автоматической установки программ"""
 | ||
|         (
 | ||
|             scripts, buttons, layout,
 | ||
|             search_edit, scroll_area
 | ||
|         ) = self._create_and_populate_install_tab(
 | ||
|             "Автоматическая установка", "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):
 | ||
|         """Создает вкладку для ручной установки программ"""
 | ||
|         (
 | ||
|             scripts, buttons, layout,
 | ||
|             search_edit, scroll_area
 | ||
|         ) = self._create_and_populate_install_tab(
 | ||
|             "Ручная установка", "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, self.installed_scroll_layout, self.installed_search_edit, self.installed_scroll_area = self._create_searchable_grid_tab(
 | ||
|             "Поиск установленной программы...", self.filter_installed_buttons
 | ||
|         )
 | ||
|         self.add_tab(installed_tab, "Установленные")
 | ||
| 
 | ||
|     def open_wine_version_dialog(self):
 | ||
|         """Открывает диалог выбора версии Wine."""
 | ||
|         architecture = "win32" if self.arch_win32_radio.isChecked() else "win64"
 | ||
|         dialog = WineVersionSelectionDialog(architecture, self)
 | ||
|         if dialog.exec_() == QDialog.Accepted and dialog.selected_version:
 | ||
|             self.wine_version_edit.setText(dialog.selected_display_text)
 | ||
|             self.selected_wine_version_value = dialog.selected_version
 | ||
| 
 | ||
|     def clear_wine_version_selection(self):
 | ||
|         """
 | ||
|         Сбрасывает выбор версии Wine при смене архитектуры,
 | ||
|         чтобы заставить пользователя выбрать заново.
 | ||
|         """
 | ||
|         self.wine_version_edit.clear()
 | ||
|         self.selected_wine_version_value = None
 | ||
| 
 | ||
|     def create_prefix_tab(self):
 | ||
|         """Создает вкладку для создания нового префикса"""
 | ||
|         self.prefix_tab = QWidget()
 | ||
|         layout = QVBoxLayout(self.prefix_tab)
 | ||
|         layout.setContentsMargins(10, 10, 10, 10)
 | ||
| 
 | ||
|         form_layout = QFormLayout()
 | ||
|         form_layout.setSpacing(10)
 | ||
| 
 | ||
|         self.prefix_name_edit = QLineEdit()
 | ||
|         self.prefix_name_edit.setPlaceholderText("Например: my_prefix")
 | ||
|         form_layout.addRow("<b>Имя нового префикса:</b>", self.prefix_name_edit)
 | ||
| 
 | ||
|         arch_widget = QWidget()
 | ||
|         arch_layout = QHBoxLayout(arch_widget)
 | ||
|         arch_layout.setContentsMargins(0, 0, 0, 0)
 | ||
|         self.arch_win32_radio = QRadioButton("32-bit")
 | ||
|         self.arch_win64_radio = QRadioButton("64-bit")
 | ||
|         self.arch_win64_radio.setChecked(True)
 | ||
|         arch_layout.addWidget(self.arch_win32_radio)
 | ||
|         arch_layout.addWidget(self.arch_win64_radio)
 | ||
|         form_layout.addRow("<b>Разрядность:</b>", arch_widget)
 | ||
| 
 | ||
|         type_widget = QWidget()
 | ||
|         type_layout = QHBoxLayout(type_widget)
 | ||
|         type_layout.setContentsMargins(0, 0, 0, 0)
 | ||
|         self.type_clean_radio = QRadioButton("Чистый")
 | ||
|         self.type_clean_radio.setToolTip("Создает пустой префикс Wine без каких-либо дополнительных компонентов.")
 | ||
|         self.type_recommended_radio = QRadioButton("С рекомендуемыми библиотеками")
 | ||
|         tooltip_text = "Устанавливает базовый набор компонентов, необходимый для большинства приложений"
 | ||
|         self.type_recommended_radio.setToolTip(tooltip_text)
 | ||
|         self.type_clean_radio.setChecked(True)
 | ||
|         type_layout.addWidget(self.type_clean_radio)
 | ||
|         type_layout.addWidget(self.type_recommended_radio)
 | ||
|         form_layout.addRow("<b>Наполнение:</b>", type_widget)
 | ||
| 
 | ||
|         self.wine_version_edit = QLineEdit()
 | ||
|         self.wine_version_edit.setReadOnly(True)
 | ||
|         self.wine_version_edit.setPlaceholderText("Версия не выбрана")
 | ||
| 
 | ||
|         select_version_button = QPushButton("Выбрать версию...")
 | ||
|         select_version_button.clicked.connect(self.open_wine_version_dialog)
 | ||
| 
 | ||
|         version_layout = QHBoxLayout()
 | ||
|         version_layout.addWidget(self.wine_version_edit)
 | ||
|         version_layout.addWidget(select_version_button)
 | ||
|         form_layout.addRow("<b>Версия Wine/Proton:</b>", version_layout)
 | ||
| 
 | ||
|         self.create_prefix_button = QPushButton("Создать префикс")
 | ||
|         self.create_prefix_button.setFont(QFont('Arial', 12, QFont.Bold))
 | ||
|         self.create_prefix_button.setStyleSheet("background-color: #0078d7; color: white;")
 | ||
|         self.create_prefix_button.setEnabled(False)
 | ||
|         self.create_prefix_button.clicked.connect(self.start_prefix_creation)
 | ||
| 
 | ||
|         layout.addLayout(form_layout)
 | ||
|         layout.addWidget(self.create_prefix_button)
 | ||
| 
 | ||
|         # --- Контейнер для выбора и управления созданными префиксами ---
 | ||
|         self.management_container_groupbox = QGroupBox("Управление созданными префиксами")
 | ||
|         self.management_container_groupbox.setVisible(False)  # Скрыт, пока нет префиксов
 | ||
|         container_layout = QVBoxLayout(self.management_container_groupbox)
 | ||
| 
 | ||
|         selector_layout = QHBoxLayout()
 | ||
|         self.created_prefix_selector = QComboBox()
 | ||
|         self.created_prefix_selector.setPlaceholderText("Выберите префикс для управления")
 | ||
|         self.created_prefix_selector.currentIndexChanged.connect(self.on_created_prefix_selected)
 | ||
|         selector_layout.addWidget(self.created_prefix_selector, 1)
 | ||
| 
 | ||
|         self.delete_prefix_button = QPushButton()
 | ||
|         self.delete_prefix_button.setIcon(QIcon.fromTheme("user-trash"))
 | ||
|         self.delete_prefix_button.setToolTip("Удалить выбранный префикс")
 | ||
|         self.delete_prefix_button.setEnabled(False)
 | ||
|         self.delete_prefix_button.clicked.connect(self.delete_selected_prefix)
 | ||
|         selector_layout.addWidget(self.delete_prefix_button)
 | ||
| 
 | ||
|         container_layout.addLayout(selector_layout)
 | ||
| 
 | ||
|         # --- Виджет для управления выбранным префиксом ---
 | ||
|         self.prefix_management_groupbox = QWidget()
 | ||
|         self.prefix_management_groupbox.setEnabled(False)
 | ||
|         management_layout = QGridLayout(self.prefix_management_groupbox)
 | ||
|         management_layout.setSpacing(5)
 | ||
| 
 | ||
|         # --- Левая сторона: Кнопки ---
 | ||
|         self.prefix_winetricks_button = QPushButton("Менеджер компонентов")
 | ||
|         self.prefix_winetricks_button.setMinimumHeight(32)
 | ||
|         self.prefix_winetricks_button.clicked.connect(
 | ||
|             lambda: self.open_winetricks_manager(prefix_name=self.current_managed_prefix_name))
 | ||
|         self.prefix_winetricks_button.setToolTip(
 | ||
|             "Установка компонентов, библиотек и шрифтов в префикс с помощью Winetricks.")
 | ||
|         management_layout.addWidget(self.prefix_winetricks_button, 0, 0)
 | ||
| 
 | ||
|         self.prefix_winecfg_button = QPushButton("Редактор настроек")
 | ||
|         self.prefix_winecfg_button.setMinimumHeight(32)
 | ||
|         self.prefix_winecfg_button.clicked.connect(
 | ||
|             lambda: self._run_wine_util('winecfg', prefix_name=self.current_managed_prefix_name))
 | ||
|         self.prefix_winecfg_button.setToolTip(
 | ||
|             "Запуск утилиты winecfg для настройки параметров Wine (версия Windows, диски, аудио и т.д.).")
 | ||
|         management_layout.addWidget(self.prefix_winecfg_button, 0, 1)
 | ||
| 
 | ||
|         self.prefix_regedit_button = QPushButton("Редактор реестра")
 | ||
|         self.prefix_regedit_button.setMinimumHeight(32)
 | ||
|         self.prefix_regedit_button.clicked.connect(
 | ||
|             lambda: self._run_wine_util('regedit', prefix_name=self.current_managed_prefix_name))
 | ||
|         self.prefix_regedit_button.setToolTip(
 | ||
|             "Запуск редактора реестра Wine (regedit) для просмотра и изменения ключей реестра в префиксе.")
 | ||
|         management_layout.addWidget(self.prefix_regedit_button, 1, 0)
 | ||
| 
 | ||
|         self.prefix_uninstaller_button = QPushButton("Удаление программ")
 | ||
|         self.prefix_uninstaller_button.setMinimumHeight(32)
 | ||
|         self.prefix_uninstaller_button.clicked.connect(
 | ||
|             lambda: self._run_wine_util('uninstaller', prefix_name=self.current_managed_prefix_name))
 | ||
|         self.prefix_uninstaller_button.setToolTip(
 | ||
|             "Запуск стандартного деинсталлятора Wine для удаления установленных в префикс Windows-программ.")
 | ||
|         management_layout.addWidget(self.prefix_uninstaller_button, 1, 1)
 | ||
| 
 | ||
|         self.prefix_cmd_button = QPushButton("Командная строка")
 | ||
|         self.prefix_cmd_button.setMinimumHeight(32)
 | ||
|         self.prefix_cmd_button.clicked.connect(lambda: self._run_wine_util('cmd', prefix_name=self.current_managed_prefix_name))
 | ||
|         self.prefix_cmd_button.setToolTip("Запуск командной строки (cmd) в окружении выбранного префикса.")
 | ||
|         management_layout.addWidget(self.prefix_cmd_button, 2, 0)
 | ||
| 
 | ||
|         self.prefix_winefile_button = QPushButton("Файловый менеджер")
 | ||
|         self.prefix_winefile_button.setMinimumHeight(32)
 | ||
|         self.prefix_winefile_button.clicked.connect(
 | ||
|             lambda: self._run_wine_util('winefile', prefix_name=self.current_managed_prefix_name))
 | ||
|         self.prefix_winefile_button.setToolTip("Запуск файлового менеджера Wine (winefile) для просмотра файлов внутри префикса.")
 | ||
|         management_layout.addWidget(self.prefix_winefile_button, 2, 1)
 | ||
| 
 | ||
|         # --- Правая сторона: Информационный блок ---
 | ||
|         self.prefix_info_display = QTextBrowser()
 | ||
|         self.prefix_info_display.setReadOnly(True)
 | ||
|         self.prefix_info_display.setFrameStyle(QFrame.StyledPanel)
 | ||
|         management_layout.addWidget(self.prefix_info_display, 0, 2, 3, 1)
 | ||
| 
 | ||
|         management_layout.setColumnStretch(0, 1)
 | ||
|         management_layout.setColumnStretch(1, 1)
 | ||
|         management_layout.setColumnStretch(2, 2)
 | ||
| 
 | ||
|         # --- Separator and Installer ---
 | ||
|         separator = QFrame()
 | ||
|         separator.setFrameShape(QFrame.HLine)
 | ||
|         separator.setFrameShadow(QFrame.Sunken)
 | ||
|         management_layout.addWidget(separator, 3, 0, 1, 3)
 | ||
| 
 | ||
|         install_group = QWidget()
 | ||
|         install_layout = QVBoxLayout(install_group)
 | ||
|         install_layout.setContentsMargins(0, 5, 0, 0)
 | ||
|         install_layout.setSpacing(5)
 | ||
| 
 | ||
|         install_path_layout = QHBoxLayout()
 | ||
|         self.prefix_install_path_edit = QLineEdit()
 | ||
|         self.prefix_install_path_edit.setPlaceholderText("Укажите путь к установочному файлу .exe или .msi...")
 | ||
|         install_path_layout.addWidget(self.prefix_install_path_edit)
 | ||
| 
 | ||
|         self.prefix_browse_button = QPushButton("Обзор...")
 | ||
|         self.prefix_browse_button.clicked.connect(self.browse_for_prefix_installer)
 | ||
|         install_path_layout.addWidget(self.prefix_browse_button)
 | ||
|         install_layout.addLayout(install_path_layout)
 | ||
| 
 | ||
|         # Layout для кнопок установки и создания ярлыка
 | ||
|         action_buttons_layout = QHBoxLayout()
 | ||
| 
 | ||
|         self.prefix_install_button = QPushButton("Установить приложение в префикс")
 | ||
|         self.prefix_install_button.setEnabled(False)
 | ||
|         self.prefix_install_button.clicked.connect(self.run_prefix_installer)
 | ||
|         action_buttons_layout.addWidget(self.prefix_install_button)
 | ||
| 
 | ||
|         self.create_launcher_button = QPushButton("Создать ярлык для приложения в префиксе")
 | ||
|         self.create_launcher_button.setToolTip(
 | ||
|             "Создает ярлык в меню и на вкладке 'Установленные' для .exe файла внутри префикса.")
 | ||
|         self.create_launcher_button.clicked.connect(self.create_launcher_for_prefix)
 | ||
|         self.create_launcher_button.setEnabled(False)  # Изначально неактивна
 | ||
|         action_buttons_layout.addWidget(self.create_launcher_button)
 | ||
|         install_layout.addLayout(action_buttons_layout)
 | ||
| 
 | ||
|         management_layout.addWidget(install_group, 4, 0, 1, 3)
 | ||
| 
 | ||
|         container_layout.addWidget(self.prefix_management_groupbox)
 | ||
|         layout.addWidget(self.management_container_groupbox)
 | ||
|         layout.addStretch()
 | ||
|         self.add_tab(self.prefix_tab, "Создать префикс")
 | ||
| 
 | ||
|         self.arch_win32_radio.toggled.connect(self.clear_wine_version_selection)
 | ||
|         self.prefix_name_edit.textChanged.connect(self.update_create_prefix_button_state)
 | ||
|         self.prefix_name_edit.textChanged.connect(self.on_prefix_name_edited)
 | ||
|         self.wine_version_edit.textChanged.connect(self.update_create_prefix_button_state)
 | ||
|         self.prefix_install_path_edit.textChanged.connect(self.update_prefix_install_button_state)
 | ||
| 
 | ||
|     def _remove_prefix_from_gui_state(self, prefix_name):
 | ||
|         """Удаляет префикс из внутреннего состояния и пользовательского интерфейса вкладки 'Создать префикс'."""
 | ||
|         if prefix_name in self.created_prefixes_info:
 | ||
|             del self.created_prefixes_info[prefix_name]
 | ||
| 
 | ||
|         index_to_remove = self.created_prefix_selector.findText(prefix_name)
 | ||
|         if index_to_remove != -1:
 | ||
|             self.created_prefix_selector.removeItem(index_to_remove)
 | ||
| 
 | ||
|         # Сохраняем состояние после удаления. on_created_prefix_selected также вызовет сохранение,
 | ||
|         # но этот вызов гарантирует сохранение, даже если сигналы были заблокированы.
 | ||
|         self._save_state()
 | ||
| 
 | ||
|     def _load_state(self):
 | ||
|         """Загружает последнее состояние GUI из файла."""
 | ||
|         if not os.path.exists(self.state_file):
 | ||
|             return
 | ||
|         try:
 | ||
|             with open(self.state_file, 'r', encoding='utf-8') as f:
 | ||
|                 state = json.load(f)
 | ||
| 
 | ||
|             loaded_prefixes = state.get('created_prefixes_info', {})
 | ||
|             if not loaded_prefixes:
 | ||
|                 return
 | ||
| 
 | ||
|             # Блокируем сигналы, чтобы избежать преждевременного срабатывания
 | ||
|             self.created_prefix_selector.blockSignals(True)
 | ||
|             self.created_prefix_selector.clear()
 | ||
|             self.created_prefixes_info.clear()
 | ||
| 
 | ||
|             for prefix_name, prefix_info in loaded_prefixes.items():
 | ||
|                 prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name)
 | ||
|                 if os.path.isdir(prefix_path):
 | ||
|                     self.created_prefixes_info[prefix_name] = prefix_info
 | ||
|                     self.created_prefix_selector.addItem(prefix_name)
 | ||
| 
 | ||
|             self.created_prefix_selector.blockSignals(False)
 | ||
| 
 | ||
|             if self.created_prefix_selector.count() > 0:
 | ||
|                 self.management_container_groupbox.setVisible(True)
 | ||
|                 last_managed = state.get('current_managed_prefix_name')
 | ||
|                 index = self.created_prefix_selector.findText(last_managed)
 | ||
|                 if index != -1:
 | ||
|                     self.created_prefix_selector.setCurrentIndex(index)
 | ||
|                 else:
 | ||
|                     self.created_prefix_selector.setCurrentIndex(0) # Выбираем первый, если предыдущий не найден
 | ||
|             else:
 | ||
|                 self.management_container_groupbox.setVisible(False)
 | ||
| 
 | ||
|         except (IOError, json.JSONDecodeError, TypeError) as e:
 | ||
|             print(f"Предупреждение: не удалось загрузить состояние GUI: {e}")
 | ||
| 
 | ||
|     def _save_state(self):
 | ||
|         """Сохраняет текущее состояние GUI в файл."""
 | ||
|         state = {
 | ||
|             'created_prefixes_info': self.created_prefixes_info,
 | ||
|             'current_managed_prefix_name': self.current_managed_prefix_name
 | ||
|         }
 | ||
|         try:
 | ||
|             with open(self.state_file, 'w', encoding='utf-8') as f:
 | ||
|                 json.dump(state, f, indent=2, ensure_ascii=False)
 | ||
|         except IOError as e:
 | ||
|             print(f"Предупреждение: не удалось сохранить состояние GUI: {e}")
 | ||
| 
 | ||
|     def on_created_prefix_selected(self, index):
 | ||
|         """Обрабатывает выбор префикса из выпадающего списка."""
 | ||
|         if index == -1:
 | ||
|             self.current_managed_prefix_name = None
 | ||
|             self._setup_prefix_management_panel(None)
 | ||
|             self.delete_prefix_button.setEnabled(False)
 | ||
|         else:
 | ||
|             prefix_name = self.created_prefix_selector.itemText(index)
 | ||
|             self.current_managed_prefix_name = prefix_name
 | ||
|             self._setup_prefix_management_panel(prefix_name)
 | ||
|             self.delete_prefix_button.setEnabled(True)
 | ||
|         self._save_state()
 | ||
| 
 | ||
|     def delete_selected_prefix(self):
 | ||
|         """Удаляет префикс, выбранный в выпадающем списке на вкладке 'Создать префикс'."""
 | ||
|         prefix_name = self.current_managed_prefix_name
 | ||
|         if not prefix_name:
 | ||
|             return
 | ||
| 
 | ||
|         msg_box = QMessageBox(self)
 | ||
|         msg_box.setIcon(QMessageBox.Question)
 | ||
|         msg_box.setWindowTitle('Подтверждение удаления')
 | ||
|         msg_box.setText(f'Вы уверены, что хотите удалить префикс "{prefix_name}"?\n\n'
 | ||
|                         'Это действие необратимо и удалит все данные внутри префикса, а также все связанные с ним ярлыки.')
 | ||
| 
 | ||
|         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:
 | ||
|             return
 | ||
| 
 | ||
|         # Используем модальный диалог для отображения процесса удаления
 | ||
|         self.command_dialog = QDialog(self)
 | ||
|         self.command_dialog.setWindowTitle(f"Удаление префикса: {prefix_name}")
 | ||
|         self.command_dialog.setMinimumSize(750, 400)
 | ||
|         self.command_dialog.setModal(True)
 | ||
|         self.command_dialog.setWindowFlags(self.command_dialog.windowFlags() & ~Qt.WindowCloseButtonHint)
 | ||
| 
 | ||
|         layout = QVBoxLayout()
 | ||
|         self.command_log_output = QTextEdit()
 | ||
|         self.command_log_output.setReadOnly(True)
 | ||
|         self.command_log_output.setFont(QFont('DejaVu Sans Mono', 10))
 | ||
|         layout.addWidget(self.command_log_output)
 | ||
| 
 | ||
|         self.command_close_button = QPushButton("Закрыть")
 | ||
|         self.command_close_button.setEnabled(False)
 | ||
|         self.command_close_button.clicked.connect(self.command_dialog.close)
 | ||
|         layout.addWidget(self.command_close_button)
 | ||
|         self.command_dialog.setLayout(layout)
 | ||
| 
 | ||
|         self.command_process = QProcess(self.command_dialog)
 | ||
|         self.command_process.setProcessChannelMode(QProcess.MergedChannels)
 | ||
|         self.command_process.readyReadStandardOutput.connect(self._handle_command_output)
 | ||
| 
 | ||
|         self.command_process.finished.connect(
 | ||
|             lambda exit_code, exit_status: self._handle_prefix_deletion_finished(prefix_name, exit_code, exit_status)
 | ||
|         )
 | ||
| 
 | ||
|         args = ["remove-prefix", prefix_name, "--force"]
 | ||
|         self.command_log_output.append(f"Выполнение: {shlex.quote(self.winehelper_path)} {' '.join(shlex.quote(a) for a in args)}")
 | ||
|         self.command_process.start(self.winehelper_path, args)
 | ||
|         self.command_dialog.exec_()
 | ||
| 
 | ||
|     def _handle_prefix_deletion_finished(self, prefix_name, exit_code, exit_status):
 | ||
|         """Обрабатывает завершение процесса удаления префикса."""
 | ||
|         self._handle_command_finished(exit_code, exit_status)
 | ||
|         if exit_code == 0:
 | ||
|             # Успешное удаление, обновляем GUI
 | ||
|             self._remove_prefix_from_gui_state(prefix_name)
 | ||
|             self.update_installed_apps()
 | ||
|             QMessageBox.information(self, "Успех", f"Префикс '{prefix_name}' и все связанные с ним данные были успешно удалены.")
 | ||
|         else:
 | ||
|             QMessageBox.critical(self, "Ошибка удаления", f"Не удалось удалить префикс '{prefix_name}'.\nПодробности смотрите в логе.")
 | ||
| 
 | ||
|     def on_prefix_name_edited(self, text):
 | ||
|         """Сбрасывает состояние управления префиксом, когда пользователь вводит новое имя."""
 | ||
|         if text:
 | ||
|             if self.created_prefix_selector.currentIndex() != -1:
 | ||
|                 self.created_prefix_selector.setCurrentIndex(-1)
 | ||
| 
 | ||
|     def _setup_prefix_management_panel(self, prefix_name):
 | ||
|         """Настраивает панель управления префиксом на основе текущего состояния."""
 | ||
|         if prefix_name and prefix_name in self.created_prefixes_info:
 | ||
|             self.prefix_management_groupbox.setEnabled(True)
 | ||
|             self.update_prefix_info_display(prefix_name)
 | ||
|         else:
 | ||
|             self.prefix_management_groupbox.setEnabled(False)
 | ||
|             self.prefix_info_display.clear()
 | ||
|             self.prefix_install_path_edit.clear()
 | ||
|         # Кнопка "Создать ярлык" должна быть активна, если выбран действительный префикс
 | ||
|         is_prefix_selected = bool(prefix_name and prefix_name in self.created_prefixes_info)
 | ||
|         self.create_launcher_button.setEnabled(is_prefix_selected)
 | ||
|         self.update_prefix_install_button_state()
 | ||
| 
 | ||
|     def update_prefix_info_display(self, prefix_name):
 | ||
|         """Обновляет информационный блок для созданного префикса."""
 | ||
|         info = self.created_prefixes_info.get(prefix_name)
 | ||
|         if not info:
 | ||
|             self.prefix_info_display.clear()
 | ||
|             return
 | ||
| 
 | ||
|         html_content = f"""
 | ||
|             <p style="line-height: 1.3; font-size: 9pt;">
 | ||
|             <b>Имя:</b> {html.escape(info['name'])}<br>
 | ||
|             <b>Архитектура:</b> {html.escape(info['arch'])}<br>
 | ||
|             <b>Версия Wine:</b> {html.escape(info['wine_version'])}<br>
 | ||
|             <b>Путь:</b> {html.escape(info['path'])}</p>"""
 | ||
|         self.prefix_info_display.setHtml(html_content)
 | ||
| 
 | ||
|     def browse_for_prefix_installer(self):
 | ||
|         """Открывает диалог выбора файла для установки в созданный префикс."""
 | ||
|         file_path, _ = QFileDialog.getOpenFileName(
 | ||
|             self,
 | ||
|             "Выберите установочный файл",
 | ||
|             os.path.expanduser("~"),
 | ||
|             "Исполняемые файлы (*.exe *.msi);;Все файлы (*)"
 | ||
|         )
 | ||
|         if file_path:
 | ||
|             self.prefix_install_path_edit.setText(file_path)
 | ||
| 
 | ||
|     def update_prefix_install_button_state(self):
 | ||
|         """Обновляет состояние кнопки установки в префикс."""
 | ||
|         path_ok = bool(self.prefix_install_path_edit.text().strip())
 | ||
|         prefix_selected = self.current_managed_prefix_name is not None
 | ||
|         self.prefix_install_button.setEnabled(path_ok and prefix_selected)
 | ||
| 
 | ||
|     def run_prefix_installer(self):
 | ||
|         """Запускает установку файла в выбранный префикс."""
 | ||
|         prefix_name = self.current_managed_prefix_name
 | ||
|         installer_path = self.prefix_install_path_edit.text().strip()
 | ||
| 
 | ||
|         if not prefix_name:
 | ||
|             QMessageBox.warning(self, "Ошибка", "Не выбран префикс для установки.")
 | ||
|             return
 | ||
|         if not installer_path or not os.path.isfile(installer_path):
 | ||
|             QMessageBox.warning(self, "Ошибка", "Указан неверный путь к установочному файлу.")
 | ||
|             return
 | ||
| 
 | ||
|         prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name)
 | ||
|         wine_executable = self._get_wine_executable_for_prefix(prefix_name)
 | ||
| 
 | ||
|         self.command_dialog = QDialog(self)
 | ||
|         self.command_dialog.setWindowTitle(f"Установка в префикс: {prefix_name}")
 | ||
|         self.command_dialog.setMinimumSize(750, 400)
 | ||
|         self.command_dialog.setModal(True)
 | ||
|         self.command_dialog.setWindowFlags(self.command_dialog.windowFlags() & ~Qt.WindowCloseButtonHint)
 | ||
| 
 | ||
|         layout = QVBoxLayout()
 | ||
|         self.command_log_output = QTextEdit()
 | ||
|         self.command_log_output.setReadOnly(True)
 | ||
|         self.command_log_output.setFont(QFont('DejaVu Sans Mono', 10))
 | ||
|         layout.addWidget(self.command_log_output)
 | ||
| 
 | ||
|         self.command_close_button = QPushButton("Закрыть")
 | ||
|         self.command_close_button.setEnabled(False)
 | ||
|         self.command_close_button.clicked.connect(self.command_dialog.close)
 | ||
|         layout.addWidget(self.command_close_button)
 | ||
|         self.command_dialog.setLayout(layout)
 | ||
| 
 | ||
|         self.command_process = QProcess(self.command_dialog)
 | ||
|         self.command_process.setProcessChannelMode(QProcess.MergedChannels)
 | ||
|         self.command_process.readyReadStandardOutput.connect(self._handle_command_output)
 | ||
|         self.command_process.finished.connect(self._handle_prefix_install_finished)
 | ||
| 
 | ||
|         env = QProcessEnvironment.systemEnvironment()
 | ||
|         env.insert("WINEPREFIX", prefix_path)
 | ||
|         self.command_process.setProcessEnvironment(env)
 | ||
| 
 | ||
|         args = [installer_path]
 | ||
|         self.command_log_output.append(f"Запуск установки: {shlex.quote(wine_executable)} {shlex.quote(installer_path)}")
 | ||
|         self.command_process.start(wine_executable, args)
 | ||
|         self.command_dialog.exec_()
 | ||
| 
 | ||
|     def create_launcher_for_prefix(self):
 | ||
|         """
 | ||
|         Открывает диалог для создания ярлыка для приложения внутри выбранного префикса.
 | ||
|         """
 | ||
|         prefix_name = self.current_managed_prefix_name
 | ||
|         if not prefix_name:
 | ||
|             QMessageBox.warning(self, "Ошибка", "Сначала выберите префикс.")
 | ||
|             return
 | ||
| 
 | ||
|         prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name)
 | ||
|         drive_c_path = os.path.join(prefix_path, "drive_c")
 | ||
| 
 | ||
|         if not os.path.isdir(drive_c_path):
 | ||
|             QMessageBox.critical(self, "Ошибка", f"Диск C: для префикса '{prefix_name}' не найден.")
 | ||
|             return
 | ||
| 
 | ||
|         # 1. Открываем диалог выбора файла для .exe
 | ||
|         exe_path, _ = QFileDialog.getOpenFileName(
 | ||
|             self,
 | ||
|             "Выберите исполняемый файл (.exe) для создания ярлыка",
 | ||
|             drive_c_path,
 | ||
|             "Исполняемые файлы (*.exe)"
 | ||
|         )
 | ||
| 
 | ||
|         if not exe_path:
 | ||
|             return  # Пользователь отменил
 | ||
| 
 | ||
|         # 2. Запрашиваем имя для ярлыка
 | ||
|         app_name, ok = QInputDialog.getText(
 | ||
|             self,
 | ||
|             "Имя ярлыка",
 | ||
|             "Введите имя для нового ярлыка:",
 | ||
|             QLineEdit.Normal,
 | ||
|             os.path.splitext(os.path.basename(exe_path))[0]  # Предлагаем имя из .exe
 | ||
|         )
 | ||
| 
 | ||
|         if not ok or not app_name.strip():
 | ||
|             return  # Пользователь отменил или ввел пустое имя
 | ||
| 
 | ||
|         # 3. Вызываем winehelper.sh create-desktop
 | ||
|         self.command_dialog = QDialog(self)
 | ||
|         self.command_dialog.setWindowTitle(f"Создание ярлыка для: {app_name}")
 | ||
|         self.command_dialog.setMinimumSize(750, 400)
 | ||
|         self.command_dialog.setModal(True)
 | ||
|         self.command_dialog.setWindowFlags(self.command_dialog.windowFlags() & ~Qt.WindowCloseButtonHint)
 | ||
| 
 | ||
|         layout = QVBoxLayout()
 | ||
|         self.command_log_output = QTextEdit()
 | ||
|         self.command_log_output.setReadOnly(True)
 | ||
|         self.command_log_output.setFont(QFont('DejaVu Sans Mono', 10))
 | ||
|         layout.addWidget(self.command_log_output)
 | ||
| 
 | ||
|         self.command_close_button = QPushButton("Закрыть")
 | ||
|         self.command_close_button.setEnabled(False)
 | ||
|         self.command_close_button.clicked.connect(self.command_dialog.close)
 | ||
|         layout.addWidget(self.command_close_button)
 | ||
|         self.command_dialog.setLayout(layout)
 | ||
| 
 | ||
|         self.command_process = QProcess(self.command_dialog)
 | ||
|         self.command_process.setProcessChannelMode(QProcess.MergedChannels)
 | ||
|         self.command_process.readyReadStandardOutput.connect(self._handle_command_output)
 | ||
|         self.command_process.finished.connect(self._handle_launcher_creation_finished)
 | ||
| 
 | ||
|         env = QProcessEnvironment.systemEnvironment()
 | ||
|         env.insert("WINEPREFIX", prefix_path)
 | ||
|         self.command_process.setProcessEnvironment(env)
 | ||
| 
 | ||
|         args = ["desktop", app_name, exe_path, "auto"]
 | ||
| 
 | ||
|         self.command_log_output.append(f"Выполнение: {shlex.quote(self.winehelper_path)} {' '.join(shlex.quote(a) for a in args)}")
 | ||
|         self.command_process.start(self.winehelper_path, args)
 | ||
|         self.command_dialog.exec_()
 | ||
| 
 | ||
|     def create_help_tab(self):
 | ||
|         """Создает вкладку 'Справка' с подвкладками"""
 | ||
|         help_tab = QWidget()
 | ||
|         help_layout = QVBoxLayout(help_tab)
 | ||
|         help_layout.setContentsMargins(5, 5, 5, 5)
 | ||
| 
 | ||
|         help_subtabs = QTabWidget()
 | ||
|         help_layout.addWidget(help_subtabs)
 | ||
| 
 | ||
|         # Подвкладка "Руководство"
 | ||
|         guide_tab = QWidget()
 | ||
|         guide_layout = QVBoxLayout(guide_tab)
 | ||
|         guide_text = QTextBrowser()
 | ||
|         guide_text.setOpenExternalLinks(True)
 | ||
|         guide_text.setHtml("""
 | ||
|             <h2>Руководство пользователя</h2>
 | ||
|             <p>Подробное и актуальное руководство по использованию WineHelper смотрите на <a href="https://www.altlinux.org/Winehelper">https://www.altlinux.org/Winehelper</a></p>
 | ||
|         """)
 | ||
|         guide_layout.addWidget(guide_text)
 | ||
|         help_subtabs.addTab(guide_tab, "Руководство")
 | ||
| 
 | ||
|         # Подвкладка "Авторы"
 | ||
|         authors_tab = QWidget()
 | ||
|         authors_layout = QVBoxLayout(authors_tab)
 | ||
|         authors_text = QTextEdit()
 | ||
|         authors_text.setReadOnly(True)
 | ||
|         authors_text.setHtml("""
 | ||
|             <div style="text-align: center;">
 | ||
|                 <h2>Разработчики</h2>
 | ||
|                 Михаил Тергоев (fidel)<br>
 | ||
|                 Сергей Пальчех (minergenon)</p>
 | ||
|                 <p><b>Помощники</b><br>
 | ||
|                 Иван Мажукин (vanomj)</p>
 | ||
|                 <p><b>Идея и поддержка:</b><br>
 | ||
|                 сообщество ALT Linux</p>
 | ||
|                 <br>
 | ||
|                 <p>Отдельная благодарность всем, кто вносит свой вклад в развитие проекта,<br>
 | ||
|                 тестирует и сообщает об ошибках!</p>
 | ||
|             </div>
 | ||
|         """)
 | ||
|         authors_layout.addWidget(authors_text)
 | ||
|         help_subtabs.addTab(authors_tab, "Авторы")
 | ||
| 
 | ||
|         # Подвкладка "Лицензия"
 | ||
|         license_tab = QWidget()
 | ||
|         license_layout = QVBoxLayout(license_tab)
 | ||
|         license_text = QTextBrowser()
 | ||
|         license_text.setOpenExternalLinks(True)
 | ||
| 
 | ||
|         try:
 | ||
|             if not Var.LICENSE_FILE or not os.path.exists(Var.LICENSE_FILE):
 | ||
|                 raise FileNotFoundError
 | ||
| 
 | ||
|             with open(Var.LICENSE_FILE, 'r', encoding='utf-8') as f:
 | ||
|                 license_content = f.read()
 | ||
| 
 | ||
|             escaped_license_content = html.escape(license_content)
 | ||
| 
 | ||
|             license_html = f"""
 | ||
|                 <h2>Лицензия</h2>
 | ||
|                 <pre style="font-family: 'DejaVu Sans Mono', monospace; font-size: 9pt; padding: 10px; border: 1px solid #a0a0a0; border-radius: 5px;">{escaped_license_content}</pre>
 | ||
|                 <hr>
 | ||
|                 <h2>Сторонние компоненты</h2>
 | ||
|                 <p>Некоторые компоненты, используемые или устанавливаемые данным ПО, могут иметь собственные лицензии. Пользователь несет полную ответственность за соблюдение этих лицензионных соглашений.</p>
 | ||
|                 <p>Ниже приведен список основных сторонних компонентов и ссылки на их исходный код:</p>
 | ||
|             """
 | ||
| 
 | ||
|             # Читаем и парсим файл THIRD-PARTY
 | ||
|             third_party_html = ""
 | ||
|             third_party_file_path = os.path.join(Var.DATA_PATH, "THIRD-PARTY")
 | ||
|             if os.path.exists(third_party_file_path):
 | ||
|                 with open(third_party_file_path, 'r', encoding='utf-8') as f_tp:
 | ||
|                     third_party_content = f_tp.read()
 | ||
| 
 | ||
|                 # Преобразуем контент в HTML
 | ||
|                 third_party_html += '<blockquote>'
 | ||
|                 for line in third_party_content.splitlines():
 | ||
|                     line = line.strip()
 | ||
|                     if not line:
 | ||
|                         third_party_html += '<br>'
 | ||
|                         continue
 | ||
|                     escaped_line = html.escape(line)
 | ||
|                     if line.startswith('http'):
 | ||
|                         third_party_html += f'    <a href="{escaped_line}" style="font-size: 10pt;">{escaped_line}</a><br>'
 | ||
|                     else:
 | ||
|                         third_party_html += f'<b>{escaped_line}</b><br>'
 | ||
|                 third_party_html += '</blockquote>'
 | ||
| 
 | ||
|             license_text.setHtml(license_html + third_party_html)
 | ||
|         except (FileNotFoundError, TypeError):
 | ||
|             license_text.setHtml(f'<h2>Лицензия</h2><p>Не удалось загрузить файл лицензии по пути:<br>{Var.LICENSE_FILE}</p>')
 | ||
|         except Exception as e:
 | ||
|             license_text.setHtml(f'<h2>Лицензия</h2><p>Произошла ошибка при чтении файла лицензии:<br>{str(e)}</p>')
 | ||
| 
 | ||
|         license_layout.addWidget(license_text)
 | ||
|         help_subtabs.addTab(license_tab, "Лицензия")
 | ||
| 
 | ||
|         # Подвкладка "История изменений"
 | ||
|         changelog_tab = QWidget()
 | ||
|         changelog_layout = QVBoxLayout(changelog_tab)
 | ||
|         changelog_text = QTextEdit()
 | ||
|         changelog_text.setReadOnly(True)
 | ||
|         changelog_text.setFont(QFont('DejaVu Sans Mono', 9))
 | ||
| 
 | ||
|         try:
 | ||
|             if not Var.CHANGELOG_FILE or not os.path.exists(Var.CHANGELOG_FILE):
 | ||
|                 raise FileNotFoundError
 | ||
|             with open(Var.CHANGELOG_FILE, 'r', encoding='utf-8') as f:
 | ||
|                 changelog_content = f.read()
 | ||
|             changelog_text.setText(changelog_content)
 | ||
|         except (FileNotFoundError, TypeError):
 | ||
|             changelog_text.setText(f"Файл истории изменений не найден по пути:\n{Var.CHANGELOG_FILE}")
 | ||
|         except Exception as e:
 | ||
|             changelog_text.setText(f"Не удалось прочитать файл истории изменений:\n{str(e)}")
 | ||
| 
 | ||
|         changelog_layout.addWidget(changelog_text)
 | ||
|         help_subtabs.addTab(changelog_tab, "История изменений")
 | ||
| 
 | ||
|         self.add_tab(help_tab, "Справка")
 | ||
| 
 | ||
|     def update_create_prefix_button_state(self):
 | ||
|         """Включает или выключает кнопку 'Создать префикс' в зависимости от заполнения полей."""
 | ||
|         name_ok = bool(self.prefix_name_edit.text().strip())
 | ||
|         version_ok = bool(self.wine_version_edit.text().strip())
 | ||
|         self.create_prefix_button.setEnabled(name_ok and version_ok)
 | ||
| 
 | ||
|     def start_prefix_creation(self):
 | ||
|         """Запускает создание префикса после валидации."""
 | ||
|         if not self._show_license_agreement_dialog():
 | ||
|             return
 | ||
| 
 | ||
|         # Сбрасываем выбор в выпадающем списке, чтобы панель управления скрылась на время создания
 | ||
|         if self.created_prefix_selector.count() > 0:
 | ||
|             self.created_prefix_selector.setCurrentIndex(-1)
 | ||
| 
 | ||
|         prefix_name = self.prefix_name_edit.text().strip()
 | ||
| 
 | ||
|         if not prefix_name:
 | ||
|             QMessageBox.warning(self, "Ошибка", "Имя префикса не может быть пустым.")
 | ||
|             return
 | ||
| 
 | ||
|         if not re.match(r'^[a-zA-Z0-9_.-]+$', prefix_name):
 | ||
|             QMessageBox.warning(self, "Ошибка", "Имя префикса может содержать только латинские буквы, цифры, точки, дефисы и подчеркивания.")
 | ||
|             return
 | ||
| 
 | ||
|         prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name)
 | ||
|         if os.path.exists(prefix_path):
 | ||
|             QMessageBox.warning(self, "Ошибка", f"Префикс с именем '{prefix_name}' уже существует.")
 | ||
|             return
 | ||
| 
 | ||
|         wine_arch = "win32" if self.arch_win32_radio.isChecked() else "win64"
 | ||
|         base_pfx = "none" if self.type_clean_radio.isChecked() else ""
 | ||
|         wine_use = self.selected_wine_version_value
 | ||
|         wine_use_display = self.wine_version_edit.text()
 | ||
| 
 | ||
|         # Сохраняем информацию для отображения после создания
 | ||
|         self.pending_prefix_info = {
 | ||
|             'name': prefix_name,
 | ||
|             'path': prefix_path,
 | ||
|             'arch': "32-bit" if wine_arch == "win32" else "64-bit",
 | ||
|             'type': 'Чистый' if base_pfx else 'С рекомендуемыми библиотеками',
 | ||
|             'wine_version': wine_use_display
 | ||
|         }
 | ||
| 
 | ||
|         self.command_dialog = QDialog(self)
 | ||
|         self.command_dialog.setWindowTitle(f"Создание префикса: {prefix_name}")
 | ||
|         self.command_dialog.setMinimumSize(750, 400)
 | ||
|         self.command_dialog.setModal(True)
 | ||
|         self.command_dialog.setWindowFlags(self.command_dialog.windowFlags() & ~Qt.WindowCloseButtonHint)
 | ||
| 
 | ||
|         layout = QVBoxLayout()
 | ||
|         self.command_log_output = QTextEdit()
 | ||
|         self.command_log_output.setReadOnly(True)
 | ||
|         self.command_log_output.setFont(QFont('DejaVu Sans Mono', 10))
 | ||
|         layout.addWidget(self.command_log_output)
 | ||
| 
 | ||
|         self.command_close_button = QPushButton("Закрыть")
 | ||
|         self.command_close_button.setEnabled(False)
 | ||
|         self.command_close_button.clicked.connect(self.command_dialog.close)
 | ||
|         layout.addWidget(self.command_close_button)
 | ||
|         self.command_dialog.setLayout(layout)
 | ||
| 
 | ||
|         # Сбрасываем состояние для обработки лога с прогрессом
 | ||
|         self.command_output_buffer = ""
 | ||
|         self.command_last_line_was_progress = False
 | ||
| 
 | ||
|         self.command_process = QProcess(self.command_dialog)
 | ||
|         self.command_process.setProcessChannelMode(QProcess.MergedChannels)
 | ||
| 
 | ||
|         # Для создания префикса используем специальный обработчик вывода с поддержкой прогресс-бара
 | ||
|         self.command_process.readyReadStandardOutput.connect(self._handle_prefix_creation_output)
 | ||
|         self.command_process.finished.connect(self._handle_prefix_creation_finished)
 | ||
| 
 | ||
|         env = QProcessEnvironment.systemEnvironment()
 | ||
|         env.insert("WINEPREFIX", prefix_path)
 | ||
|         env.insert("WINEARCH", wine_arch)
 | ||
|         env.insert("WH_WINE_USE", wine_use)
 | ||
|         if base_pfx:
 | ||
|             env.insert("BASE_PFX", base_pfx)
 | ||
|         self.command_process.setProcessEnvironment(env)
 | ||
| 
 | ||
|         args = ["init-prefix"]
 | ||
|         self.command_log_output.append(f"=== Параметры создания префикса ===\nИмя: {prefix_name}\nПуть: {prefix_path}\nАрхитектура: {wine_arch}\nВерсия Wine: {wine_use_display}\nТип: {'Чистый' if base_pfx else 'С рекомендуемыми библиотеками'}\n\n" + "="*40 + "\n")
 | ||
|         self.command_log_output.textCursor().insertText(f"Выполнение: {shlex.quote(self.winehelper_path)} {' '.join(shlex.quote(a) for a in args)}\n")
 | ||
|         QApplication.processEvents()
 | ||
|         self.command_process.start(self.winehelper_path, args)
 | ||
|         self.command_dialog.exec_()
 | ||
| 
 | ||
|     def _handle_prefix_creation_finished(self, exit_code, exit_status):
 | ||
|         """Обрабатывает завершение создания префикса."""
 | ||
|         # Обрабатываем остаток в буфере, если он есть
 | ||
|         if self.command_output_buffer:
 | ||
|             self._process_command_log_line(self.command_output_buffer)
 | ||
|             self.command_output_buffer = ""
 | ||
| 
 | ||
|         # Если последней строкой был прогресс, "завершаем" его переносом строки.
 | ||
|         if self.command_last_line_was_progress:
 | ||
|             cursor = self.command_log_output.textCursor()
 | ||
|             cursor.movePosition(QTextCursor.End)
 | ||
|             cursor.insertText("\n")
 | ||
|             self.command_last_line_was_progress = False
 | ||
| 
 | ||
|         prefix_name = self.command_process.processEnvironment().value('WINEPREFIX').split('/')[-1]
 | ||
| 
 | ||
|         self._handle_command_finished(exit_code, exit_status)
 | ||
|         if exit_code == 0:
 | ||
|             # Добавляем новый префикс в список и выбираем его
 | ||
|             if self.pending_prefix_info:
 | ||
|                 self.created_prefixes_info[prefix_name] = self.pending_prefix_info
 | ||
|                 self.pending_prefix_info = None
 | ||
| 
 | ||
|             if self.created_prefix_selector.findText(prefix_name) == -1:
 | ||
|                 self.created_prefix_selector.addItem(prefix_name)
 | ||
| 
 | ||
|             self.created_prefix_selector.setCurrentText(prefix_name)
 | ||
| 
 | ||
|             if not self.management_container_groupbox.isVisible():
 | ||
|                 self.management_container_groupbox.setVisible(True)
 | ||
| 
 | ||
|             self._save_state()
 | ||
|             self.prefix_name_edit.clear()
 | ||
|             self.wine_version_edit.clear()
 | ||
|             QMessageBox.information(self, "Успех",
 | ||
|                                     f"Префикс '{prefix_name}' успешно создан.\n"
 | ||
|                                     "Теперь вы можете управлять им, выбрав его из выпадающего списка.")
 | ||
| 
 | ||
|     def update_installed_apps(self):
 | ||
|         """Обновляет список установленных приложений в виде кнопок"""
 | ||
|         # Если активная кнопка находится в списке удаляемых, сбрасываем ее
 | ||
|         if self.current_active_button in self.installed_buttons:
 | ||
|             self.current_active_button = None
 | ||
| 
 | ||
|         # Полностью очищаем layout перед обновлением, удаляя старые виджеты (рамки с кнопками)
 | ||
|         while self.installed_scroll_layout.count():
 | ||
|             item = self.installed_scroll_layout.takeAt(0)
 | ||
|             widget = item.widget()
 | ||
|             if widget:
 | ||
|                 widget.deleteLater()
 | ||
|         self.installed_buttons.clear()
 | ||
| 
 | ||
|         if not os.path.exists(Var.USER_WORK_PATH):
 | ||
|             os.makedirs(Var.USER_WORK_PATH, exist_ok=True)
 | ||
|             return
 | ||
| 
 | ||
|         desktop_files = []
 | ||
|         for entry in os.scandir(Var.USER_WORK_PATH):
 | ||
|             if entry.is_file() and entry.name.endswith('.desktop'):
 | ||
|                 desktop_files.append(entry.path)
 | ||
| 
 | ||
|         desktop_files.sort()
 | ||
| 
 | ||
|         for i, desktop_path in enumerate(desktop_files):
 | ||
|             display_name = os.path.splitext(os.path.basename(desktop_path))[0]
 | ||
|             icon_path = None
 | ||
|             try:
 | ||
|                 with open(desktop_path, 'r', encoding='utf-8') as f:
 | ||
|                     for line in f:
 | ||
|                         if line.startswith('Name='):
 | ||
|                             display_name = line.split('=', 1)[1].strip()
 | ||
|                         elif line.startswith('Icon='):
 | ||
|                             icon_file = line.split('=', 1)[1].strip()
 | ||
|                             if os.path.exists(icon_file):
 | ||
|                                 icon_path = icon_file
 | ||
|             except Exception as e:
 | ||
|                 print(f"Error reading {desktop_path}: {str(e)}")
 | ||
| 
 | ||
|             btn = self._create_app_button(display_name, [icon_path], self.INSTALLED_BUTTON_LIST_STYLE)
 | ||
|             # Обертка для рамки выделения
 | ||
|             frame = QFrame()
 | ||
|             frame.setStyleSheet(self.FRAME_STYLE_DEFAULT)
 | ||
|             layout = QVBoxLayout(frame)
 | ||
|             layout.setContentsMargins(0, 0, 0, 0)
 | ||
|             layout.addWidget(btn)
 | ||
| 
 | ||
|             # Клик по кнопке показывает информацию о приложении
 | ||
|             btn.clicked.connect(lambda _, p=desktop_path, b=btn: self.show_installed_app_info(p, b))
 | ||
| 
 | ||
|             row = i // 2
 | ||
|             column = i % 2
 | ||
|             self.installed_scroll_layout.addWidget(frame, row, column)
 | ||
|             self.installed_buttons.append(btn)
 | ||
| 
 | ||
|     def _handle_prefix_install_finished(self, exit_code, exit_status):
 | ||
|         """Обрабатывает завершение установки в префикс."""
 | ||
|         if exit_code == 0:
 | ||
|             self.command_log_output.append("\n=== Установка успешно завершена ===")
 | ||
|             self.create_launcher_button.setEnabled(True)  # Активируем кнопку создания ярлыка
 | ||
|         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)
 | ||
|         self.prefix_install_path_edit.clear()
 | ||
|         self.update_installed_apps()
 | ||
| 
 | ||
|     def _set_active_button(self, button_widget):
 | ||
|         """Устанавливает активную кнопку и обновляет стили ее обертки (QFrame)."""
 | ||
|         # Сброс стиля предыдущей активной кнопки
 | ||
|         if self.current_active_button and self.current_active_button != button_widget:
 | ||
|             parent_frame = self.current_active_button.parent()
 | ||
|             if isinstance(parent_frame, QFrame):
 | ||
|                 parent_frame.setStyleSheet(self.FRAME_STYLE_DEFAULT)
 | ||
| 
 | ||
|         # Применение стиля к новой кнопке
 | ||
|         parent_frame = button_widget.parent()
 | ||
|         if isinstance(parent_frame, QFrame):
 | ||
|             parent_frame.setStyleSheet(self.FRAME_STYLE_SELECTED)
 | ||
| 
 | ||
|         self.current_active_button = button_widget
 | ||
| 
 | ||
|     def show_installed_app_info(self, desktop_path, button_widget):
 | ||
|         """Показывает информацию об установленном приложении в правой панели."""
 | ||
|         self._set_active_button(button_widget)
 | ||
|         # Очищаем поле поиска и принудительно обновляем список, чтобы показать все приложения
 | ||
|         self.installed_search_edit.blockSignals(True)
 | ||
|         self.installed_search_edit.clear()
 | ||
|         self.installed_search_edit.blockSignals(False)
 | ||
|         self.filter_installed_buttons()
 | ||
| 
 | ||
|         # Прокручиваем к выбранному элементу
 | ||
|         frame = button_widget.parent()
 | ||
|         if isinstance(frame, QFrame):
 | ||
|             # Даем циклу событий обработать перерисовку перед прокруткой
 | ||
|             QTimer.singleShot(0, lambda: self.installed_scroll_area.ensureWidgetVisible(frame))
 | ||
| 
 | ||
|         self.current_selected_app = {'desktop_path': desktop_path}
 | ||
| 
 | ||
|         # Меняем растяжение: убираем у описания (индекс 1) и добавляем
 | ||
|         # виджету с действиями для приложения (индекс 4), чтобы он оттолкнул кнопку "Восстановить" вниз.
 | ||
|         self.info_panel_layout.setStretch(1, 0)
 | ||
|         self.info_panel_layout.setStretch(4, 1)
 | ||
| 
 | ||
|         try:
 | ||
|             with open(desktop_path, 'r', encoding='utf-8') as f:
 | ||
|                 content = f.read()
 | ||
|                 # Парсим .desktop файл для получения информации
 | ||
|                 name = ""
 | ||
|                 comment = ""
 | ||
|                 exec_cmd = ""
 | ||
|                 icon = ""
 | ||
| 
 | ||
|                 for line in content.split('\n'):
 | ||
|                     if line.startswith('Name='):
 | ||
|                         name = line.split('=', 1)[1].strip()
 | ||
|                     elif line.startswith('Comment='):
 | ||
|                         comment = line.split('=', 1)[1].strip()
 | ||
|                     elif line.startswith('Exec='):
 | ||
|                         exec_cmd = line.split('=', 1)[1].strip()
 | ||
|                     elif line.startswith('Icon='):
 | ||
|                         icon = line.split('=', 1)[1].strip()
 | ||
| 
 | ||
|                 self.current_selected_app['name'] = name
 | ||
|                 self.current_selected_app['exec'] = exec_cmd
 | ||
| 
 | ||
|                 # Показываем панель информации
 | ||
|                 self.info_panel.setVisible(True)
 | ||
| 
 | ||
|                 # Показываем информацию в правой панели
 | ||
|                 self.script_title.setPixmap(QPixmap())  # Сначала очищаем иконку, чтобы отобразился текст
 | ||
|                 self.script_title.setText(name)
 | ||
|                 self.script_description.setVisible(False)
 | ||
| 
 | ||
|                 # Управляем видимостью кнопок
 | ||
|                 self.install_action_widget.setVisible(False)
 | ||
|                 self.installed_action_widget.setVisible(True)
 | ||
|                 self.installed_global_action_widget.setVisible(True)
 | ||
|                 self.backup_button.setVisible(True)
 | ||
|                 self.create_log_button.setVisible(True)
 | ||
|                 self.uninstall_button.setVisible(True)
 | ||
|                 self.manual_install_path_widget.setVisible(False)
 | ||
| 
 | ||
|         except Exception as e:
 | ||
|             QMessageBox.warning(self, "Ошибка", f"Не удалось прочитать информацию о приложении: {str(e)}")
 | ||
|             self.current_selected_app = None
 | ||
|             self.info_panel.setVisible(False)
 | ||
| 
 | ||
|     def _get_prefix_name_for_selected_app(self):
 | ||
|         """Извлекает имя префикса для выбранного приложения."""
 | ||
|         if not self.current_selected_app or 'desktop_path' not in self.current_selected_app:
 | ||
|             return None
 | ||
| 
 | ||
|         desktop_file = self.current_selected_app['desktop_path']
 | ||
|         if os.path.exists(desktop_file):
 | ||
|             try:
 | ||
|                 with open(desktop_file, 'r', encoding='utf-8') as f:
 | ||
|                     for line in f:
 | ||
|                         if line.startswith('Exec='):
 | ||
|                             exec_line = line.strip()
 | ||
|                             # Ищем часть пути, например: .../prefixes/some_prefix_name/...
 | ||
|                             if "prefixes/" in exec_line:
 | ||
|                                 prefix_part = exec_line.split("prefixes/")[1].split("/")[0]
 | ||
|                                 if prefix_part:
 | ||
|                                     return prefix_part
 | ||
|             except Exception as e:
 | ||
|                 print(f"Error getting prefix name from {desktop_file}: {e}")
 | ||
|         return None
 | ||
| 
 | ||
|     def _get_current_app_title(self):
 | ||
|         """Возвращает отображаемое имя для текущей выбранной программы."""
 | ||
|         # Если display_name не установлено (например, при ошибке), используем имя скрипта
 | ||
|         return self.current_display_name or self.current_script
 | ||
| 
 | ||
|     def backup_prefix_for_app(self):
 | ||
|         """Создает резервную копию префикса для выбранного приложения."""
 | ||
|         prefix_name = self._get_prefix_name_for_selected_app()
 | ||
|         if not prefix_name:
 | ||
|             QMessageBox.warning(self, "Ошибка", "Не удалось определить префикс для выбранного приложения.")
 | ||
|             return
 | ||
| 
 | ||
|         # Создаем кастомные кнопки
 | ||
|         yes_button = QPushButton("Да")
 | ||
|         no_button = QPushButton("Нет")
 | ||
| 
 | ||
|         msg_box = QMessageBox(self)
 | ||
|         msg_box.setWindowTitle("Создание резервной копии")
 | ||
|         msg_box.setText(
 | ||
|             f"Будет создана резервная копия префикса '{prefix_name}'.\n"
 | ||
|             f"Файл будет сохранен на вашем Рабочем столе в формате .whpack.\n\nПродолжить?"
 | ||
|         )
 | ||
|         msg_box.addButton(yes_button, QMessageBox.YesRole)
 | ||
|         msg_box.addButton(no_button, QMessageBox.NoRole)
 | ||
|         msg_box.setDefaultButton(no_button)
 | ||
| 
 | ||
|         msg_box.exec_()
 | ||
| 
 | ||
|         if msg_box.clickedButton() != yes_button:
 | ||
|             return  # Отмена, если нажато "Нет" или крестик
 | ||
|         # Используем модальный диалог для отображения процесса резервного копирования (бэкап)
 | ||
|         self.command_dialog = QDialog(self)
 | ||
|         self.command_dialog.setWindowTitle(f"Резервное копирование: {prefix_name}")
 | ||
|         self.command_dialog.setMinimumSize(750, 400)
 | ||
|         self.command_dialog.setModal(True)
 | ||
|         self.command_dialog.setWindowFlags(self.command_dialog.windowFlags() & ~Qt.WindowCloseButtonHint)
 | ||
| 
 | ||
|         layout = QVBoxLayout()
 | ||
|         self.command_log_output = QTextEdit()
 | ||
|         self.command_log_output.setReadOnly(True)
 | ||
|         self.command_log_output.setFont(QFont('DejaVu Sans Mono', 10))
 | ||
|         layout.addWidget(self.command_log_output)
 | ||
| 
 | ||
|         self.command_close_button = QPushButton("Закрыть")
 | ||
|         self.command_close_button.setEnabled(False)
 | ||
|         self.command_close_button.clicked.connect(self.command_dialog.close)
 | ||
|         layout.addWidget(self.command_close_button)
 | ||
|         self.command_dialog.setLayout(layout)
 | ||
|         # Устанавливаем родителя, чтобы избежать утечек памяти
 | ||
|         self.command_process = QProcess(self.command_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)
 | ||
| 
 | ||
|         winehelper_path = self.winehelper_path
 | ||
|         args = ["backup-prefix", prefix_name]
 | ||
| 
 | ||
|         self.command_log_output.append(f"Выполнение: {shlex.quote(winehelper_path)} {' '.join(shlex.quote(a) for a in args)}")
 | ||
|         self.command_process.start(winehelper_path, args)
 | ||
|         self.command_dialog.exec_()
 | ||
| 
 | ||
|     def restore_prefix(self):
 | ||
|         """Восстанавливает префикс из резервной копии."""
 | ||
|         backup_path, _ = QFileDialog.getOpenFileName(
 | ||
|             self,
 | ||
|             "Выберите файл резервной копии для восстановления",
 | ||
|             os.path.expanduser("~"),
 | ||
|             "WineHelper Backups (*.whpack);;Все файлы (*)"
 | ||
|         )
 | ||
| 
 | ||
|         if not backup_path:
 | ||
|             return
 | ||
| 
 | ||
|         # Используем модальный диалог для отображения процесса восстановления из бэкапа
 | ||
|         self.command_dialog = QDialog(self)
 | ||
|         self.command_dialog.setWindowTitle(f"Восстановление из: {os.path.basename(backup_path)}")
 | ||
|         self.command_dialog.setMinimumSize(600, 400)
 | ||
|         self.command_dialog.setModal(True)
 | ||
|         self.command_dialog.setWindowFlags(self.command_dialog.windowFlags() & ~Qt.WindowCloseButtonHint)
 | ||
| 
 | ||
|         layout = QVBoxLayout()
 | ||
|         self.command_log_output = QTextEdit()
 | ||
|         self.command_log_output.setReadOnly(True)
 | ||
|         self.command_log_output.setFont(QFont('DejaVu Sans Mono', 10))
 | ||
|         layout.addWidget(self.command_log_output)
 | ||
| 
 | ||
|         self.command_close_button = QPushButton("Закрыть")
 | ||
|         self.command_close_button.setEnabled(False)
 | ||
|         self.command_close_button.clicked.connect(self.command_dialog.close)
 | ||
|         layout.addWidget(self.command_close_button)
 | ||
|         self.command_dialog.setLayout(layout)
 | ||
|         # Устанавливаем родителя, чтобы избежать утечек памяти
 | ||
|         self.command_process = QProcess(self.command_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)
 | ||
| 
 | ||
|         winehelper_path = self.winehelper_path
 | ||
|         args = ["restore-prefix", backup_path]
 | ||
| 
 | ||
|         self.command_log_output.append(f"Выполнение: {shlex.quote(winehelper_path)} {' '.join(shlex.quote(a) for a in args)}")
 | ||
|         self.command_process.start(winehelper_path, args)
 | ||
|         self.command_dialog.exec_()
 | ||
| 
 | ||
|     def run_installed_app_with_debug(self):
 | ||
|         """Запускает выбранное установленное приложение с созданием лога"""
 | ||
|         # Создаем кастомные кнопки
 | ||
|         yes_button = QPushButton("Да")
 | ||
|         no_button = QPushButton("Нет")
 | ||
| 
 | ||
|         msg_box = QMessageBox(self)
 | ||
|         msg_box.setWindowTitle("Создание лога")
 | ||
|         msg_box.setText(
 | ||
|             "Приложение будет запущено в режиме отладки.\n"
 | ||
|             "После закрытия приложения лог будет сохранен в вашем домашнем каталоге "
 | ||
|             "под именем 'winehelper.log'."
 | ||
|         )
 | ||
|         msg_box.addButton(yes_button, QMessageBox.YesRole)
 | ||
|         msg_box.addButton(no_button, QMessageBox.NoRole)
 | ||
|         msg_box.exec_()
 | ||
| 
 | ||
|         if msg_box.clickedButton() == yes_button:
 | ||
|             self._run_app_launcher(debug=True)
 | ||
| 
 | ||
|     def open_winetricks_manager(self, prefix_name=None):
 | ||
|         """Открывает новый диалог для управления компонентами Winetricks."""
 | ||
|         if not prefix_name:
 | ||
|             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
 | ||
| 
 | ||
|         wine_executable = self._get_wine_executable_for_prefix(prefix_name)
 | ||
|         dialog = WinetricksManagerDialog(prefix_path, winetricks_path, self, wine_executable=wine_executable)
 | ||
|         dialog.exec_()
 | ||
| 
 | ||
|     def _get_wine_executable_for_prefix(self, prefix_name):
 | ||
|         """Определяет и возвращает путь к исполняемому файлу wine для указанного префикса."""
 | ||
|         prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name)
 | ||
|         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:
 | ||
|                             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):
 | ||
|                 return local_wine_path
 | ||
|             QMessageBox.warning(self, "Предупреждение",
 | ||
|                                 f"Локальная версия Wine '{wh_wine_use}' не найдена по пути:\n{local_wine_path}\n\n"
 | ||
|                                 "Будет использована системная версия Wine.")
 | ||
|         return 'wine'  # По умолчанию системный wine
 | ||
| 
 | ||
|     def _run_wine_util(self, util_name, prefix_name=None):
 | ||
|         """Запускает стандартную утилиту Wine для выбранного префикса."""
 | ||
|         if not prefix_name:
 | ||
|             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_executable = self._get_wine_executable_for_prefix(prefix_name)
 | ||
| 
 | ||
|         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)
 | ||
| 
 | ||
|     def _run_app_launcher(self, debug=False):
 | ||
|         """Внутренний метод для запуска приложения (с отладкой или без)"""
 | ||
|         if not self.current_selected_app or 'exec' not in self.current_selected_app:
 | ||
|             QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
 | ||
|             return
 | ||
| 
 | ||
|         command_str = self.current_selected_app['exec']
 | ||
| 
 | ||
|         try:
 | ||
|             # Используем shlex для безопасного разбора командной строки
 | ||
|             command_parts = shlex.split(command_str)
 | ||
| 
 | ||
|             # Удаляем параметры (%F и подобные)
 | ||
|             clean_command = [part for part in command_parts if not part.startswith('%')]
 | ||
| 
 | ||
|             if debug:
 | ||
|                 # Команда имеет вид: ['env', '/path/to/winehelper', '/path/to/app.exe']
 | ||
|                 # Нужно вставить '--debug' после скрипта winehelper
 | ||
|                 try:
 | ||
|                     script_index = -1
 | ||
|                     for i, part in enumerate(clean_command):
 | ||
|                         if 'winehelper' in os.path.basename(part):
 | ||
|                             script_index = i
 | ||
|                             break
 | ||
| 
 | ||
|                     if script_index != -1:
 | ||
|                         clean_command.insert(script_index + 1, '--debug')
 | ||
|                     else:
 | ||
|                         QMessageBox.critical(self, "Ошибка", "Не удалось найти скрипт winehelper в команде запуска.")
 | ||
|                         return
 | ||
|                 except Exception as e:
 | ||
|                     QMessageBox.critical(self, "Ошибка", f"Не удалось модифицировать команду для отладки: {e}")
 | ||
|                     return
 | ||
| 
 | ||
|             # Удаление префикса
 | ||
|             try:
 | ||
|                 subprocess.Popen(clean_command)
 | ||
|                 print(f"Запущено: {' '.join(clean_command)}")
 | ||
|             except Exception as e:
 | ||
|                 QMessageBox.critical(self, "Ошибка запуска",
 | ||
|                                      f"Не удалось запустить команду:\n{' '.join(clean_command)}\n\nОшибка: {str(e)}")
 | ||
|         except Exception as e:
 | ||
|             QMessageBox.critical(self, "Ошибка",
 | ||
|                                  f"Не удалось обработать команду запуска:\n{command_str}\n\nОшибка: {str(e)}")
 | ||
| 
 | ||
|     def uninstall_app(self):
 | ||
|         """Удаляет выбранное установленное приложение и его префикс"""
 | ||
|         if not self.current_selected_app or 'desktop_path' not in self.current_selected_app:
 | ||
|             QMessageBox.warning(self, "Ошибка", "Сначала выберите приложение.")
 | ||
|             return
 | ||
| 
 | ||
|         app_name = self.current_selected_app.get('name', 'это приложение')
 | ||
|         prefix_name = self._get_prefix_name_for_selected_app()
 | ||
| 
 | ||
|         if not prefix_name:
 | ||
|             QMessageBox.warning(self, "Ошибка", "Не удалось определить префикс для выбранного приложения.")
 | ||
|             return
 | ||
| 
 | ||
|         # Создаем кастомные кнопки
 | ||
|         yes_button = QPushButton("Да")
 | ||
|         no_button = QPushButton("Нет")
 | ||
| 
 | ||
|         msg_box = QMessageBox(self)
 | ||
|         msg_box.setWindowTitle('Подтверждение')
 | ||
|         msg_box.setText(
 | ||
|             f'Вы действительно хотите удалить "{app_name}" и все связанные файлы (включая префикс "{prefix_name}")?'
 | ||
|         )
 | ||
|         msg_box.addButton(yes_button, QMessageBox.YesRole)
 | ||
|         msg_box.addButton(no_button, QMessageBox.NoRole)
 | ||
|         msg_box.setDefaultButton(no_button)
 | ||
| 
 | ||
|         msg_box.exec_()
 | ||
| 
 | ||
|         if msg_box.clickedButton() == yes_button:
 | ||
|             try:
 | ||
|                 # Полный путь к префиксу
 | ||
|                 prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name)
 | ||
| 
 | ||
|                 # 1. Сначала собираем ВСЕ .desktop файлы, связанные с этим префиксом
 | ||
|                 all_desktop_files = set()
 | ||
|                 # Добавляем основной .desktop файл
 | ||
|                 if 'desktop_path' in self.current_selected_app:
 | ||
|                     all_desktop_files.add(self.current_selected_app['desktop_path'])
 | ||
| 
 | ||
|                 desktop_locations = [
 | ||
|                     Var.USER_WORK_PATH,
 | ||
|                     os.path.join(os.path.expanduser("~"), ".local/share/applications/WineHelper"),
 | ||
|                     os.path.join(os.path.expanduser("~"), "Desktop"),
 | ||
|                     os.path.join(os.path.expanduser("~"), "Рабочий стол"),
 | ||
|                 ]
 | ||
| 
 | ||
|                 # Проверяем все .desktop файлы в стандартных местах
 | ||
|                 for location in desktop_locations:
 | ||
|                     if not os.path.exists(location):
 | ||
|                         continue
 | ||
| 
 | ||
|                     for file in os.listdir(location):
 | ||
|                         if file.endswith('.desktop'):
 | ||
|                             file_path = os.path.join(location, file)
 | ||
|                             try:
 | ||
|                                 with open(file_path, 'r', encoding='utf-8') as f:
 | ||
|                                     content = f.read()
 | ||
|                                     if f"prefixes/{prefix_name}/" in content:
 | ||
|                                         all_desktop_files.add(file_path)
 | ||
|                             except:
 | ||
|                                 continue
 | ||
| 
 | ||
|                 # 2. Удаляем сам префикс
 | ||
|                 try:
 | ||
|                     if os.path.exists(prefix_path):
 | ||
|                         shutil.rmtree(prefix_path)
 | ||
|                         print(f"Удален префикс: {prefix_path}")
 | ||
|                     else:
 | ||
|                         print(f"Префикс не найден: {prefix_path}")
 | ||
|                 except Exception as e:
 | ||
|                     raise RuntimeError(f"Ошибка удаления префикса: {str(e)}")
 | ||
| 
 | ||
|                 # Обновляем состояние на вкладке "Создать префикс"
 | ||
|                 self._remove_prefix_from_gui_state(prefix_name)
 | ||
| 
 | ||
|                 # 3. Удаляем ВСЕ найденные .desktop файлы, связанные с этим префиксом
 | ||
|                 removed_files = []
 | ||
|                 for file_path in all_desktop_files:
 | ||
|                     try:
 | ||
|                         os.remove(file_path)
 | ||
|                         removed_files.append(file_path)
 | ||
|                     except Exception as e:
 | ||
|                         print(f"Ошибка удаления {file_path}: {str(e)}")
 | ||
| 
 | ||
|                 # 4. Удаляем категорию меню, если она пуста
 | ||
|                 menu_dir = os.path.join(os.path.expanduser("~"), ".local/share/applications/WineHelper")
 | ||
|                 if os.path.exists(menu_dir) and not os.listdir(menu_dir):
 | ||
|                     try:
 | ||
|                         os.rmdir(menu_dir)
 | ||
|                         # Удаляем связанные файлы меню
 | ||
|                         menu_files = [
 | ||
|                             os.path.join(os.path.expanduser("~"),
 | ||
|                                          ".local/share/desktop-directories/WineHelper.directory"),
 | ||
|                             os.path.join(os.path.expanduser("~"), ".config/menus/applications-merged/WineHelper.menu")
 | ||
|                         ]
 | ||
|                         for f in menu_files:
 | ||
|                             if os.path.exists(f):
 | ||
|                                 os.remove(f)
 | ||
|                     except Exception as e:
 | ||
|                         print(f"Ошибка удаления пустой категории меню: {str(e)}")
 | ||
| 
 | ||
|                 # Обновляем кэш desktop файлов
 | ||
|                 try:
 | ||
|                     subprocess.run(
 | ||
|                         ["update-desktop-database", os.path.join(os.path.expanduser("~"), ".local/share/applications")])
 | ||
|                 except Exception as e:
 | ||
|                     print(f"Ошибка обновления кэша desktop файлов: {str(e)}")
 | ||
| 
 | ||
|                 # Обновляем список установленных приложений
 | ||
|                 self.update_installed_apps()
 | ||
| 
 | ||
|                 # Формируем отчет об удалении
 | ||
|                 report = f"Удаление завершено:\n"
 | ||
|                 report += f"- Префикс: {prefix_path}\n"
 | ||
|                 report += f"- Удаленные .desktop файлы ({len(removed_files)}):\n"
 | ||
|                 report += "\n".join(f"  - {f}" for f in removed_files) if removed_files else "  (не найдены)"
 | ||
| 
 | ||
|                 # Создаем кастомный диалог, чтобы кнопка была на русском
 | ||
|                 success_box = QMessageBox(self)
 | ||
|                 success_box.setWindowTitle("Успех")
 | ||
|                 success_box.setText(report)
 | ||
|                 success_box.setIcon(QMessageBox.Information)
 | ||
|                 success_box.addButton("Готово", QMessageBox.AcceptRole)
 | ||
|                 success_box.exec_()
 | ||
| 
 | ||
|                 self._reset_info_panel_to_default("Установленные")
 | ||
| 
 | ||
|             except Exception as e:
 | ||
|                 QMessageBox.critical(self, "Ошибка",
 | ||
|                                      f"Не удалось удалить приложение: {str(e)}\n\n"
 | ||
|                                      f"Desktop файл: {self.current_selected_app.get('desktop_path', 'не определен')}\n"
 | ||
|                                      f"Префикс: {prefix_name}\n"
 | ||
|                                      f"Путь к префиксу: {prefix_path if 'prefix_path' in locals() else 'не определен'}")
 | ||
|     def _filter_buttons_in_grid(self, search_text, button_list, grid_layout):
 | ||
|         """Общий метод для фильтрации кнопок и перестроения сетки (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 show_script_info(self, script_name, button_widget):
 | ||
|         """Показывает информацию о выбранном скрипте"""
 | ||
|         self._set_active_button(button_widget)
 | ||
|         self.current_script = script_name
 | ||
| 
 | ||
|         # Определяем виджеты и действия в зависимости от типа скрипта
 | ||
|         if script_name in self.autoinstall_scripts:
 | ||
|             script_path = os.path.join(Var.DATA_PATH, "autoinstall", script_name)
 | ||
|             tab_type = 'auto'
 | ||
|             self.manual_install_path_widget.setVisible(False)
 | ||
|         else:
 | ||
|             script_path = os.path.join(Var.DATA_PATH, "manualinstall", script_name)
 | ||
|             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)
 | ||
|         self.filter_buttons(tab_type)
 | ||
|         frame = button_widget.parent()
 | ||
|         if isinstance(frame, QFrame):
 | ||
|             QTimer.singleShot(0, lambda: scroll_area.ensureWidgetVisible(frame))
 | ||
| 
 | ||
|         # Обновляем информацию в правой панели
 | ||
|         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
 | ||
| 
 | ||
|         if icon_names:
 | ||
|             # Для заголовка используем первую иконку из списка
 | ||
|             icon_path = os.path.join(Var.DATA_PATH, "image", f"{icon_names[0]}.png")
 | ||
|             if os.path.exists(icon_path):
 | ||
|                 self.script_title.setPixmap(QPixmap(icon_path).scaled(64, 64, Qt.KeepAspectRatio))
 | ||
|             else:
 | ||
|                 self.script_title.setPixmap(QPixmap())
 | ||
|         else:
 | ||
|             self.script_title.setPixmap(QPixmap())
 | ||
| 
 | ||
|         self.script_title.setText(display_name)
 | ||
| 
 | ||
|         html_description = f"<p>{description}</p>"
 | ||
|         if prog_url:
 | ||
|             html_description += f'<p><b>Официальный сайт:</b> <a href="{prog_url}">{prog_url}</a></p>'
 | ||
| 
 | ||
|         self.script_description.setHtml(html_description)
 | ||
|         self.script_description.setVisible(True)
 | ||
|         self.install_action_widget.setVisible(True)
 | ||
|         self.installed_action_widget.setVisible(False)
 | ||
|         self.installed_global_action_widget.setVisible(False)
 | ||
|         self.install_button.setText(f"Установить «{display_name}»")
 | ||
| 
 | ||
|     def _show_license_agreement_dialog(self):
 | ||
|         """Показывает модальный диалог с лицензионным соглашением."""
 | ||
|         dialog = QDialog(self)
 | ||
|         dialog.setWindowTitle("Лицензионное соглашение")
 | ||
|         dialog.setMinimumSize(750, 400)
 | ||
|         dialog.setModal(True)
 | ||
| 
 | ||
|         layout = QVBoxLayout(dialog)
 | ||
| 
 | ||
|         license_text = QTextBrowser()
 | ||
|         try:
 | ||
|             license_file_path = Var.LICENSE_AGREEMENT_FILE
 | ||
|             if not license_file_path or not os.path.exists(license_file_path):
 | ||
|                 raise FileNotFoundError
 | ||
| 
 | ||
|             with open(license_file_path, 'r', encoding='utf-8') as f:
 | ||
|                 license_content = f.read()
 | ||
| 
 | ||
|             escaped_license_content = html.escape(license_content)
 | ||
|             license_text.setHtml(f"""
 | ||
|                 <pre style="font-family: sans-serif; font-size: 10pt; white-space: pre-wrap; word-wrap: break-word;">{escaped_license_content}</pre>
 | ||
|             """)
 | ||
|         except (FileNotFoundError, TypeError):
 | ||
|             license_text.setHtml(f'<h3>Лицензионные соглашения</h3><p>Не удалось загрузить файл лицензионного соглашения по пути:<br>{Var.LICENSE_AGREEMENT_FILE}</p>')
 | ||
|         except Exception as e:
 | ||
|             license_text.setHtml(f'<h3>Лицензионные соглашения</h3><p>Произошла ошибка при чтении файла лицензии:<br>{str(e)}</p>')
 | ||
| 
 | ||
|         layout.addWidget(license_text)
 | ||
| 
 | ||
|         checkbox = QCheckBox("Я принимаю условия лицензионного соглашения")
 | ||
|         layout.addWidget(checkbox)
 | ||
| 
 | ||
|         button_layout = QHBoxLayout()
 | ||
|         accept_button = QPushButton("Принять")
 | ||
|         accept_button.setEnabled(False)
 | ||
|         accept_button.clicked.connect(dialog.accept)
 | ||
| 
 | ||
|         cancel_button = QPushButton("Отклонить")
 | ||
|         cancel_button.clicked.connect(dialog.reject)
 | ||
| 
 | ||
|         button_layout.addStretch()
 | ||
|         button_layout.addWidget(accept_button)
 | ||
|         button_layout.addWidget(cancel_button)
 | ||
|         layout.addLayout(button_layout)
 | ||
| 
 | ||
|         checkbox.stateChanged.connect(lambda state: accept_button.setEnabled(state == Qt.Checked))
 | ||
| 
 | ||
|         return dialog.exec_() == QDialog.Accepted
 | ||
| 
 | ||
|     def install_current_script(self):
 | ||
|         """Устанавливает текущий выбранный скрипт"""
 | ||
|         if not self.current_script:
 | ||
|             QMessageBox.warning(self, "Ошибка", "Не выбрана программа для установки")
 | ||
|             return
 | ||
| 
 | ||
|         if self.current_script in self.manualinstall_scripts and not self.install_path_edit.text().strip():
 | ||
|             QMessageBox.warning(self, "Ошибка", "Укажите путь к установочному файлу")
 | ||
|             return
 | ||
| 
 | ||
|         if not self._show_license_agreement_dialog():
 | ||
|             return  # Пользователь отклонил лицензию
 | ||
| 
 | ||
|         self.installation_cancelled = False
 | ||
| 
 | ||
|         # Создаем диалоговое окно установки
 | ||
|         self.install_dialog = QDialog(self)
 | ||
|         title_name = self._get_current_app_title()
 | ||
|         self.install_dialog.setWindowTitle(f"Установка «{title_name}»")
 | ||
|         self.install_dialog.setMinimumSize(750, 400)
 | ||
|         self.install_dialog.setWindowModality(Qt.WindowModal)
 | ||
|         self.install_dialog.setAttribute(Qt.WA_DeleteOnClose)  # Удалять диалог при закрытии
 | ||
|         log_layout = QVBoxLayout(self.install_dialog)
 | ||
| 
 | ||
|         self.log_output = QTextEdit()
 | ||
|         self.log_output.setReadOnly(True)
 | ||
|         self.log_output.setFont(QFont('DejaVu Sans Mono', 10))
 | ||
|         log_layout.addWidget(self.log_output)
 | ||
| 
 | ||
|         control_buttons = QWidget()
 | ||
|         btn_layout = QHBoxLayout(control_buttons)
 | ||
|         self.btn_abort = QPushButton("Прервать")
 | ||
|         self.btn_abort.clicked.connect(self.abort_installation)
 | ||
|         btn_layout.addWidget(self.btn_abort)
 | ||
| 
 | ||
|         self.btn_close = QPushButton("Закрыть")
 | ||
|         self.btn_close.setEnabled(False)
 | ||
|         self.btn_close.clicked.connect(self.install_dialog.close)
 | ||
|         btn_layout.addWidget(self.btn_close)
 | ||
|         log_layout.addWidget(control_buttons)
 | ||
| 
 | ||
|         # Назначение кастомного обработчика закрытия окна
 | ||
|         def dialog_close_handler(event):
 | ||
|             self.handle_install_dialog_close(event)
 | ||
|         self.install_dialog.closeEvent = dialog_close_handler
 | ||
| 
 | ||
|         self.install_dialog.show()
 | ||
|         # Сразу же готовим и запускаем установку
 | ||
|         self._prepare_installation()
 | ||
| 
 | ||
|     def _reset_log_state(self):
 | ||
|         """Сбрасывает состояние буфера и флага прогресса для лога установки."""
 | ||
|         self.output_buffer = ""
 | ||
|         self.last_line_was_progress = False
 | ||
| 
 | ||
|     def _prepare_installation(self):
 | ||
|         """Подготавливает и запускает процесс установки"""
 | ||
|         self._reset_log_state()  # Сбрасываем состояние для обработки лога
 | ||
| 
 | ||
|         winehelper_path = self.winehelper_path
 | ||
|         script_path = os.path.join(Var.DATA_PATH,
 | ||
|                                    "autoinstall" if self.current_script in self.autoinstall_scripts else "manualinstall",
 | ||
|                                    self.current_script)
 | ||
| 
 | ||
|         if not os.path.exists(winehelper_path):
 | ||
|             QMessageBox.critical(self.install_dialog, "Ошибка", f"winehelper не найден по пути:\n{winehelper_path}")
 | ||
|             return
 | ||
|         if not os.path.exists(script_path):
 | ||
|             QMessageBox.critical(self.install_dialog, "Ошибка", f"Скрипт установки не найден:\n{script_path}")
 | ||
|             return
 | ||
| 
 | ||
|         if self.current_script in self.manualinstall_scripts:
 | ||
|             install_file = self.install_path_edit.text().strip()
 | ||
|             if not install_file:
 | ||
|                 QMessageBox.critical(self.install_dialog, "Ошибка", "Не указан путь к установочному файлу")
 | ||
|                 return
 | ||
|             QTimer.singleShot(100, lambda: self._start_installation(winehelper_path, script_path, install_file))
 | ||
|         else:
 | ||
|             QTimer.singleShot(100, lambda: self._start_installation(winehelper_path, script_path))
 | ||
| 
 | ||
|     def _start_installation(self, winehelper_path, script_path, install_file=None):
 | ||
|         """Запускает процесс установки"""
 | ||
|         # Устанавливаем родителя для QProcess, чтобы он корректно удалялся вместе с диалогом
 | ||
|         self.install_process = QProcess(self.install_dialog)
 | ||
|         self.install_process.setProcessChannelMode(QProcess.MergedChannels)
 | ||
|         self.install_process.setWorkingDirectory(os.path.dirname(winehelper_path))
 | ||
| 
 | ||
|         env = QProcessEnvironment.systemEnvironment()
 | ||
|         env.insert("GUI_MODE", "1")
 | ||
|         self.install_process.setProcessEnvironment(env)
 | ||
| 
 | ||
|         self.install_process.readyReadStandardOutput.connect(self.handle_process_output)
 | ||
|         self.install_process.finished.connect(self.handle_process_finished)
 | ||
| 
 | ||
|         args = ["install", os.path.basename(script_path)]
 | ||
|         if install_file:
 | ||
|             args.append(install_file)
 | ||
| 
 | ||
|         title_name = self._get_current_app_title()
 | ||
|         self.append_log(f"=== Начало установки «{title_name}» ===")
 | ||
|         self.append_log(f"Исполняемый файл: {winehelper_path}")
 | ||
|         self.append_log(f"Аргументы: {' '.join(shlex.quote(a) for a in args)}")
 | ||
| 
 | ||
|         try:
 | ||
|             self.install_process.start(winehelper_path, args)
 | ||
|             if not self.install_process.waitForStarted(3000):
 | ||
|                 raise RuntimeError("Не удалось запустить процесс установки")
 | ||
|             self.append_log("Процесс установки запущен...")
 | ||
|         except Exception as e:
 | ||
|             self.append_log(f"\n=== ОШИБКА: {str(e)} ===", is_error=True)
 | ||
|             QMessageBox.critical(self.install_dialog, "Ошибка", f"Не удалось запустить установку:\n{str(e)}")
 | ||
|             self.cleanup_process()
 | ||
| 
 | ||
|     def _append_to_log(self, log_widget, text, is_error=False, add_newline=True):
 | ||
|         """Helper to append text to a QTextEdit log widget."""
 | ||
|         if not log_widget:
 | ||
|             return
 | ||
|         cursor = log_widget.textCursor()
 | ||
|         cursor.movePosition(QTextCursor.End)
 | ||
| 
 | ||
|         if is_error:
 | ||
|             # Для ошибок всегда добавляем перенос строки для лучшей читаемости
 | ||
|             cursor.insertHtml(f'<font color="red">{text}</font><br>')
 | ||
|         else:
 | ||
|             # Вставляем текст. Добавляем перенос строки, если нужно.
 | ||
|             formatted_text = f"{text}\n" if add_newline else text
 | ||
|             cursor.insertText(formatted_text)
 | ||
| 
 | ||
|         log_widget.ensureCursorVisible()
 | ||
|         QApplication.processEvents()
 | ||
| 
 | ||
|     def append_log(self, text, is_error=False, add_newline=True):
 | ||
|         """Добавляет сообщение в лог установки."""
 | ||
|         self._append_to_log(self.log_output, text, is_error, add_newline)
 | ||
| 
 | ||
|     def _process_log_line(self, line_with_delimiter):
 | ||
|         """Обрабатывает одну строку лога, управляя заменой строк прогресса."""
 | ||
|         is_progress_line = '\r' in line_with_delimiter
 | ||
| 
 | ||
|         # Фильтруем "мусорные" строки прогресса (например, '-=O=-' от wget),
 | ||
|         # обрабатывая только те, что содержат знак процента.
 | ||
|         if is_progress_line:
 | ||
|             if not re.search(r'\d\s*%', line_with_delimiter):
 | ||
|                 return  # Игнорируем строку прогресса без процентов
 | ||
| 
 | ||
|         clean_line = line_with_delimiter.replace('\r', '').replace('\n', '').strip()
 | ||
| 
 | ||
|         if not clean_line:
 | ||
|             return
 | ||
| 
 | ||
|         cursor = self.log_output.textCursor()
 | ||
| 
 | ||
|         # Если новая строка - это прогресс, и предыдущая тоже была прогрессом,
 | ||
|         # то мы удаляем старую, чтобы заменить ее новой.
 | ||
|         if is_progress_line and self.last_line_was_progress:
 | ||
|             cursor.movePosition(QTextCursor.End)
 | ||
|             cursor.select(QTextCursor.LineUnderCursor)
 | ||
|             cursor.removeSelectedText()
 | ||
|         elif not is_progress_line and self.last_line_was_progress:
 | ||
|             # Это переход от строки прогресса к финальной строке.
 | ||
|             # Вместо добавления переноса, мы заменяем предыдущую строку новой.
 | ||
|             cursor.movePosition(QTextCursor.End)
 | ||
|             cursor.select(QTextCursor.LineUnderCursor)
 | ||
|             cursor.removeSelectedText()
 | ||
| 
 | ||
|         # Добавляем новую очищенную строку.
 | ||
|         # Для прогресса - без переноса строки, для обычных строк - с переносом.
 | ||
|         self.append_log(clean_line, add_newline=not is_progress_line)
 | ||
| 
 | ||
|         self.last_line_was_progress = is_progress_line
 | ||
| 
 | ||
|     def _process_command_log_line(self, line_with_delimiter):
 | ||
|         """Обрабатывает одну строку лога для диалога создания префикса, управляя заменой строк прогресса."""
 | ||
|         is_progress_line = '\r' in line_with_delimiter
 | ||
| 
 | ||
|         # Фильтруем "мусорные" строки прогресса (например, '-=O=-' от wget),
 | ||
|         # обрабатывая только те, что содержат знак процента.
 | ||
|         if is_progress_line:
 | ||
|             if not re.search(r'\d\s*%', line_with_delimiter):
 | ||
|                 return  # Игнорируем строку прогресса без процентов
 | ||
| 
 | ||
|         clean_line = line_with_delimiter.replace('\r', '').replace('\n', '').strip()
 | ||
| 
 | ||
|         if not clean_line:
 | ||
|             return
 | ||
| 
 | ||
|         cursor = self.command_log_output.textCursor()
 | ||
| 
 | ||
|         # Если новая строка - это прогресс, и предыдущая тоже была прогрессом,
 | ||
|         # то мы удаляем старую, чтобы заменить ее новой.
 | ||
|         if is_progress_line and self.command_last_line_was_progress:
 | ||
|             cursor.movePosition(QTextCursor.End)
 | ||
|             cursor.select(QTextCursor.LineUnderCursor)
 | ||
|             cursor.removeSelectedText()
 | ||
|         elif not is_progress_line and self.command_last_line_was_progress:
 | ||
|             # Это переход от строки прогресса к финальной строке.
 | ||
|             # Вместо добавления переноса, мы заменяем предыдущую строку новой.
 | ||
|             cursor.movePosition(QTextCursor.End)
 | ||
|             cursor.select(QTextCursor.LineUnderCursor)
 | ||
|             cursor.removeSelectedText()
 | ||
| 
 | ||
|         # Добавляем новую очищенную строку.
 | ||
|         # Для прогресса - без переноса строки, для обычных строк - с переносом.
 | ||
|         self._append_to_log(self.command_log_output, clean_line, add_newline=not is_progress_line)
 | ||
| 
 | ||
|         self.command_last_line_was_progress = is_progress_line
 | ||
| 
 | ||
|     def handle_process_output(self):
 | ||
|         """Обрабатывает вывод процесса, корректно отображая однострочный прогресс."""
 | ||
|         new_data = self.install_process.readAllStandardOutput().data().decode('utf-8', errors='ignore')
 | ||
|         self.output_buffer += new_data
 | ||
| 
 | ||
|         while True:
 | ||
|             # Ищем ближайший разделитель (\n или \r)
 | ||
|             idx_n = self.output_buffer.find('\n')
 | ||
|             idx_r = self.output_buffer.find('\r')
 | ||
| 
 | ||
|             if idx_n == -1 and idx_r == -1:
 | ||
|                 break  # Нет полных строк для обработки
 | ||
| 
 | ||
|             split_idx = min(idx for idx in [idx_n, idx_r] if idx != -1)
 | ||
| 
 | ||
|             # Получаем строку, включая разделитель
 | ||
|             line = self.output_buffer[:split_idx + 1]
 | ||
|             self.output_buffer = self.output_buffer[split_idx + 1:]
 | ||
| 
 | ||
|             self._process_log_line(line)
 | ||
| 
 | ||
|     def handle_process_finished(self, exit_code, exit_status):
 | ||
|         """Обрабатывает завершение процесса"""
 | ||
|         # Обрабатываем остаток в буфере, если он есть
 | ||
|         if self.output_buffer:
 | ||
|             self._process_log_line(self.output_buffer)
 | ||
| 
 | ||
|         # Если последней строкой был прогресс, "завершаем" его переносом строки.
 | ||
|         if self.last_line_was_progress:
 | ||
|             cursor = self.log_output.textCursor()
 | ||
|             cursor.movePosition(QTextCursor.End)
 | ||
|             cursor.insertText("\n")
 | ||
| 
 | ||
|         self._reset_log_state()
 | ||
| 
 | ||
|         if hasattr(self, 'installation_cancelled') and self.installation_cancelled:
 | ||
|             self.append_log("\n=== Установка была прервана. ===")
 | ||
|             self.cleanup_process()
 | ||
|             if self.install_dialog:
 | ||
|                 self.install_dialog.close()
 | ||
|             return
 | ||
| 
 | ||
|         if exit_code == 0 and exit_status == QProcess.NormalExit:
 | ||
|             self.append_log("\n=== Установка успешно завершена ===")
 | ||
|             # Создаем кастомный диалог, чтобы кнопка была на русском
 | ||
|             success_box = QMessageBox(self.install_dialog)
 | ||
|             success_box.setWindowTitle("Успех")
 | ||
|             title_name = self._get_current_app_title()
 | ||
|             success_box.setText(f"Программа «{title_name}» установлена успешно!")
 | ||
|             success_box.setIcon(QMessageBox.Information)
 | ||
|             success_box.addButton("Готово", QMessageBox.AcceptRole)
 | ||
|             success_box.exec_()
 | ||
| 
 | ||
|             self.update_installed_apps()
 | ||
| 
 | ||
|         # Кнопка закрыть
 | ||
|         self.btn_close.setEnabled(True)
 | ||
|         # Кнопка прервать
 | ||
|         self.btn_abort.setEnabled(False)
 | ||
|         self.install_process = None
 | ||
| 
 | ||
|     def _handle_prefix_creation_output(self):
 | ||
|         """Обрабатывает вывод процесса создания префикса, корректно отображая прогресс."""
 | ||
|         if not hasattr(self, 'command_process') or not self.command_process:
 | ||
|             return
 | ||
|         new_data = self.command_process.readAllStandardOutput().data().decode('utf-8', errors='ignore')
 | ||
|         self.command_output_buffer += new_data
 | ||
| 
 | ||
|         while True:
 | ||
|             # Ищем ближайший разделитель (\n или \r)
 | ||
|             idx_n = self.command_output_buffer.find('\n')
 | ||
|             idx_r = self.command_output_buffer.find('\r')
 | ||
| 
 | ||
|             if idx_n == -1 and idx_r == -1:
 | ||
|                 break  # Нет полных строк для обработки
 | ||
| 
 | ||
|             split_idx = min(idx for idx in [idx_n, idx_r] if idx != -1)
 | ||
| 
 | ||
|             line = self.command_output_buffer[:split_idx + 1]
 | ||
|             self.command_output_buffer = self.command_output_buffer[split_idx + 1:]
 | ||
|             self._process_command_log_line(line)
 | ||
| 
 | ||
|     def handle_install_dialog_close(self, event):
 | ||
|         """Обрабатывает событие закрытия диалога установки."""
 | ||
|         # Проверяем, запущен ли еще процесс установки
 | ||
|         if hasattr(self, 'install_process') and self.install_process and self.install_process.state() == QProcess.Running:
 | ||
|             yes_button = QPushButton("Да, прервать")
 | ||
|             no_button = QPushButton("Нет")
 | ||
| 
 | ||
|             msg_box = QMessageBox(self.install_dialog)
 | ||
|             msg_box.setWindowTitle("Прервать установку?")
 | ||
|             msg_box.setText("Установка еще не завершена.\nВы действительно хотите прервать процесс?")
 | ||
|             msg_box.setIcon(QMessageBox.Question)
 | ||
|             msg_box.addButton(yes_button, QMessageBox.YesRole)
 | ||
|             msg_box.addButton(no_button, QMessageBox.NoRole)
 | ||
|             msg_box.setDefaultButton(no_button)
 | ||
| 
 | ||
|             msg_box.exec_()
 | ||
| 
 | ||
|             if msg_box.clickedButton() == yes_button:
 | ||
|                 self.append_log("\n=== Пользователь прервал установку через закрытие окна. Ожидание... ===", is_error=True)
 | ||
|                 self.installation_cancelled = True
 | ||
|                 self.install_process.terminate()
 | ||
|                 event.ignore()  # Запрещаем закрытие, handle_process_finished обработает его
 | ||
|             else:
 | ||
|                 # Пользователь нажал "Нет", поэтому игнорируем событие закрытия
 | ||
|                 event.ignore()
 | ||
|         else:
 | ||
|             # Процесс не запущен (установка завершена или еще не началась),
 | ||
|             # поэтому просто закрываем окно
 | ||
|             event.accept()
 | ||
| 
 | ||
|     def abort_installation(self):
 | ||
|         """Прерывает текущую установку"""
 | ||
|         if hasattr(self, 'install_process') and self.install_process and self.install_process.state() == QProcess.Running:
 | ||
|             yes_button = QPushButton("Да")
 | ||
|             no_button = QPushButton("Нет")
 | ||
|             msg_box = QMessageBox(self.install_dialog)
 | ||
|             msg_box.setWindowTitle("Подтверждение")
 | ||
|             msg_box.setText("Вы действительно хотите прервать установку?")
 | ||
|             msg_box.addButton(yes_button, QMessageBox.YesRole)
 | ||
|             msg_box.addButton(no_button, QMessageBox.NoRole)
 | ||
|             msg_box.setDefaultButton(no_button)
 | ||
|             msg_box.exec_()
 | ||
|             if msg_box.clickedButton() == yes_button:
 | ||
|                 self.append_log("\n=== Пользователь прервал установку ===", is_error=True)
 | ||
|                 self.installation_cancelled = True
 | ||
|                 self.install_process.terminate()
 | ||
| 
 | ||
|     def _handle_command_output(self):
 | ||
|         """Обрабатывает вывод для диалога команды"""
 | ||
|         if hasattr(self, 'command_process') and self.command_process:
 | ||
|             output = self.command_process.readAllStandardOutput().data().decode('utf-8', errors='ignore').strip()
 | ||
|             if output and hasattr(self, 'command_log_output'):
 | ||
|                 self.command_log_output.append(output)
 | ||
|                 QApplication.processEvents()
 | ||
| 
 | ||
|     def _handle_command_finished(self, exit_code, exit_status):
 | ||
|         """Обрабатывает завершение для диалога команды"""
 | ||
|         if exit_code == 0:
 | ||
|             self.command_log_output.append(f"\n=== Команда успешно завершена ===")
 | ||
|         else:
 | ||
|             self.command_log_output.append(f"\n=== Ошибка выполнения (код: {exit_code}) ===")
 | ||
|         if self.command_process:
 | ||
|             self.command_process.deleteLater()
 | ||
|             self.command_process = None
 | ||
|         self.command_close_button.setEnabled(True)
 | ||
| 
 | ||
|     def _handle_launcher_creation_finished(self, exit_code, exit_status):
 | ||
|         """Обрабатывает завершение создания ярлыка."""
 | ||
|         self._handle_command_finished(exit_code, exit_status)
 | ||
|         if exit_code == 0:
 | ||
|             QMessageBox.information(self, "Успех", "Ярлык успешно создан.")
 | ||
|             self.update_installed_apps()
 | ||
|             # Переключаемся на вкладку "Установленные"
 | ||
|             for i in range(self.tab_bar.count()):
 | ||
|                 if self.tab_bar.tabText(i) == "Установленные":
 | ||
|                     self.tab_bar.setCurrentIndex(i)
 | ||
|                     break
 | ||
| 
 | ||
|     def _handle_restore_finished(self, exit_code, exit_status):
 | ||
|         """Обрабатывает завершение для диалога команды восстановления."""
 | ||
|         if exit_code == 0:
 | ||
|             self.command_log_output.append(f"\n=== Восстановление успешно завершено ===")
 | ||
|             self.update_installed_apps()
 | ||
|             self.filter_installed_buttons()
 | ||
|         else:
 | ||
|             self.command_log_output.append(f"\n=== Ошибка выполнения (код: {exit_code}) ===")
 | ||
|         if self.command_process:
 | ||
|             self.command_process.deleteLater()
 | ||
|             self.command_process = None
 | ||
|         self.command_close_button.setEnabled(True)
 | ||
| 
 | ||
|     def cleanup_process(self):
 | ||
|         """Очищает ресурсы процесса, принудительно завершая его, если он активен."""
 | ||
|         if hasattr(self, 'install_process') and self.install_process:
 | ||
|             if self.install_process.state() == QProcess.Running:
 | ||
|                 self.install_process.terminate()
 | ||
|                 if not self.install_process.waitForFinished(3000):
 | ||
|                     self.append_log("Процесс не ответил на terminate, отправляется kill...", is_error=True)
 | ||
|                     self.install_process.kill()
 | ||
|                     self.install_process.waitForFinished()
 | ||
|             self.install_process.deleteLater()
 | ||
|             self.install_process = None
 | ||
| 
 | ||
| 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__":
 | ||
|     sys.exit(main())
 |