Merge branch 'minergenon-devel'

This commit is contained in:
Mikhail Tergoev
2025-09-29 14:06:36 +03:00

View File

@@ -10,11 +10,11 @@ import time
import json
import hashlib
from functools import partial
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,QPushButton, QLabel, QTabWidget, QTabBar,
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QTabWidget, QTabBar,
QTextEdit, QFileDialog, QMessageBox, QLineEdit, QCheckBox, QStackedWidget, QScrollArea, QFormLayout, QGroupBox, QRadioButton, QComboBox,
QListWidget, QListWidgetItem, QGridLayout, QFrame, QDialog, QTextBrowser, QInputDialog, QDialogButtonBox)
QListWidget, QListWidgetItem, QGridLayout, QFrame, QDialog, QTextBrowser, QInputDialog, QDialogButtonBox, QSystemTrayIcon, QMenu)
from PyQt5.QtCore import Qt, QProcess, QSize, QTimer, QProcessEnvironment, QPropertyAnimation, QEasingCurve
from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPixmap, QPainter, QDesktopServices
from PyQt5.QtGui import QIcon, QFont, QTextCursor, QPixmap, QPainter, QCursor
from PyQt5.QtNetwork import QLocalServer, QLocalSocket
@@ -1581,6 +1581,7 @@ class WineHelperGUI(QMainWindow):
self.current_managed_prefix_name = None # Имя префикса, выбранного в выпадающем списке
self.prefixes_before_install = set()
self.is_quitting = False # Флаг для корректного выхода из приложения
self.command_output_buffer = ""
self.command_last_line_was_progress = False
# Создаем главный виджет и layout
@@ -1647,6 +1648,50 @@ class WineHelperGUI(QMainWindow):
self.raise_()
self.activateWindow()
def create_tray_icon(self):
"""Создает и настраивает иконку в системном трее."""
if not QSystemTrayIcon.isSystemTrayAvailable():
print("Системный трей не доступен.")
return
self.tray_icon = QSystemTrayIcon(self)
icon_path = Var.WH_ICON_PATH
if icon_path and os.path.exists(icon_path):
pixmap = QPixmap(icon_path)
if not pixmap.isNull():
self.tray_icon.setIcon(QIcon(pixmap))
# Создаем и сохраняем меню как атрибут класса, чтобы оно не удалялось
self.tray_menu = QMenu(self)
toggle_visibility_action = self.tray_menu.addAction("Показать/Скрыть")
toggle_visibility_action.triggered.connect(self.toggle_visibility)
self.tray_menu.addSeparator()
quit_action = self.tray_menu.addAction("Выход")
quit_action.triggered.connect(self.quit_application)
self.tray_icon.activated.connect(self.on_tray_icon_activated)
self.tray_icon.show()
def on_tray_icon_activated(self, reason):
"""Обрабатывает клики по иконке в трее."""
# Показываем меню при левом клике
if reason == QSystemTrayIcon.Trigger:
# Получаем позицию курсора и показываем меню
self.tray_menu.popup(QCursor.pos())
def toggle_visibility(self):
"""Переключает видимость главного окна."""
if self.isVisible() and self.isActiveWindow():
self.hide()
else:
# Сначала скрываем, чтобы "сбросить" состояние, затем активируем.
# Это помогает обойти проблемы с фокусом и переключением рабочих столов.
self.hide()
self.activate()
def add_tab(self, widget, title):
"""Добавляет вкладку в кастомный TabBar и страницу в StackedWidget."""
self.tab_bar.addTab(title)
@@ -2097,6 +2142,13 @@ class WineHelperGUI(QMainWindow):
self.created_prefix_selector.currentIndexChanged.connect(self.on_created_prefix_selected)
selector_layout.addWidget(self.created_prefix_selector, 1)
self.create_base_pfx_button = QPushButton()
self.create_base_pfx_button.setIcon(QIcon.fromTheme("document-export"))
self.create_base_pfx_button.setToolTip("Создать шаблон из выбранного префикса (для опытных пользователей)")
self.create_base_pfx_button.setEnabled(False)
self.create_base_pfx_button.clicked.connect(self.create_base_prefix_from_selected)
selector_layout.addWidget(self.create_base_pfx_button)
self.delete_prefix_button = QPushButton()
self.delete_prefix_button.setIcon(QIcon.fromTheme("user-trash"))
self.delete_prefix_button.setToolTip("Удалить выбранный префикс")
@@ -2293,6 +2345,7 @@ class WineHelperGUI(QMainWindow):
self.current_managed_prefix_name = None
self._setup_prefix_management_panel(None)
self.delete_prefix_button.setEnabled(False)
self.create_base_pfx_button.setEnabled(False)
else:
# Прокручиваем к выбранному элементу, чтобы он был виден в списке
self.created_prefix_selector.view().scrollTo(
@@ -2302,6 +2355,7 @@ class WineHelperGUI(QMainWindow):
self.current_managed_prefix_name = prefix_name
self._setup_prefix_management_panel(prefix_name)
self.delete_prefix_button.setEnabled(True)
self.create_base_pfx_button.setEnabled(True)
def delete_selected_prefix(self):
"""Удаляет префикс, выбранный в выпадающем списке на вкладке 'Менеджер префиксов'."""
@@ -2366,6 +2420,50 @@ class WineHelperGUI(QMainWindow):
else:
QMessageBox.critical(self, "Ошибка удаления", f"Не удалось удалить префикс '{prefix_name}'.\nПодробности смотрите в логе.")
def create_base_prefix_from_selected(self):
"""Создает шаблон префикса из выбранного в выпадающем списке."""
prefix_name = self.current_managed_prefix_name
if not prefix_name:
return
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Question)
msg_box.setWindowTitle("Создание шаблона префикса")
msg_box.setText(
f"Будет создан 'шаблон' из префикса '{prefix_name}'.\n"
"Это продвинутая функция для создания базовых архивов префиксов.\n\n"
"Продолжить?"
)
yes_button = msg_box.addButton("Да, создать", QMessageBox.YesRole)
no_button = msg_box.addButton("Нет", QMessageBox.NoRole)
msg_box.setDefaultButton(no_button)
msg_box.exec_()
if msg_box.clickedButton() != yes_button:
return
self.command_dialog = QDialog(self)
self.command_dialog.setWindowTitle(f"Создание шаблона: {prefix_name}")
self.command_dialog.setMinimumSize(750, 400)
self.command_dialog.setModal(True)
self.command_dialog.setWindowFlags(self.command_dialog.windowFlags() & ~Qt.WindowCloseButtonHint)
layout = QVBoxLayout()
self.command_log_output = QTextEdit()
self.command_log_output.setReadOnly(True)
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._run_simple_command("create-base-pfx", [prefix_name])
self.command_dialog.exec_()
def _setup_prefix_management_panel(self, prefix_name):
"""Настраивает панель управления префиксом на основе текущего состояния."""
is_prefix_selected = bool(prefix_name)
@@ -2585,11 +2683,12 @@ class WineHelperGUI(QMainWindow):
# Для удаления лицензия не нужна, запускаем сразу.
self.run_component_install_command(prefix_name, command, version)
else:
# Установка: сначала показываем лицензионное соглашение.
if not self._show_license_agreement_dialog():
return # Пользователь отклонил лицензию
# Установка: для DXVK и VKD3D лицензию не показываем.
if component not in ['dxvk', 'vkd3d-proton']:
if not self._show_license_agreement_dialog():
return # Пользователь отклонил лицензию
# Если лицензия принята, запускаем установку.
# Запускаем установку.
self.run_component_install_command(prefix_name, command, version)
def open_wine_version_manager(self):
@@ -2620,9 +2719,6 @@ class WineHelperGUI(QMainWindow):
new_version = dialog.selected_version
new_version_display = dialog.selected_display_text
if not self._show_license_agreement_dialog():
return # Пользователь отклонил лицензию
self.run_change_wine_version_command(prefix_name, new_version, new_version_display)
def run_change_wine_version_command(self, prefix_name, new_version, new_version_display):
@@ -2743,17 +2839,43 @@ class WineHelperGUI(QMainWindow):
QMessageBox.warning(self, "Ошибка", "Сначала выберите префикс.")
return
current_associations = self._get_prefix_component_version(prefix_name, "WH_XDG_OPEN") or ""
current_associations = self._get_prefix_component_version(prefix_name, "WH_XDG_OPEN") or "0"
dialog = FileAssociationsDialog(current_associations, self)
dialog = FileAssociationsDialog(current_associations if current_associations != "0" else "", self)
if dialog.exec_() == QDialog.Accepted:
new_associations = dialog.new_associations
# Запускаем обновление, только если значение изменилось
if new_associations != current_associations:
if new_associations != (current_associations if current_associations != "0" else "0"):
self.run_update_associations_command(prefix_name, new_associations)
def run_update_associations_command(self, prefix_name, new_associations):
"""Выполняет команду обновления ассоциаций файлов."""
# --- Прямое редактирование last.conf, чтобы обойти перезапись переменных в winehelper ---
last_conf_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name, "last.conf")
if not os.path.exists(last_conf_path):
QMessageBox.critical(self, "Ошибка", f"Файл конфигурации last.conf не найден для префикса '{prefix_name}'.")
return
try:
with open(last_conf_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
updated = False
for i, line in enumerate(lines):
if line.strip().startswith("export WH_XDG_OPEN="):
lines[i] = f'export WH_XDG_OPEN="{new_associations}"\n'
updated = True
break
if not updated:
lines.append(f'export WH_XDG_OPEN="{new_associations}"\n')
with open(last_conf_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
except IOError as e:
QMessageBox.critical(self, "Ошибка записи", f"Не удалось обновить файл last.conf: {e}")
return
prefix_path = os.path.join(Var.USER_WORK_PATH, "prefixes", prefix_name)
self.command_dialog = QDialog(self)
@@ -2779,16 +2901,14 @@ class WineHelperGUI(QMainWindow):
self.command_process.readyReadStandardOutput.connect(self._handle_command_output)
self.command_process.finished.connect(
lambda exit_code, exit_status: self._handle_component_install_finished(
prefix_name, exit_code, exit_status
)
)
prefix_name, exit_code, exit_status))
env = QProcessEnvironment.systemEnvironment()
env.insert("WINEPREFIX", prefix_path)
# Устанавливаем новую переменную окружения для скрипта
env.insert("WH_XDG_OPEN", new_associations)
# Переменная WH_XDG_OPEN теперь читается из измененного last.conf
self.command_process.setProcessEnvironment(env)
# Вызываем init-prefix, который теперь прочитает правильное значение из last.conf
args = ["init-prefix"]
self.command_log_output.append(f"Выполнение: {shlex.quote(self.winehelper_path)} {' '.join(shlex.quote(a) for a in args)}")
self.command_process.start(self.winehelper_path, args)
@@ -3666,8 +3786,14 @@ class WineHelperGUI(QMainWindow):
QMessageBox.critical(self, "Ошибка",
f"Не удалось обработать команду запуска:\n{command_str}\n\nОшибка: {str(e)}")
def quit_application(self):
"""Инициирует процесс выхода из приложения."""
self.is_quitting = True
self.close() # Инициируем событие закрытия, которое будет обработано в closeEvent
def closeEvent(self, event):
"""Обрабатывает событие закрытия главного окна."""
# Теперь любое закрытие окна (крестик или выход из меню) инициирует выход
if self.running_apps:
msg_box = QMessageBox(self)
msg_box.setWindowTitle('Подтверждение выхода')
@@ -3683,18 +3809,27 @@ class WineHelperGUI(QMainWindow):
msg_box.exec_()
if msg_box.clickedButton() == yes_button:
# Корректно завершаем все дочерние процессы
for desktop_path, process in list(self.running_apps.items()):
if process.state() == QProcess.Running:
print(f"Завершение процесса для {desktop_path}...")
process.terminate()
if not process.waitForFinished(2000): # Ждем 2 сек
process.kill() # Если не закрылся, убиваем
# Отключаем обработчики сигналов от всех запущенных процессов,
# так как мы собираемся их принудительно завершить и выйти.
# Это предотвращает ошибку RuntimeError при закрытии.
for process in self.running_apps.values():
process.finished.disconnect()
# Используем встроенную команду killall для надежного завершения всех процессов wine
print("Завершение всех запущенных приложений через 'winehelper killall'...")
# Используем subprocess.run, который дождется завершения команды
subprocess.run([self.winehelper_path, "killall"], check=False, capture_output=True)
# Принудительно дожидаемся завершения всех дочерних процессов
for process in self.running_apps.values():
process.waitForFinished(5000) # Ждем до 5 секунд
QApplication.instance().quit()
event.accept()
else:
event.ignore()
else:
super().closeEvent(event)
QApplication.instance().quit() # Если нет запущенных приложений, просто выходим
def uninstall_app(self):
"""Удаляет выбранное установленное приложение и его префикс"""
@@ -4325,13 +4460,22 @@ class WineHelperGUI(QMainWindow):
self.install_process.terminate()
def _handle_command_output(self):
"""Обрабатывает вывод для диалога команды"""
"""Обрабатывает вывод для общих команд в модальном диалоге."""
if hasattr(self, 'command_process') and self.command_process:
output = self.command_process.readAllStandardOutput().data().decode('utf-8', errors='ignore').strip()
# Используем readAll, чтобы получить и stdout, и stderr
output_bytes = self.command_process.readAll()
output = output_bytes.data().decode('utf-8', errors='ignore').strip()
if output and hasattr(self, 'command_log_output'):
self.command_log_output.append(output)
QApplication.processEvents()
def _run_simple_command(self, command, args=None):
"""Запускает простую команду winehelper и выводит лог."""
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)
self.command_process.start(self.winehelper_path, [command] + (args or []))
def _handle_command_finished(self, exit_code, exit_status):
"""Обрабатывает завершение для диалога команды"""
if exit_code == 0:
@@ -4432,6 +4576,8 @@ def main():
# Сохраняем ссылку на сервер, чтобы он не был удален сборщиком мусора
window.server = server
window.show()
# Создаем иконку в системном трее после создания окна
window.create_tray_icon()
return app.exec_()
return 1