forked from Boria138/PortProtonQt
		
	Compare commits
	
		
			6 Commits
		
	
	
		
			567203b0b0
			...
			v0.1.8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					b59ee5ae8e | ||
| 
						
						
							
						
						33176590fd
	
				 | 
					
					
						|||
| 
						
						
							
						
						8046065929
	
				 | 
					
					
						|||
| 
						 | 
					fbad5add6c | ||
| 
						
						
							
						
						438e9737ea
	
				 | 
					
					
						|||
| 
						
						
							
						
						2d39a4c740
	
				 | 
					
					
						
@@ -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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -188,4 +188,4 @@ jobs:
 | 
				
			|||||||
          tag_name: v${{ env.VERSION }}
 | 
					          tag_name: v${{ env.VERSION }}
 | 
				
			||||||
          prerelease: true
 | 
					          prerelease: true
 | 
				
			||||||
          files: release/**/*
 | 
					          files: release/**/*
 | 
				
			||||||
          sha256sum: true
 | 
					          sha256sum: false
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -3057,15 +3065,33 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
 | 
					        Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        minimize_to_tray = read_minimize_to_tray()
 | 
					        minimize_to_tray = read_minimize_to_tray()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if minimize_to_tray:
 | 
				
			||||||
 | 
					            # Просто сворачиваем в трей
 | 
				
			||||||
 | 
					            event.ignore()
 | 
				
			||||||
 | 
					            self.hide()
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Полное закрытие приложения
 | 
				
			||||||
 | 
					        self.is_exiting = True
 | 
				
			||||||
 | 
					        event.accept()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Скрываем и удаляем иконку трея
 | 
				
			||||||
 | 
					        if hasattr(self, "tray_manager") and self.tray_manager.tray_icon:
 | 
				
			||||||
 | 
					            self.tray_manager.tray_icon.hide()
 | 
				
			||||||
 | 
					            self.tray_manager.tray_icon.deleteLater()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Сохраняем размеры карточек
 | 
				
			||||||
        save_card_size(self.card_width)
 | 
					        save_card_size(self.card_width)
 | 
				
			||||||
        save_auto_card_size(self.auto_card_width)
 | 
					        save_auto_card_size(self.auto_card_width)
 | 
				
			||||||
        # Сохраняем настройки окна
 | 
					
 | 
				
			||||||
 | 
					        # Сохраняем размеры окна (если не в полноэкранном режиме)
 | 
				
			||||||
        if not read_fullscreen_config():
 | 
					        if not read_fullscreen_config():
 | 
				
			||||||
            logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
 | 
					            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())
 | 
				
			||||||
        if hasattr(self, 'is_exiting') and self.is_exiting or not minimize_to_tray:
 | 
					
 | 
				
			||||||
            # Принудительное закрытие: завершаем процессы и приложение
 | 
					        # Завершаем все игровые процессы
 | 
				
			||||||
            for proc in self.game_processes:
 | 
					        for proc in getattr(self, "game_processes", []):
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                parent = psutil.Process(proc.pid)
 | 
					                parent = psutil.Process(proc.pid)
 | 
				
			||||||
                children = parent.children(recursive=True)
 | 
					                children = parent.children(recursive=True)
 | 
				
			||||||
