Compare commits

..

41 Commits

Author SHA1 Message Date
7185019a3f feat: update context menu for egs games
All checks were successful
Code and build check / Check code (pull_request) Successful in 1m23s
Code and build check / Build with uv (pull_request) Successful in 48s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-22 11:58:16 +05:00
1f4f4093bd feat(egs-api): Implement add_egs_to_steam to add EGS games to Steam via shortcuts.vdf
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-22 10:51:06 +05:00
2d72fdb4c7 feat(egs-api): add Steam ID
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-22 10:37:42 +05:00
d9729ebbea feat: added playtime and last launch to EGS
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-22 10:24:09 +05:00
43e7d5b65b fix: prevent premature game termination detection for EGS games
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-22 10:24:07 +05:00
70dca2b704 feat: added import to context menu
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-22 10:24:05 +05:00
2875efb050 feat: replace steam placeholder icon to real egs icon
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-22 10:24:03 +05:00
ce097e489b feat: added handle egs games to toggleGame
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-22 10:24:02 +05:00
f8de5ec589 Revert "feat: hide the games from EGS until after the workout"
This reverts commit a21705da15.

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-22 10:23:42 +05:00
fea07e19fe reworked styles to look the same in normal use and gamescope session
All checks were successful
Code and build check / Check code (pull_request) Successful in 1m44s
Code and build check / Build with uv (pull_request) Successful in 59s
Code and build check / Check code (push) Successful in 1m28s
Code and build check / Build with uv (push) Successful in 1m0s
2025-06-21 22:33:03 +07:00
37b108f689 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-21 17:45:27 +05:00
78f5118709 native package: fix combobox list
All checks were successful
Code and build check / Check code (push) Successful in 1m40s
Code and build check / Build with uv (push) Successful in 51s
2025-06-21 12:39:23 +00:00
1f14dd7fdf native package: fix combobox full width in PORTPROTON SETTINGS thanks to @Boria138 2025-06-21 12:39:23 +00:00
3d3bdd8f98 dark_theme remove duplicate SETTINGS_CHECKBOX_STYLE 2025-06-21 12:39:23 +00:00
9d7c674544 native package: combobox full width in PORTPROTON SETTINGS 2025-06-21 12:39:23 +00:00
e6c90508ab native package: left alignment for portproton & addgame QLabels 2025-06-21 12:39:23 +00:00
d0eea92139 chore(readme): update todo
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-21 11:29:19 +05:00
04726491c0 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-21 11:25:18 +05:00
bd1b7c07ae fix: force Fusion style for consistent QComboBox styling
All checks were successful
Code and build check / Check code (push) Successful in 1m28s
Code and build check / Build with uv (push) Successful in 49s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-21 10:46:51 +05:00
e6161d2e3f feat(ci): disable renovate untill uppstream fixed work with .python-version
All checks were successful
Code and build check / Check code (push) Successful in 1m38s
Code and build check / Build with uv (push) Successful in 56s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-21 09:15:01 +05:00
b82080600f fix(renovate): disable workflow and python version update
All checks were successful
Code and build check / Check code (push) Successful in 1m37s
Code and build check / Build with uv (push) Successful in 57s
renovate / renovate (push) Successful in 24s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-20 23:01:00 +05:00
05693514aa fix(renovate): uv lock file maintance
All checks were successful
Code and build check / Check code (push) Successful in 1m57s
Code and build check / Build with uv (push) Successful in 1m2s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-20 22:50:52 +05:00
1c2835a933 chore(deps): update
All checks were successful
Code and build check / Check code (push) Successful in 1m42s
Code and build check / Build with uv (push) Successful in 1m0s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-20 22:41:01 +05:00
d229914fb6 Revert "chore(deps): pin dependencies"
All checks were successful
Code and build check / Check code (push) Successful in 1m45s
Code and build check / Build with uv (push) Successful in 58s
This reverts commit 4d58830910.
2025-06-20 22:06:21 +05:00
ce69a18249 fix(renovate): workflow ignore
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-20 22:06:01 +05:00
4d58830910 chore(deps): pin dependencies
All checks were successful
Code and build check / Check code (pull_request) Successful in 1m48s
Code and build check / Build with uv (pull_request) Successful in 56s
Code and build check / Check code (push) Successful in 1m49s
Code and build check / Build with uv (push) Successful in 59s
renovate / renovate (push) Successful in 56s
2025-06-20 16:52:33 +00:00
016ba537be fix(renovate): config syntax again
Some checks failed
Code and build check / Build with uv (push) Has been cancelled
Code and build check / Check code (push) Has been cancelled
renovate / renovate (push) Successful in 39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-20 21:51:37 +05:00
6eeb93f6ba fix(renovate): config syntax
Some checks failed
Code and build check / Build with uv (push) Has been cancelled
Code and build check / Check code (push) Has been cancelled
renovate / renovate (push) Successful in 12s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-20 21:48:49 +05:00
3f5d058740 fix(renovate): RENOVATE_CONFIG_FILE
Some checks failed
Code and build check / Build with uv (push) Has been cancelled
Code and build check / Check code (push) Has been cancelled
renovate / renovate (push) Successful in 10s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-20 21:44:25 +05:00
1a9228b76d ci: added renovate auto update bot
Some checks failed
Code and build check / Check code (push) Successful in 1m32s
Code and build check / Build with uv (push) Successful in 49s
renovate / renovate (push) Failing after 54s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-20 21:37:14 +05:00
e9e0bea854 feat: stay overlay on top
All checks were successful
Code and build check / Check code (push) Successful in 1m30s
Code and build check / Build with uv (push) Successful in 50s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-19 19:31:33 +05:00
f7d9f5c150 chore(readme): update todo
All checks were successful
Code and build check / Check code (push) Successful in 1m48s
Code and build check / Build with uv (push) Successful in 55s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-19 18:36:45 +05:00
bcb5987d31 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-19 18:32:50 +05:00
b1aa987e4e fix: ensure application quits on window close
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-19 18:30:24 +05:00
f4c8b70bd0 feat: add --session CLI argument for start gamescope
All checks were successful
Code and build check / Check code (push) Successful in 1m39s
Code and build check / Build with uv (push) Successful in 51s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-18 22:48:24 +05:00
ff960df77c feat: transfer focus to hovered GameCard with mutual exclusivity
All checks were successful
Code and build check / Check code (push) Successful in 1m50s
Code and build check / Build with uv (push) Successful in 54s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-17 23:11:25 +05:00
a57f509295 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-17 23:01:38 +05:00
32bbe89911 fix: enforce mutual exclusivity of hovered and focused states in GameCard
All checks were successful
Code and build check / Check code (push) Successful in 1m40s
Code and build check / Build with uv (push) Successful in 54s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-17 22:58:57 +05:00
593db00166 fix(themes): typo in GAME_CARD_ANIMATION
All checks were successful
Code and build check / Check code (push) Successful in 1m44s
Code and build check / Build with uv (push) Successful in 58s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-17 14:39:21 +05:00
79a78c785b chore(changelog): update
Some checks failed
Code and build check / Check code (push) Failing after 1m43s
Code and build check / Build with uv (push) Successful in 57s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-17 14:26:19 +05:00
0b92d058a9 feat: move GameCard animation properties to styles
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-17 14:24:06 +05:00
15 changed files with 1642 additions and 635 deletions

View File

@ -6,12 +6,21 @@
## [Unreleased]
### Added
- Аргумент `--session` для запуска приложения в gamescope с GAMESCOPE_CMD
### Changed
- Удалены сборки для Fedora 40
- Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем.
- Статус выделения и наведения на карточки теперь взаимоисключают друг друга
### Fixed
- Дублирование обводки выделения карточек при быстром перемешении мыши
- Завершение приложения при закритие окна
- Использование системной палитры в темах
- Ошибки темы в нативном пакете
### Contributors
- @Dervart
---
@ -76,6 +85,10 @@
- Корректная обработка событий геймпада во время игры
- Убийсво всех процессов "зомби" при закрытии программы
### Contributors
- @Vector_null
- @Dervart
---
## [0.1.1] 2025-05-17
@ -96,6 +109,11 @@
- Зависание GUI
- Сбой при повреждённом Steam
### Contributors
- @Vector_null
- @Dervart
- @alex2844
---
> См. подробности по каждому коммиту в истории репозитория.

