Compare commits
11 Commits
e37422fc95
...
renovate/p
Author | SHA1 | Date | |
---|---|---|---|
|
8849e90697 | ||
ac20447ba3
|
|||
ba143c15a8
|
|||
13068f3959
|
|||
|
c8360d08ca | ||
b070ff1fca
|
|||
b5a2f41bdf
|
|||
9a37f31841
|
|||
aeed0112cd
|
|||
027ae68d4d
|
|||
37d41fef8d
|
@@ -8,11 +8,24 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
renovate:
|
renovate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: ghcr.io/renovatebot/renovate:41.1.4
|
container: ghcr.io/renovatebot/renovate:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
- run: renovate
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: https://github.com/astral-sh/setup-uv@v6
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: Download external renovate config
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/renovate-config
|
||||||
|
curl -fsSL "https://git.linux-gaming.ru/Linux-Gaming/renovate-config/raw/branch/main/config.js" \
|
||||||
|
-o /tmp/renovate-config/config.js
|
||||||
|
|
||||||
|
- name: Run Renovate
|
||||||
|
run: renovate
|
||||||
env:
|
env:
|
||||||
RENOVATE_CONFIG_FILE: "/workspace/Boria138/PortProtonQt/config.js"
|
RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
|
||||||
LOG_LEVEL: "debug"
|
LOG_LEVEL: "debug"
|
||||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
|
exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v5.0.0
|
rev: v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
@@ -11,15 +11,14 @@ repos:
|
|||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||||
rev: 0.6.14
|
rev: 0.8.9
|
||||||
hooks:
|
hooks:
|
||||||
- id: uv-lock
|
- id: uv-lock
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.11.5
|
rev: v0.12.8
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff-check
|
||||||
args: [--fix]
|
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
@@ -1,8 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
@@ -7,6 +7,19 @@ from portprotonqt.logger import get_logger
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
class SafeOpacityEffect(QGraphicsOpacityEffect):
|
||||||
|
def __init__(self, parent=None, disable_at_full=True):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.disable_at_full = disable_at_full
|
||||||
|
|
||||||
|
def setOpacity(self, opacity: float):
|
||||||
|
opacity = max(0.0, min(1.0, opacity))
|
||||||
|
super().setOpacity(opacity)
|
||||||
|
if opacity < 1.0:
|
||||||
|
self.setEnabled(True)
|
||||||
|
elif self.disable_at_full:
|
||||||
|
self.setEnabled(False)
|
||||||
|
|
||||||
class GameCardAnimations:
|
class GameCardAnimations:
|
||||||
def __init__(self, game_card, theme=None):
|
def __init__(self, game_card, theme=None):
|
||||||
self.game_card = game_card
|
self.game_card = game_card
|
||||||
@@ -138,7 +151,9 @@ class GameCardAnimations:
|
|||||||
self.thickness_anim.start()
|
self.thickness_anim.start()
|
||||||
|
|
||||||
def paint_border(self, painter: QPainter):
|
def paint_border(self, painter: QPainter):
|
||||||
"""Paint the animated border for the GameCard."""
|
if not painter.isActive():
|
||||||
|
logger.warning("Painter is not active; skipping border paint")
|
||||||
|
return
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
pen = QPen()
|
pen = QPen()
|
||||||
pen.setWidth(self.game_card._borderWidth)
|
pen.setWidth(self.game_card._borderWidth)
|
||||||
@@ -154,6 +169,8 @@ class GameCardAnimations:
|
|||||||
radius = 18
|
radius = 18
|
||||||
bw = round(self.game_card._borderWidth / 2)
|
bw = round(self.game_card._borderWidth / 2)
|
||||||
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
|
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
|
||||||
|
if rect.isEmpty():
|
||||||
|
return # Avoid drawing invalid rect
|
||||||
painter.drawRoundedRect(rect, radius, radius)
|
painter.drawRoundedRect(rect, radius, radius)
|
||||||
|
|
||||||
class DetailPageAnimations:
|
class DetailPageAnimations:
|
||||||
@@ -164,21 +181,28 @@ class DetailPageAnimations:
|
|||||||
|
|
||||||
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
|
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
|
||||||
"""Animate the detail page based on theme settings."""
|
"""Animate the detail page based on theme settings."""
|
||||||
shadow = detail_page.graphicsEffect()
|
|
||||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
||||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
|
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
|
||||||
|
|
||||||
if animation_type == "fade":
|
if animation_type == "fade":
|
||||||
opacity_effect = QGraphicsOpacityEffect(detail_page)
|
original_effect = detail_page.graphicsEffect()
|
||||||
|
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
|
||||||
|
opacity_effect.setOpacity(0.0)
|
||||||
detail_page.setGraphicsEffect(opacity_effect)
|
detail_page.setGraphicsEffect(opacity_effect)
|
||||||
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
|
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
|
||||||
animation.setDuration(duration)
|
animation.setDuration(duration)
|
||||||
animation.setStartValue(0)
|
animation.setStartValue(0.0)
|
||||||
animation.setEndValue(1)
|
animation.setEndValue(0.999)
|
||||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||||
self.animations[detail_page] = animation
|
self.animations[detail_page] = animation
|
||||||
animation.finished.connect(lambda: detail_page.setGraphicsEffect(shadow) if shadow is not None else detail_page.setGraphicsEffect(None)) # type: ignore
|
def restore_effect():
|
||||||
|
try:
|
||||||
|
detail_page.setGraphicsEffect(original_effect) # type: ignore
|
||||||
|
except RuntimeError:
|
||||||
|
logger.debug("Original effect already deleted")
|
||||||
|
animation.finished.connect(restore_effect)
|
||||||
animation.finished.connect(load_image_and_restore_effect)
|
animation.finished.connect(load_image_and_restore_effect)
|
||||||
|
animation.finished.connect(opacity_effect.deleteLater)
|
||||||
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
||||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
|
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
|
||||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
|
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
|
||||||
@@ -243,15 +267,24 @@ class DetailPageAnimations:
|
|||||||
# Define animation based on type
|
# Define animation based on type
|
||||||
if animation_type == "fade":
|
if animation_type == "fade":
|
||||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
|
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
|
||||||
opacity_effect = QGraphicsOpacityEffect(detail_page)
|
original_effect = detail_page.graphicsEffect()
|
||||||
|
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
|
||||||
|
opacity_effect.setOpacity(0.999)
|
||||||
detail_page.setGraphicsEffect(opacity_effect)
|
detail_page.setGraphicsEffect(opacity_effect)
|
||||||
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
|
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
|
||||||
animation.setDuration(duration)
|
animation.setDuration(duration)
|
||||||
animation.setStartValue(1)
|
animation.setStartValue(0.999)
|
||||||
animation.setEndValue(0)
|
animation.setEndValue(0.0)
|
||||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||||
self.animations[detail_page] = animation
|
self.animations[detail_page] = animation
|
||||||
animation.finished.connect(cleanup_callback)
|
def restore_and_cleanup():
|
||||||
|
try:
|
||||||
|
detail_page.setGraphicsEffect(original_effect) # type: ignore
|
||||||
|
except RuntimeError:
|
||||||
|
logger.debug("Original effect already deleted")
|
||||||
|
cleanup_callback()
|
||||||
|
animation.finished.connect(restore_and_cleanup)
|
||||||
|
animation.finished.connect(opacity_effect.deleteLater) # Clean up effect
|
||||||
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
||||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
|
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
|
||||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
||||||
|
@@ -144,14 +144,21 @@ class Downloader(QObject):
|
|||||||
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
|
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
|
||||||
return None
|
return None
|
||||||
if url in self._cache:
|
if url in self._cache:
|
||||||
return self._cache[url]
|
cached_path = self._cache[url]
|
||||||
|
if os.path.exists(cached_path):
|
||||||
|
if os.path.abspath(cached_path) == os.path.abspath(local_path):
|
||||||
|
return cached_path
|
||||||
|
else:
|
||||||
|
del self._cache[url]
|
||||||
url_lock = self._get_url_lock(url)
|
url_lock = self._get_url_lock(url)
|
||||||
with url_lock:
|
with url_lock:
|
||||||
with self._global_lock:
|
with self._global_lock:
|
||||||
if url in self._last_error:
|
if url in self._last_error:
|
||||||
return None
|
return None
|
||||||
if url in self._cache:
|
if url in self._cache:
|
||||||
return self._cache[url]
|
cached_path = self._cache[url]
|
||||||
|
if os.path.exists(cached_path) and os.path.abspath(cached_path) == os.path.abspath(local_path):
|
||||||
|
return cached_path
|
||||||
result = download_with_cache(url, local_path, timeout, self)
|
result = download_with_cache(url, local_path, timeout, self)
|
||||||
with self._global_lock:
|
with self._global_lock:
|
||||||
if result:
|
if result:
|
||||||
|
@@ -16,13 +16,14 @@ from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_la
|
|||||||
from portprotonqt.config_utils import get_portproton_location
|
from portprotonqt.config_utils import get_portproton_location
|
||||||
from portprotonqt.steam_api import (
|
from portprotonqt.steam_api import (
|
||||||
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
|
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
|
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
|
||||||
)
|
)
|
||||||
import vdf
|
import vdf
|
||||||
import shutil
|
import shutil
|
||||||
import zlib
|
import zlib
|
||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
from PySide6.QtGui import QPixmap
|
from PySide6.QtGui import QPixmap
|
||||||
|
import base64
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
@@ -66,7 +67,8 @@ def get_cache_dir() -> Path:
|
|||||||
|
|
||||||
def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None:
|
def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None:
|
||||||
"""
|
"""
|
||||||
Removes an EGS game from Steam by modifying shortcuts.vdf and deleting the launch script.
|
Removes an EGS game from Steam using CEF API or by modifying shortcuts.vdf and deleting the launch script.
|
||||||
|
Also deletes associated cover files in the Steam grid directory.
|
||||||
Calls the callback with (success, message).
|
Calls the callback with (success, message).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -74,6 +76,7 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
|||||||
portproton_dir: Path to the PortProton directory.
|
portproton_dir: Path to the PortProton directory.
|
||||||
callback: Callback function to handle the result (success, message).
|
callback: Callback function to handle the result (success, message).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not portproton_dir:
|
if not portproton_dir:
|
||||||
logger.error("PortProton directory not found")
|
logger.error("PortProton directory not found")
|
||||||
callback((False, "PortProton directory not found"))
|
callback((False, "PortProton directory not found"))
|
||||||
@@ -101,51 +104,89 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
|||||||
unsigned_id = convert_steam_id(user_id)
|
unsigned_id = convert_steam_id(user_id)
|
||||||
user_dir = os.path.join(userdata_dir, str(unsigned_id))
|
user_dir = os.path.join(userdata_dir, str(unsigned_id))
|
||||||
steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
|
steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
|
||||||
backup_path = f"{steam_shortcuts_path}.backup"
|
grid_dir = os.path.join(user_dir, "config", "grid")
|
||||||
|
|
||||||
if not os.path.exists(steam_shortcuts_path):
|
if not os.path.exists(steam_shortcuts_path):
|
||||||
logger.error("Steam shortcuts file not found")
|
logger.error("Steam shortcuts file not found")
|
||||||
callback((False, "Steam shortcuts file not found"))
|
callback((False, "Steam shortcuts file not found"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Find appid for the shortcut
|
||||||
|
try:
|
||||||
|
with open(steam_shortcuts_path, 'rb') as f:
|
||||||
|
shortcuts_data = vdf.binary_load(f)
|
||||||
|
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||||
|
appid = None
|
||||||
|
for _key, entry in shortcuts.items():
|
||||||
|
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||||
|
appid = convert_steam_id(int(entry.get("appid")))
|
||||||
|
logger.info(f"Found matching shortcut for '{game_name}' with AppID {appid}")
|
||||||
|
break
|
||||||
|
if not appid:
|
||||||
|
logger.info(f"Game '{game_name}' not found in Steam shortcuts")
|
||||||
|
callback((False, f"Game '{game_name}' not found in Steam"))
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load shortcuts.vdf: {e}")
|
||||||
|
callback((False, f"Failed to load shortcuts.vdf: {e}"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Try CEF API first
|
||||||
|
logger.info(f"Attempting to remove EGS game '{game_name}' via Steam CEF API with AppID {appid}")
|
||||||
|
api_response = call_steam_api("removeShortcut", appid)
|
||||||
|
if api_response is not None: # API responded, even if empty
|
||||||
|
logger.info(f"Shortcut for AppID {appid} successfully removed via CEF API")
|
||||||
|
|
||||||
|
# Delete cover files
|
||||||
|
cover_files = [
|
||||||
|
os.path.join(grid_dir, f"{appid}.jpg"),
|
||||||
|
os.path.join(grid_dir, f"{appid}p.jpg"),
|
||||||
|
os.path.join(grid_dir, f"{appid}_hero.jpg"),
|
||||||
|
os.path.join(grid_dir, f"{appid}_logo.png")
|
||||||
|
]
|
||||||
|
for cover_file in cover_files:
|
||||||
|
if os.path.exists(cover_file):
|
||||||
|
try:
|
||||||
|
os.remove(cover_file)
|
||||||
|
logger.info(f"Deleted cover file: {cover_file}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete cover file {cover_file}: {e}")
|
||||||
|
|
||||||
|
# Delete launch script
|
||||||
|
if os.path.exists(script_path):
|
||||||
|
try:
|
||||||
|
os.remove(script_path)
|
||||||
|
logger.info(f"Removed EGS script: {script_path}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
|
||||||
|
|
||||||
|
callback((True, f"Game '{game_name}' was removed from Steam. Please restart Steam for changes to take effect."))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fallback to VDF modification
|
||||||
|
logger.warning("CEF API failed for EGS game removal; falling back to VDF modification")
|
||||||
|
backup_path = f"{steam_shortcuts_path}.backup"
|
||||||
try:
|
try:
|
||||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||||
logger.info("Created backup of shortcuts.vdf at %s", backup_path)
|
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(steam_shortcuts_path, 'rb') as f:
|
new_shortcuts = {}
|
||||||
shortcuts_data = vdf.binary_load(f)
|
index = 0
|
||||||
except Exception as e:
|
for _key, entry in shortcuts.items():
|
||||||
logger.error(f"Failed to load shortcuts.vdf: {e}")
|
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||||
callback((False, f"Failed to load shortcuts.vdf: {e}"))
|
logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
|
||||||
return
|
continue
|
||||||
|
new_shortcuts[str(index)] = entry
|
||||||
|
index += 1
|
||||||
|
|
||||||
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("Removing EGS game '%s' from Steam shortcuts", game_name)
|
|
||||||
continue
|
|
||||||
new_shortcuts[str(index)] = entry
|
|
||||||
index += 1
|
|
||||||
|
|
||||||
if not modified:
|
|
||||||
logger.error("Game '%s' not found in Steam shortcuts", game_name)
|
|
||||||
callback((False, f"Game '{game_name}' not found in Steam shortcuts"))
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(steam_shortcuts_path, 'wb') as f:
|
with open(steam_shortcuts_path, 'wb') as f:
|
||||||
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
|
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
|
||||||
logger.info("Updated shortcuts.vdf, removed '%s'", game_name)
|
logger.info(f"Updated shortcuts.vdf, removed '{game_name}'")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||||
if os.path.exists(backup_path):
|
if os.path.exists(backup_path):
|
||||||
@@ -157,10 +198,26 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
|||||||
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Delete cover files
|
||||||
|
cover_files = [
|
||||||
|
os.path.join(grid_dir, f"{appid}.jpg"),
|
||||||
|
os.path.join(grid_dir, f"{appid}p.jpg"),
|
||||||
|
os.path.join(grid_dir, f"{appid}_hero.jpg"),
|
||||||
|
os.path.join(grid_dir, f"{appid}_logo.png")
|
||||||
|
]
|
||||||
|
for cover_file in cover_files:
|
||||||
|
if os.path.exists(cover_file):
|
||||||
|
try:
|
||||||
|
os.remove(cover_file)
|
||||||
|
logger.info(f"Deleted cover file: {cover_file}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete cover file {cover_file}: {e}")
|
||||||
|
|
||||||
|
# Delete launch script
|
||||||
if os.path.exists(script_path):
|
if os.path.exists(script_path):
|
||||||
try:
|
try:
|
||||||
os.remove(script_path)
|
os.remove(script_path)
|
||||||
logger.info("Removed EGS script: %s", script_path)
|
logger.info(f"Removed EGS script: {script_path}")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
|
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
|
||||||
|
|
||||||
@@ -168,11 +225,17 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
|||||||
|
|
||||||
def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None:
|
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.
|
Asynchronously adds an EGS game to Steam via CEF API or shortcuts.vdf with PortProton tag.
|
||||||
Creates a launch script using legendary CLI with --no-wine and PortProton wrapper.
|
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.
|
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.
|
Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
|
||||||
Calls the callback with (success, message).
|
Calls the callback with (success, message).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_name: The Legendary app_name (unique identifier for the game).
|
||||||
|
game_title: The display name of the game.
|
||||||
|
legendary_path: Path to the Legendary CLI executable.
|
||||||
|
callback: Callback function to handle the result (success, message).
|
||||||
"""
|
"""
|
||||||
if not app_name or not app_name.strip() or not game_title or not game_title.strip():
|
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")
|
logger.error("Invalid app_name or game_title: empty or whitespace")
|
||||||
@@ -267,47 +330,47 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
|||||||
grid_dir = user_dir / "config" / "grid"
|
grid_dir = user_dir / "config" / "grid"
|
||||||
os.makedirs(grid_dir, exist_ok=True)
|
os.makedirs(grid_dir, exist_ok=True)
|
||||||
|
|
||||||
# Backup shortcuts.vdf
|
# Try CEF API first
|
||||||
backup_path = f"{steam_shortcuts_path}.backup"
|
logger.info(f"Attempting to add EGS game '{game_title}' via Steam CEF API")
|
||||||
if os.path.exists(steam_shortcuts_path):
|
api_response = call_steam_api(
|
||||||
try:
|
"createShortcut",
|
||||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
game_title,
|
||||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
script_path,
|
||||||
except Exception as e:
|
str(Path(script_path).parent),
|
||||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
icon_path,
|
||||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
""
|
||||||
return
|
)
|
||||||
|
|
||||||
# Generate unique appid
|
appid = None
|
||||||
unique_string = f"{script_path}{game_title}"
|
was_api_used = False
|
||||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
|
||||||
appid = baseid | 0x80000000
|
if api_response and isinstance(api_response, dict) and 'id' in api_response:
|
||||||
if appid > 0x7FFFFFFF:
|
appid = api_response['id']
|
||||||
aidvdf = appid - 0x100000000
|
was_api_used = True
|
||||||
|
logger.info(f"EGS game '{game_title}' successfully added via CEF API. AppID: {appid}")
|
||||||
else:
|
else:
|
||||||
aidvdf = appid
|
logger.warning("CEF API failed for EGS game addition; falling back to VDF modification")
|
||||||
|
# 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
|
||||||
|
|
||||||
steam_appid = None
|
# Generate unique appid
|
||||||
downloaded_count = 0
|
unique_string = f"{script_path}{game_title}"
|
||||||
total_covers = 4
|
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||||
download_lock = threading.Lock()
|
appid = baseid | 0x80000000
|
||||||
|
if appid > 0x7FFFFFFF:
|
||||||
|
aidvdf = appid - 0x100000000
|
||||||
|
else:
|
||||||
|
aidvdf = appid
|
||||||
|
|
||||||
def on_cover_download(cover_file: str, cover_type: str):
|
# Create shortcut entry
|
||||||
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 = {
|
shortcut = {
|
||||||
"appid": aidvdf,
|
"appid": aidvdf,
|
||||||
"AppName": game_title,
|
"AppName": game_title,
|
||||||
@@ -322,7 +385,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
|||||||
"Devkit": 0,
|
"Devkit": 0,
|
||||||
"DevkitGameID": "",
|
"DevkitGameID": "",
|
||||||
"LastPlayTime": 0,
|
"LastPlayTime": 0,
|
||||||
"tags": tags_dict
|
"tags": {'0': 'PortProton'}
|
||||||
}
|
}
|
||||||
logger.info(f"Shortcut entry for EGS game: {shortcut}")
|
logger.info(f"Shortcut entry for EGS game: {shortcut}")
|
||||||
|
|
||||||
@@ -353,6 +416,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
|||||||
|
|
||||||
with open(steam_shortcuts_path, 'wb') as f:
|
with open(steam_shortcuts_path, 'wb') as f:
|
||||||
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
||||||
|
logger.info(f"EGS game '{game_title}' added to Steam via VDF")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||||
if os.path.exists(backup_path):
|
if os.path.exists(backup_path):
|
||||||
@@ -364,8 +428,42 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
|||||||
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"EGS game '{game_title}' added to Steam")
|
if not appid:
|
||||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
callback((False, "Failed to create shortcut via any method"))
|
||||||
|
return
|
||||||
|
|
||||||
|
steam_appid = None
|
||||||
|
downloaded_count = 0
|
||||||
|
total_covers = 4
|
||||||
|
download_lock = threading.Lock()
|
||||||
|
|
||||||
|
def on_cover_download(cover_file: str | None, cover_type: str, index: int):
|
||||||
|
nonlocal downloaded_count
|
||||||
|
try:
|
||||||
|
if cover_file is None or not os.path.exists(cover_file):
|
||||||
|
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
|
||||||
|
with download_lock:
|
||||||
|
downloaded_count += 1
|
||||||
|
if downloaded_count == total_covers:
|
||||||
|
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
|
||||||
|
if was_api_used:
|
||||||
|
try:
|
||||||
|
with open(cover_file, 'rb') as f:
|
||||||
|
img_b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||||
|
logger.info(f"Applying cover type '{cover_type}' via API for AppID {appid}")
|
||||||
|
ext = Path(cover_type).suffix.lstrip('.')
|
||||||
|
call_steam_api("setGrid", appid, index, ext, img_b64)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error applying cover '{cover_type}' via API: {e}")
|
||||||
|
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:
|
||||||
|
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||||
|
|
||||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||||
nonlocal steam_appid
|
nonlocal steam_appid
|
||||||
@@ -375,24 +473,24 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
|||||||
|
|
||||||
if not steam_appid:
|
if not steam_appid:
|
||||||
logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
|
logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
|
||||||
finalize_shortcut()
|
callback((True, f"Game '{game_title}' added to Steam"))
|
||||||
return
|
return
|
||||||
|
|
||||||
cover_types = [
|
cover_types = [
|
||||||
(".jpg", "header.jpg"),
|
(".jpg", "header.jpg", 0),
|
||||||
("p.jpg", "library_600x900_2x.jpg"),
|
("p.jpg", "library_600x900_2x.jpg", 1),
|
||||||
("_hero.jpg", "library_hero.jpg"),
|
("_hero.jpg", "library_hero.jpg", 2),
|
||||||
("_logo.png", "logo.png")
|
("_logo.png", "logo.png", 3)
|
||||||
]
|
]
|
||||||
|
|
||||||
for suffix, cover_type in cover_types:
|
for suffix, cover_type, index in cover_types:
|
||||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
|
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
|
||||||
downloader.download_async(
|
downloader.download_async(
|
||||||
cover_url,
|
cover_url,
|
||||||
cover_file,
|
cover_file,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
|
callback=lambda result, ctype=cover_type, idx=index: on_cover_download(result, ctype, idx)
|
||||||
)
|
)
|
||||||
|
|
||||||
get_steam_apps_and_index_async(on_steam_apps)
|
get_steam_apps_and_index_async(on_steam_apps)
|
||||||
|
@@ -28,15 +28,15 @@ requires-python = ">=3.10"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"babel>=2.17.0",
|
"babel>=2.17.0",
|
||||||
"beautifulsoup4>=4.13.4",
|
"beautifulsoup4>=4.13.4",
|
||||||
"evdev>=1.9.1",
|
"evdev>=1.9.2",
|
||||||
"icoextract>=0.1.6",
|
"icoextract>=0.2.0",
|
||||||
"numpy>=2.2.4",
|
"numpy>=2.2.4",
|
||||||
"orjson>=3.10.16",
|
"orjson>=3.11.2",
|
||||||
"pillow>=11.2.1",
|
"pillow>=11.3.0",
|
||||||
"psutil>=7.0.0",
|
"psutil>=7.0.0",
|
||||||
"pyside6>=6.9.0",
|
"pyside6>=6.9.1",
|
||||||
"pyudev>=0.24.3",
|
"pyudev>=0.24.3",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.4",
|
||||||
"tqdm>=4.67.1",
|
"tqdm>=4.67.1",
|
||||||
"vdf>=3.4",
|
"vdf>=3.4",
|
||||||
"websocket-client>=1.8.0",
|
"websocket-client>=1.8.0",
|
||||||
@@ -103,7 +103,7 @@ ignore = [
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.3.0",
|
||||||
"pyaspeller>=2.0.2",
|
"pyaspeller>=2.0.2",
|
||||||
"pyright>=1.1.400",
|
"pyright>=1.1.403",
|
||||||
]
|
]
|
||||||
|
@@ -15,12 +15,23 @@
|
|||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchFileNames": [".gitea/workflows/**.yaml", ".gitea/workflows/**.yml"],
|
"matchFileNames": [".python-version"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchFileNames": [".python-version"],
|
"matchManagers": ["github-actions", "pre-commit"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": ["pep621"],
|
||||||
|
"rangeStrategy": "bump",
|
||||||
|
"versioning": "pep440",
|
||||||
|
"groupName": "Python dependencies"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["numpy", "setuptools"],
|
||||||
|
"enabled": false,
|
||||||
|
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
6
uv.lock
generated
6
uv.lock
generated
@@ -562,15 +562,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyright"
|
name = "pyright"
|
||||||
version = "1.1.402"
|
version = "1.1.403"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nodeenv" },
|
{ name = "nodeenv" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207 }
|
sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004 },
|
{ url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
Reference in New Issue
Block a user