Files
winehelper/winehelper_gui.py
2025-08-26 11:16:50 +06:00

3589 lines
186 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)
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:
info_box = QMessageBox(dialog)
info_box.setWindowIcon(self.app_icon)
info_box.setIcon(QMessageBox.Information)
info_box.setWindowTitle("Успех")
info_box.setText("Все необходимые зависимости были успешно установлены.")
info_box.exec_()
installation_successful = True
else:
warn_box = QMessageBox(dialog)
warn_box.setWindowIcon(self.app_icon)
warn_box.setIcon(QMessageBox.Warning)
warn_box.setWindowTitle("Установка не завершена")
warn_box.setText(
"Не все зависимости были установлены. WineHelper может работать некорректно.\n\n"
f"Отсутствуют: {', '.join(sorted(still_missing))}\n\n"
"Попробуйте запустить установку снова или установите пакеты вручную."
)
warn_box.exec_()
else:
log_tag = "ПРЕРВАНО" if exit_status == QProcess.CrashExit else "ОШИБКА"
log_output.append(f"\n<b><font color='red'>=== {log_tag} (код: {exit_code}) ===</font></b>")
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):
super().__init__(parent)
self.prefix_path = prefix_path
self.winetricks_path = winetricks_path
self.initial_states = {}
self.apply_process = None
self.installation_finished = False
self.user_cancelled = False
self.processes = {}
self.category_statuses = {}
self.previous_tab_widget = None
self.cache_dir = os.path.join(os.path.expanduser("~"), ".cache", "winehelper", "winetricks")
os.makedirs(self.cache_dir, exist_ok=True)
self.setWindowTitle(f"Менеджер компонентов для префикса: {os.path.basename(prefix_path)}")
self.setMinimumSize(800, 500)
# Основной layout
main_layout = QVBoxLayout(self)
# Табы для категорий
self.tabs = QTabWidget()
main_layout.addWidget(self.tabs)
# Создаем табы
self.categories = {
"Библиотеки": "dlls",
"Шрифты": "fonts",
"Настройки": "settings"
}
self.list_widgets = {}
self.search_edits = {}
for display_name, internal_name in self.categories.items():
list_widget, search_edit = self._create_category_tab(display_name)
self.list_widgets[internal_name] = list_widget
self.search_edits[internal_name] = search_edit
# Лог для вывода команд
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setFont(QFont('DejaVu Sans Mono', 10))
self.log_output.setMaximumHeight(150)
self.log_output.setText(self.INFO_TEXT)
main_layout.addWidget(self.log_output)
# Кнопки управления
button_layout = QHBoxLayout()
self.status_label = QLabel("Загрузка компонентов...")
button_layout.addWidget(self.status_label, 1)
self.apply_button = QPushButton("Применить")
self.apply_button.setEnabled(False)
self.apply_button.clicked.connect(self.apply_changes)
button_layout.addWidget(self.apply_button)
self.reinstall_button = QPushButton("Переустановить")
self.reinstall_button.setEnabled(False)
self.reinstall_button.clicked.connect(self.reinstall_selected)
button_layout.addWidget(self.reinstall_button)
self.close_button = QPushButton("Закрыть")
self.close_button.clicked.connect(self.close)
button_layout.addWidget(self.close_button)
main_layout.addLayout(button_layout)
# Подключаем сигнал после создания всех виджетов, чтобы избежать ошибки атрибута
self.tabs.currentChanged.connect(self.on_tab_switched)
# Загружаем данные
self.load_all_categories()
# Устанавливаем начальное состояние для отслеживания покинутой вкладки
self.previous_tab_widget = self.tabs.currentWidget()
def on_tab_switched(self, index):
"""
Обрабатывает переключение вкладок.
Если установка только что завершилась, сбрасывает лог к информационному тексту.
"""
# Очищаем поле поиска на той вкладке, которую покинули.
if self.previous_tab_widget:
search_edit = self.previous_tab_widget.findChild(QLineEdit)
if search_edit:
search_edit.clear()
if self.installation_finished:
self.log_output.setText(self.INFO_TEXT)
self.installation_finished = False
self._update_ui_state()
# Сохраняем текущую вкладку для следующего переключения
self.previous_tab_widget = self.tabs.widget(index)
def _create_category_tab(self, title):
"""Создает вкладку с поиском и списком."""
tab = QWidget()
layout = QVBoxLayout(tab)
search_edit = QLineEdit()
search_edit.setPlaceholderText("Поиск...")
layout.addWidget(search_edit)
list_widget = QListWidget()
list_widget.itemChanged.connect(self._on_item_changed)
list_widget.currentItemChanged.connect(self._update_ui_state)
layout.addWidget(list_widget)
search_edit.textChanged.connect(lambda text, lw=list_widget: self.filter_list(text, lw))
self.tabs.addTab(tab, title)
return list_widget, search_edit
def filter_list(self, text, list_widget):
"""Фильтрует элементы в списке."""
for i in range(list_widget.count()):
item = list_widget.item(i)
item.setHidden(text.lower() not in item.text().lower())
def load_all_categories(self):
"""Запускает загрузку всех категорий."""
self.loading_count = len(self.categories)
self.category_statuses = {name: "загрузка..." for name in self.categories.keys()}
for internal_name in self.categories.values():
self._start_load_process(internal_name)
def _get_cache_path(self, category):
"""Возвращает путь к файлу кэша для указанной категории."""
return os.path.join(self.cache_dir, f"{category}.json")
def _get_winetricks_hash(self):
"""Вычисляет хэш файла winetricks для проверки его обновления."""
try:
hasher = hashlib.sha256()
with open(self.winetricks_path, 'rb') as f:
while chunk := f.read(4096):
hasher.update(chunk)
return hasher.hexdigest()
except (IOError, OSError):
return None
def _start_load_process(self, category):
"""Запускает QProcess для получения списка компонентов, используя кэш."""
cache_path = self._get_cache_path(category)
cache_ttl_seconds = 86400 # 24 часа
# Попытка прочитать из кэша
if os.path.exists(cache_path):
try:
with open(cache_path, 'r', encoding='utf-8') as f:
cache_data = json.load(f)
cache_age = time.time() - cache_data.get("timestamp", 0)
winetricks_hash = self._get_winetricks_hash()
if cache_age < cache_ttl_seconds and cache_data.get("hash") == winetricks_hash:
QTimer.singleShot(0, lambda: self._on_load_finished(
category, 0, QProcess.NormalExit, from_cache=cache_data.get("output")
))
return
except (json.JSONDecodeError, IOError, KeyError):
self._log(f"--- Кэш для '{category}' поврежден, будет выполнена перезагрузка. ---")
process = QProcess(self)
self.processes[category] = process
process.setProcessChannelMode(QProcess.MergedChannels)
env = QProcessEnvironment.systemEnvironment()
env.insert("WINEPREFIX", self.prefix_path)
# Отключаем winemenubuilder, чтобы избежать зависаний, связанных с 'wineserver -w'.
# Это известная проблема при запуске winetricks из ГУИ.
process.setProcessEnvironment(env)
# Используем functools.partial для надежной привязки категории к слоту.
# Это стандартный и самый надежный способ избежать проблем с замыканием в цикле.
process.finished.connect(partial(self._on_load_finished, category))
process.start(self.winetricks_path, [category, "list"])
def _update_status_label(self):
"""Обновляет текстовую метку состояния загрузки."""
status_parts = []
for name, status in self.category_statuses.items():
status_parts.append(f"{name}: {status}")
self.status_label.setText(" | ".join(status_parts))
def _parse_winetricks_log(self):
"""Читает winetricks.log и возвращает множество установленных компонентов."""
installed_verbs = set()
log_path = os.path.join(self.prefix_path, "winetricks.log")
if not os.path.exists(log_path):
return installed_verbs
try:
with open(log_path, 'r', encoding='utf-8') as f:
for line in f:
verb = line.split('#', 1)[0].strip()
if verb:
installed_verbs.add(verb)
except Exception as e:
self._log(f"--- Предупреждение: не удалось прочитать {log_path}: {e} ---")
return installed_verbs
def _parse_winetricks_list_output(self, output, installed_verbs, list_widget):
"""Парсит вывод 'winetricks list' и заполняет QListWidget."""
# Regex, который обрабатывает строки как с префиксом статуса '[ ]', так и без него.
# 1. `(?:\[(.)]\s+)?` - опциональная группа для статуса (напр. '[x]').
# 2. `([^\s]+)` - имя компонента (без пробелов).
# 3. `(.*)` - оставшаяся часть строки (описание).
line_re = re.compile(r"^\s*(?:\[(.)]\s+)?([^\s]+)\s*(.*)")
found_items = False
for line in output.splitlines():
match = line_re.match(line)
if not match:
continue
found_items = True
_status, name, description = match.groups()
# Удаляем из описания информацию о доступности для скачивания, так как она избыточна
description = re.sub(r'\[\s*доступно для скачивания[^]]*]', '', description)
description = re.sub(r'\[\s*в кэше\s*]', '', description)
# Фильтруем служебные строки, которые могут быть ошибочно распознаны.
# Имена компонентов winetricks не содержат слэшей и не являются командами.
if '/' in name or '\\' in name or name.lower() in ('executing', 'using', 'warning:') or name.endswith(':'):
continue
is_checked = name in installed_verbs
item_text = f"{name.ljust(27)}{description.strip()}"
item = QListWidgetItem(item_text)
item.setData(Qt.UserRole, name)
item.setFont(QFont("DejaVu Sans Mono", 10))
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked if is_checked else Qt.Unchecked)
list_widget.addItem(item)
self.initial_states[name] = is_checked
return found_items
def _on_load_finished(self, category, exit_code, exit_status, from_cache=None):
"""Обрабатывает завершение загрузки списка компонентов."""
if from_cache is not None:
output = from_cache
process = None
else:
process = self.processes[category]
output = process.readAllStandardOutput().data().decode('utf-8', 'ignore')
list_widget = self.list_widgets[category]
category_display_name = next(k for k, v in self.categories.items() if v == category)
# Очищаем список перед заполнением.
list_widget.clear()
if exit_code != 0 or exit_status != QProcess.NormalExit:
error_string = process.errorString() if process else "N/A"
self._log(f"--- Ошибка загрузки категории '{category}' (код: {exit_code}) ---", "red")
self.category_statuses[category_display_name] = "ошибка"
self._update_status_label() # Показываем ошибку в статусе
if exit_status == QProcess.CrashExit:
self._log("--- Процесс winetricks завершился аварийно. ---", "red")
# По умолчанию используется "Неизвестная ошибка", которая не очень полезна.
if error_string != "Неизвестная ошибка":
self._log(f"--- Системная ошибка: {error_string} ---", "red")
self._log(output if output.strip() else "Winetricks не вернул вывод. Проверьте, что он работает корректно.")
self._log("--------------------------------------------------", "red")
else:
self.category_statuses[category_display_name] = "готово"
installed_verbs = self._parse_winetricks_log()
# Обновляем статус только если это была сетевая загрузка
if from_cache is None:
self._update_status_label()
found_items = self._parse_winetricks_list_output(output, installed_verbs, list_widget)
if from_cache is None: # Только если мы не читали из кэша
# Сохраняем успешный результат в кэш
cache_path = self._get_cache_path(category)
winetricks_hash = self._get_winetricks_hash()
if winetricks_hash:
cache_data = {
"timestamp": time.time(),
"hash": winetricks_hash,
"output": output
}
try:
with open(cache_path, 'w', encoding='utf-8') as f:
json.dump(cache_data, f, ensure_ascii=False, indent=2)
except (IOError, OSError) as e:
self._log(f"--- Не удалось сохранить кэш для '{category}': {e} ---")
if not found_items and output.strip():
self._log(f"--- Не удалось распознать вывод для категории '{category}' ---")
self._log(output)
self._log("--------------------------------------------------")
self.loading_count -= 1
if self.loading_count == 0:
self.status_label.setText("Готово.")
self._update_ui_state()
def _on_item_changed(self, item):
"""Обрабатывает изменение состояния чекбокса, предотвращая снятие галочки с установленных."""
name = item.data(Qt.UserRole)
# Если компонент был изначально установлен и пользователь пытается его снять
if name in self.initial_states and self.initial_states.get(name) is True:
if item.checkState() == Qt.Unchecked:
# Блокируем сигналы, чтобы избежать рекурсии, и возвращаем галочку на место.
list_widget = item.listWidget()
if list_widget:
list_widget.blockSignals(True)
item.setCheckState(Qt.Checked)
if list_widget:
list_widget.blockSignals(False)
self._update_ui_state()
def _update_ui_state(self, *args):
"""Централизованно обновляет состояние кнопок 'Применить' и 'Переустановить'."""
# 1. Проверяем, есть ли изменения в чекбоксах (установка новых или снятие галочек с новых)
has_changes = False
for list_widget in self.list_widgets.values():
for i in range(list_widget.count()):
item = list_widget.item(i)
name = item.data(Qt.UserRole)
if name in self.initial_states:
initial_state = self.initial_states[name]
current_state = item.checkState() == Qt.Checked
if current_state != initial_state:
has_changes = True
break
if has_changes:
break
self.apply_button.setEnabled(has_changes)
# 2. Проверяем, можно ли переустановить выбранный компонент
is_reinstallable = False
# Переустановка возможна только если нет других изменений
if not has_changes:
current_list_widget = self.tabs.currentWidget().findChild(QListWidget)
if current_list_widget:
current_item = current_list_widget.currentItem()
if current_item:
name = current_item.data(Qt.UserRole)
# Компонент можно переустановить, если он был изначально установлен
if self.initial_states.get(name, False):
is_reinstallable = True
self.reinstall_button.setEnabled(is_reinstallable)
def reinstall_selected(self):
"""Переустанавливает выбранный компонент."""
current_list_widget = self.tabs.currentWidget().findChild(QListWidget)
if not current_list_widget:
return
current_item = current_list_widget.currentItem()
if not current_item:
return
name = current_item.data(Qt.UserRole)
if not name:
return
self.log_output.setText(self.INFO_TEXT)
self.apply_button.setEnabled(False)
self.reinstall_button.setEnabled(False)
self.close_button.setEnabled(False)
# Установка будет форсированной
verbs_to_reinstall = [name]
self._start_install_process(verbs_to_reinstall)
def apply_changes(self):
"""Применяет выбранные изменения."""
# Собираем все компоненты, которые были отмечены для установки.
verbs_to_install = []
for list_widget in self.list_widgets.values():
for i in range(list_widget.count()):
item = list_widget.item(i)
name = item.data(Qt.UserRole)
if name not in self.initial_states:
continue
initial_state = self.initial_states[name]
current_state = item.checkState() == Qt.Checked
if current_state != initial_state:
verbs_to_install.append(name)
if not verbs_to_install:
QMessageBox.information(self, "Нет изменений", "Не выбрано ни одного компонента для установки.")
return
self.log_output.setText(self.INFO_TEXT)
self.apply_button.setEnabled(False)
self.reinstall_button.setEnabled(False)
self.close_button.setEnabled(False)
self._start_install_process(verbs_to_install)
def _start_install_process(self, verbs_to_install):
"""Запускает процесс установки/переустановки winetricks."""
# Добавляем флаг --force, чтобы разрешить переустановку
self._log(f"Выполнение установки: winetricks --unattended --force {' '.join(verbs_to_install)}")
self.apply_process = QProcess(self)
self.apply_process.setProcessChannelMode(QProcess.MergedChannels)
env = QProcessEnvironment.systemEnvironment()
env.insert("WINEPREFIX", self.prefix_path)
self.apply_process.setProcessEnvironment(env)
self.apply_process.readyReadStandardOutput.connect(lambda: self.log_output.append(self.apply_process.readAllStandardOutput().data().decode('utf-8', 'ignore').strip()))
self.apply_process.finished.connect(self.on_apply_finished)
self.apply_process.start(self.winetricks_path, ["--unattended", "--force"] + verbs_to_install)
def on_apply_finished(self, exit_code, exit_status):
"""Обрабатывает завершение применения изменений."""
# 1. Проверяем, была ли отмена пользователем
if self.user_cancelled:
self._log("\n=== Установка прервана пользователем. ===")
# Процесс завершен, теперь можно безопасно закрыть диалог.
self.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:
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, winehelper_path, user_work_path, parent=None):
super().__init__(parent)
self.architecture = architecture
self.winehelper_path = winehelper_path
self.user_work_path = user_work_path
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)
button_layout = QHBoxLayout()
self.refresh_button = QPushButton("Обновить список")
self.refresh_button.setIcon(QIcon.fromTheme("view-refresh"))
self.refresh_button.clicked.connect(self.load_versions)
button_layout.addStretch()
button_layout.addWidget(self.refresh_button)
main_layout.addLayout(button_layout)
self.load_versions()
def load_versions(self):
"""Запускает процесс получения списка версий Wine."""
if not shutil.which('jq'):
QMessageBox.critical(self, "Ошибка", "Утилита 'jq' не найдена. Невозможно получить список версий Wine.\n\nУстановите пакет 'jq'.")
return
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)
self.refresh_button.setEnabled(False)
self.db_process = QProcess(self)
self.db_process.setProcessChannelMode(QProcess.MergedChannels)
self.db_process.finished.connect(self._on_db_generation_finished)
self.db_process.start(self.winehelper_path, ["generate-db"])
def _on_db_generation_finished(self, exit_code, exit_status):
"""Обрабатывает завершение генерации метаданных Wine."""
self.refresh_button.setEnabled(True)
self.version_tabs.setEnabled(True)
error_message = None
if exit_code != 0:
error_output = self.db_process.readAll().data().decode('utf-8', 'ignore')
QMessageBox.warning(self, "Ошибка", f"Не удалось получить список версий Wine.\n\n{error_output}")
error_message = "Ошибка загрузки списка версий."
else:
metadata_file = os.path.join(self.user_work_path, "tmp", "wine_metadata.json")
if not os.path.exists(metadata_file):
QMessageBox.warning(self, "Ошибка", f"Файл метаданных не найден:\n{metadata_file}")
error_message = "Ошибка: файл метаданных не найден."
else:
try:
with open(metadata_file, 'r', encoding='utf-8') as f:
self.wine_versions_data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
QMessageBox.warning(self, "Ошибка", f"Не удалось прочитать или обработать файл метаданных:\n{e}")
error_message = "Ошибка парсинга JSON."
if error_message:
self.version_tabs.clear()
error_widget = QWidget()
error_layout = QVBoxLayout(error_widget)
error_label = QLabel(error_message)
error_label.setAlignment(Qt.AlignCenter)
error_layout.addWidget(error_label)
self.version_tabs.addTab(error_widget, "Ошибка")
return
self.populate_ui()
def populate_ui(self):
"""Заполняет UI отфильтрованными версиями."""
self.version_tabs.clear()
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, [])
all_version_names = {v.get("name", "") for v in versions if v.get("name")}
filtered_versions = []
for name in sorted(list(all_version_names), reverse=True):
if is_win64:
if re_64bit.search(name) or not re_32bit.search(name):
filtered_versions.append(name)
else: # win32
if re_32bit.search(name):
filtered_versions.append(name)
if not filtered_versions:
continue
pretty_key = key.replace('_', ' ').title()
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.winehelper_path, Var.USER_WORK_PATH, 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)
self.prefix_install_button = QPushButton("Установить приложение в префикс")
self.prefix_install_button.setEnabled(False)
self.prefix_install_button.clicked.connect(self.run_prefix_installer)
install_layout.addWidget(self.prefix_install_button)
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 _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
prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name)
try:
if os.path.isdir(prefix_path):
shutil.rmtree(prefix_path)
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)
QMessageBox.information(self, "Успех", f"Префикс '{prefix_name}' был успешно удален.")
except Exception as e:
QMessageBox.critical(self, "Ошибка удаления", f"Не удалось удалить префикс '{prefix_name}':\n{e}")
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()
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_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'&nbsp;&nbsp;&nbsp;&nbsp;<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=== Установка успешно завершена ===")
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
dialog = WinetricksManagerDialog(prefix_path, winetricks_path, self)
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)}")
# 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_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())