Compare commits
15 Commits
ec10d184ae
...
a6ea998535
Author | SHA1 | Date | |
---|---|---|---|
a6ea998535
|
|||
5a1ac2206b
|
|||
3f3e2f8d2c
|
|||
3600499d20
|
|||
5ade36a237
|
|||
96386f2815
|
|||
9df22edfc9
|
|||
4559231712
|
|||
18dbd42369
|
|||
76c0e607c5
|
|||
a91c9dacd8
|
|||
62b8da2dc4
|
|||
b77609cb5f
|
|||
56b105d7b4
|
|||
14687d12ca
|
@ -40,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
fedora_version: [40, 41, 42, rawhide]
|
fedora_version: [41, 42, rawhide]
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: fedora:${{ matrix.fedora_version }}
|
image: fedora:${{ matrix.fedora_version }}
|
||||||
|
@ -97,7 +97,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
fedora_version: [40, 41, 42, rawhide]
|
fedora_version: [41, 42, rawhide]
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: fedora:${{ matrix.fedora_version }}
|
image: fedora:${{ matrix.fedora_version }}
|
||||||
@ -145,13 +145,20 @@ jobs:
|
|||||||
- name: Install required dependencies
|
- name: Install required dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y original-awk
|
sudo apt install -y original-awk unzip
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: https://gitea.com/actions/download-artifact@v3
|
uses: https://gitea.com/actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: release/
|
path: release/
|
||||||
|
|
||||||
|
- name: Extract downloaded artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p extracted
|
||||||
|
find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
|
||||||
|
find extracted/ -type f -exec mv {} release/ \;
|
||||||
|
rm -rf extracted/
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Extract changelog for version
|
||||||
id: changelog
|
id: changelog
|
||||||
run: |
|
run: |
|
||||||
@ -163,7 +170,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
body_path: changelog.txt
|
body_path: changelog.txt
|
||||||
token: ${{ env.GITEA_TOKEN }}
|
token: ${{ env.GITEA_TOKEN }}
|
||||||
tag_name: ${{ env.VERSION }}
|
tag_name: v${{ env.VERSION }}
|
||||||
prerelease: true
|
prerelease: true
|
||||||
files: release/**/*
|
files: release/**/*
|
||||||
sha256sum: true
|
sha256sum: true
|
||||||
|
13
CHANGELOG.md
13
CHANGELOG.md
@ -3,6 +3,18 @@
|
|||||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Удалены сборки для Fedora 40
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Дублирование обводки выделения карточек при быстром перемешении мыши
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.1.2] - 2025-06-15
|
## [0.1.2] - 2025-06-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@ -62,6 +74,7 @@
|
|||||||
- Исправлены ошибки при подключении геймпада
|
- Исправлены ошибки при подключении геймпада
|
||||||
- Предотвращено многократное открытие диалога добавления игры через геймпад
|
- Предотвращено многократное открытие диалога добавления игры через геймпад
|
||||||
- Корректная обработка событий геймпада во время игры
|
- Корректная обработка событий геймпада во время игры
|
||||||
|
- Убийсво всех процессов "зомби" при закрытии программы
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -28,19 +28,19 @@ BuildRequires: git
|
|||||||
%package -n python3-%{pypi_name}-git
|
%package -n python3-%{pypi_name}-git
|
||||||
Summary: %{summary}
|
Summary: %{summary}
|
||||||
%{?python_provide:%python_provide python3-%{pypi_name}}
|
%{?python_provide:%python_provide python3-%{pypi_name}}
|
||||||
Requires: python3dist(babel)
|
Requires: python3-babel
|
||||||
Requires: python3dist(evdev)
|
Requires: python3-evdev
|
||||||
Requires: python3dist(icoextract)
|
Requires: python3-icoextract
|
||||||
Requires: python3dist(numpy)
|
Requires: python3-numpy
|
||||||
Requires: python3dist(orjson)
|
Requires: python3-orjson
|
||||||
Requires: python3dist(psutil)
|
Requires: python3-psutil
|
||||||
Requires: python3dist(pyside6)
|
Requires: python3-pyside6
|
||||||
Requires: python3dist(pyudev)
|
Requires: python3-pyudev
|
||||||
Requires: python3dist(requests)
|
Requires: python3-requests
|
||||||
Requires: python3dist(tqdm)
|
Requires: python3-tqdm
|
||||||
Requires: python3dist(vdf)
|
Requires: python3-vdf
|
||||||
Requires: python3dist(pefile)
|
Requires: python3-pefile
|
||||||
Requires: python3dist(pillow)
|
Requires: python3-pillow
|
||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
|
|
||||||
|
@ -25,19 +25,19 @@ BuildRequires: git
|
|||||||
%package -n python3-%{pypi_name}
|
%package -n python3-%{pypi_name}
|
||||||
Summary: %{summary}
|
Summary: %{summary}
|
||||||
%{?python_provide:%python_provide python3-%{pypi_name}}
|
%{?python_provide:%python_provide python3-%{pypi_name}}
|
||||||
Requires: python3dist(babel)
|
Requires: python3-babel
|
||||||
Requires: python3dist(evdev)
|
Requires: python3-evdev
|
||||||
Requires: python3dist(icoextract)
|
Requires: python3-icoextract
|
||||||
Requires: python3dist(numpy)
|
Requires: python3-numpy
|
||||||
Requires: python3dist(orjson)
|
Requires: python3-orjson
|
||||||
Requires: python3dist(psutil)
|
Requires: python3-psutil
|
||||||
Requires: python3dist(pyside6)
|
Requires: python3-pyside6
|
||||||
Requires: python3dist(pyudev)
|
Requires: python3-pyudev
|
||||||
Requires: python3dist(requests)
|
Requires: python3-requests
|
||||||
Requires: python3dist(tqdm)
|
Requires: python3-tqdm
|
||||||
Requires: python3dist(vdf)
|
Requires: python3-vdf
|
||||||
Requires: python3dist(pefile)
|
Requires: python3-pefile
|
||||||
Requires: python3dist(pillow)
|
Requires: python3-pillow
|
||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
|
|
||||||
|
@ -29,14 +29,12 @@ def main():
|
|||||||
else:
|
else:
|
||||||
logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
|
logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
|
||||||
|
|
||||||
# Парсинг аргументов командной строки
|
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
|
|
||||||
# Обработка флага --fullscreen
|
|
||||||
if args.fullscreen:
|
if args.fullscreen:
|
||||||
logger.info("Запуск в полноэкранном режиме по флагу --fullscreen")
|
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
||||||
save_fullscreen_config(True)
|
save_fullscreen_config(True)
|
||||||
window.showFullScreen()
|
window.showFullScreen()
|
||||||
|
|
||||||
@ -47,13 +45,29 @@ def main():
|
|||||||
|
|
||||||
def recreate_tray():
|
def recreate_tray():
|
||||||
nonlocal tray
|
nonlocal tray
|
||||||
tray.hide_tray()
|
if tray:
|
||||||
|
logger.debug("Recreating system tray")
|
||||||
|
tray.cleanup()
|
||||||
|
tray = None
|
||||||
current_theme = read_theme_from_config()
|
current_theme = read_theme_from_config()
|
||||||
tray = SystemTray(app, current_theme)
|
tray = SystemTray(app, current_theme)
|
||||||
|
# Ensure window is not None before connecting signals
|
||||||
|
if window:
|
||||||
tray.show_action.triggered.connect(window.show)
|
tray.show_action.triggered.connect(window.show)
|
||||||
tray.hide_action.triggered.connect(window.hide)
|
tray.hide_action.triggered.connect(window.hide)
|
||||||
|
|
||||||
|
def cleanup_on_exit():
|
||||||
|
nonlocal tray, window
|
||||||
|
app.aboutToQuit.disconnect()
|
||||||
|
if tray:
|
||||||
|
tray.cleanup()
|
||||||
|
tray = None
|
||||||
|
if window:
|
||||||
|
window.close()
|
||||||
|
app.quit()
|
||||||
|
|
||||||
window.settings_saved.connect(recreate_tray)
|
window.settings_saved.connect(recreate_tray)
|
||||||
|
app.aboutToQuit.connect(cleanup_on_exit)
|
||||||
|
|
||||||
window.show()
|
window.show()
|
||||||
|
|
||||||
|
@ -12,10 +12,36 @@ from collections.abc import Callable
|
|||||||
from portprotonqt.localization import get_egs_language, _
|
from portprotonqt.localization import get_egs_language, _
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
from portprotonqt.image_utils import load_pixmap_async
|
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 PySide6.QtGui import QPixmap
|
from PySide6.QtGui import QPixmap
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
def get_egs_executable(app_name: str, legendary_config_path: str) -> str | None:
|
||||||
|
"""Получает путь к исполняемому файлу EGS-игры из installed.json с использованием orjson."""
|
||||||
|
installed_json_path = os.path.join(legendary_config_path, "installed.json")
|
||||||
|
try:
|
||||||
|
with open(installed_json_path, "rb") as f:
|
||||||
|
installed_data = orjson.loads(f.read())
|
||||||
|
if app_name in installed_data:
|
||||||
|
install_path = installed_data[app_name].get("install_path", "").decode('utf-8') if isinstance(installed_data[app_name].get("install_path"), bytes) else installed_data[app_name].get("install_path", "")
|
||||||
|
executable = installed_data[app_name].get("executable", "").decode('utf-8') if isinstance(installed_data[app_name].get("executable"), bytes) else installed_data[app_name].get("executable", "")
|
||||||
|
if install_path and executable:
|
||||||
|
return os.path.join(install_path, executable)
|
||||||
|
return None
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"installed.json not found at {installed_json_path}")
|
||||||
|
return None
|
||||||
|
except orjson.JSONDecodeError:
|
||||||
|
logger.error(f"Invalid JSON in {installed_json_path}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading installed.json: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_cache_dir() -> Path:
|
def get_cache_dir() -> Path:
|
||||||
"""Returns the path to the cache directory, creating it if necessary."""
|
"""Returns the path to the cache directory, creating it if necessary."""
|
||||||
xdg_cache_home = os.getenv(
|
xdg_cache_home = os.getenv(
|
||||||
@ -281,6 +307,7 @@ def get_egs_game_description_async(
|
|||||||
|
|
||||||
thread = threading.Thread(target=fetch_description, daemon=True)
|
thread = threading.Thread(target=fetch_description, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
|
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
|
||||||
"""
|
"""
|
||||||
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.
|
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.
|
||||||
@ -326,6 +353,7 @@ def run_legendary_list_async(legendary_path: str, callback: Callable[[list | Non
|
|||||||
def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]], None], downloader, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
|
def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]], None], downloader, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
|
||||||
"""
|
"""
|
||||||
Асинхронно загружает Epic Games Store игры с использованием legendary CLI.
|
Асинхронно загружает Epic Games Store игры с использованием legendary CLI.
|
||||||
|
Читает статистику времени игры и последнего запуска из файла statistics.
|
||||||
"""
|
"""
|
||||||
logger.debug("Starting to load Epic Games Store games")
|
logger.debug("Starting to load Epic Games Store games")
|
||||||
games: list[tuple] = []
|
games: list[tuple] = []
|
||||||
@ -334,6 +362,14 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
|
|||||||
cache_file = cache_dir / "legendary_games.json"
|
cache_file = cache_dir / "legendary_games.json"
|
||||||
cache_ttl = 3600 # Cache TTL in seconds (1 hour)
|
cache_ttl = 3600 # Cache TTL in seconds (1 hour)
|
||||||
|
|
||||||
|
# Путь к файлу statistics
|
||||||
|
portproton_location = get_portproton_location()
|
||||||
|
if portproton_location is None:
|
||||||
|
logger.error("PortProton location is not set, cannot locate statistics file")
|
||||||
|
statistics_file = ""
|
||||||
|
else:
|
||||||
|
statistics_file = os.path.join(portproton_location, "data", "tmp", "statistics")
|
||||||
|
|
||||||
if not os.path.exists(legendary_path):
|
if not os.path.exists(legendary_path):
|
||||||
logger.info("Legendary binary not found, downloading...")
|
logger.info("Legendary binary not found, downloading...")
|
||||||
def on_legendary_downloaded(result):
|
def on_legendary_downloaded(result):
|
||||||
@ -345,7 +381,7 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
|
|||||||
logger.error(f"Failed to make legendary binary executable: {e}")
|
logger.error(f"Failed to make legendary binary executable: {e}")
|
||||||
callback(games) # Return empty games list on failure
|
callback(games) # Return empty games list on failure
|
||||||
return
|
return
|
||||||
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message)
|
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message, statistics_file)
|
||||||
else:
|
else:
|
||||||
logger.error("Failed to download legendary binary")
|
logger.error("Failed to download legendary binary")
|
||||||
callback(games) # Return empty games list on failure
|
callback(games) # Return empty games list on failure
|
||||||
@ -356,9 +392,9 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
|
|||||||
callback(games)
|
callback(games)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message)
|
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message, statistics_file)
|
||||||
|
|
||||||
def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tuple]], None], metadata_dir: Path, cache_dir: Path, cache_file: Path, cache_ttl: int, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
|
def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tuple]], None], metadata_dir: Path, cache_dir: Path, cache_file: Path, cache_ttl: int, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None], statistics_file: str):
|
||||||
"""
|
"""
|
||||||
Продолжает процесс загрузки EGS игр, либо из кэша, либо через legendary CLI.
|
Продолжает процесс загрузки EGS игр, либо из кэша, либо через legendary CLI.
|
||||||
"""
|
"""
|
||||||
@ -410,6 +446,33 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
|||||||
callback(final_games)
|
callback(final_games)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Получаем путь к .exe для извлечения имени
|
||||||
|
game_exe = get_egs_executable(app_name, os.path.dirname(legendary_path))
|
||||||
|
exe_name = ""
|
||||||
|
if game_exe:
|
||||||
|
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
|
||||||
|
|
||||||
|
# Читаем статистику из файла statistics
|
||||||
|
playtime_seconds = 0
|
||||||
|
formatted_playtime = ""
|
||||||
|
last_launch = _("Never")
|
||||||
|
last_launch_timestamp = 0
|
||||||
|
if exe_name and os.path.exists(statistics_file):
|
||||||
|
try:
|
||||||
|
playtime_data = parse_playtime_file(statistics_file)
|
||||||
|
matching_key = next(
|
||||||
|
(key for key in playtime_data if os.path.basename(key).split('.')[0] == exe_name),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if matching_key:
|
||||||
|
playtime_seconds = playtime_data[matching_key]
|
||||||
|
formatted_playtime = format_playtime(playtime_seconds)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to parse playtime data for {app_name}: {e}")
|
||||||
|
if exe_name:
|
||||||
|
last_launch = get_last_launch(exe_name) or _("Never")
|
||||||
|
last_launch_timestamp = get_last_launch_timestamp(exe_name)
|
||||||
|
|
||||||
metadata_file = metadata_dir / f"{app_name}.json"
|
metadata_file = metadata_dir / f"{app_name}.json"
|
||||||
cover_url = ""
|
cover_url = ""
|
||||||
try:
|
try:
|
||||||
@ -430,7 +493,6 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
|||||||
final_description = api_description or _("No description available")
|
final_description = api_description or _("No description available")
|
||||||
|
|
||||||
def on_cover_loaded(pixmap: QPixmap):
|
def on_cover_loaded(pixmap: QPixmap):
|
||||||
from portprotonqt.steam_api import get_weanticheatyet_status_async
|
|
||||||
def on_anticheat_status(status: str):
|
def on_anticheat_status(status: str):
|
||||||
nonlocal pending_images
|
nonlocal pending_images
|
||||||
with results_lock:
|
with results_lock:
|
||||||
@ -441,12 +503,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
|||||||
app_name,
|
app_name,
|
||||||
f"legendary:launch:{app_name}",
|
f"legendary:launch:{app_name}",
|
||||||
"",
|
"",
|
||||||
_("Never"),
|
last_launch, # Время последнего запуска
|
||||||
"",
|
formatted_playtime, # Форматированное время игры
|
||||||
"",
|
"",
|
||||||
status or "",
|
status or "",
|
||||||
0,
|
last_launch_timestamp, # Временная метка последнего запуска
|
||||||
0,
|
playtime_seconds, # Время игры в секундах
|
||||||
"epic"
|
"epic"
|
||||||
)
|
)
|
||||||
pending_images -= 1
|
pending_images -= 1
|
||||||
|
@ -25,6 +25,7 @@ class GameCard(QFrame):
|
|||||||
addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path
|
addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path
|
||||||
removeFromSteamRequested = Signal(str, str) # name, exec_line
|
removeFromSteamRequested = Signal(str, str) # name, exec_line
|
||||||
openGameFolderRequested = Signal(str, str) # name, exec_line
|
openGameFolderRequested = Signal(str, str) # name, exec_line
|
||||||
|
hoverChanged = Signal(str, bool)
|
||||||
|
|
||||||
def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
|
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,
|
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
|
||||||
@ -476,6 +477,7 @@ class GameCard(QFrame):
|
|||||||
|
|
||||||
def enterEvent(self, event):
|
def enterEvent(self, event):
|
||||||
self._hovered = True
|
self._hovered = True
|
||||||
|
self.hoverChanged.emit(self.name, True)
|
||||||
self.thickness_anim.stop()
|
self.thickness_anim.stop()
|
||||||
if self._isPulseAnimationConnected:
|
if self._isPulseAnimationConnected:
|
||||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||||
@ -500,22 +502,21 @@ class GameCard(QFrame):
|
|||||||
|
|
||||||
def leaveEvent(self, event):
|
def leaveEvent(self, event):
|
||||||
self._hovered = False
|
self._hovered = False
|
||||||
if not self._focused: # Сохраняем анимацию, если есть фокус
|
self.hoverChanged.emit(self.name, False)
|
||||||
|
if not self._focused:
|
||||||
if self.gradient_anim:
|
if self.gradient_anim:
|
||||||
self.gradient_anim.stop()
|
self.gradient_anim.stop()
|
||||||
self.gradient_anim = None
|
self.gradient_anim = None
|
||||||
|
if self.pulse_anim:
|
||||||
|
self.pulse_anim.stop()
|
||||||
|
self.pulse_anim = None
|
||||||
|
if self.thickness_anim:
|
||||||
self.thickness_anim.stop()
|
self.thickness_anim.stop()
|
||||||
if self._isPulseAnimationConnected:
|
if self._isPulseAnimationConnected:
|
||||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||||
self._isPulseAnimationConnected = False
|
self._isPulseAnimationConnected = False
|
||||||
if self.pulse_anim:
|
self.setBorderWidth(2)
|
||||||
self.pulse_anim.stop()
|
self.update()
|
||||||
self.pulse_anim = None
|
|
||||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack))
|
|
||||||
self.thickness_anim.setStartValue(self._borderWidth)
|
|
||||||
self.thickness_anim.setEndValue(2)
|
|
||||||
self.thickness_anim.start()
|
|
||||||
|
|
||||||
super().leaveEvent(event)
|
super().leaveEvent(event)
|
||||||
|
|
||||||
def focusInEvent(self, event):
|
def focusInEvent(self, event):
|
||||||
|
@ -17,7 +17,7 @@ from portprotonqt.system_overlay import SystemOverlay
|
|||||||
|
|
||||||
from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel
|
from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel
|
||||||
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games
|
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games
|
||||||
from portprotonqt.egs_api import load_egs_games_async
|
from portprotonqt.egs_api import load_egs_games_async, get_egs_executable
|
||||||
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots, load_logo
|
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots, load_logo
|
||||||
from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch
|
from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch
|
||||||
from portprotonqt.config_utils import (
|
from portprotonqt.config_utils import (
|
||||||
@ -65,6 +65,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.games_load_timer.timeout.connect(self.finalize_game_loading)
|
self.games_load_timer.timeout.connect(self.finalize_game_loading)
|
||||||
self.games_loaded.connect(self.on_games_loaded)
|
self.games_loaded.connect(self.on_games_loaded)
|
||||||
self.current_add_game_dialog = None
|
self.current_add_game_dialog = None
|
||||||
|
self.current_hovered_card = None
|
||||||
|
|
||||||
# Добавляем таймер для дебаунсинга сохранения настроек
|
# Добавляем таймер для дебаунсинга сохранения настроек
|
||||||
self.settingsDebounceTimer = QTimer(self)
|
self.settingsDebounceTimer = QTimer(self)
|
||||||
@ -241,6 +242,32 @@ class MainWindow(QMainWindow):
|
|||||||
self.updateGameGrid()
|
self.updateGameGrid()
|
||||||
self.progress_bar.setVisible(False)
|
self.progress_bar.setVisible(False)
|
||||||
|
|
||||||
|
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
|
||||||
|
break
|
||||||
|
|
||||||
|
if not card_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
card = self.game_card_cache[card_key]
|
||||||
|
|
||||||
|
if is_hovered:
|
||||||
|
# Если мышь наведена на карточку
|
||||||
|
if self.current_hovered_card and self.current_hovered_card != card:
|
||||||
|
# Сбрасываем предыдущую выделенную карточку
|
||||||
|
self.current_hovered_card._hovered = False
|
||||||
|
self.current_hovered_card.leaveEvent(None) # Принудительно вызываем leaveEvent
|
||||||
|
self.current_hovered_card = card
|
||||||
|
else:
|
||||||
|
# Если мышь покинула карточку
|
||||||
|
if self.current_hovered_card == card:
|
||||||
|
self.current_hovered_card = None
|
||||||
|
|
||||||
def loadGames(self):
|
def loadGames(self):
|
||||||
display_filter = read_display_filter()
|
display_filter = read_display_filter()
|
||||||
favorites = read_favorites()
|
favorites = read_favorites()
|
||||||
@ -693,6 +720,7 @@ class MainWindow(QMainWindow):
|
|||||||
card_width=self.card_width,
|
card_width=self.card_width,
|
||||||
context_menu_manager=self.context_menu_manager
|
context_menu_manager=self.context_menu_manager
|
||||||
)
|
)
|
||||||
|
card.hoverChanged.connect(self._on_card_hovered)
|
||||||
# Подключаем сигналы контекстного меню
|
# Подключаем сигналы контекстного меню
|
||||||
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
|
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
|
||||||
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
|
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
|
||||||
@ -1835,11 +1863,65 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Обработка EGS-игр
|
# Обработка EGS-игр
|
||||||
if exec_line.startswith("legendary:launch:"):
|
if exec_line.startswith("legendary:launch:"):
|
||||||
# Извлекаем app_name из exec_line
|
|
||||||
app_name = exec_line.split("legendary:launch:")[1]
|
app_name = exec_line.split("legendary:launch:")[1]
|
||||||
legendary_path = self.legendary_path # Путь к legendary
|
|
||||||
|
|
||||||
# Формируем переменные окружения
|
# Получаем путь к .exe из installed.json
|
||||||
|
game_exe = get_egs_executable(app_name, self.legendary_config_path)
|
||||||
|
if not game_exe or not os.path.exists(game_exe):
|
||||||
|
QMessageBox.warning(self, _("Error"), _("Executable not found for EGS game: {0}").format(app_name))
|
||||||
|
return
|
||||||
|
|
||||||
|
current_exe = os.path.basename(game_exe)
|
||||||
|
if self.game_processes and self.target_exe is not None and self.target_exe != current_exe:
|
||||||
|
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Обновляем кнопку
|
||||||
|
update_button = button if button is not None else self.current_play_button
|
||||||
|
self.current_running_button = update_button
|
||||||
|
self.target_exe = current_exe
|
||||||
|
exe_name = os.path.splitext(current_exe)[0]
|
||||||
|
|
||||||
|
# Проверяем, запущена ли игра
|
||||||
|
if self.game_processes and self.target_exe == current_exe:
|
||||||
|
# Останавливаем игру
|
||||||
|
if hasattr(self, 'input_manager'):
|
||||||
|
self.input_manager.enable_gamepad_handling()
|
||||||
|
|
||||||
|
for proc in self.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.game_processes = []
|
||||||
|
if update_button:
|
||||||
|
update_button.setText(_("Play"))
|
||||||
|
icon = self.theme_manager.get_icon("play")
|
||||||
|
if isinstance(icon, str):
|
||||||
|
icon = QIcon(icon)
|
||||||
|
elif icon is None:
|
||||||
|
icon = QIcon()
|
||||||
|
update_button.setIcon(icon)
|
||||||
|
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
|
||||||
|
self.checkProcessTimer.stop()
|
||||||
|
self.checkProcessTimer.deleteLater()
|
||||||
|
self.checkProcessTimer = None
|
||||||
|
self.current_running_button = None
|
||||||
|
self.target_exe = None
|
||||||
|
self._gameLaunched = False
|
||||||
|
else:
|
||||||
|
# Запускаем игру через PortProton
|
||||||
env_vars = os.environ.copy()
|
env_vars = os.environ.copy()
|
||||||
env_vars['START_FROM_STEAM'] = '1'
|
env_vars['START_FROM_STEAM'] = '1'
|
||||||
env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
|
env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
|
||||||
@ -1849,23 +1931,8 @@ class MainWindow(QMainWindow):
|
|||||||
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||||
wrapper = start_sh
|
wrapper = start_sh
|
||||||
|
|
||||||
# Формируем команду
|
cmd = [wrapper, game_exe]
|
||||||
cmd = [
|
|
||||||
legendary_path, "launch", app_name, "--no-wine", "--wrapper", wrapper
|
|
||||||
]
|
|
||||||
|
|
||||||
current_exe = os.path.basename(legendary_path)
|
|
||||||
if self.game_processes and self.target_exe is not None and self.target_exe != current_exe:
|
|
||||||
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Обновляем кнопку
|
|
||||||
update_button = button if button is not None else self.current_play_button
|
|
||||||
self.current_running_button = update_button
|
|
||||||
self.target_exe = current_exe
|
|
||||||
exe_name = app_name # Используем app_name для EGS-игр
|
|
||||||
|
|
||||||
# Запускаем процесс
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(cmd, env=env_vars, shell=False, preexec_fn=os.setsid)
|
process = subprocess.Popen(cmd, env=env_vars, shell=False, preexec_fn=os.setsid)
|
||||||
self.game_processes.append(process)
|
self.game_processes.append(process)
|
||||||
@ -1879,6 +1946,10 @@ class MainWindow(QMainWindow):
|
|||||||
icon = QIcon()
|
icon = QIcon()
|
||||||
update_button.setIcon(icon)
|
update_button.setIcon(icon)
|
||||||
|
|
||||||
|
# Delay disabling gamepad handling
|
||||||
|
if hasattr(self, 'input_manager'):
|
||||||
|
QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
|
||||||
|
|
||||||
self.checkProcessTimer = QTimer(self)
|
self.checkProcessTimer = QTimer(self)
|
||||||
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||||
self.checkProcessTimer.start(500)
|
self.checkProcessTimer.start(500)
|
||||||
@ -1996,14 +2067,46 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
|
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
|
"""Завершает все дочерние процессы и сохраняет настройки при закрытии окна."""
|
||||||
|
# Завершаем все игровые процессы
|
||||||
for proc in self.game_processes:
|
for proc in self.game_processes:
|
||||||
try:
|
try:
|
||||||
|
parent = psutil.Process(proc.pid)
|
||||||
|
children = parent.children(recursive=True)
|
||||||
|
for child in children:
|
||||||
|
try:
|
||||||
|
logger.debug(f"Terminating child process {child.pid}")
|
||||||
|
child.terminate()
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
logger.debug(f"Child process {child.pid} already terminated")
|
||||||
|
psutil.wait_procs(children, timeout=5)
|
||||||
|
for child in children:
|
||||||
|
if child.is_running():
|
||||||
|
logger.debug(f"Killing child process {child.pid}")
|
||||||
|
child.kill()
|
||||||
|
logger.debug(f"Terminating process group {proc.pid}")
|
||||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||||
except ProcessLookupError:
|
except (psutil.NoSuchProcess, ProcessLookupError) as e:
|
||||||
pass # процесс уже завершился
|
logger.debug(f"Process {proc.pid} already terminated: {e}")
|
||||||
|
|
||||||
|
self.game_processes = [] # Очищаем список процессов
|
||||||
|
|
||||||
|
# Сохраняем настройки окна
|
||||||
if not read_fullscreen_config():
|
if not read_fullscreen_config():
|
||||||
|
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
|
||||||
save_window_geometry(self.width(), self.height())
|
save_window_geometry(self.width(), self.height())
|
||||||
|
|
||||||
save_card_size(self.card_width)
|
save_card_size(self.card_width)
|
||||||
|
|
||||||
|
# Очищаем таймеры и другие ресурсы
|
||||||
|
if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive():
|
||||||
|
self.games_load_timer.stop()
|
||||||
|
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive():
|
||||||
|
self.settingsDebounceTimer.stop()
|
||||||
|
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
|
||||||
|
self.searchDebounceTimer.stop()
|
||||||
|
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
|
||||||
|
self.checkProcessTimer.stop()
|
||||||
|
self.checkProcessTimer.deleteLater()
|
||||||
|
self.checkProcessTimer = None
|
||||||
|
|
||||||
event.accept()
|
event.accept()
|
||||||
|
@ -7,12 +7,13 @@ from portprotonqt.config_utils import read_theme_from_config
|
|||||||
|
|
||||||
class SystemTray:
|
class SystemTray:
|
||||||
def __init__(self, app, theme=None):
|
def __init__(self, app, theme=None):
|
||||||
|
self.app = app
|
||||||
self.theme_manager = ThemeManager()
|
self.theme_manager = ThemeManager()
|
||||||
self.theme = theme if theme is not None else default_styles
|
self.theme = theme if theme is not None else default_styles
|
||||||
self.current_theme_name = read_theme_from_config()
|
self.current_theme_name = read_theme_from_config()
|
||||||
self.tray = QSystemTrayIcon()
|
self.tray = QSystemTrayIcon()
|
||||||
self.tray.setIcon(cast(QIcon, self.theme_manager.get_icon("ppqt-tray", self.current_theme_name)))
|
self.tray.setIcon(cast(QIcon, self.theme_manager.get_icon("ppqt-tray", self.current_theme_name)))
|
||||||
self.tray.setToolTip("PortProton QT")
|
self.tray.setToolTip("PortProtonQt")
|
||||||
self.tray.setVisible(True)
|
self.tray.setVisible(True)
|
||||||
|
|
||||||
# Создаём меню
|
# Создаём меню
|
||||||
@ -32,4 +33,17 @@ class SystemTray:
|
|||||||
|
|
||||||
def hide_tray(self):
|
def hide_tray(self):
|
||||||
"""Скрыть иконку трея"""
|
"""Скрыть иконку трея"""
|
||||||
self.tray.hide()
|
if self.tray:
|
||||||
|
self.tray.setVisible(False)
|
||||||
|
if self.menu:
|
||||||
|
self.menu.deleteLater()
|
||||||
|
self.menu = None
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Очистка ресурсов трея"""
|
||||||
|
if self.tray:
|
||||||
|
self.tray.setVisible(False)
|
||||||
|
self.tray = None
|
||||||
|
if self.menu:
|
||||||
|
self.menu.deleteLater()
|
||||||
|
self.menu = None
|
||||||
|
Reference in New Issue
Block a user