@@ -3075,36 +3101,36 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
                        child.terminate()
 | 
					                        child.terminate()
 | 
				
			||||||
                    except psutil.NoSuchProcess:
 | 
					                    except psutil.NoSuchProcess:
 | 
				
			||||||
                        logger.debug(f"Child process {child.pid} already terminated")
 | 
					                        logger.debug(f"Child process {child.pid} already terminated")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                psutil.wait_procs(children, timeout=5)
 | 
					                psutil.wait_procs(children, timeout=5)
 | 
				
			||||||
                for child in children:
 | 
					                for child in children:
 | 
				
			||||||
                    if child.is_running():
 | 
					                    if child.is_running():
 | 
				
			||||||
                        logger.debug(f"Killing child process {child.pid}")
 | 
					                        logger.debug(f"Killing child process {child.pid}")
 | 
				
			||||||
                        child.kill()
 | 
					                        child.kill()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                logger.debug(f"Terminating process group {proc.pid}")
 | 
					                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 (psutil.NoSuchProcess, ProcessLookupError) as e:
 | 
					            except (psutil.NoSuchProcess, ProcessLookupError) as e:
 | 
				
			||||||
                    logger.debug(f"Process {proc.pid} already terminated: {e}")
 | 
					                logger.debug(f"Process {getattr(proc, 'pid', '?')} already terminated: {e}")
 | 
				
			||||||
 | 
					            except Exception as e:
 | 
				
			||||||
 | 
					                logger.warning(f"Failed to terminate process {getattr(proc, 'pid', '?')}: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            self.game_processes = []  # Очищаем список процессов
 | 
					        self.game_processes = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Очищаем таймеры
 | 
					        # Универсальная остановка и удаление таймеров
 | 
				
			||||||
            if hasattr(self, 'games_load_timer') and self.games_load_timer is not None and self.games_load_timer.isActive():
 | 
					        timers = [
 | 
				
			||||||
                self.games_load_timer.stop()
 | 
					            "games_load_timer",
 | 
				
			||||||
            if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer is not None and self.settingsDebounceTimer.isActive():
 | 
					            "settingsDebounceTimer",
 | 
				
			||||||
                self.settingsDebounceTimer.stop()
 | 
					            "searchDebounceTimer",
 | 
				
			||||||
            if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer is not None and self.searchDebounceTimer.isActive():
 | 
					            "checkProcessTimer",
 | 
				
			||||||
                self.searchDebounceTimer.stop()
 | 
					            "wine_monitor_timer",
 | 
				
			||||||
            if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None and self.checkProcessTimer.isActive():
 | 
					        ]
 | 
				
			||||||
                self.checkProcessTimer.stop()
 | 
					 | 
				
			||||||
                self.checkProcessTimer.deleteLater()
 | 
					 | 
				
			||||||
                self.checkProcessTimer = None
 | 
					 | 
				
			||||||
            if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None:
 | 
					 | 
				
			||||||
                self.wine_monitor_timer.stop()
 | 
					 | 
				
			||||||
                self.wine_monitor_timer.deleteLater()
 | 
					 | 
				
			||||||
                self.wine_monitor_timer = None
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            event.accept()
 | 
					        for tname in timers:
 | 
				
			||||||
        else:
 | 
					            timer = getattr(self, tname, None)
 | 
				
			||||||
            # Сворачиваем в трей вместо закрытия
 | 
					            if timer and timer.isActive():
 | 
				
			||||||
            self.hide()
 | 
					                timer.stop()
 | 
				
			||||||
            event.ignore()
 | 
					            if timer:
 | 
				
			||||||
 | 
					                timer.deleteLater()
 | 
				
			||||||
 | 
					                setattr(self, tname, None)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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,38 +235,113 @@ 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 ""
 | 
				
			||||||
 | 
					        scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
				
			||||||
 | 
					        # Simple hash: concatenate sorted filenames and hash
 | 
				
			||||||
 | 
					        filenames_str = "".join(sorted([os.path.basename(s) for s in scripts]))
 | 
				
			||||||
 | 
					        return hashlib.md5(filenames_str.encode()).hexdigest()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _load_autoinstall_cache(self):
 | 
				
			||||||
 | 
					        """Load cached autoinstall games if fresh and scripts unchanged."""
 | 
				
			||||||
 | 
					        if self._autoinstall_cache is not None:
 | 
				
			||||||
 | 
					            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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _save_autoinstall_cache(self, games):
 | 
				
			||||||
 | 
					        """Save parsed autoinstall games to cache with scripts signature."""
 | 
				
			||||||
 | 
					        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}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
 | 
				
			||||||
 | 
					        """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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # No cache: Start background thread
 | 
				
			||||||
 | 
					        class AutoinstallWorker(QThread):
 | 
				
			||||||
 | 
					            finished = Signal(list)
 | 
				
			||||||
 | 
					            api: "PortProtonAPI"
 | 
				
			||||||
 | 
					            portproton_location: str | None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            def run(self):
 | 
				
			||||||
 | 
					                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):
 | 
				
			||||||
 | 
					                    self.finished.emit(games)
 | 
				
			||||||
                    return
 | 
					                    return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
					                scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
				
			||||||
                if not scripts:
 | 
					                if not scripts:
 | 
				
			||||||
            callback(games)
 | 
					                    self.finished.emit(games)
 | 
				
			||||||
                    return
 | 
					                    return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        xdg_data_home = os.getenv("XDG_DATA_HOME",
 | 
					                xdg_data_home = os.getenv(
 | 
				
			||||||
                                os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
					                    "XDG_DATA_HOME",
 | 
				
			||||||
        base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
 | 
					                    os.path.join(os.path.expanduser("~"), ".local", "share"),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                base_autoinstall_dir = os.path.join(
 | 
				
			||||||
 | 
					                    xdg_data_home, "PortProtonQt", "custom_data", "autoinstall"
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
                os.makedirs(base_autoinstall_dir, exist_ok=True)
 | 
					                os.makedirs(base_autoinstall_dir, exist_ok=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                for script_path in scripts:
 | 
					                for script_path in scripts:
 | 
				
			||||||
            display_name, exe_name = self.parse_autoinstall_script(script_path)
 | 
					                    display_name, exe_name = self.api.parse_autoinstall_script(script_path)
 | 
				
			||||||
                    script_name = os.path.splitext(os.path.basename(script_path))[0]
 | 
					                    script_name = os.path.splitext(os.path.basename(script_path))[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    if not (display_name and exe_name):
 | 
					                    if not (display_name and exe_name):
 | 
				
			||||||
                        continue
 | 
					                        continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            exe_name = os.path.splitext(exe_name)[0]  # Без .exe
 | 
					                    exe_name = os.path.splitext(exe_name)[0]
 | 
				
			||||||
                    user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
 | 
					                    user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
 | 
				
			||||||
                    os.makedirs(user_game_folder, exist_ok=True)
 | 
					                    os.makedirs(user_game_folder, exist_ok=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Поиск обложки
 | 
					                    # Find cover
 | 
				
			||||||
                    cover_path = ""
 | 
					                    cover_path = ""
 | 
				
			||||||
            user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set()
 | 
					                    user_files = (
 | 
				
			||||||
 | 
					                        set(os.listdir(user_game_folder))
 | 
				
			||||||
 | 
					                        if os.path.exists(user_game_folder)
 | 
				
			||||||
 | 
					                        else set()
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
                    for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
 | 
					                    for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
 | 
				
			||||||
                        candidate = f"cover{ext}"
 | 
					                        candidate = f"cover{ext}"
 | 
				
			||||||
                        if candidate in user_files:
 | 
					                        if candidate in user_files:
 | 
				
			||||||
@@ -272,26 +351,23 @@ class PortProtonAPI:
 | 
				
			|||||||
                    if not cover_path:
 | 
					                    if not cover_path:
 | 
				
			||||||
                        logger.debug(f"No local cover found for autoinstall {exe_name}")
 | 
					                        logger.debug(f"No local cover found for autoinstall {exe_name}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Формируем кортеж игры (добавлен exe_name в конец)
 | 
					 | 
				
			||||||
                    game_tuple = (
 | 
					                    game_tuple = (
 | 
				
			||||||
                display_name,  # name
 | 
					                        display_name, "", cover_path, "", f"autoinstall:{script_name}",
 | 
				
			||||||
                "",  # description
 | 
					                        "", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
 | 
				
			||||||
                cover_path,  # cover
 | 
					 | 
				
			||||||
                "",  # appid
 | 
					 | 
				
			||||||
                f"autoinstall:{script_name}",  # exec_line
 | 
					 | 
				
			||||||
                "",  # controller_support
 | 
					 | 
				
			||||||
                "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)
 | 
					                    games.append(game_tuple)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        callback(games)
 | 
					                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."""
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user