Compare commits
	
		
			5 Commits
		
	
	
		
			renovate/a
			...
			3736bb279e
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						3736bb279e
	
				 | 
					
					
						|||
| 
						 | 
					b59ee5ae8e | ||
| 
						
						
							
						
						33176590fd
	
				 | 
					
					
						|||
| 
						
						
							
						
						8046065929
	
				 | 
					
					
						|||
| 
						 | 
					fbad5add6c | 
@@ -94,7 +94,7 @@ jobs:
 | 
				
			|||||||
    name: Build Arch Package
 | 
					    name: Build Arch Package
 | 
				
			||||||
    runs-on: ubuntu-22.04
 | 
					    runs-on: ubuntu-22.04
 | 
				
			||||||
    container:
 | 
					    container:
 | 
				
			||||||
      image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821
 | 
					      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
				
			||||||
      volumes:
 | 
					      volumes:
 | 
				
			||||||
        - /usr:/usr-host
 | 
					        - /usr:/usr-host
 | 
				
			||||||
        - /opt:/opt-host
 | 
					        - /opt:/opt-host
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -138,7 +138,7 @@ jobs:
 | 
				
			|||||||
    needs: changes
 | 
					    needs: changes
 | 
				
			||||||
    if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
 | 
					    if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
 | 
				
			||||||
    container:
 | 
					    container:
 | 
				
			||||||
      image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821
 | 
					      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
				
			||||||
      volumes:
 | 
					      volumes:
 | 
				
			||||||
        - /usr:/usr-host
 | 
					        - /usr:/usr-host
 | 
				
			||||||
        - /opt:/opt-host
 | 
					        - /opt:/opt-host
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,7 @@ repos:
 | 
				
			|||||||
      - 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.14.0
 | 
					    rev: v0.14.1
 | 
				
			||||||
    hooks:
 | 
					    hooks:
 | 
				
			||||||
      - id: ruff-check
 | 
					      - id: ruff-check
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -83,6 +83,43 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
 | 
				
			|||||||
            except Exception as e:
 | 
					            except Exception as e:
 | 
				
			||||||
                logger.error(f"Ошибка обработки URL {cover}: {e}")
 | 
					                logger.error(f"Ошибка обработки URL {cover}: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # SteamGridDB (SGDB)
 | 
				
			||||||
 | 
					        if cover and cover.startswith("https://cdn2.steamgriddb.com"):
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                parts = cover.split("/")
 | 
				
			||||||
 | 
					                filename = parts[-1] if parts else "sgdb_cover.png"
 | 
				
			||||||
 | 
					                # SGDB ссылки содержат уникальный хеш в названии — используем как имя
 | 
				
			||||||
 | 
					                local_path = os.path.join(image_folder, filename)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if os.path.exists(local_path):
 | 
				
			||||||
 | 
					                    pixmap = QPixmap(local_path)
 | 
				
			||||||
 | 
					                    finish_with(pixmap)
 | 
				
			||||||
 | 
					                    return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                def on_downloaded(result: str | None):
 | 
				
			||||||
 | 
					                    pixmap = QPixmap()
 | 
				
			||||||
 | 
					                    if result and os.path.exists(result):
 | 
				
			||||||
 | 
					                        pixmap.load(result)
 | 
				
			||||||
 | 
					                    if pixmap.isNull():
 | 
				
			||||||
 | 
					                        placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
 | 
				
			||||||
 | 
					                        if placeholder_path and QFile.exists(placeholder_path):
 | 
				
			||||||
 | 
					                            pixmap.load(placeholder_path)
 | 
				
			||||||
 | 
					                        else:
 | 
				
			||||||
 | 
					                            pixmap = QPixmap(width, height)
 | 
				
			||||||
 | 
					                            pixmap.fill(QColor("#333333"))
 | 
				
			||||||
 | 
					                            painter = QPainter(pixmap)
 | 
				
			||||||
 | 
					                            painter.setPen(QPen(QColor("white")))
 | 
				
			||||||
 | 
					                            painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
 | 
				
			||||||
 | 
					                            painter.end()
 | 
				
			||||||
 | 
					                    finish_with(pixmap)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
 | 
				
			||||||
 | 
					                downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            except Exception as e:
 | 
				
			||||||
 | 
					                logger.error(f"Ошибка обработки SGDB URL {cover}: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if cover and cover.startswith(("http://", "https://")):
 | 
					        if cover and cover.startswith(("http://", "https://")):
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                local_path = os.path.join(image_folder, f"{app_name}.jpg")
 | 
					                local_path = os.path.join(image_folder, f"{app_name}.jpg")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1440,6 +1440,7 @@ class InputManager(QObject):
 | 
				
			|||||||
        self.udev_context = Context()
 | 
					        self.udev_context = Context()
 | 
				
			||||||
        self.Devices = Devices
 | 
					        self.Devices = Devices
 | 
				
			||||||
        self.monitor_ready = False
 | 
					        self.monitor_ready = False
 | 
				
			||||||
 | 
					        self.monitor_event = threading.Event()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Подключаем сигнал hotplug к обработчику в главном потоке
 | 
					        # Подключаем сигнал hotplug к обработчику в главном потоке
 | 
				
			||||||
        self.gamepad_hotplug.connect(self._on_gamepad_hotplug)
 | 
					        self.gamepad_hotplug.connect(self._on_gamepad_hotplug)
 | 
				
			||||||
@@ -1491,6 +1492,7 @@ class InputManager(QObject):
 | 
				
			|||||||
                    break
 | 
					                    break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            self.monitor_ready = True
 | 
					            self.monitor_ready = True
 | 
				
			||||||
 | 
					            self.monitor_event.set()
 | 
				
			||||||
            logger.info(f"Drained {drained_count} initial events, now monitoring hotplug...")
 | 
					            logger.info(f"Drained {drained_count} initial events, now monitoring hotplug...")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Основной цикл
 | 
					            # Основной цикл
 | 
				
			||||||
@@ -1592,7 +1594,6 @@ class InputManager(QObject):
 | 
				
			|||||||
        except Exception as e:
 | 
					        except Exception as e:
 | 
				
			||||||
            logger.error(f"Error in hotplug handler: {e}", exc_info=True)
 | 
					            logger.error(f"Error in hotplug handler: {e}", exc_info=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
    def check_gamepad(self) -> None:
 | 
					    def check_gamepad(self) -> None:
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        Проверка и подключение геймпада.
 | 
					        Проверка и подключение геймпада.
 | 
				
			||||||
@@ -1601,18 +1602,23 @@ class InputManager(QObject):
 | 
				
			|||||||
        try:
 | 
					        try:
 | 
				
			||||||
            new_gamepad = self.find_gamepad()
 | 
					            new_gamepad = self.find_gamepad()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Проверяем, действительно ли это новый геймпад
 | 
					 | 
				
			||||||
            if new_gamepad:
 | 
					            if new_gamepad:
 | 
				
			||||||
                if not self.gamepad or new_gamepad.path != self.gamepad.path:
 | 
					                if not self.gamepad or new_gamepad.path != self.gamepad.path:
 | 
				
			||||||
                    logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}")
 | 
					                    logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}")
 | 
				
			||||||
                    self.stop_rumble()
 | 
					                    self.stop_rumble()
 | 
				
			||||||
                    self.gamepad = new_gamepad
 | 
					                    self.gamepad = new_gamepad
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    if self.gamepad_thread:
 | 
					                    if self.gamepad_thread and self.gamepad_thread.is_alive():
 | 
				
			||||||
                        self.gamepad_thread.join(timeout=2.0)
 | 
					                        self.gamepad_thread.join(timeout=2.0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    def start_monitoring():
 | 
				
			||||||
 | 
					                        # Ожидание готовности udev monitor без busy-wait
 | 
				
			||||||
 | 
					                        if not self.monitor_event.wait(timeout=2.0):
 | 
				
			||||||
 | 
					                            logger.warning("Timeout waiting for udev monitor readiness")
 | 
				
			||||||
 | 
					                        self.monitor_gamepad()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    self.gamepad_thread = threading.Thread(
 | 
					                    self.gamepad_thread = threading.Thread(
 | 
				
			||||||
                        target=self.monitor_gamepad,
 | 
					                        target=start_monitoring,
 | 
				
			||||||
                        daemon=True
 | 
					                        daemon=True
 | 
				
			||||||
                    )
 | 
					                    )
 | 
				
			||||||
                    self.gamepad_thread.start()
 | 
					                    self.gamepad_thread.start()
 | 
				
			||||||
@@ -1622,12 +1628,11 @@ class InputManager(QObject):
 | 
				
			|||||||
                        self.toggle_fullscreen.emit(True)
 | 
					                        self.toggle_fullscreen.emit(True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            elif self.gamepad and not any(self.gamepad.path == path for path in list_devices()):
 | 
					            elif self.gamepad and not any(self.gamepad.path == path for path in list_devices()):
 | 
				
			||||||
                # Геймпад был подключён, но теперь его нет в системе
 | 
					 | 
				
			||||||
                logger.info("Gamepad no longer detected")
 | 
					                logger.info("Gamepad no longer detected")
 | 
				
			||||||
                self.stop_rumble()
 | 
					                self.stop_rumble()
 | 
				
			||||||
                self.gamepad = None
 | 
					                self.gamepad = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if self.gamepad_thread:
 | 
					                if self.gamepad_thread and self.gamepad_thread.is_alive():
 | 
				
			||||||
                    self.gamepad_thread.join(timeout=2.0)
 | 
					                    self.gamepad_thread.join(timeout=2.0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
 | 
					                if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
 | 
				
			||||||
@@ -1636,7 +1641,6 @@ class InputManager(QObject):
 | 
				
			|||||||
        except Exception as e:
 | 
					        except Exception as e:
 | 
				
			||||||
            logger.error(f"Error checking gamepad: {e}", exc_info=True)
 | 
					            logger.error(f"Error checking gamepad: {e}", exc_info=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
    def find_gamepad(self) -> InputDevice | None:
 | 
					    def find_gamepad(self) -> InputDevice | None:
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        Находит первый доступный геймпад.
 | 
					        Находит первый доступный геймпад.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1253,7 +1253,15 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        # Показываем прогресс
 | 
					        # Показываем прогресс
 | 
				
			||||||
        self.autoInstallProgress.setVisible(True)
 | 
					        self.autoInstallProgress.setVisible(True)
 | 
				
			||||||
        self.autoInstallProgress.setRange(0, 0)
 | 
					        self.autoInstallProgress.setRange(0, 0)
 | 
				
			||||||
        self.portproton_api.get_autoinstall_games_async(on_autoinstall_games_loaded)
 | 
					
 | 
				
			||||||
 | 
					        # Store the thread to prevent premature destruction
 | 
				
			||||||
 | 
					        self.autoInstallLoadThread = self.portproton_api.start_autoinstall_games_load(on_autoinstall_games_loaded)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Optional: Clean up thread when finished (prevents leak)
 | 
				
			||||||
 | 
					        if self.autoInstallLoadThread:
 | 
				
			||||||
 | 
					            def on_thread_finished():
 | 
				
			||||||
 | 
					                self.autoInstallLoadThread = None  # Release reference
 | 
				
			||||||
 | 
					            self.autoInstallLoadThread.finished.connect(on_thread_finished)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.stackedWidget.addWidget(autoInstallPage)
 | 
					        self.stackedWidget.addWidget(autoInstallPage)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -6,13 +6,16 @@ import urllib.parse
 | 
				
			|||||||
import time
 | 
					import time
 | 
				
			||||||
import glob
 | 
					import glob
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
 | 
					import hashlib
 | 
				
			||||||
from collections.abc import Callable
 | 
					from collections.abc import Callable
 | 
				
			||||||
 | 
					from PySide6.QtCore import QThread, Signal
 | 
				
			||||||
from portprotonqt.downloader import Downloader
 | 
					from portprotonqt.downloader import Downloader
 | 
				
			||||||
from portprotonqt.logger import get_logger
 | 
					from portprotonqt.logger import get_logger
 | 
				
			||||||
from portprotonqt.config_utils import get_portproton_location
 | 
					from portprotonqt.config_utils import get_portproton_location
 | 
				
			||||||
 | 
					
 | 
				
			||||||
logger = get_logger(__name__)
 | 
					logger = get_logger(__name__)
 | 
				
			||||||
CACHE_DURATION = 30 * 24 * 60 * 60  # 30 days in seconds
 | 
					CACHE_DURATION = 30 * 24 * 60 * 60  # 30 days in seconds
 | 
				
			||||||
 | 
					AUTOINSTALL_CACHE_DURATION = 3600  # 1 hour for autoinstall cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def normalize_name(s):
 | 
					def normalize_name(s):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
@@ -59,6 +62,7 @@ class PortProtonAPI:
 | 
				
			|||||||
        self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 | 
					        self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 | 
				
			||||||
        self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
 | 
					        self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
 | 
				
			||||||
        self._topics_data = None
 | 
					        self._topics_data = None
 | 
				
			||||||
 | 
					        self._autoinstall_cache = None  # New: In-memory cache
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _get_game_dir(self, exe_name: str) -> str:
 | 
					    def _get_game_dir(self, exe_name: str) -> str:
 | 
				
			||||||
        game_dir = os.path.join(self.custom_data_dir, exe_name)
 | 
					        game_dir = os.path.join(self.custom_data_dir, exe_name)
 | 
				
			||||||
@@ -231,67 +235,139 @@ class PortProtonAPI:
 | 
				
			|||||||
            logger.error(f"Failed to parse {file_path}: {e}")
 | 
					            logger.error(f"Failed to parse {file_path}: {e}")
 | 
				
			||||||
            return None, None
 | 
					            return None, None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
 | 
					    def _compute_scripts_signature(self, auto_dir: str) -> str:
 | 
				
			||||||
        """Load auto-install games with user/builtin covers (no async download here)."""
 | 
					        """Compute a hash-based signature of the autoinstall scripts to detect changes."""
 | 
				
			||||||
        games = []
 | 
					 | 
				
			||||||
        auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") if self.portproton_location else ""
 | 
					 | 
				
			||||||
        if not os.path.exists(auto_dir):
 | 
					        if not os.path.exists(auto_dir):
 | 
				
			||||||
            callback(games)
 | 
					            return ""
 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
					        scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
				
			||||||
        if not scripts:
 | 
					        # Simple hash: concatenate sorted filenames and hash
 | 
				
			||||||
            callback(games)
 | 
					        filenames_str = "".join(sorted([os.path.basename(s) for s in scripts]))
 | 
				
			||||||
            return
 | 
					        return hashlib.md5(filenames_str.encode()).hexdigest()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        xdg_data_home = os.getenv("XDG_DATA_HOME",
 | 
					    def _load_autoinstall_cache(self):
 | 
				
			||||||
                                os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
					        """Load cached autoinstall games if fresh and scripts unchanged."""
 | 
				
			||||||
        base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
 | 
					        if self._autoinstall_cache is not None:
 | 
				
			||||||
        os.makedirs(base_autoinstall_dir, exist_ok=True)
 | 
					            return self._autoinstall_cache
 | 
				
			||||||
 | 
					        cache_dir = get_cache_dir()
 | 
				
			||||||
 | 
					        cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
 | 
				
			||||||
 | 
					        if os.path.exists(cache_file):
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                mod_time = os.path.getmtime(cache_file)
 | 
				
			||||||
 | 
					                if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
 | 
				
			||||||
 | 
					                    with open(cache_file, "rb") as f:
 | 
				
			||||||
 | 
					                        data = orjson.loads(f.read())
 | 
				
			||||||
 | 
					                        # Check signature
 | 
				
			||||||
 | 
					                        cached_signature = data.get("scripts_signature", "")
 | 
				
			||||||
 | 
					                        current_signature = self._compute_scripts_signature(
 | 
				
			||||||
 | 
					                            os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                        if cached_signature != current_signature:
 | 
				
			||||||
 | 
					                            logger.info("Scripts signature mismatch; invalidating cache")
 | 
				
			||||||
 | 
					                            return None
 | 
				
			||||||
 | 
					                        self._autoinstall_cache = data["games"]
 | 
				
			||||||
 | 
					                        logger.info(f"Loaded {len(self._autoinstall_cache)} cached autoinstall games")
 | 
				
			||||||
 | 
					                        return self._autoinstall_cache
 | 
				
			||||||
 | 
					            except Exception as e:
 | 
				
			||||||
 | 
					                logger.error(f"Failed to load autoinstall cache: {e}")
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for script_path in scripts:
 | 
					    def _save_autoinstall_cache(self, games):
 | 
				
			||||||
            display_name, exe_name = self.parse_autoinstall_script(script_path)
 | 
					        """Save parsed autoinstall games to cache with scripts signature."""
 | 
				
			||||||
            script_name = os.path.splitext(os.path.basename(script_path))[0]
 | 
					        try:
 | 
				
			||||||
 | 
					            cache_dir = get_cache_dir()
 | 
				
			||||||
 | 
					            cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
 | 
				
			||||||
 | 
					            auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
 | 
				
			||||||
 | 
					            scripts_signature = self._compute_scripts_signature(auto_dir)
 | 
				
			||||||
 | 
					            data = {"games": games, "scripts_signature": scripts_signature, "timestamp": time.time()}
 | 
				
			||||||
 | 
					            with open(cache_file, "wb") as f:
 | 
				
			||||||
 | 
					                f.write(orjson.dumps(data))
 | 
				
			||||||
 | 
					            logger.debug(f"Saved {len(games)} autoinstall games to cache with signature {scripts_signature}")
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            logger.error(f"Failed to save autoinstall cache: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if not (display_name and exe_name):
 | 
					    def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
 | 
				
			||||||
                continue
 | 
					        """Start loading auto-install games in a background thread. Returns the thread for management."""
 | 
				
			||||||
 | 
					        # Check cache first (sync, fast)
 | 
				
			||||||
 | 
					        cached_games = self._load_autoinstall_cache()
 | 
				
			||||||
 | 
					        if cached_games is not None:
 | 
				
			||||||
 | 
					            # Emit via callback immediately if cached
 | 
				
			||||||
 | 
					            QThread.msleep(0)  # Yield to Qt event loop
 | 
				
			||||||
 | 
					            callback(cached_games)
 | 
				
			||||||
 | 
					            return None  # No thread needed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            exe_name = os.path.splitext(exe_name)[0]  # Без .exe
 | 
					        # No cache: Start background thread
 | 
				
			||||||
            user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
 | 
					        class AutoinstallWorker(QThread):
 | 
				
			||||||
            os.makedirs(user_game_folder, exist_ok=True)
 | 
					            finished = Signal(list)
 | 
				
			||||||
 | 
					            api: "PortProtonAPI"
 | 
				
			||||||
 | 
					            portproton_location: str | None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Поиск обложки
 | 
					            def run(self):
 | 
				
			||||||
            cover_path = ""
 | 
					                games = []
 | 
				
			||||||
            user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set()
 | 
					                auto_dir = os.path.join(
 | 
				
			||||||
            for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
 | 
					                    self.portproton_location or "", "data", "scripts", "pw_autoinstall"
 | 
				
			||||||
                candidate = f"cover{ext}"
 | 
					                ) if self.portproton_location else ""
 | 
				
			||||||
                if candidate in user_files:
 | 
					                if not os.path.exists(auto_dir):
 | 
				
			||||||
                    cover_path = os.path.join(user_game_folder, candidate)
 | 
					                    self.finished.emit(games)
 | 
				
			||||||
                    break
 | 
					                    return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if not cover_path:
 | 
					                scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
				
			||||||
                logger.debug(f"No local cover found for autoinstall {exe_name}")
 | 
					                if not scripts:
 | 
				
			||||||
 | 
					                    self.finished.emit(games)
 | 
				
			||||||
 | 
					                    return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Формируем кортеж игры (добавлен exe_name в конец)
 | 
					                xdg_data_home = os.getenv(
 | 
				
			||||||
            game_tuple = (
 | 
					                    "XDG_DATA_HOME",
 | 
				
			||||||
                display_name,  # name
 | 
					                    os.path.join(os.path.expanduser("~"), ".local", "share"),
 | 
				
			||||||
                "",  # description
 | 
					                )
 | 
				
			||||||
                cover_path,  # cover
 | 
					                base_autoinstall_dir = os.path.join(
 | 
				
			||||||
                "",  # appid
 | 
					                    xdg_data_home, "PortProtonQt", "custom_data", "autoinstall"
 | 
				
			||||||
                f"autoinstall:{script_name}",  # exec_line
 | 
					                )
 | 
				
			||||||
                "",  # controller_support
 | 
					                os.makedirs(base_autoinstall_dir, exist_ok=True)
 | 
				
			||||||
                "Never",  # last_launch
 | 
					 | 
				
			||||||
                "0h 0m",  # formatted_playtime
 | 
					 | 
				
			||||||
                "",  # protondb_tier
 | 
					 | 
				
			||||||
                "",  # anticheat_status
 | 
					 | 
				
			||||||
                0,  # last_played
 | 
					 | 
				
			||||||
                0,  # playtime_seconds
 | 
					 | 
				
			||||||
                "autoinstall",  # game_source
 | 
					 | 
				
			||||||
                exe_name  # exe_name
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            games.append(game_tuple)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        callback(games)
 | 
					                for script_path in scripts:
 | 
				
			||||||
 | 
					                    display_name, exe_name = self.api.parse_autoinstall_script(script_path)
 | 
				
			||||||
 | 
					                    script_name = os.path.splitext(os.path.basename(script_path))[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    if not (display_name and exe_name):
 | 
				
			||||||
 | 
					                        continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    exe_name = os.path.splitext(exe_name)[0]
 | 
				
			||||||
 | 
					                    user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
 | 
				
			||||||
 | 
					                    os.makedirs(user_game_folder, exist_ok=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    # Find cover
 | 
				
			||||||
 | 
					                    cover_path = ""
 | 
				
			||||||
 | 
					                    user_files = (
 | 
				
			||||||
 | 
					                        set(os.listdir(user_game_folder))
 | 
				
			||||||
 | 
					                        if os.path.exists(user_game_folder)
 | 
				
			||||||
 | 
					                        else set()
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                    for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
 | 
				
			||||||
 | 
					                        candidate = f"cover{ext}"
 | 
				
			||||||
 | 
					                        if candidate in user_files:
 | 
				
			||||||
 | 
					                            cover_path = os.path.join(user_game_folder, candidate)
 | 
				
			||||||
 | 
					                            break
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    if not cover_path:
 | 
				
			||||||
 | 
					                        logger.debug(f"No local cover found for autoinstall {exe_name}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                    game_tuple = (
 | 
				
			||||||
 | 
					                        display_name, "", cover_path, "", f"autoinstall:{script_name}",
 | 
				
			||||||
 | 
					                        "", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                    games.append(game_tuple)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                self.api._save_autoinstall_cache(games)
 | 
				
			||||||
 | 
					                self.api._autoinstall_cache = games
 | 
				
			||||||
 | 
					                self.finished.emit(games)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        worker = AutoinstallWorker()
 | 
				
			||||||
 | 
					        worker.api = self
 | 
				
			||||||
 | 
					        worker.portproton_location = self.portproton_location
 | 
				
			||||||
 | 
					        worker.finished.connect(lambda games: callback(games))
 | 
				
			||||||
 | 
					        worker.start()
 | 
				
			||||||
 | 
					        logger.info("Started background load of autoinstall games")
 | 
				
			||||||
 | 
					        return worker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _load_topics_data(self):
 | 
					    def _load_topics_data(self):
 | 
				
			||||||
        """Load and cache linux_gaming_topics_min.json from the archive."""
 | 
					        """Load and cache linux_gaming_topics_min.json from the archive."""
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -23,6 +23,7 @@ import requests
 | 
				
			|||||||
import random
 | 
					import random
 | 
				
			||||||
import base64
 | 
					import base64
 | 
				
			||||||
import glob
 | 
					import glob
 | 
				
			||||||
 | 
					import urllib.parse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
downloader = Downloader()
 | 
					downloader = Downloader()
 | 
				
			||||||
logger = get_logger(__name__)
 | 
					logger = get_logger(__name__)
 | 
				
			||||||
@@ -411,6 +412,39 @@ def save_app_details(app_id, data):
 | 
				
			|||||||
    with open(cache_file, "wb") as f:
 | 
					    with open(cache_file, "wb") as f:
 | 
				
			||||||
        f.write(orjson.dumps(data))
 | 
					        f.write(orjson.dumps(data))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def fetch_sgdb_cover(game_name: str) -> str:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Fetch a cover image URL from steamgrid.usebottles.com for the given game.
 | 
				
			||||||
 | 
					    The API returns a single string (quoted URL).
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        encoded = urllib.parse.quote(game_name)
 | 
				
			||||||
 | 
					        url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
 | 
				
			||||||
 | 
					        resp = requests.get(url, timeout=5)
 | 
				
			||||||
 | 
					        if resp.status_code != 200:
 | 
				
			||||||
 | 
					            logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
 | 
				
			||||||
 | 
					            return ""
 | 
				
			||||||
 | 
					        text = resp.text.strip()
 | 
				
			||||||
 | 
					        # Убираем возможные кавычки вокруг строки
 | 
				
			||||||
 | 
					        if text.startswith('"') and text.endswith('"'):
 | 
				
			||||||
 | 
					            text = text[1:-1]
 | 
				
			||||||
 | 
					        if text:
 | 
				
			||||||
 | 
					            logger.info("Fetched SGDB cover for %s: %s", game_name, text)
 | 
				
			||||||
 | 
					        return text
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e)
 | 
				
			||||||
 | 
					    return ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def check_url_exists(url: str) -> bool:
 | 
				
			||||||
 | 
					    """Check whether a URL returns HTTP 200."""
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        r = requests.head(url, timeout=3)
 | 
				
			||||||
 | 
					        return r.status_code == 200
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
 | 
					def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    Asynchronously fetches detailed app info from Steam API.
 | 
					    Asynchronously fetches detailed app info from Steam API.
 | 
				
			||||||
@@ -629,6 +663,11 @@ def get_full_steam_game_info_async(appid: int, callback: Callable[[dict], None])
 | 
				
			|||||||
        title = decode_text(app_info.get("name", ""))
 | 
					        title = decode_text(app_info.get("name", ""))
 | 
				
			||||||
        description = decode_text(app_info.get("short_description", ""))
 | 
					        description = decode_text(app_info.get("short_description", ""))
 | 
				
			||||||
        cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
 | 
					        cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
 | 
				
			||||||
 | 
					        if not check_url_exists(cover):
 | 
				
			||||||
 | 
					            logger.info("Steam cover not found for %s, trying SGDB", title)
 | 
				
			||||||
 | 
					            alt_cover = fetch_sgdb_cover(title)
 | 
				
			||||||
 | 
					            if alt_cover:
 | 
				
			||||||
 | 
					                cover = alt_cover
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        def on_protondb_tier(tier: str):
 | 
					        def on_protondb_tier(tier: str):
 | 
				
			||||||
            def on_anticheat_status(anticheat_status: str):
 | 
					            def on_anticheat_status(anticheat_status: str):
 | 
				
			||||||
@@ -722,12 +761,15 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
 | 
				
			|||||||
        game_name = desktop_name or exe_name.capitalize()
 | 
					        game_name = desktop_name or exe_name.capitalize()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if not matching_app:
 | 
					        if not matching_app:
 | 
				
			||||||
 | 
					            cover = fetch_sgdb_cover(game_name) or ""
 | 
				
			||||||
 | 
					            logger.info("Using SGDB cover for non-Steam game '%s': %s", game_name, cover)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            def on_anticheat_status(anticheat_status: str):
 | 
					            def on_anticheat_status(anticheat_status: str):
 | 
				
			||||||
                callback({
 | 
					                callback({
 | 
				
			||||||
                    "appid": "",
 | 
					                    "appid": "",
 | 
				
			||||||
                    "name": decode_text(game_name),
 | 
					                    "name": decode_text(game_name),
 | 
				
			||||||
                    "description": "",
 | 
					                    "description": "",
 | 
				
			||||||
                    "cover": "",
 | 
					                    "cover": cover,
 | 
				
			||||||
                    "controller_support": "",
 | 
					                    "controller_support": "",
 | 
				
			||||||
                    "protondb_tier": "",
 | 
					                    "protondb_tier": "",
 | 
				
			||||||
                    "steam_game": "false",
 | 
					                    "steam_game": "false",
 | 
				
			||||||
@@ -758,6 +800,11 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
 | 
				
			|||||||
            title = decode_text(app_info.get("name", game_name))
 | 
					            title = decode_text(app_info.get("name", game_name))
 | 
				
			||||||
            description = decode_text(app_info.get("short_description", ""))
 | 
					            description = decode_text(app_info.get("short_description", ""))
 | 
				
			||||||
            cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
 | 
					            cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
 | 
				
			||||||
 | 
					            if not check_url_exists(cover):
 | 
				
			||||||
 | 
					                logger.info("Steam cover not found for %s, trying SGDB", title)
 | 
				
			||||||
 | 
					                alt_cover = fetch_sgdb_cover(title)
 | 
				
			||||||
 | 
					                if alt_cover:
 | 
				
			||||||
 | 
					                    cover = alt_cover
 | 
				
			||||||
            controller_support = app_info.get("controller_support", "")
 | 
					            controller_support = app_info.get("controller_support", "")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            def on_protondb_tier(tier: str):
 | 
					            def on_protondb_tier(tier: str):
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user