forked from CastroFidel/winehelper
2735 lines
141 KiB
Python
2735 lines
141 KiB
Python
#!/usr/bin/env python3
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import re
|
||
import shlex
|
||
import shutil
|
||
import html
|
||
import time
|
||
import json
|
||
import hashlib
|
||
from functools import partial
|
||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,QPushButton, QLabel, QTabWidget,
|
||
QTextEdit, QFileDialog, QMessageBox, QLineEdit, QCheckBox, QStackedWidget, QScrollArea,
|
||
QListWidget, QListWidgetItem, QGridLayout, QFrame, QDialog, QTextBrowser)
|
||
from PyQt5.QtCore import Qt, QProcess, QSize, QTimer, QProcessEnvironment, QPropertyAnimation, QEasingCurve
|
||
from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPixmap, QPainter
|
||
from PyQt5.QtNetwork import QLocalServer, QLocalSocket
|
||
|
||
|
||
class Var:
|
||
# Переменные определяемые в скрипте 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._show_message_box("Отмена", "Установки компонентов прервана пользователем.",
|
||
QMessageBox.Warning, {"buttons": {"Да": QMessageBox.AcceptRole}})
|
||
|
||
# Сбрасываем флаг и восстанавливаем UI
|
||
self.user_cancelled = False
|
||
self.apply_button.setEnabled(True)
|
||
self.close_button.setEnabled(True)
|
||
return
|
||
|
||
# 2. Обрабатываем реальную ошибку
|
||
if exit_code != 0 or exit_status != QProcess.NormalExit:
|
||
self._log(f"\n=== Ошибка во время выполнения операций (код: {exit_code}). ===", "red")
|
||
self._show_message_box("Ошибка",
|
||
"Произошла ошибка во время выполнения операций.\n"
|
||
"Подробности смотрите в логе.",
|
||
QMessageBox.Warning,
|
||
{"buttons": {"OK": QMessageBox.AcceptRole}})
|
||
self.apply_button.setEnabled(True)
|
||
self.close_button.setEnabled(True)
|
||
return
|
||
|
||
# 3. Обрабатываем успех
|
||
self._log("\n=== Все операции успешно завершены ===")
|
||
self._show_message_box("Успех",
|
||
"Операции с компонентами были успешно выполнены.",
|
||
QMessageBox.Information,
|
||
{"buttons": {"Да": QMessageBox.AcceptRole}})
|
||
|
||
self.apply_button.setEnabled(True)
|
||
self.reinstall_button.setEnabled(False) # Сбрасываем в неактивное состояние
|
||
self.close_button.setEnabled(True)
|
||
|
||
# Очищаем все поля поиска.
|
||
for search_edit in self.search_edits.values():
|
||
search_edit.clear()
|
||
|
||
# Перезагружаем данные, чтобы обновить состояние
|
||
self.status_label.setText("Обновление данных...")
|
||
self.initial_states.clear()
|
||
self.load_all_categories()
|
||
self.installation_finished = True
|
||
|
||
def closeEvent(self, event):
|
||
"""Обрабатывает закрытие окна, чтобы предотвратить выход во время установки."""
|
||
# Проверяем, запущен ли процесс установки/переустановки
|
||
if self.apply_process and self.apply_process.state() == QProcess.Running:
|
||
reply = self._show_message_box('Подтверждение',
|
||
"Процесс установки еще не завершен. Вы уверены, что хотите прервать его?",
|
||
QMessageBox.Question,
|
||
{"buttons": {"Да": QMessageBox.YesRole, "Нет": QMessageBox.NoRole}, "default": "Нет"})
|
||
if reply == "Да":
|
||
self.user_cancelled = True
|
||
self.apply_process.terminate() # Попытка мягкого завершения
|
||
event.accept() # Разрешаем закрытие
|
||
else:
|
||
event.ignore() # Запрещаем закрытие
|
||
else:
|
||
event.accept() # Процесс не запущен, можно закрывать
|
||
|
||
def _show_message_box(self, title, text, icon, config):
|
||
"""Централизованный метод для создания и показа QMessageBox."""
|
||
msg_box = QMessageBox(self)
|
||
msg_box.setWindowTitle(title)
|
||
msg_box.setText(text)
|
||
msg_box.setIcon(icon)
|
||
|
||
buttons = {}
|
||
for btn_text, role in config.get("buttons", {}).items():
|
||
buttons[btn_text] = msg_box.addButton(btn_text, role)
|
||
|
||
default_btn_text = config.get("default")
|
||
if default_btn_text and default_btn_text in buttons:
|
||
msg_box.setDefaultButton(buttons[default_btn_text])
|
||
|
||
msg_box.exec_()
|
||
|
||
clicked_button = msg_box.clickedButton()
|
||
return clicked_button.text() if clicked_button else None
|
||
|
||
def _log(self, message, color=None):
|
||
"""Добавляет сообщение в лог с возможностью указания цвета."""
|
||
if color:
|
||
self.log_output.append(f'<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 WineHelperGUI(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("WineHelper")
|
||
self.setMinimumSize(950, 500)
|
||
|
||
if Var.WH_ICON_PATH and os.path.exists(Var.WH_ICON_PATH):
|
||
self.setWindowIcon(QIcon(Var.WH_ICON_PATH))
|
||
|
||
# Центрирование окна
|
||
screen = QApplication.primaryScreen()
|
||
screen_geometry = screen.availableGeometry()
|
||
self.move(
|
||
(screen_geometry.width() - self.width()) // 2,
|
||
(screen_geometry.height() - self.height()) // 2
|
||
)
|
||
|
||
# Стиль для кнопок в списках
|
||
self.BUTTON_LIST_STYLE = """
|
||
QPushButton {
|
||
text-align: left;
|
||
padding-left: 10px;
|
||
padding-right: 10px;
|
||
height: 42px; min-height: 42px; max-height: 42px;
|
||
}
|
||
QPushButton::icon {
|
||
padding-left: 10px;
|
||
}
|
||
"""
|
||
|
||
self.INSTALLED_BUTTON_LIST_STYLE = self.BUTTON_LIST_STYLE.replace(
|
||
"padding-left: 10px;", "padding-left: 15px;"
|
||
)
|
||
|
||
# Стили для оберток кнопок (для рамки выделения)
|
||
self.FRAME_STYLE_DEFAULT = "QFrame { border: 2px solid transparent; border-radius: 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
|
||
|
||
# Создаем главный виджет и layout
|
||
self.main_widget = QWidget()
|
||
self.setCentralWidget(self.main_widget)
|
||
self.main_layout = QHBoxLayout()
|
||
self.main_widget.setLayout(self.main_layout)
|
||
|
||
# Создаем табы
|
||
self.tabs = QTabWidget()
|
||
self.main_layout.addWidget(self.tabs, stretch=1)
|
||
|
||
# Создаем панель информации о скрипте
|
||
self.create_info_panel()
|
||
self.main_layout.addWidget(self.info_panel, stretch=1)
|
||
|
||
# Фиксируем минимальные размеры
|
||
self.tabs.setMinimumWidth(520)
|
||
self.info_panel.setMinimumWidth(415)
|
||
|
||
# Вкладки
|
||
self.create_auto_install_tab()
|
||
self.create_manual_install_tab()
|
||
self.create_installed_tab()
|
||
self.create_help_tab()
|
||
|
||
# Инициализируем состояние, которое будет использоваться для логов
|
||
self._reset_log_state()
|
||
|
||
# Обновляем список установленных приложений
|
||
self.update_installed_apps()
|
||
|
||
# Соединяем сигнал смены вкладок с функцией
|
||
self.tabs.currentChanged.connect(self.on_tab_changed)
|
||
# Устанавливаем начальное состояние видимости панели
|
||
self.on_tab_changed(self.tabs.currentIndex())
|
||
|
||
def activate(self):
|
||
"""
|
||
Активирует и показывает окно приложения, поднимая его из свернутого состояния
|
||
и перемещая на передний план.
|
||
"""
|
||
# Убеждаемся, что окно не свернуто
|
||
self.setWindowState(self.windowState() & ~Qt.WindowMinimized | Qt.WindowActive)
|
||
self.show()
|
||
self.raise_()
|
||
self.activateWindow()
|
||
|
||
def _reset_info_panel_to_default(self, tab_name):
|
||
"""Сбрасывает правую панель в состояние по умолчанию для указанной вкладки."""
|
||
if tab_name == "Автоматическая установка":
|
||
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.tabs.widget(self.previous_tab_index)
|
||
if previous_widget:
|
||
# Ищем QLineEdit в дочерних элементах
|
||
search_edit = previous_widget.findChild(QLineEdit)
|
||
if search_edit:
|
||
search_edit.clear()
|
||
|
||
# Обновляем индекс предыдущей вкладки для следующего переключения
|
||
self.previous_tab_index = index
|
||
|
||
current_tab_text = self.tabs.tabText(index)
|
||
|
||
# Сбрасываем растяжение к состоянию по умолчанию:
|
||
# растягивается виджет с описанием (индекс 1), а не виджет с действиями (индекс 4)
|
||
self.info_panel_layout.setStretch(1, 1)
|
||
self.info_panel_layout.setStretch(4, 0)
|
||
|
||
if current_tab_text in ["Автоматическая установка", "Ручная установка", "Установленные"]:
|
||
self.info_panel.setVisible(True)
|
||
self._reset_info_panel_to_default(current_tab_text)
|
||
if current_tab_text == "Установленные":
|
||
self.filter_installed_buttons()
|
||
else:
|
||
self.info_panel.setVisible(False)
|
||
|
||
def create_info_panel(self):
|
||
"""Создает правую панель с информацией о скрипте"""
|
||
self.info_panel = QFrame()
|
||
self.info_panel.setFrameShape(QFrame.StyledPanel)
|
||
self.info_panel.setMinimumWidth(400) # Размер инф. панели
|
||
self.info_panel.setFont(QFont('Arial', 10)) # Шрифт и размер шрифта в инф. панели
|
||
self.info_panel_layout = QVBoxLayout()
|
||
self.info_panel.setLayout(self.info_panel_layout)
|
||
|
||
# Заголовок
|
||
self.script_title = QLabel("Выберите программу")
|
||
self.script_title.setFont(QFont('Arial', 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.tabs.addTab(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.tabs.addTab(installed_tab, "Установленные")
|
||
|
||
def create_help_tab(self):
|
||
"""Создает вкладку 'Справка' с подвкладками"""
|
||
help_tab = QWidget()
|
||
help_layout = QVBoxLayout(help_tab)
|
||
help_layout.setContentsMargins(5, 5, 5, 5)
|
||
|
||
help_subtabs = QTabWidget()
|
||
help_layout.addWidget(help_subtabs)
|
||
|
||
# Подвкладка "Руководство"
|
||
guide_tab = QWidget()
|
||
guide_layout = QVBoxLayout(guide_tab)
|
||
guide_text = QTextBrowser()
|
||
guide_text.setOpenExternalLinks(True)
|
||
guide_text.setHtml("""
|
||
<h2>Руководство пользователя</h2>
|
||
<p>Подробное и актуальное руководство по использованию WineHelper смотрите на <a href="https://www.altlinux.org/Winehelper">https://www.altlinux.org/Winehelper</a></p>
|
||
""")
|
||
guide_layout.addWidget(guide_text)
|
||
help_subtabs.addTab(guide_tab, "Руководство")
|
||
|
||
# Подвкладка "Авторы"
|
||
authors_tab = QWidget()
|
||
authors_layout = QVBoxLayout(authors_tab)
|
||
authors_text = QTextEdit()
|
||
authors_text.setReadOnly(True)
|
||
authors_text.setHtml("""
|
||
<div style="text-align: center;">
|
||
<h2>Разработчики</h2>
|
||
Михаил Тергоев (fidel)<br>
|
||
Сергей Пальчех (minergenon)</p>
|
||
<p><b>Помощники</b><br>
|
||
Иван Мажукин (vanomj)</p>
|
||
<p><b>Идея и поддержка:</b><br>
|
||
сообщество ALT Linux</p>
|
||
<br>
|
||
<p>Отдельная благодарность всем, кто вносит свой вклад в развитие проекта,<br>
|
||
тестирует и сообщает об ошибках!</p>
|
||
</div>
|
||
""")
|
||
authors_layout.addWidget(authors_text)
|
||
help_subtabs.addTab(authors_tab, "Авторы")
|
||
|
||
# Подвкладка "Лицензия"
|
||
license_tab = QWidget()
|
||
license_layout = QVBoxLayout(license_tab)
|
||
license_text = QTextBrowser()
|
||
license_text.setOpenExternalLinks(True)
|
||
|
||
try:
|
||
if not Var.LICENSE_FILE or not os.path.exists(Var.LICENSE_FILE):
|
||
raise FileNotFoundError
|
||
|
||
with open(Var.LICENSE_FILE, 'r', encoding='utf-8') as f:
|
||
license_content = f.read()
|
||
|
||
escaped_license_content = html.escape(license_content)
|
||
|
||
license_html = f"""
|
||
<h2>Лицензия</h2>
|
||
<pre style="font-family: 'DejaVu Sans Mono', monospace; font-size: 9pt; padding: 10px; border: 1px solid #a0a0a0; border-radius: 5px;">{escaped_license_content}</pre>
|
||
<hr>
|
||
<h2>Сторонние компоненты</h2>
|
||
<p>Некоторые компоненты, используемые или устанавливаемые данным ПО, могут иметь собственные лицензии. Пользователь несет полную ответственность за соблюдение этих лицензионных соглашений.</p>
|
||
<p>Ниже приведен список основных сторонних компонентов и ссылки на их исходный код:</p>
|
||
"""
|
||
|
||
# Читаем и парсим файл THIRD-PARTY
|
||
third_party_html = ""
|
||
third_party_file_path = os.path.join(Var.DATA_PATH, "THIRD-PARTY")
|
||
if os.path.exists(third_party_file_path):
|
||
with open(third_party_file_path, 'r', encoding='utf-8') as f_tp:
|
||
third_party_content = f_tp.read()
|
||
|
||
# Преобразуем контент в HTML
|
||
third_party_html += '<blockquote>'
|
||
for line in third_party_content.splitlines():
|
||
line = line.strip()
|
||
if not line:
|
||
third_party_html += '<br>'
|
||
continue
|
||
escaped_line = html.escape(line)
|
||
if line.startswith('http'):
|
||
third_party_html += f' <a href="{escaped_line}" style="font-size: 10pt;">{escaped_line}</a><br>'
|
||
else:
|
||
third_party_html += f'<b>{escaped_line}</b><br>'
|
||
third_party_html += '</blockquote>'
|
||
|
||
license_text.setHtml(license_html + third_party_html)
|
||
except (FileNotFoundError, TypeError):
|
||
license_text.setHtml(f'<h2>Лицензия</h2><p>Не удалось загрузить файл лицензии по пути:<br>{Var.LICENSE_FILE}</p>')
|
||
except Exception as e:
|
||
license_text.setHtml(f'<h2>Лицензия</h2><p>Произошла ошибка при чтении файла лицензии:<br>{str(e)}</p>')
|
||
|
||
license_layout.addWidget(license_text)
|
||
help_subtabs.addTab(license_tab, "Лицензия")
|
||
|
||
# Подвкладка "История изменений"
|
||
changelog_tab = QWidget()
|
||
changelog_layout = QVBoxLayout(changelog_tab)
|
||
changelog_text = QTextEdit()
|
||
changelog_text.setReadOnly(True)
|
||
changelog_text.setFont(QFont('DejaVu Sans Mono', 9))
|
||
|
||
try:
|
||
if not Var.CHANGELOG_FILE or not os.path.exists(Var.CHANGELOG_FILE):
|
||
raise FileNotFoundError
|
||
with open(Var.CHANGELOG_FILE, 'r', encoding='utf-8') as f:
|
||
changelog_content = f.read()
|
||
changelog_text.setText(changelog_content)
|
||
except (FileNotFoundError, TypeError):
|
||
changelog_text.setText(f"Файл истории изменений не найден по пути:\n{Var.CHANGELOG_FILE}")
|
||
except Exception as e:
|
||
changelog_text.setText(f"Не удалось прочитать файл истории изменений:\n{str(e)}")
|
||
|
||
changelog_layout.addWidget(changelog_text)
|
||
help_subtabs.addTab(changelog_tab, "История изменений")
|
||
|
||
self.tabs.addTab(help_tab, "Справка")
|
||
|
||
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 _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):
|
||
"""Открывает новый диалог для управления компонентами Winetricks."""
|
||
prefix_name = self._get_prefix_name_for_selected_app()
|
||
if not prefix_name:
|
||
QMessageBox.warning(self, "Менеджер Winetricks", "Сначала выберите установленное приложение, чтобы открыть менеджер для его префикса.")
|
||
return
|
||
|
||
prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name)
|
||
if not os.path.isdir(prefix_path):
|
||
QMessageBox.critical(self, "Ошибка", f"Каталог префикса не найден:\n{prefix_path}")
|
||
return
|
||
|
||
winehelper_dir = os.path.dirname(self.winehelper_path)
|
||
winetricks_path = None
|
||
try:
|
||
# Ищем файл, который начинается с 'winetricks_'
|
||
for filename in os.listdir(winehelper_dir):
|
||
if filename.startswith("winetricks_"):
|
||
winetricks_path = os.path.join(winehelper_dir, filename)
|
||
break # Нашли, выходим из цикла
|
||
except OSError as e:
|
||
QMessageBox.critical(self, "Ошибка", f"Не удалось прочитать директорию {winehelper_dir}: {e}")
|
||
return
|
||
|
||
if not winetricks_path:
|
||
QMessageBox.critical(self, "Ошибка", f"Скрипт winetricks не найден в директории:\n{winehelper_dir}")
|
||
return
|
||
|
||
dialog = WinetricksManagerDialog(prefix_path, winetricks_path, self)
|
||
dialog.exec_()
|
||
|
||
def _run_wine_util(self, util_name):
|
||
"""Запускает стандартную утилиту Wine для выбранного префикса."""
|
||
prefix_name = self._get_prefix_name_for_selected_app()
|
||
if not prefix_name:
|
||
QMessageBox.warning(self, "Ошибка", "Сначала выберите установленное приложение.")
|
||
return
|
||
|
||
prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name)
|
||
if not os.path.isdir(prefix_path):
|
||
QMessageBox.critical(self, "Ошибка", f"Каталог префикса не найден:\n{prefix_path}")
|
||
return
|
||
|
||
env = os.environ.copy()
|
||
env["WINEPREFIX"] = prefix_path
|
||
|
||
# 'wine cmd' - особый случай, требует запуска в терминале
|
||
if util_name == 'cmd':
|
||
terminal_command = f"env WINEPREFIX='{prefix_path}' wine cmd"
|
||
try:
|
||
# x-terminal-emulator - стандартный способ вызова терминала по умолчанию
|
||
subprocess.Popen(['x-terminal-emulator', '-e', terminal_command])
|
||
except FileNotFoundError:
|
||
QMessageBox.critical(self, "Ошибка", "Не удалось найти `x-terminal-emulator`.\nУбедитесь, что у вас установлен терминал по умолчанию (например, mate-terminal или xterm).")
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Ошибка", f"Не удалось запустить терминал: {e}")
|
||
return
|
||
|
||
# Для остальных утилит
|
||
command = ['wine', util_name]
|
||
try:
|
||
subprocess.Popen(command, env=env)
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Ошибка запуска",
|
||
f"Не удалось запустить команду:\n{' '.join(command)}\n\nОшибка: {str(e)}")
|
||
|
||
def run_installed_app(self):
|
||
"""Запускает выбранное установленное приложение"""
|
||
self._run_app_launcher(debug=False)
|
||
|
||
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 install_current_script(self):
|
||
"""Устанавливает текущий выбранный скрипт"""
|
||
if not self.current_script:
|
||
QMessageBox.warning(self, "Ошибка", "Не выбрана программа для установки")
|
||
return
|
||
|
||
if self.current_script in self.manualinstall_scripts and not self.install_path_edit.text().strip():
|
||
QMessageBox.warning(self, "Ошибка", "Укажите путь к установочному файлу")
|
||
return
|
||
|
||
# Создаем диалоговое окно установки
|
||
self.install_dialog = QDialog(self)
|
||
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.stacked_widget = QStackedWidget()
|
||
layout = QVBoxLayout()
|
||
layout.addWidget(self.stacked_widget)
|
||
self.install_dialog.setLayout(layout)
|
||
|
||
# Первая страница - лицензионное соглашение
|
||
license_page = QWidget()
|
||
license_layout = QVBoxLayout(license_page)
|
||
|
||
license_found = False
|
||
|
||
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>
|
||
""")
|
||
license_found = True
|
||
except (FileNotFoundError, TypeError):
|
||
license_text.setHtml(f'<h3>Лицензионные соглашения</h3><p>Не удалось загрузить файл лицензионного соглашения по пути:<br>{Var.LICENSE_AGREEMENT_FILE}</p>')
|
||
except Exception as e:
|
||
print(f"Ошибка чтения файла лицензии: {str(e)}")
|
||
license_text.setHtml(f"""
|
||
<h3>Лицензионные соглашения</h3>
|
||
<p>Произошла ошибка при чтении файла лицензии:<br>{str(e)}</p>
|
||
""")
|
||
|
||
license_layout.addWidget(license_text)
|
||
|
||
self.license_checkbox = QCheckBox("Я принимаю условия лицензионного соглашения")
|
||
license_layout.addWidget(self.license_checkbox)
|
||
|
||
self.btn_continue = QPushButton("Продолжить установку")
|
||
self.btn_continue.setEnabled(False)
|
||
self.btn_continue.clicked.connect(self._prepare_installation)
|
||
license_layout.addWidget(self.btn_continue)
|
||
|
||
# Вторая страница - лог установки
|
||
log_page = QWidget()
|
||
log_layout = QVBoxLayout(log_page)
|
||
|
||
self.log_output = QTextEdit()
|
||
self.log_output.setReadOnly(True)
|
||
self.log_output.setFont(QFont('DejaVu Sans Mono', 10))
|
||
log_layout.addWidget(self.log_output)
|
||
|
||
self.control_buttons = QWidget()
|
||
btn_layout = QHBoxLayout(self.control_buttons)
|
||
self.btn_abort = QPushButton("Прервать")
|
||
self.btn_abort.clicked.connect(self.abort_installation)
|
||
btn_layout.addWidget(self.btn_abort)
|
||
|
||
self.btn_close = QPushButton("Закрыть")
|
||
self.btn_close.setEnabled(False)
|
||
self.btn_close.clicked.connect(self.install_dialog.close)
|
||
btn_layout.addWidget(self.btn_close)
|
||
|
||
log_layout.addWidget(self.control_buttons)
|
||
|
||
self.stacked_widget.addWidget(license_page)
|
||
self.stacked_widget.addWidget(log_page)
|
||
|
||
# Назначение кастомного обработчика закрытия окна
|
||
def dialog_close_handler(event):
|
||
self.handle_install_dialog_close(event)
|
||
self.install_dialog.closeEvent = dialog_close_handler
|
||
|
||
self.license_checkbox.stateChanged.connect(
|
||
lambda state: self.btn_continue.setEnabled(state == Qt.Checked)
|
||
)
|
||
|
||
if not license_found:
|
||
self.license_checkbox.setEnabled(False)
|
||
|
||
self.install_dialog.show()
|
||
|
||
def _reset_log_state(self):
|
||
"""Сбрасывает состояние буфера и флага прогресса для лога установки."""
|
||
self.output_buffer = ""
|
||
self.last_line_was_progress = False
|
||
|
||
def _prepare_installation(self):
|
||
"""Подготавливает и запускает процесс установки"""
|
||
self.stacked_widget.setCurrentIndex(1)
|
||
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_log(self, text, is_error=False, add_newline=True):
|
||
"""Добавляет сообщение в лог"""
|
||
if not hasattr(self, 'log_output'): return
|
||
cursor = self.log_output.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)
|
||
|
||
self.log_output.ensureCursorVisible()
|
||
QApplication.processEvents()
|
||
|
||
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 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 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)
|
||
|
||
# Процесс завершен, можно запланировать его удаление и очистить ссылку,
|
||
# чтобы избежать утечек и висячих ссылок.
|
||
if self.install_process:
|
||
self.install_process.deleteLater()
|
||
self.install_process = None
|
||
|
||
def handle_install_dialog_close(self, event):
|
||
"""Обрабатывает событие закрытия диалога установки."""
|
||
# Проверяем, запущен ли еще процесс установки
|
||
if hasattr(self, 'install_process') and self.install_process and self.install_process.state() == QProcess.Running:
|
||
yes_button = QPushButton("Да, прервать")
|
||
no_button = QPushButton("Нет")
|
||
|
||
msg_box = QMessageBox(self.install_dialog)
|
||
msg_box.setWindowTitle("Прервать установку?")
|
||
msg_box.setText("Установка еще не завершена.\nВы действительно хотите прервать процесс?")
|
||
msg_box.setIcon(QMessageBox.Question)
|
||
msg_box.addButton(yes_button, QMessageBox.YesRole)
|
||
msg_box.addButton(no_button, QMessageBox.NoRole)
|
||
msg_box.setDefaultButton(no_button)
|
||
|
||
msg_box.exec_()
|
||
|
||
if msg_box.clickedButton() == yes_button:
|
||
self.append_log("\n=== Пользователь прервал установку через закрытие окна ===", is_error=True)
|
||
# Завершаем процесс. Сигнал finished вызовет handle_process_finished,
|
||
# который обновит состояние кнопок.
|
||
self.install_process.terminate()
|
||
event.accept() # Разрешаем закрытие окна
|
||
else:
|
||
# Пользователь нажал "Нет", поэтому игнорируем событие закрытия
|
||
event.ignore()
|
||
else:
|
||
# Процесс не запущен (установка завершена или еще не началась),
|
||
# поэтому просто закрываем окно
|
||
event.accept()
|
||
|
||
def abort_installation(self):
|
||
"""Прерывает текущую установку"""
|
||
if hasattr(self, 'install_process') and self.install_process and self.install_process.state() == QProcess.Running:
|
||
yes_button = QPushButton("Да")
|
||
no_button = QPushButton("Нет")
|
||
msg_box = QMessageBox(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.cleanup_process()
|
||
|
||
def _handle_command_output(self):
|
||
"""Обрабатывает вывод для диалога команды"""
|
||
if hasattr(self, 'command_process') and self.command_process:
|
||
output = self.command_process.readAllStandardOutput().data().decode('utf-8', errors='ignore').strip()
|
||
if output and hasattr(self, 'command_log_output'):
|
||
self.command_log_output.append(output)
|
||
QApplication.processEvents()
|
||
|
||
def _handle_command_finished(self, exit_code, exit_status):
|
||
"""Обрабатывает завершение для диалога команды"""
|
||
if exit_code == 0:
|
||
self.command_log_output.append(f"\n=== Команда успешно завершена ===")
|
||
else:
|
||
self.command_log_output.append(f"\n=== Ошибка выполнения (код: {exit_code}) ===")
|
||
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())
|