diff --git a/winehelper_gui.py b/winehelper_gui.py index eec3f65..7015e4f 100644 --- a/winehelper_gui.py +++ b/winehelper_gui.py @@ -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