diff --git a/portprotonqt/dialogs.py b/portprotonqt/dialogs.py index 14efc97..90f4496 100644 --- a/portprotonqt/dialogs.py +++ b/portprotonqt/dialogs.py @@ -4,7 +4,7 @@ import re from typing import cast, TYPE_CHECKING from PySide6.QtGui import QPixmap, QIcon from PySide6.QtWidgets import ( - QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication + QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar ) from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer from icoextract import IconExtractor, IconExtractorError @@ -16,6 +16,7 @@ import portprotonqt.themes.standart.styles as default_styles from portprotonqt.theme_manager import ThemeManager from portprotonqt.custom_widgets import AutoSizeButton from portprotonqt.downloader import Downloader +import psutil if TYPE_CHECKING: from portprotonqt.main_window import MainWindow @@ -89,6 +90,86 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True): class FileSelectedSignal(QObject): file_selected = Signal(str) # Сигнал с путем к выбранному файлу +class GameLaunchDialog(QDialog): + """Modal dialog to indicate game launch progress, similar to Steam's launch dialog.""" + def __init__(self, parent=None, game_name=None, theme=None, target_exe=None): + super().__init__(parent) + self.theme = theme if theme else default_styles + self.theme_manager = ThemeManager() + self.game_name = game_name if game_name else _("Game") + self.target_exe = target_exe # Store the target executable name + self.setWindowTitle(_("Launching {0}").format(self.game_name)) + self.setModal(True) + self.setFixedSize(400, 200) + self.setStyleSheet(self.theme.MESSAGE_BOX_STYLE) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + + # Layout + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + + # Game name label + label = QLabel(_("Launching {0}...").format(self.game_name)) + label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(label) + + # Progress bar (indeterminate) + self.progress_bar = QProgressBar() + self.progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE) + self.progress_bar.setRange(0, 0) # Indeterminate mode + layout.addWidget(self.progress_bar) + + # Cancel button + self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel")) + self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) + self.cancel_button.clicked.connect(self.reject) + layout.addWidget(self.cancel_button, alignment=Qt.AlignmentFlag.AlignCenter) + + # Center dialog on parent + if parent: + parent_geometry = parent.geometry() + center_point = parent_geometry.center() + dialog_geometry = self.geometry() + dialog_geometry.moveCenter(center_point) + self.setGeometry(dialog_geometry) + + # Timer to check if the game process is running + self.check_process_timer = QTimer(self) + self.check_process_timer.timeout.connect(self.check_target_exe) + self.check_process_timer.start(500) + + def is_target_exe_running(self): + """Check if the target executable is running using psutil.""" + if not self.target_exe: + return False + for proc in psutil.process_iter(attrs=["name"]): + if proc.info["name"].lower() == self.target_exe.lower(): + return True + return False + + def check_target_exe(self): + """Check if the game process is running and close the dialog if it is.""" + if self.is_target_exe_running(): + logger.info(f"Game {self.game_name} process detected as running, closing launch dialog") + self.accept() # Close dialog when game is running + self.check_process_timer.stop() + self.check_process_timer.deleteLater() + elif not hasattr(self.parent(), 'game_processes') or not any(proc.poll() is None for proc in cast("MainWindow", self.parent()).game_processes): + # If no child processes are running, stop the timer but keep dialog open + self.check_process_timer.stop() + self.check_process_timer.deleteLater() + + + def reject(self): + """Handle dialog cancellation.""" + logger.info(f"Game launch cancelled for {self.game_name}") + self.check_process_timer.stop() + self.check_process_timer.deleteLater() + super().reject() + class FileExplorer(QDialog): def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False): super().__init__(parent) diff --git a/portprotonqt/tray_manager.py b/portprotonqt/tray_manager.py index 14a84e7..667741c 100644 --- a/portprotonqt/tray_manager.py +++ b/portprotonqt/tray_manager.py @@ -1,6 +1,10 @@ import sys import subprocess -from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication +import shlex +import signal +import psutil +import os +from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QMessageBox from PySide6.QtGui import QIcon, QAction from PySide6.QtCore import QTimer from portprotonqt.logger import get_logger @@ -8,6 +12,7 @@ from portprotonqt.theme_manager import ThemeManager import portprotonqt.themes.standart.styles as default_styles from portprotonqt.localization import _ from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config +from portprotonqt.dialogs import GameLaunchDialog logger = get_logger(__name__) @@ -16,18 +21,25 @@ class TrayManager: Обеспечивает: - Показ/скрытие главного окна по двойному клику на иконку трея. - - Контекстное меню с опциями: Show/Hide (переключается в зависимости от состояния окна), - Favorites (быстрый запуск избранных игр), Recent Games (быстрый запуск недавних игр), Themes (быстрая смена тем), Exit. - - Меню Favorites, Recent Games и Themes динамически заполняются при показе (через aboutToShow). - - При закрытии окна (крестик) приложение сворачивается в трей, а не закрывается. - Полное закрытие только через Exit в меню. + - Контекстное меню с опциями: Show/Hide, Favorites, Recent Games, Themes, Exit. + - Динамическое заполнение меню Favorites, Recent Games и Themes. + - Сворачивание в трей при закрытии окна, полное закрытие через Exit. """ def __init__(self, main_window, app_name: str | None = None, theme=None): self.app_name = app_name if app_name is not None else "PortProtonQt" self.theme_manager = ThemeManager() - self.theme = theme if theme is not None else default_styles - self.current_theme_name = read_theme_from_config() + selected_theme = read_theme_from_config() + self.current_theme_name = selected_theme + try: + self.theme = self.theme_manager.apply_theme(selected_theme) + except FileNotFoundError: + logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'") + self.theme = self.theme_manager.apply_theme("standart") + self.current_theme_name = "standart" + save_theme_to_config("standart") + if not self.theme: + self.theme = default_styles self.main_window = main_window self.tray_icon = QSystemTrayIcon(self.main_window) @@ -41,24 +53,19 @@ class TrayManager: self.tray_icon.activated.connect(self.handle_tray_click) self.tray_icon.setToolTip(self.app_name) - # Контекстное меню self.tray_menu = QMenu() self.toggle_action = QAction(_("Show"), self.main_window) self.toggle_action.triggered.connect(self.toggle_window_action) - # Подменю для избранных игр self.favorites_menu = QMenu(_("Favorites")) self.favorites_menu.aboutToShow.connect(self.populate_favorites_menu) - # Подменю для недавних игр self.recent_menu = QMenu(_("Recent Games")) self.recent_menu.aboutToShow.connect(self.populate_recent_menu) - # Подменю для тем self.themes_menu = QMenu(_("Themes")) self.themes_menu.aboutToShow.connect(self.populate_themes_menu) - # Добавляем действия в меню self.tray_menu.addAction(self.toggle_action) self.tray_menu.addSeparator() self.tray_menu.addMenu(self.favorites_menu) @@ -74,41 +81,35 @@ class TrayManager: self.tray_icon.setContextMenu(self.tray_menu) self.tray_icon.show() - # Флаг для принудительного выхода self.main_window.is_exiting = False - # Переменные для отслеживания двойного клика self.click_count = 0 self.click_timer = QTimer() self.click_timer.setSingleShot(True) self.click_timer.timeout.connect(self.reset_click_count) + self.launch_dialog = None + def update_toggle_action(self): - """Update toggle_action text based on window visibility.""" if self.main_window.isVisible(): self.toggle_action.setText(_("Hide")) else: self.toggle_action.setText(_("Show")) def handle_tray_click(self, reason): - """Обрабатывает клики по иконке трея, отслеживая двойной клик.""" if reason == QSystemTrayIcon.ActivationReason.Trigger: self.click_count += 1 if self.click_count == 1: - # Запускаем таймер для ожидания второго клика (300 мс - стандартное время для двойного клика) self.click_timer.start(300) elif self.click_count == 2: - # Двойной клик зафиксирован self.click_timer.stop() self.toggle_window_action() self.click_count = 0 def reset_click_count(self): - """Сбрасывает счетчик кликов, если таймер истек.""" self.click_count = 0 def toggle_window_action(self): - """Toggle window visibility and update action text.""" if self.main_window.isVisible(): self.main_window.hide() else: @@ -117,7 +118,6 @@ class TrayManager: self.main_window.activateWindow() def populate_favorites_menu(self): - """Динамически заполняет меню избранных игр с указанием источника.""" self.favorites_menu.clear() favorites = read_favorites() if not favorites: @@ -134,13 +134,12 @@ class TrayManager: exec_line, source = game_data action_text = f"{fav} ({source})" action = QAction(action_text, self.main_window) - action.triggered.connect(lambda checked=False, el=exec_line: self.main_window.toggleGame(el)) + action.triggered.connect(lambda checked=False, el=exec_line, name=fav: self.launch_game_with_dialog(el, name)) self.favorites_menu.addAction(action) else: logger.warning(f"Exec line not found for favorite: {fav}") def populate_recent_menu(self): - """Динамически заполняет меню недавних игр (топ-5 по timestamp) с указанием источника.""" self.recent_menu.clear() if not self.main_window.games: no_recent_action = QAction(_("No recent games"), self.main_window) @@ -156,45 +155,99 @@ class TrayManager: source = game[12] action_text = f"{game_name} ({source})" action = QAction(action_text, self.main_window) - action.triggered.connect(lambda checked=False, el=exec_line: self.main_window.toggleGame(el)) + action.triggered.connect(lambda checked=False, el=exec_line, name=game_name: self.launch_game_with_dialog(el, name)) self.recent_menu.addAction(action) + def launch_game_with_dialog(self, exec_line, game_name): + """Launch a game with a modal dialog indicating progress.""" + try: + # Determine target executable + target_exe = None + if exec_line.startswith("steam://"): + # Steam games are handled differently, no target_exe needed + self.launch_dialog = GameLaunchDialog(self.main_window, game_name=game_name, theme=self.theme) + else: + # Extract target executable from exec_line + entry_exec_split = shlex.split(exec_line) + if entry_exec_split[0] == "env" and len(entry_exec_split) > 2: + file_to_check = entry_exec_split[2] + elif entry_exec_split[0] == "flatpak" and len(entry_exec_split) > 3: + file_to_check = entry_exec_split[3] + else: + file_to_check = entry_exec_split[0] + + if not os.path.exists(file_to_check): + logger.error(f"File not found: {file_to_check}") + QMessageBox.warning(self.main_window, _("Error"), _("File not found: {0}").format(file_to_check)) + return + + target_exe = os.path.basename(file_to_check) + self.launch_dialog = GameLaunchDialog(self.main_window, game_name=game_name, theme=self.theme, target_exe=target_exe) + + self.launch_dialog.rejected.connect(lambda: self.cancel_game_launch(exec_line)) + self.launch_dialog.show() + + self.main_window.toggleGame(exec_line) + except Exception as e: + logger.error(f"Failed to launch game {game_name}: {e}") + if self.launch_dialog: + self.launch_dialog.reject() + self.launch_dialog = None + QMessageBox.warning(self.main_window, _("Error"), _("Failed to launch game: {0}").format(str(e))) + + def cancel_game_launch(self, exec_line): + """Cancel the game launch and terminate the process, using MainWindow's stop logic.""" + if self.main_window.game_processes and self.main_window.target_exe: + for proc in self.main_window.game_processes: + try: + parent = psutil.Process(proc.pid) + children = parent.children(recursive=True) + for child in children: + try: + child.terminate() + except psutil.NoSuchProcess: + pass + psutil.wait_procs(children, timeout=5) + for child in children: + if child.is_running(): + child.kill() + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except psutil.NoSuchProcess: + pass + self.main_window.game_processes = [] + self.main_window.resetPlayButton() + if self.launch_dialog: + self.launch_dialog.reject() + self.launch_dialog = None + logger.info(f"Game launch cancelled for exec line: {exec_line}") + def populate_themes_menu(self): - """Динамически заполняет меню тем, позволяя переключать доступные темы.""" self.themes_menu.clear() available_themes = self.theme_manager.get_available_themes() - current_theme = read_theme_from_config() for theme_name in sorted(available_themes): action = QAction(theme_name, self.main_window) action.setCheckable(True) - action.setChecked(theme_name == current_theme) + action.setChecked(theme_name == self.current_theme_name) action.triggered.connect(lambda checked=False, tn=theme_name: self.switch_theme(tn)) self.themes_menu.addAction(action) def switch_theme(self, theme_name: str): - """Сохраняет выбранную тему и перезапускает приложение для применения изменений.""" try: - # Сохраняем новую тему в конфигурации save_theme_to_config(theme_name) logger.info(f"Saved theme {theme_name}, restarting application to apply changes") - # Получаем текущий исполняемый файл и аргументы executable = sys.executable args = sys.argv - # Закрываем текущее приложение self.main_window.is_exiting = True QApplication.quit() - # Перезапускаем приложение subprocess.Popen([executable] + args) except Exception as e: logger.error(f"Failed to switch theme to {theme_name}: {e}") - # В случае ошибки сохраняем стандартную тему save_theme_to_config("standart") - # Перезапускаем приложение с дефолтной темой executable = sys.executable args = sys.argv self.main_window.is_exiting = True @@ -202,7 +255,6 @@ class TrayManager: subprocess.Popen([executable] + args) def force_exit(self): - """Принудительно закрывает приложение.""" self.main_window.is_exiting = True self.main_window.close() sys.exit(0)