View File

@ -4,7 +4,6 @@
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
</div>
## В планах
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
@ -15,7 +14,8 @@
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
- [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор)
- [ ] Продумать систему вкладок вместо текущей
- [X] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
- [ ] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
- [ ] Разобраться почему теряется часть стилей в Gamescope
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
- [X] Получать описания и названия игр из базы данных Steam
@ -41,7 +41,10 @@
- [X] Добавить парсинг ярлыков из Steam
- [X] Добавить парсинг ярлыков из EGS (скрыто для переработки)
- [ ] Избавиться от бинарника legendary
- [ ] Добавить запуск и скачивание игр из EGS
- [X] Добавить запуск игр из EGS
- [ ] Добавить скачивание игр из EGS
- [ ] Добавить поддержку запуска сторонних игр из EGS
- [ ] Добавить поддержку запуска игр с EOS
- [ ] Добавить авторизацию в EGS через WebView вместо ручного ввода
- [X] Получать описания для игр из EGS через их [API](https://store-content.ak.epicgames.com/api)
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
@ -68,6 +71,7 @@
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
- [ ] Доделать светлую тему
- [ ] Добавить подсказки к управлению с геймпада
- [ ] Добавить загрузку звуков в темы например для добавления звука запуска в тему и тд
### Установка (devel)

8
config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
"endpoint": "https://git.linux-gaming.ru/api/v1",
"gitAuthor": "Renovate Bot <noreply@linux-gaming.ru>",
"platform": "gitea",
"onboardingConfigFileName": "renovate.json",
"autodiscover": true,
"optimizeForDisabled": true,
};

View File

@ -1,4 +1,6 @@
import sys
import os
import subprocess
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
@ -33,6 +35,13 @@ def main():
window = MainWindow()
if args.session:
gamescope_cmd = os.getenv("GAMESCOPE_CMD", "gamescope -f --xwayland-count 2")
cmd = f"{gamescope_cmd} -- portprotonqt"
logger.info(f"Executing: {cmd}")
subprocess.Popen(cmd, shell=True)
sys.exit(0)
if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag")
save_fullscreen_config(True)

View File

@ -13,4 +13,9 @@ def parse_args():
action="store_true",
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
)
parser.add_argument(
"--session",
action="store_true",
help="Запустить приложение с использованием gamescope"
)
return parser.parse_args()

View File

