252 lines
10 KiB
Python
252 lines
10 KiB
Python
import sys
|
||
import subprocess
|
||
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
|
||
from portprotonqt.theme_manager import ThemeManager
|
||
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__)
|
||
|
||
class TrayManager:
|
||
"""Модуль управления системным треем для PortProtonQt.
|
||
|
||
Обеспечивает:
|
||
- Показ/скрытие главного окна по двойному клику на иконку трея.
|
||
- Контекстное меню с опциями: 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()
|
||
selected_theme = read_theme_from_config()
|
||
self.current_theme_name = selected_theme
|
||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||
self.main_window = main_window
|
||
self.tray_icon = QSystemTrayIcon(self.main_window)
|
||
|
||
icon = self.theme_manager.get_icon("portproton", self.current_theme_name)
|
||
if isinstance(icon, str):
|
||
icon = QIcon(icon)
|
||
elif icon is None:
|
||
icon = QIcon()
|
||
self.tray_icon.setIcon(icon)
|
||
|
||
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)
|
||
self.tray_menu.addMenu(self.recent_menu)
|
||
self.tray_menu.addMenu(self.themes_menu)
|
||
self.tray_menu.addSeparator()
|
||
exit_action = QAction(_("Exit"), self.main_window)
|
||
exit_action.triggered.connect(self.force_exit)
|
||
self.tray_menu.addAction(exit_action)
|
||
|
||
self.tray_menu.aboutToShow.connect(self.update_toggle_action)
|
||
|
||
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):
|
||
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:
|
||
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):
|
||
if self.main_window.isVisible():
|
||
self.main_window.hide()
|
||
else:
|
||
self.main_window.show()
|
||
self.main_window.raise_()
|
||
self.main_window.activateWindow()
|
||
|
||
def populate_favorites_menu(self):
|
||
self.favorites_menu.clear()
|
||
favorites = read_favorites()
|
||
if not favorites:
|
||
no_fav_action = QAction(_("No favorites"), self.main_window)
|
||
no_fav_action.setEnabled(False)
|
||
self.favorites_menu.addAction(no_fav_action)
|
||
return
|
||
|
||
game_map = {game[0]: (game[4], game[12]) for game in self.main_window.games}
|
||
|
||
for fav in sorted(favorites):
|
||
game_data = game_map.get(fav)
|
||
if game_data:
|
||
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, 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):
|
||
self.recent_menu.clear()
|
||
if not self.main_window.games:
|
||
no_recent_action = QAction(_("No recent games"), self.main_window)
|
||
no_recent_action.setEnabled(False)
|
||
self.recent_menu.addAction(no_recent_action)
|
||
return
|
||
|
||
recent_games = sorted(self.main_window.games, key=lambda g: g[10], reverse=True)[:5]
|
||
|
||
for game in recent_games:
|
||
game_name = game[0]
|
||
exec_line = game[4]
|
||
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, 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()
|
||
|
||
for theme_name in sorted(available_themes):
|
||
action = QAction(theme_name, self.main_window)
|
||
action.setCheckable(True)
|
||
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
|
||
QApplication.quit()
|
||
subprocess.Popen([executable] + args)
|
||
|
||
def force_exit(self):
|
||
self.main_window.is_exiting = True
|
||
self.main_window.close()
|
||
sys.exit(0)
|