@ -3,13 +3,26 @@ import shlex
import glob
import shutil
import subprocess
import threading
import logging
import re
import json
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QFileDialog
from PySide6.QtCore import QUrl, QPoint
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
from PySide6.QtGui import QDesktopServices
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
from portprotonqt.localization import _
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable
import vdf
logger = logging.getLogger(__name__)
class ContextMenuSignals(QObject):
"""Signals for thread-safe UI updates from worker threads."""
show_status_message = Signal(str, int)
show_warning_dialog = Signal(str, str)
class ContextMenuManager:
"""Manages context menu actions for game management in PortProtonQt."""
@ -30,6 +43,56 @@ class ContextMenuManager:
self.theme = theme
self.load_games = load_games_callback
self.update_game_grid = update_game_grid_callback
self.legendary_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache", "legendary"
)
self.legendary_config_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache"
)
# Initialize signals for thread-safe UI updates
self.signals = ContextMenuSignals()
if self.parent.statusBar() is None:
logger.warning("Status bar is not initialized in MainWindow")
else:
self.signals.show_status_message.connect(
self.parent.statusBar().showMessage,
Qt.ConnectionType.QueuedConnection
)
logger.debug("Connected show_status_message signal to statusBar")
self.signals.show_warning_dialog.connect(
self._show_warning_dialog,
Qt.ConnectionType.QueuedConnection
)
def _show_warning_dialog(self, title: str, message: str):
"""Show a warning dialog in the main thread."""
logger.debug("Showing warning dialog: %s - %s", title, message)
QMessageBox.warning(self.parent, title, message)
def _is_egs_game_installed(self, app_name: str) -> bool:
"""
Check if an EGS game is installed by reading installed.json.
Args:
app_name: The Legendary app_name (unique identifier for the game).
Returns:
bool: True if the game is installed, False otherwise.
"""
installed_json_path = os.path.join(self.legendary_config_path, "installed.json")
if not os.path.exists(installed_json_path):
logger.debug("installed.json not found at %s", installed_json_path)
return False
try:
with open(installed_json_path, encoding="utf-8") as f:
installed_games = json.load(f)
return app_name in installed_games
except (OSError, json.JSONDecodeError) as e:
logger.error("Failed to read installed.json: %s", e)
return False
def show_context_menu(self, game_card, pos: QPoint):
"""
@ -39,7 +102,6 @@ class ContextMenuManager:
game_card: The GameCard instance requesting the context menu.
pos: The position (in widget coordinates) where the menu should appear.
"""
menu = QMenu(self.parent)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
@ -54,9 +116,31 @@ class ContextMenuManager:
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, True))
if game_card.game_source == "epic":
import_action = menu.addAction(_("Import to Legendary"))
import_action.triggered.connect(
lambda: self.import_to_legendary(game_card.name, game_card.appid)
# Always show Import to Legendary
import_action = menu.addAction(_("Import to Legendary"))
import_action.triggered.connect(
lambda: self.import_to_legendary(game_card.name, game_card.appid)
)
# Show other actions only if the game is installed
if self._is_egs_game_installed(game_card.appid):
uninstall_action = menu.addAction(_("Uninstall Game"))
uninstall_action.triggered.connect(
lambda: self.uninstall_egs_game(game_card.name, game_card.appid)
)
is_in_steam = is_game_in_steam(game_card.name)
if is_in_steam:
remove_steam_action = menu.addAction(_("Remove from Steam"))
remove_steam_action.triggered.connect(
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
)
else:
add_steam_action = menu.addAction(_("Add to Steam"))
add_steam_action.triggered.connect(
lambda: self.add_egs_to_steam(game_card.name, game_card.appid)
)
open_folder_action = menu.addAction(_("Open Game Folder"))
open_folder_action.triggered.connect(
lambda: self.open_egs_game_folder(game_card.appid)
)
if game_card.game_source not in ("steam", "epic"):
@ -70,13 +154,17 @@ class ContextMenuManager:
add_action.triggered.connect(lambda: self.add_to_desktop(game_card.name, game_card.exec_line))
edit_action = menu.addAction(_("Edit Shortcut"))
edit_action.triggered.connect(lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path))
edit_action.triggered.connect(
lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
)
delete_action = menu.addAction(_("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
open_folder_action = menu.addAction(_("Open Game Folder"))
open_folder_action.triggered.connect(lambda: self.open_game_folder(game_card.name, game_card.exec_line))
open_folder_action.triggered.connect(
lambda: self.open_game_folder(game_card.name, game_card.exec_line)
)
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
desktop_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
@ -87,20 +175,88 @@ class ContextMenuManager:
add_action = menu.addAction(_("Add to Menu"))
add_action.triggered.connect(lambda: self.add_to_menu(game_card.name, game_card.exec_line))
# Add Steam-related actions
is_in_steam = is_game_in_steam(game_card.name)
if is_in_steam:
remove_steam_action = menu.addAction(_("Remove from Steam"))
remove_steam_action.triggered.connect(lambda: self.remove_from_steam(game_card.name, game_card.exec_line))
remove_steam_action.triggered.connect(
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
)
else:
add_steam_action = menu.addAction(_("Add to Steam"))
add_steam_action.triggered.connect(lambda: self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path))
add_steam_action.triggered.connect(
lambda: self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
)
menu.exec(game_card.mapToGlobal(pos))
def import_to_legendary(self, game_name, app_name):
def add_egs_to_steam(self, game_name: str, app_name: str):
"""
Imports an installed Epic Games Store game to Legendary using the provided app_name.
Adds an EGS game to Steam using the egs_api.
Args:
game_name: The display name of the game.
app_name: The Legendary app_name (unique identifier for the game).
"""
if not self._check_portproton():
return
if not os.path.exists(self.legendary_path):
self._show_warning_dialog(_("Error"), _("Legendary executable not found at {0}").format(self.legendary_path))
return
def on_add_to_steam_result(result: tuple[bool, str]):
success, message = result
if success:
self.signals.show_status_message.emit(
_("The game was added successfully. Please restart Steam for changes to take effect."), 5000
)
else:
self.signals.show_warning_dialog.emit(_("Error"), message)
if self.parent.statusBar():
self.parent.statusBar().showMessage(_("Adding '{0}' to Steam...").format(game_name), 0)
logger.debug("Direct status message: Adding '%s' to Steam", game_name)
else:
logger.warning("Status bar not available when adding '%s' to Steam", game_name)
add_egs_to_steam(app_name, game_name, self.legendary_path, on_add_to_steam_result)
def open_egs_game_folder(self, app_name: str):
"""
Opens the folder containing the EGS game's executable.
Args:
app_name: The Legendary app_name (unique identifier for the game).
"""
if not self._check_portproton():
return
exe_path = get_egs_executable(app_name, self.legendary_config_path)
if not exe_path or not os.path.exists(exe_path):
self._show_warning_dialog(
_("Error"),
_("Executable file not found for game: {0}").format(app_name)
)
return
try:
folder_path = os.path.dirname(os.path.abspath(exe_path))
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
if self.parent.statusBar():
self.parent.statusBar().showMessage(
_("Opened folder for EGS game '{0}'").format(app_name), 3000
)
logger.debug("Direct status message: Opened folder for '%s'", app_name)
else:
logger.warning("Status bar not available when opening folder for '%s'", app_name)
except Exception as e:
self._show_warning_dialog(
_("Error"),
_("Failed to open game folder: {0}").format(str(e))
)
def import_to_legendary(self, game_name, app_name):
"""
Imports an installed Epic Games Store game to Legendary asynchronously.
Args:
game_name: The display name of the game.
@ -109,63 +265,118 @@ class ContextMenuManager:
if not self._check_portproton():
return
# Открываем диалог для выбора папки с установленной игрой
folder_path = QFileDialog.getExistingDirectory(
self.parent,
_("Select Game Installation Folder"),
os.path.expanduser("~")
)
if not folder_path:
self.parent.statusBar().showMessage(_("No folder selected"), 3000)
if self.parent.statusBar():
self.parent.statusBar().showMessage(_("No folder selected"), 3000)
logger.debug("Direct status message: No folder selected for '%s'", game_name)
else:
logger.warning("Status bar not available when no folder selected for '%s'", game_name)
return
# Путь к legendary
legendary_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache", "legendary"
)
if not os.path.exists(legendary_path):
QMessageBox.warning(
self.parent,
if not os.path.exists(self.legendary_path):
self._show_warning_dialog(
_("Error"),
_("Legendary executable not found at {0}").format(legendary_path)
_("Legendary executable not found at {0}").format(self.legendary_path)
)
return
# Формируем команду для импорта
cmd = [legendary_path, "import", app_name, folder_path]
try:
# Выполняем команду legendary import
def run_import():
cmd = [self.legendary_path, "import", app_name, folder_path]
subprocess.run(
cmd,
capture_output=True,
text=True,
check=True
)
if self.parent.statusBar():
self.parent.statusBar().showMessage(
_("Successfully imported '{0}' to Legendary").format(game_name), 3000
_("Importing '{0}' to Legendary...").format(game_name), 0
)
logger.debug("Direct status message: Importing '%s' to Legendary", game_name)
else:
logger.warning("Status bar not available when importing '%s'", game_name)
threading.Thread(target=run_import, daemon=True).start()
def uninstall_egs_game(self, game_name: str, app_name: str):
"""
Uninstalls an Epic Games Store game using Legendary asynchronously.
except subprocess.CalledProcessError as e:
QMessageBox.warning(
self.parent,
Args:
game_name: The display name of the game.
app_name: The Legendary app_name (unique identifier for the game).
"""
if not self._check_portproton():
return
if not os.path.exists(self.legendary_path):
self._show_warning_dialog(
_("Error"),
_("Failed to import '{0}' to Legendary: {1}").format(game_name, e.stderr)
_("Legendary executable not found at {0}").format(self.legendary_path)
)
except FileNotFoundError:
QMessageBox.warning(
self.parent,
_("Error"),
_("Legendary executable not found")
)
except Exception as e:
QMessageBox.warning(
self.parent,
_("Error"),
_("Unexpected error during import: {0}").format(str(e))
return
reply = QMessageBox.question(
self.parent,
_("Confirm Uninstallation"),
_("Are you sure you want to uninstall '{0}'? This will remove the game files.").format(game_name),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
def run_uninstall():
cmd = [self.legendary_path, "uninstall", app_name, "--skip-uninstaller"]
try:
subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
env={"LEGENDARY_CONFIG_PATH": self.legendary_config_path}
)
self.signals.show_status_message.emit(
_("Successfully uninstalled '{0}'").format(game_name), 3000
)
except subprocess.CalledProcessError as e:
self.signals.show_status_message.emit(
_("Failed to uninstall '{0}'").format(game_name), 3000
)
self.signals.show_warning_dialog.emit(
_("Error"),
_("Failed to uninstall '{0}': {1}").format(game_name, e.stderr)
)
except FileNotFoundError:
self.signals.show_status_message.emit(
_("Legendary executable not found"), 3000
)
self.signals.show_warning_dialog.emit(
_("Error"),
_("Legendary executable not found")
)
except Exception as e:
self.signals.show_status_message.emit(
_("Unexpected error during uninstall"), 3000
)
self.signals.show_warning_dialog.emit(
_("Error"),
_("Unexpected error during uninstall: {0}").format(str(e))
)
if self.parent.statusBar():
self.parent.statusBar().showMessage(
_("Uninstalling '{0}'...").format(game_name), 0
)
logger.debug("Direct status message: Uninstalling '%s'", game_name)
else:
logger.warning("Status bar not available when uninstalling '%s'", game_name)
threading.Thread(target=run_uninstall, daemon=True).start()
def toggle_favorite(self, game_card, add: bool):
"""
@ -179,18 +390,33 @@ class ContextMenuManager:
if add and game_card.name not in favorites:
favorites.append(game_card.name)
game_card.is_favorite = True
self.parent.statusBar().showMessage(_("Added '{0}' to favorites").format(game_card.name), 3000)
if self.parent.statusBar():
self.parent.statusBar().showMessage(
_("Added '{0}' to favorites").format(game_card.name), 3000
)
logger.debug("Direct status message: Added '%s' to favorites", game_card.name)
else:
logger.warning("Status bar not available when adding '%s' to favorites", game_card.name)
elif not add and game_card.name in favorites:
favorites.remove(game_card.name)
game_card.is_favorite = False
self.parent.statusBar().showMessage(_("Removed '{0}' from favorites").format(game_card.name), 3000)
if self.parent.statusBar():
self.parent.statusBar().showMessage(
_("Removed '{0}' from favorites").format(game_card.name), 3000
)
logger.debug("Direct status message: Removed '%s' from favorites", game_card.name)
else:
logger.warning("Status bar not available when removing '%s' from favorites", game_card.name)
save_favorites(favorites)
game_card.update_favorite_icon()
def _check_portproton(self):
"""Check if PortProton is available."""
if self.portproton_location is None:
QMessageBox.warning(self.parent, _("Error"), _("PortProton is not found."))
self._show_warning_dialog(
_("Error"),
_("PortProton is not found.")
)
return False
return True
@ -214,33 +440,32 @@ class ContextMenuManager:
if entry:
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
if not exec_line:
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("No executable command found in .desktop for game: {0}").format(game_name)
)
return None
else:
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Failed to parse .desktop file for game: {0}").format(game_name)
)
return None
except Exception as e:
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Error reading .desktop file: {0}").format(e)
)
return None
else:
# Fallback: Search all .desktop files
for file in glob.glob(os.path.join(self.portproton_location, "*.desktop")):
entry = parse_desktop_entry(file)
if entry:
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
if exec_line:
return exec_line
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_(".desktop file not found for game: {0}").format(game_name)
)
return None
@ -251,8 +476,8 @@ class ContextMenuManager:
try:
entry_exec_split = shlex.split(exec_line)
if not entry_exec_split:
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Invalid executable command: {0}").format(exec_line)
)
return None
@ -263,15 +488,15 @@ class ContextMenuManager:
else:
exe_path = entry_exec_split[-1]
if not exe_path or not os.path.exists(exe_path):
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Executable file not found: {0}").format(exe_path or "None")
)
return None
return exe_path
except Exception as e:
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Failed to parse executable command: {0}").format(e)
)
return None
@ -280,10 +505,17 @@ class ContextMenuManager:
"""Remove a file and handle errors."""
try:
os.remove(file_path)
self.parent.statusBar().showMessage(success_message.format(game_name), 3000)
if self.parent.statusBar():
self.parent.statusBar().showMessage(success_message.format(game_name), 3000)
logger.debug("Direct status message: %s", success_message.format(game_name))
else:
logger.warning("Status bar not available when removing file for '%s'", game_name)
return True
except OSError as e:
QMessageBox.warning(self.parent, _("Error"), error_message.format(e))
self._show_warning_dialog(
_("Error"),
error_message.format(e)
)
return False
def delete_game(self, game_name, exec_line):
@ -304,13 +536,12 @@ class ContextMenuManager:
desktop_path = self._get_desktop_path(game_name)
if not os.path.exists(desktop_path):
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Could not locate .desktop file for '{0}'").format(game_name)
)
return
# Get exec_line and parse exe_path
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
@ -318,7 +549,6 @@ class ContextMenuManager:
exe_path = self._parse_exe_path(exec_line, game_name)
exe_name = os.path.splitext(os.path.basename(exe_path))[0] if exe_path else None
# Remove .desktop file
if not self._remove_file(
desktop_path,
_("Failed to delete .desktop file: {0}"),
@ -327,7 +557,6 @@ class ContextMenuManager:
):
return
# Remove custom data if we got an exe_name
if exe_name:
xdg_data_home = os.getenv(
"XDG_DATA_HOME",
@ -338,15 +567,11 @@ class ContextMenuManager:
try:
shutil.rmtree(custom_folder)
except OSError as e:
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Failed to delete custom data: {0}").format(e)
)
# Refresh UI
self.parent.games = self.load_games()
self.update_game_grid()
def add_to_menu(self, game_name, exec_line):
"""Copy the .desktop file to ~/.local/share/applications."""
if not self._check_portproton():
@ -354,25 +579,29 @@ class ContextMenuManager:
desktop_path = self._get_desktop_path(game_name)
if not os.path.exists(desktop_path):
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Could not locate .desktop file for '{0}'").format(game_name)
)
return
# Destination path
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
os.makedirs(applications_dir, exist_ok=True)
dest_path = os.path.join(applications_dir, f"{game_name}.desktop")
# Copy .desktop file
try:
shutil.copyfile(desktop_path, dest_path)
os.chmod(dest_path, 0o755) # Ensure executable permissions
self.parent.statusBar().showMessage(_("Game '{0}' added to menu").format(game_name), 3000)
os.chmod(dest_path, 0o755)
if self.parent.statusBar():
self.parent.statusBar().showMessage(
_("Game '{0}' added to menu").format(game_name), 3000
)
logger.debug("Direct status message: Game '%s' added to menu", game_name)
else:
logger.warning("Status bar not available when adding '%s' to menu", game_name)
except OSError as e:
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Failed to add game to menu: {0}").format(str(e))
)
@ -394,25 +623,29 @@ class ContextMenuManager:
desktop_path = self._get_desktop_path(game_name)
if not os.path.exists(desktop_path):
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Could not locate .desktop file for '{0}'").format(game_name)
)
return
# Destination path
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
os.makedirs(desktop_dir, exist_ok=True)
dest_path = os.path.join(desktop_dir, f"{game_name}.desktop")
# Copy .desktop file
try:
shutil.copyfile(desktop_path, dest_path)
os.chmod(dest_path, 0o755) # Ensure executable permissions
self.parent.statusBar().showMessage(_("Game '{0}' added to desktop").format(game_name), 3000)
os.chmod(dest_path, 0o755)
if self.parent.statusBar():
self.parent.statusBar().showMessage(
_("Game '{0}' added to desktop").format(game_name), 3000
)
logger.debug("Direct status message: Game '%s' added to desktop", game_name)
else:
logger.warning("Status bar not available when adding '%s' to desktop", game_name)
except OSError as e:
QMessageBox.warning(
self.parent, _("Error"),
self._show_warning_dialog(
_("Error"),
_("Failed to add game to desktop: {0}").format(str(e))
)
@ -429,7 +662,6 @@ class ContextMenuManager:
def edit_game_shortcut(self, game_name, exec_line, cover_path):
"""Opens the AddGameDialog in edit mode to modify an existing .desktop file."""
if not self._check_portproton():
return
@ -441,7 +673,6 @@ class ContextMenuManager:
if not exe_path:
return
# Open dialog in edit mode
dialog = AddGameDialog(
parent=self.parent,
theme=self.theme,
@ -457,16 +688,20 @@ class ContextMenuManager:
new_cover_path = dialog.coverEdit.text().strip()
if not new_name or not new_exe_path:
QMessageBox.warning(self.parent, _("Error"), _("Game name and executable path are required."))
self._show_warning_dialog(
_("Error"),
_("Game name and executable path are required.")
)
return
# Generate new .desktop file content
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
if not desktop_entry or not new_desktop_path:
QMessageBox.warning(self.parent, _("Error"), _("Failed to generate .desktop file data."))
self._show_warning_dialog(
_("Error"),
_("Failed to generate .desktop file data.")
)
return
# If the name has changed, remove the old .desktop file
old_desktop_path = self._get_desktop_path(game_name)
if game_name != new_name and os.path.exists(old_desktop_path):
self._remove_file(
@ -476,16 +711,17 @@ class ContextMenuManager:
game_name
)
# Save the updated .desktop file
try:
with open(new_desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(new_desktop_path, 0o755)
except OSError as e:
QMessageBox.warning(self.parent, _("Error"), _("Failed to save .desktop file: {0}").format(e))
self._show_warning_dialog(
_("Error"),
_("Failed to save .desktop file: {0}").format(e)
)
return
# Update custom cover if provided
if os.path.isfile(new_cover_path):
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
xdg_data_home = os.getenv(
@ -500,16 +736,14 @@ class ContextMenuManager:
try:
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}"))
except OSError as e:
QMessageBox.warning(self.parent, _("Error"), _("Failed to copy cover image: {0}").format(e))
self._show_warning_dialog(
_("Error"),
_("Failed to copy cover image: {0}").format(e)
)
return
# Refresh the game list
self.parent.games = self.load_games()
self.update_game_grid()
def add_to_steam(self, game_name, exec_line, cover_path):
"""Handle adding a non-Steam game to Steam via steam_api."""
if not self._check_portproton():
return
@ -521,37 +755,174 @@ class ContextMenuManager:
if not exe_path:
return
success, message = add_to_steam(game_name, exec_line, cover_path)
if success:
QMessageBox.information(
self.parent, _("Restart Steam"),
_("The game was added successfully.\nPlease restart Steam for changes to take effect.")
def on_add_to_steam_result(result: tuple[bool, str]):
success, message = result
if success:
self.signals.show_status_message.emit(
_("The game was added successfully. Please restart Steam for changes to take effect."), 5000
)
else:
self.signals.show_warning_dialog.emit(_("Error"), message)
if self.parent.statusBar():
self.parent.statusBar().showMessage(
_("Adding '{0}' to Steam...").format(game_name), 0
)
logger.debug("Direct status message: Adding '%s' to Steam", game_name)
else:
QMessageBox.warning(self.parent, _("Error"), message)
def remove_from_steam(self, game_name, exec_line):
"""Handle removing a non-Steam game from Steam via steam_api."""
logger.warning("Status bar not available when adding '%s' to Steam", game_name)
add_to_steam(game_name, exec_line, cover_path)
def remove_from_steam(self, game_name, exec_line, game_source):
"""Handle removing a game from Steam via steam_api, supporting both EGS and non-EGS games."""
if not self._check_portproton():
return
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
def on_remove_from_steam_result(result: tuple[bool, str]):
success, message = result
if success:
self.signals.show_status_message.emit(
_("The game was removed successfully. Please restart Steam for changes to take effect."), 5000
)
else:
self.signals.show_warning_dialog.emit(_("Error"), message)
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
if game_source == "epic":
# For EGS games, construct the script path used in Steam shortcuts.vdf
if not self.portproton_location:
self._show_warning_dialog(
_("Error"),
_("PortProton directory not found")
)
return
steam_scripts_dir = os.path.join(self.portproton_location, "steam_scripts")
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}_egs.sh")
quoted_script_path = f'"{script_path}"'
# Directly remove the shortcut by matching AppName and Exe
try:
from portprotonqt.steam_api import get_steam_home, get_last_steam_user, convert_steam_id
steam_home = get_steam_home()
if not steam_home:
self._show_warning_dialog(_("Error"), _("Steam directory not found"))
return
last_user = get_last_steam_user(steam_home)
if not last_user or 'SteamID' not in last_user:
self._show_warning_dialog(_("Error"), _("Failed to get Steam user ID"))
return
userdata_dir = os.path.join(steam_home, "userdata")
user_id = last_user['SteamID']
unsigned_id = convert_steam_id(user_id)
user_dir = os.path.join(userdata_dir, str(unsigned_id))
steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
backup_path = f"{steam_shortcuts_path}.backup"
if not os.path.exists(steam_shortcuts_path):
self._show_warning_dialog(
_("Error"),
_("Steam shortcuts file not found")
)
return
# Backup shortcuts.vdf
try:
shutil.copy2(steam_shortcuts_path, backup_path)
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
except Exception as e:
self._show_warning_dialog(
_("Error"),
_("Failed to create backup of shortcuts.vdf: {0}").format(e)
)
return
# Load shortcuts.vdf
try:
with open(steam_shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
except Exception as e:
self._show_warning_dialog(
_("Error"),
_("Failed to load shortcuts.vdf: {0}").format(e)
)
return
shortcuts = shortcuts_data.get("shortcuts", {})
modified = False
new_shortcuts = {}
index = 0
for _key, entry in shortcuts.items():
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
modified = True
logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
continue
new_shortcuts[str(index)] = entry
index += 1
if not modified:
self._show_warning_dialog(
_("Error"),
_("Game '{0}' not found in Steam shortcuts").format(game_name)
)
return
# Save updated shortcuts.vdf
try:
with open(steam_shortcuts_path, 'wb') as f:
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
logger.info(f"Updated shortcuts.vdf, removed '{game_name}'")
on_remove_from_steam_result((True, f"Game '{game_name}' removed from Steam"))
except Exception as e:
self._show_warning_dialog(
_("Error"),
_("Failed to update shortcuts.vdf: {0}").format(e)
)
if os.path.exists(backup_path):
try:
shutil.copy2(backup_path, steam_shortcuts_path)
logger.info("Restored shortcuts.vdf from backup")
except Exception as restore_err:
logger.error(f"Failed to restore shortcuts.vdf: {restore_err}")
on_remove_from_steam_result((False, f"Failed to update shortcuts.vdf: {e}"))
return
# Optionally, remove the script file
if os.path.exists(script_path):
try:
os.remove(script_path)
logger.info(f"Removed EGS script file: {script_path}")
except Exception as e:
logger.warning(f"Failed to remove EGS script file {script_path}: {e}")
except Exception as e:
self._show_warning_dialog(
_("Error"),
_("Failed to remove EGS game from Steam: {0}").format(e)
)
on_remove_from_steam_result((False, f"Failed to remove EGS game from Steam: {e}"))
return
success, message = remove_from_steam(game_name, exec_line)
if success:
QMessageBox.information(
self.parent, _("Restart Steam"),
_("The game was removed successfully.\nPlease restart Steam for changes to take effect.")
)
else:
QMessageBox.warning(self.parent, _("Error"), message)
# For non-EGS games, use the existing logic
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
if self.parent.statusBar():
self.parent.statusBar().showMessage(
_("Removing '{0}' from Steam...").format(game_name), 0
)
logger.debug("Direct status message: Removing '%s' from Steam", game_name)
else:
logger.warning("Status bar not available when removing '%s' from Steam", game_name)
remove_from_steam(game_name, exec_line)
def open_game_folder(self, game_name, exec_line):
"""Open the folder containing the game's executable."""
@ -569,6 +940,15 @@ class ContextMenuManager:
try:
folder_path = os.path.dirname(os.path.abspath(exe_path))
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
self.parent.statusBar().showMessage(_("Opened folder for '{0}'").format(game_name), 3000)
if self.parent.statusBar():
self.parent.statusBar().showMessage(
_("Opened folder for '{0}'").format(game_name), 3000
)
logger.debug("Direct status message: Opened folder for '%s'", game_name)
else:
logger.warning("Status bar not available when opening folder for '%s'", game_name)
except Exception as e:
QMessageBox.warning(self.parent, _("Error"), _("Failed to open game folder: {0}").format(str(e)))
self._show_warning_dialog(
_("Error"),
_("Failed to open game folder: {0}").format(str(e))
)

View File

@ -95,6 +95,7 @@ class AddGameDialog(QDialog):
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
layout = QFormLayout(self)
layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
# Game name
self.nameEdit = QLineEdit(self)

View File

@ -14,11 +14,18 @@ from portprotonqt.logger import get_logger
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
from portprotonqt.config_utils import get_portproton_location
from portprotonqt.steam_api import get_weanticheatyet_status_async
from portprotonqt.steam_api import (
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail
)
import vdf
import shutil
import zlib
from portprotonqt.downloader import Downloader
from PySide6.QtGui import QPixmap
logger = get_logger(__name__)
downloader = Downloader()
def get_egs_executable(app_name: str, legendary_config_path: str) -> str | None:
"""Получает путь к исполняемому файлу EGS-игры из installed.json с использованием orjson."""
@ -52,6 +59,237 @@ def get_cache_dir() -> Path:
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None:
"""
Asynchronously adds an EGS game to Steam via shortcuts.vdf with PortProton tag.
Creates a launch script using legendary CLI with --no-wine and PortProton wrapper.
Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh.
Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
Calls the callback with (success, message).
"""
if not app_name or not app_name.strip() or not game_title or not game_title.strip():
logger.error("Invalid app_name or game_title: empty or whitespace")
callback((False, "Game name or app name is empty or invalid"))
return
if not os.path.exists(legendary_path):
logger.error(f"Legendary executable not found: {legendary_path}")
callback((False, f"Legendary executable not found: {legendary_path}"))
return
portproton_dir = get_portproton_location()
if not portproton_dir:
logger.error("PortProton directory not found")
callback((False, "PortProton directory not found"))
return
# Determine wrapper
wrapper = "flatpak run ru.linux_gaming.PortProton"
start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
if portproton_dir is not None and ".var" not in portproton_dir:
wrapper = start_sh_path
if not os.path.exists(start_sh_path):
logger.error(f"start.sh not found at {start_sh_path}")
callback((False, f"start.sh not found at {start_sh_path}"))
return
# Create launch script
steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
os.makedirs(steam_scripts_dir, exist_ok=True)
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_title.strip())
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}_egs.sh")
legendary_config_path = os.path.dirname(legendary_path)
script_content = f"""#!/usr/bin/env bash
export LD_PRELOAD=
export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
"{legendary_path}" launch {app_name} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}" "$@"
"""
try:
with open(script_path, "w", encoding="utf-8") as f:
f.write(script_content)
os.chmod(script_path, 0o755)
logger.info(f"Created launch script for EGS game: {script_path}")
except Exception as e:
logger.error(f"Failed to create launch script {script_path}: {e}")
callback((False, f"Failed to create launch script: {e}"))
return
# Generate thumbnail
generated_icon_path = os.path.join(portproton_dir, "data", "img", f"{safe_game_name}_egs.png")
try:
img_dir = os.path.join(portproton_dir, "data", "img")
os.makedirs(img_dir, exist_ok=True)
game_exe = get_egs_executable(app_name, legendary_config_path)
if not game_exe or not os.path.exists(game_exe):
logger.warning(f"Executable not found for {app_name}, skipping thumbnail generation")
icon_path = ""
elif os.path.exists(generated_icon_path):
logger.info(f"Reusing existing thumbnail: {generated_icon_path}")
icon_path = generated_icon_path
else:
success = generate_thumbnail(game_exe, generated_icon_path, size=128, force_resize=True)
if not success or not os.path.exists(generated_icon_path):
logger.warning(f"generate_thumbnail failed for {game_exe}")
icon_path = ""
else:
logger.info(f"Generated thumbnail: {generated_icon_path}")
icon_path = generated_icon_path
except Exception as e:
logger.error(f"Error generating thumbnail for {app_name}: {e}")
icon_path = ""
# Get Steam directories
steam_home = get_steam_home()
if not steam_home:
logger.error("Steam home directory not found")
callback((False, "Steam directory not found"))
return
last_user = get_last_steam_user(steam_home)
if not last_user or 'SteamID' not in last_user:
logger.error("Failed to retrieve Steam user ID")
callback((False, "Failed to get Steam user ID"))
return
userdata_dir = steam_home / "userdata"
user_id = last_user['SteamID']
unsigned_id = convert_steam_id(user_id)
user_dir = userdata_dir / str(unsigned_id)
steam_shortcuts_path = user_dir / "config" / "shortcuts.vdf"
grid_dir = user_dir / "config" / "grid"
os.makedirs(grid_dir, exist_ok=True)
# Backup shortcuts.vdf
backup_path = f"{steam_shortcuts_path}.backup"
if os.path.exists(steam_shortcuts_path):
try:
shutil.copy2(steam_shortcuts_path, backup_path)
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
except Exception as e:
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
return
# Generate unique appid
unique_string = f"{script_path}{game_title}"
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
appid = baseid | 0x80000000
if appid > 0x7FFFFFFF:
aidvdf = appid - 0x100000000
else:
aidvdf = appid
steam_appid = None
downloaded_count = 0
total_covers = 4
download_lock = threading.Lock()
def on_cover_download(cover_file: str, cover_type: str):
nonlocal downloaded_count
try:
if cover_file and os.path.exists(cover_file):
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
else:
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
except Exception as e:
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
with download_lock:
downloaded_count += 1
if downloaded_count == total_covers:
finalize_shortcut()
def finalize_shortcut():
tags_dict = {'0': 'PortProton'}
shortcut = {
"appid": aidvdf,
"AppName": game_title,
"Exe": f'"{script_path}"',
"StartDir": f'"{os.path.dirname(script_path)}"',
"icon": icon_path,
"LaunchOptions": "",
"IsHidden": 0,
"AllowDesktopConfig": 1,
"AllowOverlay": 1,
"openvr": 0,
"Devkit": 0,
"DevkitGameID": "",
"LastPlayTime": 0,
"tags": tags_dict
}
logger.info(f"Shortcut entry for EGS game: {shortcut}")
try:
if not os.path.exists(steam_shortcuts_path):
os.makedirs(os.path.dirname(steam_shortcuts_path), exist_ok=True)
open(steam_shortcuts_path, 'wb').close()
try:
if os.path.getsize(steam_shortcuts_path) > 0:
with open(steam_shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
else:
shortcuts_data = {"shortcuts": {}}
except Exception as load_err:
logger.warning(f"Failed to load shortcuts.vdf, starting fresh: {load_err}")
shortcuts_data = {"shortcuts": {}}
shortcuts = shortcuts_data.get("shortcuts", {})
for _key, entry in shortcuts.items():
if entry.get("AppName") == game_title and entry.get("Exe") == f'"{script_path}"':
logger.info(f"EGS game '{game_title}' already exists in Steam shortcuts")
callback((False, f"Game '{game_title}' already exists in Steam"))
return
new_index = str(len(shortcuts))
shortcuts[new_index] = shortcut
with open(steam_shortcuts_path, 'wb') as f:
vdf.binary_dump({"shortcuts": shortcuts}, f)
except Exception as e:
logger.error(f"Failed to update shortcuts.vdf: {e}")
if os.path.exists(backup_path):
try:
shutil.copy2(backup_path, steam_shortcuts_path)
logger.info("Restored shortcuts.vdf from backup")
except Exception as restore_err:
logger.error(f"Failed to restore shortcuts.vdf: {restore_err}")
callback((False, f"Failed to update shortcuts.vdf: {e}"))
return
logger.info(f"EGS game '{game_title}' added to Steam")
callback((True, f"Game '{game_title}' added to Steam with covers"))
def on_steam_apps(steam_data: tuple[list, dict]):
nonlocal steam_appid
steam_apps, steam_apps_index = steam_data
matching_app = search_app(game_title, steam_apps_index)
steam_appid = matching_app.get("appid") if matching_app else None
if not steam_appid:
logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
finalize_shortcut()
return
cover_types = [
(".jpg", "header.jpg"),
("p.jpg", "library_600x900_2x.jpg"),
("_hero.jpg", "library_hero.jpg"),
("_logo.png", "logo.png")
]
for suffix, cover_type in cover_types:
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
downloader.download_async(
cover_url,
cover_file,
timeout=5,
callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
)
get_steam_apps_and_index_async(on_steam_apps)
def get_egs_game_description_async(
app_name: str,
callback: Callable[[str], None],
@ -354,6 +592,7 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
"""
Асинхронно загружает Epic Games Store игры с использованием legendary CLI.
Читает статистику времени игры и последнего запуска из файла statistics.
Проверяет наличие игры в Steam для получения ProtonDB статуса.
"""
logger.debug("Starting to load Epic Games Store games")
games: list[tuple] = []
@ -489,39 +728,54 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images")
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
def on_description_fetched(api_description: str):
final_description = api_description or _("No description available")
def on_steam_apps(steam_data: tuple[list, dict]):
steam_apps, steam_apps_index = steam_data
matching_app = search_app(title, steam_apps_index)
steam_appid = matching_app.get("appid") if matching_app else None
def on_cover_loaded(pixmap: QPixmap):
def on_anticheat_status(status: str):
nonlocal pending_images
with results_lock:
game_results[index] = (
title,
final_description,
local_path if os.path.exists(local_path) else "",
app_name,
f"legendary:launch:{app_name}",
"",
last_launch, # Время последнего запуска
formatted_playtime, # Форматированное время игры
"",
status or "",
last_launch_timestamp, # Временная метка последнего запуска
playtime_seconds, # Время игры в секундах
"epic"
)
pending_images -= 1
update_progress(total_games - pending_images)
if pending_images == 0:
final_games = [game_results[i] for i in sorted(game_results.keys())]
callback(final_games)
def on_protondb_tier(protondb_tier: str):
def on_description_fetched(api_description: str):
final_description = api_description or _("No description available")
get_weanticheatyet_status_async(title, on_anticheat_status)
def on_cover_loaded(pixmap: QPixmap):
def on_anticheat_status(status: str):
nonlocal pending_images
with results_lock:
game_results[index] = (
title,
final_description,
local_path if os.path.exists(local_path) else "",
app_name,
f"legendary:launch:{app_name}",
"",
last_launch, # Время последнего запуска
formatted_playtime, # Форматированное время игры
protondb_tier, # ProtonDB tier
status or "",
last_launch_timestamp, # Временная метка последнего запуска
playtime_seconds, # Время игры в секундах
"epic"
)
pending_images -= 1
update_progress(total_games - pending_images)
if pending_images == 0:
final_games = [game_results[i] for i in sorted(game_results.keys())]
callback(final_games)
load_pixmap_async(cover_url, 600, 900, on_cover_loaded, app_name=app_name)
get_weanticheatyet_status_async(title, on_anticheat_status)
get_egs_game_description_async(title, on_description_fetched)
load_pixmap_async(cover_url, 600, 900, on_cover_loaded, app_name=app_name)
get_egs_game_description_async(title, on_description_fetched)
if steam_appid:
logger.info(f"Found Steam appid {steam_appid} for EGS game {title}")
get_protondb_tier_async(steam_appid, on_protondb_tier)
else:
logger.debug(f"No Steam app found for EGS game {title}")
on_protondb_tier("") # Proceed with empty ProtonDB tier
get_steam_apps_and_index_async(on_steam_apps)
max_workers = min(4, len(valid_games))
with ThreadPoolExecutor(max_workers=max_workers) as executor:

View File

@ -26,6 +26,7 @@ class GameCard(QFrame):
removeFromSteamRequested = Signal(str, str) # name, exec_line
openGameFolderRequested = Signal(str, str) # name, exec_line
hoverChanged = Signal(str, bool)
focusChanged = Signal(str, bool)
def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
@ -67,14 +68,14 @@ class GameCard(QFrame):
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
# Параметры анимации обводки
self._borderWidth = 2
self._gradientAngle = 0.0
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
self._hovered = False
self._focused = False
# Анимации
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
self.thickness_anim.setDuration(300)
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
self.gradient_anim = None
self.pulse_anim = None
@ -448,10 +449,8 @@ class GameCard(QFrame):
if self._hovered or self._focused:
center = self.rect().center()
gradient = QConicalGradient(center, self._gradientAngle)
gradient.setColorAt(0, QColor("#00fff5"))
gradient.setColorAt(0.33, QColor("#FF5733"))
gradient.setColorAt(0.66, QColor("#9B59B6"))
gradient.setColorAt(1, QColor("#00fff5"))
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
gradient.setColorAt(stop["position"], QColor(stop["color"]))
pen.setBrush(QBrush(gradient))
else:
pen.setColor(QColor(0, 0, 0, 0))
@ -468,23 +467,25 @@ class GameCard(QFrame):
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
self.pulse_anim.setDuration(800)
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
self.pulse_anim.setLoopCount(0)
self.pulse_anim.setKeyValueAt(0, 8)
self.pulse_anim.setKeyValueAt(0.5, 10)
self.pulse_anim.setKeyValueAt(1, 8)
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
self.pulse_anim.start()
def enterEvent(self, event):
self._hovered = True
self.hoverChanged.emit(self.name, True)
self.setFocus(Qt.FocusReason.MouseFocusReason)
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(8)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
self.thickness_anim.finished.connect(self.startPulseAnimation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
@ -492,9 +493,9 @@ class GameCard(QFrame):
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(3000)
self.gradient_anim.setStartValue(360)
self.gradient_anim.setEndValue(0)
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
@ -515,52 +516,58 @@ class GameCard(QFrame):
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.setBorderWidth(2)
self.update()
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
self.thickness_anim.start()
super().leaveEvent(event)
def focusInEvent(self, event):
self._focused = True
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(12)
self.thickness_anim.finished.connect(self.startPulseAnimation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
if not self._hovered:
self._focused = True
self.focusChanged.emit(self.name, True)
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(3000)
self.gradient_anim.setStartValue(360)
self.gradient_anim.setEndValue(0)
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
self.thickness_anim.finished.connect(self.startPulseAnimation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
super().focusInEvent(event)
def focusOutEvent(self, event):
self._focused = False
if not self._hovered: # Сохраняем анимацию, если есть наведение
self.focusChanged.emit(self.name, False)
if not self._hovered:
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = None
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack))
if self.thickness_anim:
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(2)
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
self.thickness_anim.start()
super().focusOutEvent(event)
def mousePressEvent(self, event):

View File

@ -40,6 +40,7 @@ from typing import cast
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from PySide6.QtWidgets import QSizePolicy
logger = get_logger(__name__)
@ -52,39 +53,6 @@ class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setAcceptDrops(True)
self.current_exec_line = None
self.currentDetailPage = None
self.current_play_button = None
self.pending_games = []
self.game_card_cache = {}
self.pending_images = {}
self.total_games = 0
self.games_load_timer = QTimer(self)
self.games_load_timer.setSingleShot(True)
self.games_load_timer.timeout.connect(self.finalize_game_loading)
self.games_loaded.connect(self.on_games_loaded)
self.current_add_game_dialog = None
self.current_hovered_card = None
# Добавляем таймер для дебаунсинга сохранения настроек
self.settingsDebounceTimer = QTimer(self)
self.settingsDebounceTimer.setSingleShot(True)
self.settingsDebounceTimer.setInterval(300) # 300 мс задержка
self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
read_time_config()
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQt/legendary_cache
self.legendary_config_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache"
)
os.makedirs(self.legendary_config_path, exist_ok=True)
os.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path
self.legendary_path = os.path.join(self.legendary_config_path, "legendary")
self.downloader = Downloader(max_workers=4)
# Создаём менеджер тем и читаем, какая тема выбрана
self.theme_manager = ThemeManager()
selected_theme = read_theme_from_config()
@ -117,9 +85,47 @@ class MainWindow(QMainWindow):
self.updateGameGrid
)
QApplication.setStyle("Fusion")
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE)
self.setAcceptDrops(True)
self.current_exec_line = None
self.currentDetailPage = None
self.current_play_button = None
self.current_focused_card = None
self.pending_games = []
self.game_card_cache = {}
self.pending_images = {}
self.total_games = 0
self.games_load_timer = QTimer(self)
self.games_load_timer.setSingleShot(True)
self.games_load_timer.timeout.connect(self.finalize_game_loading)
self.games_loaded.connect(self.on_games_loaded)
self.current_add_game_dialog = None
self.current_hovered_card = None
# Добавляем таймер для дебаунсинга сохранения настроек
self.settingsDebounceTimer = QTimer(self)
self.settingsDebounceTimer.setSingleShot(True)
self.settingsDebounceTimer.setInterval(300) # 300 мс задержка
self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
read_time_config()
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQt/legendary_cache
self.legendary_config_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache"
)
os.makedirs(self.legendary_config_path, exist_ok=True)
os.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path
self.legendary_path = os.path.join(self.legendary_config_path, "legendary")
self.downloader = Downloader(max_workers=4)
# Статус-бар
self.setStatusBar(QStatusBar(self))
self.statusBar().setStyleSheet(self.theme.STATUS_BAR_STYLE)
self.progress_bar = QProgressBar()
self.progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
self.progress_bar.setMaximumWidth(200)
self.progress_bar.setTextVisible(True)
self.progress_bar.setVisible(False)
@ -200,8 +206,6 @@ class MainWindow(QMainWindow):
self.restore_state()
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE)
self.setStyleSheet(self.theme.MESSAGE_BOX_STYLE)
self.input_manager = InputManager(self)
QTimer.singleShot(0, self.loadGames)
@ -242,10 +246,39 @@ class MainWindow(QMainWindow):
self.updateGameGrid()
self.progress_bar.setVisible(False)
def _on_card_focused(self, game_name: str, is_focused: bool):
"""Обработчик сигнала focusChanged от GameCard."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_focused:
# Если карточка получила фокус
if self.current_hovered_card and self.current_hovered_card != card:
# Сбрасываем текущую hovered карточку
self.current_hovered_card._hovered = False
self.current_hovered_card.leaveEvent(None)
self.current_hovered_card = None
if self.current_focused_card and self.current_focused_card != card:
# Сбрасываем текущую focused карточку
self.current_focused_card._focused = False
self.current_focused_card.clearFocus()
self.current_focused_card = card
else:
# Если карточка потеряла фокус
if self.current_focused_card == card:
self.current_focused_card = None
def _on_card_hovered(self, game_name: str, is_hovered: bool):
"""Обработчик сигнала hoverChanged от GameCard."""
card_key = None
# Находим ключ карточки по имени игры
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
@ -258,10 +291,14 @@ class MainWindow(QMainWindow):
if is_hovered:
# Если мышь наведена на карточку
if self.current_focused_card and self.current_focused_card != card:
# Сбрасываем текущую focused карточку
self.current_focused_card._focused = False
self.current_focused_card.clearFocus()
if self.current_hovered_card and self.current_hovered_card != card:
# Сбрасываем предыдущую выделенную карточку
# Сбрасываем предыдущую hovered карточку
self.current_hovered_card._hovered = False
self.current_hovered_card.leaveEvent(None) # Принудительно вызываем leaveEvent
self.current_hovered_card.leaveEvent(None)
self.current_hovered_card = card
else:
# Если мышь покинула карточку
@ -721,6 +758,7 @@ class MainWindow(QMainWindow):
context_menu_manager=self.context_menu_manager
)
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
# Подключаем сигналы контекстного меню
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
@ -930,9 +968,11 @@ class MainWindow(QMainWindow):
formLayout = QFormLayout()
formLayout.setContentsMargins(0, 10, 0, 0)
formLayout.setSpacing(10)
formLayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
# 1. Time detail_level
self.timeDetailCombo = QComboBox()
self.timeDetailCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.time_keys = ["detailed", "brief"]
self.time_labels = [_("detailed"), _("brief")]
self.timeDetailCombo.addItems(self.time_labels)
@ -951,6 +991,7 @@ class MainWindow(QMainWindow):
# 2. Games sort_method
self.gamesSortCombo = QComboBox()
self.gamesSortCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.sort_keys = ["last_launch", "playtime", "alphabetical", "favorites"]
self.sort_labels = [_("last launch"), _("playtime"), _("alphabetical"), _("favorites")]
self.gamesSortCombo.addItems(self.sort_labels)
@ -971,6 +1012,7 @@ class MainWindow(QMainWindow):
self.filter_keys = ["all", "steam", "portproton", "favorites", "epic"]
self.filter_labels = [_("all"), "steam", "portproton", _("favorites"), "epic games store"]
self.gamesDisplayCombo = QComboBox()
self.gamesDisplayCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.gamesDisplayCombo.addItems(self.filter_labels)
self.gamesDisplayCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
self.gamesDisplayCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@ -2068,7 +2110,6 @@ class MainWindow(QMainWindow):
def closeEvent(self, event):
"""Завершает все дочерние процессы и сохраняет настройки при закрытии окна."""
# Завершаем все игровые процессы
for proc in self.game_processes:
try:
parent = psutil.Process(proc.pid)
@ -2109,4 +2150,5 @@ class MainWindow(QMainWindow):
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
QApplication.quit()
event.accept()

View File

@ -20,8 +20,12 @@ class SystemOverlay(QDialog):
self.theme_manager = ThemeManager()
self.setStyleSheet(self.theme.OVERLAY_WINDOW_STYLE)
# Убираем рамку окна
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
# Make window stay on top and frameless
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint |
Qt.WindowType.Dialog |
Qt.WindowType.WindowStaysOnTopHint
)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)

View File

@ -8,6 +8,76 @@ current_theme_name = read_theme_from_config()
favoriteLabelSize = 48, 48
pixmapsScaledSize = 60, 60
GAME_CARD_ANIMATION = {
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
# Значение в пикселях.
"default_border_width": 2,
# Ширина обводки при наведении курсора.
# Увеличивает толщину рамки, когда курсор находится над карточкой.
# Значение в пикселях.
"hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
# Увеличивает толщину рамки, когда карточка в фокусе.
# Значение в пикселях.
"focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации.
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
# Значение в пикселях.
"pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации.
# Определяет максимальную толщину рамки при пульсации.
# Значение в пикселях.
"pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
# Влияет на скорость перехода от одной ширины обводки к другой.
# Значение в миллисекундах.
"thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации.
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
# Значение в миллисекундах.
"pulse_anim_duration": 800,
# Длительность анимации вращения градиента.
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
# Значение в миллисекундах.
"gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах).
# Определяет начальную точку вращения градиента при старте анимации.
"gradient_start_angle": 360,
# Конечный угол градиента (в градусах).
# Определяет конечную точку вращения градиента.
# Значение 0 означает полный поворот на 360 градусов.
"gradient_end_angle": 0,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
"thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
# Влияет на "чувство" возврата к исходной ширине обводки.
"thickness_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки.
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex.
# Влияет на внешний вид обводки при наведении или фокусе.
"gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
]
}
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
MAIN_WINDOW_HEADER_STYLE = """
QFrame {

View File

@ -8,6 +8,76 @@ current_theme_name = read_theme_from_config()
favoriteLabelSize = 48, 48
pixmapsScaledSize = 60, 60
GAME_CARD_ANIMATION = {
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
# Значение в пикселях.
"default_border_width": 2,
# Ширина обводки при наведении курсора.
# Увеличивает толщину рамки, когда курсор находится над карточкой.
# Значение в пикселях.
"hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
# Увеличивает толщину рамки, когда карточка в фокусе.
# Значение в пикселях.
"focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации.
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
# Значение в пикселях.
"pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации.
# Определяет максимальную толщину рамки при пульсации.
# Значение в пикселях.
"pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
# Влияет на скорость перехода от одной ширины обводки к другой.
# Значение в миллисекундах.
"thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации.
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
# Значение в миллисекундах.
"pulse_anim_duration": 800,
# Длительность анимации вращения градиента.
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
# Значение в миллисекундах.
"gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах).
# Определяет начальную точку вращения градиента при старте анимации.
"gradient_start_angle": 360,
# Конечный угол градиента (в градусах).
# Определяет конечную точку вращения градиента.
# Значение 0 означает полный поворот на 360 градусов.
"gradient_end_angle": 0,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
"thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
# Влияет на "чувство" возврата к исходной ширине обводки.
"thickness_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки.
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex.
# Влияет на внешний вид обводки при наведении или фокусе.
"gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
]
}
CONTEXT_MENU_STYLE = """
QMenu {
background: #282a33;;
@ -38,6 +108,55 @@ CONTEXT_MENU_STYLE = """
}
"""
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
MAIN_WINDOW_STYLE = """
QWidget {
background: #282a33;
}
QLabel {
color: #ffffff;
}
QPushButton {
background: #3f424d;
border: 2px solid rgba(255, 255, 255, 0.01);
border-radius: 10px;
color: #ffffff;
font-size: 16px;
font-family: 'Play';
padding: 8px 16px;
}
QPushButton:hover {
background: #409EFF;
border: 2px solid #409EFF;
}
QPushButton:pressed {
background: #282a33;
}
QPushButton:focus {
border: 2px solid #409EFF;
background-color: #409EFF;
}
"""
# СТИЛЬ ПРОГРЕСС-БАРА
PROGRESS_BAR_STYLE = """
QProgressBar {
color: #ffffff;
background-color: #3f424d;
text-align: center;
}
QProgressBar::chunk {
background-color: #409EFF;
}
"""
# СТИЛЬ СТАТУС-БАРА
STATUS_BAR_STYLE = """
QStatusBar {
color: #ffffff;
}
"""
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
MAIN_WINDOW_HEADER_STYLE = """
QFrame {
@ -63,7 +182,7 @@ TITLE_LABEL_STYLE = """
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
NAV_WIDGET_STYLE = """
QWidget {
background: #282a33;
background: transparent;
border: 0px solid;
}
"""
@ -94,16 +213,6 @@ NAV_BUTTON_STYLE = """
}
"""
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН) И QLabel
MAIN_WINDOW_STYLE = """
QMainWindow {
background: none;
}
QLabel {
color: #232627;
}
"""
# СТИЛЬ ПОЛЯ ПОИСКА
SEARCH_EDIT_STYLE = """
QLineEdit {
@ -120,13 +229,6 @@ SEARCH_EDIT_STYLE = """
}
"""
SETTINGS_CHECKBOX_STYLE = """
QCheckBox:focus {
border: 2px solid #409EFF;
background: #404554;
}
"""
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
SCROLL_AREA_STYLE = """
QWidget {
@ -633,7 +735,8 @@ SETTINGS_COMBO_STYLE = f"""
}}
QComboBox:on {{
background: #373a43;
border: 1px solid #409EFF;
border: 2px solid #409EFF;
border-bottom-style: none;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom-left-radius: 0px;
@ -671,15 +774,20 @@ SETTINGS_COMBO_STYLE = f"""
/* Список при открытом комбобоксе */
QComboBox QAbstractItemView {{
outline: none;
border: 1px solid #409EFF;
border: 2px solid #409EFF;
border-top-style: none;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}}
QListView {{
background: #3f424d;
}}
QListView::item {{
padding: 7px 7px 7px 12px;
border-radius: 0px;
margin: 0px 3px 3px 3px;
border-radius: 10px;
color: #ffffff;
}}
QListView::item:hover {{
@ -698,8 +806,10 @@ SETTINGS_COMBO_STYLE = f"""
SETTINGS_CHECKBOX_STYLE = f"""
QCheckBox {{
height: 34px;
color: #ffffff;
font-family: 'Play';
font-size: 16px;
}}
QCheckBox::indicator {{
width: 24px;
height: 24px;

26
renovate.json Normal file
View File

@ -0,0 +1,26 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:best-practices"],
"rebaseWhen": "never",
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
},
{
"automerge": true,
"matchUpdateTypes": ["pin", "pinDigest"]
},
{
"enabled": false,
"matchFileNames": [".gitea/workflows/**.yaml", ".gitea/workflows/**.yml"]
},
{
"enabled": false,
"matchFileNames": [".python-version"]
}
]
}

795
uv.lock generated

File diff suppressed because it is too large Load Diff