forked from Boria138/PortProtonQt
		
	Compare commits
	
		
			16 Commits
		
	
	
		
			438e9737ea
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						0231073b19
	
				 | 
					
					
						|||
| 
						
						
							
						
						dec24429f5
	
				 | 
					
					
						|||
| 
						
						
							
						
						4a758f3b3c
	
				 | 
					
					
						|||
| 
						
						
							
						
						0853dd1579
	
				 | 
					
					
						|||
| 
						
						
							
						
						bbb87c0455
	
				 | 
					
					
						|||
| 
						
						
							
						
						b32a71a125
	
				 | 
					
					
						|||
| 
						 | 
					bddf9f850a | ||
| 
						 | 
					a9c3cfa167 | ||
| 
						
						
							
						
						7675bc4cdc
	
				 | 
					
					
						|||
| 
						
						
							
						
						ffa203f019
	
				 | 
					
					
						|||
| 
						
						
							
						
						3eed25ecee
	
				 | 
					
					
						|||
| 
						
						
							
						
						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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,12 +11,12 @@ 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.8.22
 | 
					    rev: 0.9.5
 | 
				
			||||||
    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.14.0
 | 
					    rev: v0.14.2
 | 
				
			||||||
    hooks:
 | 
					    hooks:
 | 
				
			||||||
      - id: ruff-check
 | 
					      - id: ruff-check
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,17 @@
 | 
				
			|||||||
import sys
 | 
					import sys
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
import subprocess
 | 
					import subprocess
 | 
				
			||||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
 | 
					from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo, QTimer, Qt
 | 
				
			||||||
from PySide6.QtWidgets import QApplication
 | 
					from PySide6.QtWidgets import QApplication
 | 
				
			||||||
from PySide6.QtGui import QIcon
 | 
					from PySide6.QtGui import QIcon
 | 
				
			||||||
 | 
					from PySide6.QtNetwork import QLocalServer, QLocalSocket
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from portprotonqt.main_window import MainWindow
 | 
					from portprotonqt.main_window import MainWindow
 | 
				
			||||||
from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location
 | 
					from portprotonqt.config_utils import (
 | 
				
			||||||
 | 
					    save_fullscreen_config,
 | 
				
			||||||
 | 
					    read_fullscreen_config,
 | 
				
			||||||
 | 
					    get_portproton_start_command
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
from portprotonqt.logger import get_logger, setup_logger
 | 
					from portprotonqt.logger import get_logger, setup_logger
 | 
				
			||||||
from portprotonqt.cli import parse_args
 | 
					from portprotonqt.cli import parse_args
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -16,25 +22,24 @@ __app_version__ = "0.1.8"
 | 
				
			|||||||
def get_version():
 | 
					def get_version():
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        commit = subprocess.check_output(
 | 
					        commit = subprocess.check_output(
 | 
				
			||||||
            ['git', 'rev-parse', '--short', 'HEAD'],
 | 
					            ["git", "rev-parse", "--short", "HEAD"],
 | 
				
			||||||
            stderr=subprocess.DEVNULL
 | 
					            stderr=subprocess.DEVNULL,
 | 
				
			||||||
        ).decode('utf-8').strip()
 | 
					        ).decode("utf-8").strip()
 | 
				
			||||||
        return f"{__app_version__} ({commit})"
 | 
					        return f"{__app_version__} ({commit})"
 | 
				
			||||||
    except (subprocess.CalledProcessError, FileNotFoundError, OSError):
 | 
					    except (subprocess.CalledProcessError, FileNotFoundError, OSError):
 | 
				
			||||||
        return __app_version__
 | 
					        return __app_version__
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def main():
 | 
					def main():
 | 
				
			||||||
    os.environ['PW_CLI'] = '1'
 | 
					    os.environ["PW_CLI"] = "1"
 | 
				
			||||||
    os.environ['PROCESS_LOG'] = '1'
 | 
					    os.environ["PROCESS_LOG"] = "1"
 | 
				
			||||||
    os.environ['START_FROM_STEAM'] = '1'
 | 
					    os.environ["START_FROM_STEAM"] = "1"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    portproton_path = get_portproton_location()
 | 
					    start_sh = get_portproton_start_command()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if portproton_path is None:
 | 
					    if start_sh is None:
 | 
				
			||||||
        return
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh')
 | 
					    subprocess.run(start_sh + ["cli", "--initial"])
 | 
				
			||||||
    subprocess.run([script_path, 'cli', '--initial'])
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    app = QApplication(sys.argv)
 | 
					    app = QApplication(sys.argv)
 | 
				
			||||||
    app.setWindowIcon(QIcon.fromTheme(__app_id__))
 | 
					    app.setWindowIcon(QIcon.fromTheme(__app_id__))
 | 
				
			||||||
@@ -43,41 +48,116 @@ def main():
 | 
				
			|||||||
    app.setApplicationVersion(__app_version__)
 | 
					    app.setApplicationVersion(__app_version__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    args = parse_args()
 | 
					    args = parse_args()
 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Setup logger with specified debug level
 | 
					 | 
				
			||||||
    setup_logger(args.debug_level)
 | 
					    setup_logger(args.debug_level)
 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Reinitialize logger after setup to ensure it uses the new configuration
 | 
					 | 
				
			||||||
    logger = get_logger(__name__)
 | 
					    logger = get_logger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Single-instance logic ---
 | 
				
			||||||
 | 
					    server_name = __app_id__
 | 
				
			||||||
 | 
					    socket = QLocalSocket()
 | 
				
			||||||
 | 
					    socket.connectToServer(server_name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if socket.waitForConnected(200):
 | 
				
			||||||
 | 
					        # Второй экземпляр — передаём команду первому
 | 
				
			||||||
 | 
					        fullscreen = args.fullscreen or read_fullscreen_config()
 | 
				
			||||||
 | 
					        msg = b"show:fullscreen" if fullscreen else b"show"
 | 
				
			||||||
 | 
					        socket.write(msg)
 | 
				
			||||||
 | 
					        socket.flush()
 | 
				
			||||||
 | 
					        socket.waitForBytesWritten(500)
 | 
				
			||||||
 | 
					        socket.disconnectFromServer()
 | 
				
			||||||
 | 
					        logger.info("Restored existing instance from tray")
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Если старый сокет остался — удалить
 | 
				
			||||||
 | 
					    QLocalServer.removeServer(server_name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    local_server = QLocalServer()
 | 
				
			||||||
 | 
					    if not local_server.listen(server_name):
 | 
				
			||||||
 | 
					        logger.warning(f"Failed to start local server: {local_server.errorString()}")
 | 
				
			||||||
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Qt translations ---
 | 
				
			||||||
    system_locale = QLocale.system()
 | 
					    system_locale = QLocale.system()
 | 
				
			||||||
    qt_translator = QTranslator()
 | 
					    qt_translator = QTranslator()
 | 
				
			||||||
    translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
 | 
					    translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
 | 
				
			||||||
    if qt_translator.load(system_locale, "qtbase", "_", translations_path):
 | 
					    if qt_translator.load(system_locale, "qtbase", "_", translations_path):
 | 
				
			||||||
        app.installTranslator(qt_translator)
 | 
					        app.installTranslator(qt_translator)
 | 
				
			||||||
    else:
 | 
					    else:
 | 
				
			||||||
        logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
 | 
					        logger.warning(
 | 
				
			||||||
 | 
					            f"Qt translations for {system_locale.name()} not found in {translations_path}, using English"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Main Window ---
 | 
				
			||||||
    version = get_version()
 | 
					    version = get_version()
 | 
				
			||||||
    window = MainWindow(app_name=__app_name__, version=version)
 | 
					    window = MainWindow(app_name=__app_name__, version=version)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if args.fullscreen:
 | 
					    # --- Handle incoming connections ---
 | 
				
			||||||
        logger.info("Launching in fullscreen mode due to --fullscreen flag")
 | 
					    def handle_new_connection():
 | 
				
			||||||
 | 
					        conn = local_server.nextPendingConnection()
 | 
				
			||||||
 | 
					        if not conn:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if conn.waitForReadyRead(1000):
 | 
				
			||||||
 | 
					            data = conn.readAll().data()
 | 
				
			||||||
 | 
					            msg = bytes(data).decode("utf-8", errors="ignore")
 | 
				
			||||||
 | 
					            logger.info(f"IPC message received: {msg}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            def restore_window():
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    if msg.startswith("show"):
 | 
				
			||||||
 | 
					                        if hasattr(window, "restore_from_tray"):
 | 
				
			||||||
 | 
					                            window.restore_from_tray()  # type: ignore[attr-defined]
 | 
				
			||||||
 | 
					                        else:
 | 
				
			||||||
 | 
					                            window.showNormal()
 | 
				
			||||||
 | 
					                            window.raise_()
 | 
				
			||||||
 | 
					                            window.activateWindow()
 | 
				
			||||||
 | 
					                            window.setWindowState(
 | 
				
			||||||
 | 
					                                window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive
 | 
				
			||||||
 | 
					                            )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        if ":fullscreen" in msg:
 | 
				
			||||||
 | 
					                            logger.info("Switching to fullscreen via IPC")
 | 
				
			||||||
 | 
					                            save_fullscreen_config(True)
 | 
				
			||||||
 | 
					                            window.showFullScreen()
 | 
				
			||||||
 | 
					                        else:
 | 
				
			||||||
 | 
					                            logger.info("Switching to normal window via IPC")
 | 
				
			||||||
 | 
					                            save_fullscreen_config(False)
 | 
				
			||||||
 | 
					                            window.showNormal()
 | 
				
			||||||
 | 
					                except Exception as e:
 | 
				
			||||||
 | 
					                    logger.warning(f"Failed to restore window: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Выполняем в основном потоке
 | 
				
			||||||
 | 
					            QTimer.singleShot(0, restore_window)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        conn.disconnectFromServer()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    local_server.newConnection.connect(handle_new_connection)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Initial fullscreen state ---
 | 
				
			||||||
 | 
					    launch_fullscreen = args.fullscreen or read_fullscreen_config()
 | 
				
			||||||
 | 
					    if launch_fullscreen:
 | 
				
			||||||
 | 
					        logger.info(
 | 
				
			||||||
 | 
					            f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
        save_fullscreen_config(True)
 | 
					        save_fullscreen_config(True)
 | 
				
			||||||
        window.showFullScreen()
 | 
					        window.showFullScreen()
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        logger.info("Launching in normal mode")
 | 
				
			||||||
 | 
					        save_fullscreen_config(False)
 | 
				
			||||||
 | 
					        window.showNormal()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # --- Cleanup ---
 | 
				
			||||||
    def cleanup_on_exit():
 | 
					    def cleanup_on_exit():
 | 
				
			||||||
        nonlocal window
 | 
					        try:
 | 
				
			||||||
        app.aboutToQuit.disconnect()
 | 
					            local_server.close()
 | 
				
			||||||
        if window:
 | 
					            QLocalServer.removeServer(server_name)
 | 
				
			||||||
            window.close()
 | 
					            if window:
 | 
				
			||||||
        app.quit()
 | 
					                window.close()
 | 
				
			||||||
 | 
					        except Exception as e:
 | 
				
			||||||
 | 
					            logger.warning(f"Cleanup error: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    app.aboutToQuit.connect(cleanup_on_exit)
 | 
					    app.aboutToQuit.connect(cleanup_on_exit)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    window.show()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    sys.exit(app.exec())
 | 
					    sys.exit(app.exec())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if __name__ == '__main__':
 | 
					if __name__ == "__main__":
 | 
				
			||||||
    main()
 | 
					    main()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,11 +1,13 @@
 | 
				
			|||||||
import os
 | 
					import os
 | 
				
			||||||
import configparser
 | 
					import configparser
 | 
				
			||||||
import shutil
 | 
					import shutil
 | 
				
			||||||
 | 
					import subprocess
 | 
				
			||||||
from portprotonqt.logger import get_logger
 | 
					from portprotonqt.logger import get_logger
 | 
				
			||||||
 | 
					
 | 
				
			||||||
logger = get_logger(__name__)
 | 
					logger = get_logger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
_portproton_location = None
 | 
					_portproton_location = None
 | 
				
			||||||
 | 
					_portproton_start_sh = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Paths to configuration files
 | 
					# Paths to configuration files
 | 
				
			||||||
CONFIG_FILE = os.path.join(
 | 
					CONFIG_FILE = os.path.join(
 | 
				
			||||||
@@ -101,14 +103,14 @@ def read_file_content(file_path):
 | 
				
			|||||||
        return f.read().strip()
 | 
					        return f.read().strip()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_portproton_location():
 | 
					def get_portproton_location():
 | 
				
			||||||
    """Returns the path to the PortProton directory.
 | 
					    """Возвращает путь к PortProton каталогу (строку) или None."""
 | 
				
			||||||
    Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
 | 
					 | 
				
			||||||
    If the path is invalid, uses the default directory.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    global _portproton_location
 | 
					    global _portproton_location
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if _portproton_location is not None:
 | 
					    if _portproton_location is not None:
 | 
				
			||||||
        return _portproton_location
 | 
					        return _portproton_location
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    location = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if os.path.isfile(PORTPROTON_CONFIG_FILE):
 | 
					    if os.path.isfile(PORTPROTON_CONFIG_FILE):
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
 | 
					            location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
 | 
				
			||||||
@@ -116,19 +118,46 @@ def get_portproton_location():
 | 
				
			|||||||
                _portproton_location = location
 | 
					                _portproton_location = location
 | 
				
			||||||
                logger.info(f"PortProton path from configuration: {location}")
 | 
					                logger.info(f"PortProton path from configuration: {location}")
 | 
				
			||||||
                return _portproton_location
 | 
					                return _portproton_location
 | 
				
			||||||
            logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
 | 
					            logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
 | 
				
			||||||
        except (OSError, PermissionError) as e:
 | 
					        except (OSError, PermissionError) as e:
 | 
				
			||||||
            logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
 | 
					            logger.warning(f"Failed to read PortProton configuration file: {e}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
 | 
					    default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
 | 
				
			||||||
    if os.path.isdir(default_dir):
 | 
					    if os.path.isdir(default_flatpak_dir):
 | 
				
			||||||
        _portproton_location = default_dir
 | 
					        _portproton_location = default_flatpak_dir
 | 
				
			||||||
        logger.info(f"Using flatpak PortProton directory: {default_dir}")
 | 
					        logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}")
 | 
				
			||||||
        return _portproton_location
 | 
					        return _portproton_location
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    logger.warning("PortProton configuration and flatpak directory not found")
 | 
					    logger.warning("PortProton configuration and Flatpak directory not found")
 | 
				
			||||||
    return None
 | 
					    return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def get_portproton_start_command():
 | 
				
			||||||
 | 
					    """Возвращает список команд для запуска PortProton (start.sh или flatpak run)."""
 | 
				
			||||||
 | 
					    portproton_path = get_portproton_location()
 | 
				
			||||||
 | 
					    if not portproton_path:
 | 
				
			||||||
 | 
					        return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        result = subprocess.run(
 | 
				
			||||||
 | 
					            ["flatpak", "list"],
 | 
				
			||||||
 | 
					            capture_output=True,
 | 
				
			||||||
 | 
					            text=True,
 | 
				
			||||||
 | 
					            check=False
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if "ru.linux_gaming.PortProton" in result.stdout:
 | 
				
			||||||
 | 
					            logger.info("Detected Flatpak installation")
 | 
				
			||||||
 | 
					            return ["flatpak", "run", "ru.linux_gaming.PortProton"]
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
 | 
				
			||||||
 | 
					    if os.path.exists(start_sh_path):
 | 
				
			||||||
 | 
					        return [start_sh_path]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    logger.warning("Neither flatpak nor start.sh found for PortProton")
 | 
				
			||||||
 | 
					    return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def parse_desktop_entry(file_path):
 | 
					def parse_desktop_entry(file_path):
 | 
				
			||||||
    """Reads and parses a .desktop file using configparser.
 | 
					    """Reads and parses a .desktop file using configparser.
 | 
				
			||||||
    Returns None if the [Desktop Entry] section is missing.
 | 
					    Returns None if the [Desktop Entry] section is missing.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
 | 
				
			|||||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
 | 
					from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
 | 
				
			||||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
 | 
					from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
 | 
				
			||||||
from portprotonqt.localization import _
 | 
					from portprotonqt.localization import _
 | 
				
			||||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
 | 
					from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders, get_portproton_start_command
 | 
				
			||||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
 | 
					from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
 | 
				
			||||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
 | 
					from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
 | 
				
			||||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
 | 
					from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
 | 
				
			||||||
@@ -406,16 +406,7 @@ class ContextMenuManager:
 | 
				
			|||||||
                )
 | 
					                )
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
            # Construct EGS launch command
 | 
					            # Construct EGS launch command
 | 
				
			||||||
            wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
					            wrapper = get_portproton_start_command()
 | 
				
			||||||
            start_sh_path = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
					 | 
				
			||||||
            if self.portproton_location and ".var" not in self.portproton_location:
 | 
					 | 
				
			||||||
                wrapper = start_sh_path
 | 
					 | 
				
			||||||
                if not os.path.exists(start_sh_path):
 | 
					 | 
				
			||||||
                    self.signals.show_warning_dialog.emit(
 | 
					 | 
				
			||||||
                        _("Error"),
 | 
					 | 
				
			||||||
                        _("start.sh not found at {path}").format(path=start_sh_path)
 | 
					 | 
				
			||||||
                    )
 | 
					 | 
				
			||||||
                    return
 | 
					 | 
				
			||||||
            exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
 | 
					            exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
 | 
					            exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,12 +2,11 @@ import os
 | 
				
			|||||||
import tempfile
 | 
					import tempfile
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
from typing import cast, TYPE_CHECKING
 | 
					from typing import cast, TYPE_CHECKING
 | 
				
			||||||
from PySide6.QtGui import QPixmap, QIcon, QTextCursor
 | 
					from PySide6.QtGui import QPixmap, QIcon, QTextCursor, QColor
 | 
				
			||||||
from PySide6.QtWidgets import (
 | 
					from PySide6.QtWidgets import (
 | 
				
			||||||
    QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller,
 | 
					    QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller,
 | 
				
			||||||
    QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget
 | 
					    QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget, QComboBox
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					 | 
				
			||||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
 | 
					from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
 | 
				
			||||||
from icoextract import IconExtractor, IconExtractorError
 | 
					from icoextract import IconExtractor, IconExtractorError
 | 
				
			||||||
from PIL import Image
 | 
					from PIL import Image
 | 
				
			||||||
@@ -1674,3 +1673,555 @@ class WinetricksDialog(QDialog):
 | 
				
			|||||||
        if self.input_manager:
 | 
					        if self.input_manager:
 | 
				
			||||||
            self.input_manager.disable_winetricks_mode()
 | 
					            self.input_manager.disable_winetricks_mode()
 | 
				
			||||||
        super().reject()
 | 
					        super().reject()
 | 
				
			||||||
 | 
					class ExeSettingsDialog(QDialog):
 | 
				
			||||||
 | 
					    def __init__(self, parent=None, theme=None, exe_path=None):
 | 
				
			||||||
 | 
					        super().__init__(parent)
 | 
				
			||||||
 | 
					        self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
 | 
				
			||||||
 | 
					        self.exe_path = exe_path
 | 
				
			||||||
 | 
					        if not self.exe_path:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        self.portproton_path = get_portproton_location()
 | 
				
			||||||
 | 
					        if self.portproton_path is None:
 | 
				
			||||||
 | 
					            logger.error("PortProton location not found")
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        base_path = os.path.join(self.portproton_path, "data")
 | 
				
			||||||
 | 
					        self.start_sh = [os.path.join(base_path, "scripts", "start.sh")]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.current_settings = {}
 | 
				
			||||||
 | 
					        self.value_widgets = {}
 | 
				
			||||||
 | 
					        self.original_values = {}
 | 
				
			||||||
 | 
					        self.advanced_widgets = {}
 | 
				
			||||||
 | 
					        self.original_display_values = {}
 | 
				
			||||||
 | 
					        self.available_keys = set()
 | 
				
			||||||
 | 
					        self.blocked_keys = set()
 | 
				
			||||||
 | 
					        self.numa_nodes = {}
 | 
				
			||||||
 | 
					        self.is_amd = False
 | 
				
			||||||
 | 
					        self.locale_options = []
 | 
				
			||||||
 | 
					        self.logical_core_options = []
 | 
				
			||||||
 | 
					        self.amd_vulkan_drivers = []
 | 
				
			||||||
 | 
					        self.branch_name = _("Unknown")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.setWindowTitle(_("Exe Settings"))
 | 
				
			||||||
 | 
					        self.setModal(True)
 | 
				
			||||||
 | 
					        self.resize(900, 600)
 | 
				
			||||||
 | 
					        self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.init_toggle_settings()
 | 
				
			||||||
 | 
					        self.setup_ui()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Find input_manager and main_window
 | 
				
			||||||
 | 
					        self.input_manager = None
 | 
				
			||||||
 | 
					        self.main_window = None
 | 
				
			||||||
 | 
					        parent = self.parent()
 | 
				
			||||||
 | 
					        while parent:
 | 
				
			||||||
 | 
					            if hasattr(parent, 'input_manager'):
 | 
				
			||||||
 | 
					                self.input_manager = cast("MainWindow", parent).input_manager
 | 
				
			||||||
 | 
					                self.main_window = parent
 | 
				
			||||||
 | 
					            parent = parent.parent()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.current_theme_name = read_theme_from_config()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Load current settings (includes list-db)
 | 
				
			||||||
 | 
					        self.load_current_settings()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _get_process_args(self, subcommand_args):
 | 
				
			||||||
 | 
					        """Get the full arguments for QProcess.start, handling flatpak separator."""
 | 
				
			||||||
 | 
					        if self.start_sh[0] == "flatpak":
 | 
				
			||||||
 | 
					            return self.start_sh[1:] + ["--"] + subcommand_args
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            return self.start_sh + subcommand_args
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def init_toggle_settings(self):
 | 
				
			||||||
 | 
					        """Initialize predefined toggle settings with descriptions."""
 | 
				
			||||||
 | 
					        self.toggle_settings = {
 | 
				
			||||||
 | 
					            'PW_MANGOHUD': _("Using FPS and system load monitoring (Turns on and off by the key combination - right Shift + F12)"),
 | 
				
			||||||
 | 
					            'PW_MANGOHUD_USER_CONF': _("Forced use of MANGOHUD system settings (GOverlay, etc.)"),
 | 
				
			||||||
 | 
					            'PW_VKBASALT': _("Enable vkBasalt by default to improve graphics in games running on Vulkan. (The HOME hotkey disables vkbasalt)"),
 | 
				
			||||||
 | 
					            'PW_VKBASALT_USER_CONF': _("Forced use of VKBASALT system settings (GOverlay, etc.)"),
 | 
				
			||||||
 | 
					            'PW_DGVOODOO2': _("Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) on all 3D API."),
 | 
				
			||||||
 | 
					            'PW_GAMESCOPE': _("Super + F : Toggle fullscreen\nSuper + N : Toggle nearest neighbour filtering\nSuper + U : Toggle FSR upscaling\nSuper + Y : Toggle NIS upscaling\nSuper + I : Increase FSR sharpness by 1\nSuper + O : Decrease FSR sharpness by 1\nSuper + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\nSuper + G : Toggle keyboard grab\nSuper + C : Update clipboard"),
 | 
				
			||||||
 | 
					            'PW_USE_ESYNC': _("Enable in-process synchronization primitives based on eventfd."),
 | 
				
			||||||
 | 
					            'PW_USE_FSYNC': _("Enable futex-based in-process synchronization primitives."),
 | 
				
			||||||
 | 
					            'PW_USE_NTSYNC': _("Enable in-process synchronization via the Linux ntsync driver."),
 | 
				
			||||||
 | 
					            'PW_USE_RAY_TRACING': _("Enable vkd3d support - Ray Tracing"),
 | 
				
			||||||
 | 
					            'PW_USE_NVAPI_AND_DLSS': _("Enable DLSS on supported NVIDIA graphics cards"),
 | 
				
			||||||
 | 
					            'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
 | 
				
			||||||
 | 
					            'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
 | 
				
			||||||
 | 
					            'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
 | 
				
			||||||
 | 
					            'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"),
 | 
				
			||||||
 | 
					            'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"),
 | 
				
			||||||
 | 
					            'PW_USE_TERMINAL': _("Run the application in a terminal"),
 | 
				
			||||||
 | 
					            'PW_GUI_DISABLED_CS': _("Disable startup mode and WINE version selector window"),
 | 
				
			||||||
 | 
					            'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"),
 | 
				
			||||||
 | 
					            'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"),
 | 
				
			||||||
 | 
					            'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"),
 | 
				
			||||||
 | 
					            'PW_REDUCE_PULSE_LATENCY': _("Reduce PulseAudio latency to fix intermittent sound"),
 | 
				
			||||||
 | 
					            'PW_USE_US_LAYOUT': _("Force US keyboard layout"),
 | 
				
			||||||
 | 
					            'PW_USE_GSTREAMER': _("Use GStreamer for in-game clips (WMF support)"),
 | 
				
			||||||
 | 
					            'PW_USE_SHADER_CACHE': _("Use WINE shader caching"),
 | 
				
			||||||
 | 
					            'PW_USE_WINE_DXGI': _("Force use of built-in DXGI library"),
 | 
				
			||||||
 | 
					            'PW_USE_EAC_AND_BE': _("Enable Easy Anti-Cheat and BattlEye runtimes"),
 | 
				
			||||||
 | 
					            'PW_USE_SYSTEM_VK_LAYERS': _("Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"),
 | 
				
			||||||
 | 
					            'PW_USE_OBS_VKCAPTURE': _("Enable OBS Studio capture via obs-vkcapture"),
 | 
				
			||||||
 | 
					            'PW_DISABLE_COMPOSITING': _("Disable desktop compositing for performance"),
 | 
				
			||||||
 | 
					            'PW_USE_RUNTIME': _("Use container launch mode (recommended default)"),
 | 
				
			||||||
 | 
					            'PW_DINPUT_PROTOCOL': _("Force DirectInput protocol instead of XInput"),
 | 
				
			||||||
 | 
					            'PW_USE_NATIVE_WAYLAND': _("Enable experimental native Wayland support"),
 | 
				
			||||||
 | 
					            'PW_USE_DXVK_HDR': _("Enable HDR settings under native Wayland"),
 | 
				
			||||||
 | 
					            'PW_USE_GALLIUM_ZINK': _("Use Gallium Zink (OpenGL via Vulkan)"),
 | 
				
			||||||
 | 
					            'PW_USE_GALLIUM_NINE': _("Use Gallium Nine (native DirectX 9 for Mesa)"),
 | 
				
			||||||
 | 
					            'PW_USE_WINED3D_VULKAN': _("Use WineD3D Vulkan backend (Damavand)"),
 | 
				
			||||||
 | 
					            'PW_USE_SUPPLIED_DXVK_VKD3D': _("Use bundled dxvk/vkd3d from Wine/Proton"),
 | 
				
			||||||
 | 
					            'PW_USE_SAREK_ASYNC': _("Use async dxvk-sarek (experimental)")
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def setup_ui(self):
 | 
				
			||||||
 | 
					        """Set up the user interface."""
 | 
				
			||||||
 | 
					        self.main_layout = QVBoxLayout(self)
 | 
				
			||||||
 | 
					        self.main_layout.setContentsMargins(10, 10, 10, 10)
 | 
				
			||||||
 | 
					        self.main_layout.setSpacing(10)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Tab widget
 | 
				
			||||||
 | 
					        self.tab_widget = QTabWidget()
 | 
				
			||||||
 | 
					        self.main_tab = QWidget()
 | 
				
			||||||
 | 
					        self.main_tab_layout = QVBoxLayout(self.main_tab)
 | 
				
			||||||
 | 
					        self.advanced_tab = QWidget()
 | 
				
			||||||
 | 
					        self.advanced_tab_layout = QVBoxLayout(self.advanced_tab)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.tab_widget.addTab(self.main_tab, _("Main"))
 | 
				
			||||||
 | 
					        self.tab_widget.addTab(self.advanced_tab, _("Advanced"))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Таблица настроек
 | 
				
			||||||
 | 
					        self.settings_table = QTableWidget()
 | 
				
			||||||
 | 
					        self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
				
			||||||
 | 
					        self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
 | 
				
			||||||
 | 
					        self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
 | 
				
			||||||
 | 
					        self.settings_table.setColumnCount(3)
 | 
				
			||||||
 | 
					        self.settings_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
 | 
				
			||||||
 | 
					        self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
 | 
				
			||||||
 | 
					        self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
 | 
				
			||||||
 | 
					        self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
 | 
				
			||||||
 | 
					        self.settings_table.horizontalHeader().resizeSection(1, 100)
 | 
				
			||||||
 | 
					        self.settings_table.setWordWrap(True)
 | 
				
			||||||
 | 
					        self.settings_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
 | 
				
			||||||
 | 
					        self.settings_table.setTextElideMode(Qt.TextElideMode.ElideNone)
 | 
				
			||||||
 | 
					        self.settings_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
 | 
				
			||||||
 | 
					        self.main_tab_layout.addWidget(self.settings_table)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Таблица Advanced
 | 
				
			||||||
 | 
					        self.advanced_table = QTableWidget()
 | 
				
			||||||
 | 
					        self.advanced_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
				
			||||||
 | 
					        self.advanced_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
 | 
				
			||||||
 | 
					        self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
 | 
				
			||||||
 | 
					        self.advanced_table.setColumnCount(3)
 | 
				
			||||||
 | 
					        self.advanced_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
 | 
				
			||||||
 | 
					        self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
 | 
				
			||||||
 | 
					        self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
 | 
				
			||||||
 | 
					        self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
 | 
				
			||||||
 | 
					        self.advanced_table.horizontalHeader().resizeSection(1, 200)
 | 
				
			||||||
 | 
					        self.advanced_table.setWordWrap(True)
 | 
				
			||||||
 | 
					        self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
 | 
				
			||||||
 | 
					        self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
 | 
				
			||||||
 | 
					        self.advanced_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
 | 
				
			||||||
 | 
					        self.advanced_tab_layout.addWidget(self.advanced_table)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.main_layout.addWidget(self.tab_widget)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Кнопки
 | 
				
			||||||
 | 
					        button_layout = QHBoxLayout()
 | 
				
			||||||
 | 
					        self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply"))
 | 
				
			||||||
 | 
					        self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel"))
 | 
				
			||||||
 | 
					        self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
				
			||||||
 | 
					        self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
				
			||||||
 | 
					        button_layout.addWidget(self.apply_button)
 | 
				
			||||||
 | 
					        button_layout.addWidget(self.cancel_button)
 | 
				
			||||||
 | 
					        self.main_layout.addLayout(button_layout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.apply_button.clicked.connect(self.apply_changes)
 | 
				
			||||||
 | 
					        self.cancel_button.clicked.connect(self.reject)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def load_current_settings(self):
 | 
				
			||||||
 | 
					        """Load available toggles first, then current settings."""
 | 
				
			||||||
 | 
					        process = QProcess(self)
 | 
				
			||||||
 | 
					        process.finished.connect(self.on_list_db_finished)
 | 
				
			||||||
 | 
					        process.start(self.start_sh[0], ["cli", "--list-db"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def on_list_db_finished(self, exit_code, exit_status):
 | 
				
			||||||
 | 
					        """Handle --list-db output and extract available keys and system info."""
 | 
				
			||||||
 | 
					        process = cast(QProcess, self.sender())
 | 
				
			||||||
 | 
					        self.available_keys = set()
 | 
				
			||||||
 | 
					        self.blocked_keys = set()
 | 
				
			||||||
 | 
					        if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
 | 
				
			||||||
 | 
					            output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
 | 
				
			||||||
 | 
					            lines = output.splitlines()
 | 
				
			||||||
 | 
					            self.numa_nodes = {}
 | 
				
			||||||
 | 
					            self.is_amd = False
 | 
				
			||||||
 | 
					            self.logical_core_options = []
 | 
				
			||||||
 | 
					            self.locale_options = []
 | 
				
			||||||
 | 
					            self.amd_vulkan_drivers = []
 | 
				
			||||||
 | 
					            for line in lines:
 | 
				
			||||||
 | 
					                line_stripped = line.strip()
 | 
				
			||||||
 | 
					                if not line_stripped:
 | 
				
			||||||
 | 
					                    continue
 | 
				
			||||||
 | 
					                if re.match(r'^[A-Z_0-9]+=[^=]+$', line_stripped) and not line_stripped.startswith('PW_'):
 | 
				
			||||||
 | 
					                    # System info
 | 
				
			||||||
 | 
					                    k, v = line_stripped.split('=', 1)
 | 
				
			||||||
 | 
					                    if k.startswith('NUMA_NODE_'):
 | 
				
			||||||
 | 
					                        node_id = k[10:]
 | 
				
			||||||
 | 
					                        self.numa_nodes[node_id] = v
 | 
				
			||||||
 | 
					                    elif k == 'IS_AMD':
 | 
				
			||||||
 | 
					                        self.is_amd = v.lower() == 'true'
 | 
				
			||||||
 | 
					                    elif k == 'LOGICAL_CORE_OPTIONS':
 | 
				
			||||||
 | 
					                        self.logical_core_options = v.split('!') if v else []
 | 
				
			||||||
 | 
					                    elif k == 'LOCALE_LIST':
 | 
				
			||||||
 | 
					                        self.locale_options = v.split('!') if v else []
 | 
				
			||||||
 | 
					                    elif k == 'AMD_VULKAN_DRIVER_LIST':
 | 
				
			||||||
 | 
					                        self.amd_vulkan_drivers = v.split('!') if v else []
 | 
				
			||||||
 | 
					                    continue
 | 
				
			||||||
 | 
					                if line_stripped.startswith('PW_'):
 | 
				
			||||||
 | 
					                    parts = line_stripped.split(maxsplit=1)
 | 
				
			||||||
 | 
					                    key = parts[0]
 | 
				
			||||||
 | 
					                    self.available_keys.add(key)
 | 
				
			||||||
 | 
					                    if len(parts) > 1 and 'blocked' in parts[1]:
 | 
				
			||||||
 | 
					                        self.blocked_keys.add(key)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Показываем только пересечение
 | 
				
			||||||
 | 
					            self.available_keys &= set(self.toggle_settings.keys())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Загружаем текущие настройки
 | 
				
			||||||
 | 
					        process = QProcess(self)
 | 
				
			||||||
 | 
					        process.finished.connect(self.on_show_ppdb_finished)
 | 
				
			||||||
 | 
					        process.start(self.start_sh[0], ["cli", "--show-ppdb", f"{self.exe_path}.ppdb"])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def on_show_ppdb_finished(self, exit_code, exit_status):
 | 
				
			||||||
 | 
					        """Handle --show-ppdb output."""
 | 
				
			||||||
 | 
					        process = cast(QProcess, self.sender())
 | 
				
			||||||
 | 
					        if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
 | 
				
			||||||
 | 
					            # Fallback to defaults if load fails
 | 
				
			||||||
 | 
					            for key in self.toggle_settings:
 | 
				
			||||||
 | 
					                self.current_settings[key] = '0'
 | 
				
			||||||
 | 
					            for adv_key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS',
 | 
				
			||||||
 | 
					                            'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE',
 | 
				
			||||||
 | 
					                            'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT',
 | 
				
			||||||
 | 
					                            'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE',
 | 
				
			||||||
 | 
					                            'PW_CPU_NUMA_NODE_INDEX']:
 | 
				
			||||||
 | 
					                self.current_settings[adv_key] = 'disabled' if 'TOPOLOGY' in adv_key or 'SELECT' in adv_key or 'MODE' in adv_key or 'LEVEL' in adv_key or 'GL_VERSION' in adv_key or 'NUMA' in adv_key else ''
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore').strip()
 | 
				
			||||||
 | 
					            self.current_settings = {}
 | 
				
			||||||
 | 
					            for line in output.split('\n'):
 | 
				
			||||||
 | 
					                line_stripped = line.strip()
 | 
				
			||||||
 | 
					                if '=' in line_stripped:
 | 
				
			||||||
 | 
					                    # Parse all KEY=VALUE lines, not just specific prefixes, to catch more
 | 
				
			||||||
 | 
					                    try:
 | 
				
			||||||
 | 
					                        key, val = line_stripped.split('=', 1)
 | 
				
			||||||
 | 
					                        if key in self.toggle_settings or key in ['PW_WINDOWS_VER', 'WINEDLLOVERRIDES', 'LAUNCH_PARAMETERS',
 | 
				
			||||||
 | 
					                                                                 'PW_WINE_CPU_TOPOLOGY', 'PW_MESA_GL_VERSION_OVERRIDE',
 | 
				
			||||||
 | 
					                                                                 'PW_VKD3D_FEATURE_LEVEL', 'PW_LOCALE_SELECT',
 | 
				
			||||||
 | 
					                                                                 'PW_MESA_VK_WSI_PRESENT_MODE', 'PW_AMD_VULKAN_USE',
 | 
				
			||||||
 | 
					                                                                 'PW_CPU_NUMA_NODE_INDEX', 'PW_TASKSET_SLR']:
 | 
				
			||||||
 | 
					                            self.current_settings[key] = val
 | 
				
			||||||
 | 
					                    except ValueError:
 | 
				
			||||||
 | 
					                        continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Force blocked settings to '0'
 | 
				
			||||||
 | 
					        for key in self.blocked_keys:
 | 
				
			||||||
 | 
					            self.current_settings[key] = '0'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.original_values = self.current_settings.copy()
 | 
				
			||||||
 | 
					        for key in set(self.toggle_settings.keys()):
 | 
				
			||||||
 | 
					            self.original_values.setdefault(key, '0')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.populate_table()
 | 
				
			||||||
 | 
					        self.populate_advanced()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def populate_table(self):
 | 
				
			||||||
 | 
					        """Populate the table with settings that are available in both lists."""
 | 
				
			||||||
 | 
					        self.settings_table.setRowCount(0)
 | 
				
			||||||
 | 
					        self.value_widgets.clear()
 | 
				
			||||||
 | 
					        self.settings_table.verticalHeader().setVisible(False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        visible_keys = sorted(self.available_keys) if self.available_keys else sorted(self.toggle_settings.keys())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for toggle in visible_keys:
 | 
				
			||||||
 | 
					            description = self.toggle_settings.get(toggle)
 | 
				
			||||||
 | 
					            if not description:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            row = self.settings_table.rowCount()
 | 
				
			||||||
 | 
					            self.settings_table.insertRow(row)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            name_item = QTableWidgetItem(toggle)
 | 
				
			||||||
 | 
					            name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            current_val = self.current_settings.get(toggle, '0')
 | 
				
			||||||
 | 
					            is_blocked = toggle in self.blocked_keys
 | 
				
			||||||
 | 
					            checkbox = QTableWidgetItem()
 | 
				
			||||||
 | 
					            checkbox.setFlags(checkbox.flags() | Qt.ItemFlag.ItemIsUserCheckable)
 | 
				
			||||||
 | 
					            check_state = Qt.CheckState.Checked if current_val == '1' and not is_blocked else Qt.CheckState.Unchecked
 | 
				
			||||||
 | 
					            checkbox.setCheckState(check_state)
 | 
				
			||||||
 | 
					            checkbox.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
				
			||||||
 | 
					            if is_blocked:
 | 
				
			||||||
 | 
					                checkbox.setFlags(checkbox.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
 | 
				
			||||||
 | 
					                checkbox.setBackground(QColor(240, 240, 240))
 | 
				
			||||||
 | 
					                name_item.setForeground(QColor(128, 128, 128))
 | 
				
			||||||
 | 
					            self.settings_table.setItem(row, 1, checkbox)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            desc_item = QTableWidgetItem(description)
 | 
				
			||||||
 | 
					            desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
 | 
				
			||||||
 | 
					            desc_item.setToolTip(description)
 | 
				
			||||||
 | 
					            desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
 | 
				
			||||||
 | 
					            if is_blocked:
 | 
				
			||||||
 | 
					                desc_item.setForeground(QColor(128, 128, 128))
 | 
				
			||||||
 | 
					            self.settings_table.setItem(row, 2, desc_item)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            self.settings_table.setItem(row, 0, name_item)
 | 
				
			||||||
 | 
					            self.value_widgets[(row, 1)] = checkbox
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.settings_table.resizeRowsToContents()
 | 
				
			||||||
 | 
					        if self.settings_table.rowCount() > 0:
 | 
				
			||||||
 | 
					            self.settings_table.setCurrentCell(0, 0)
 | 
				
			||||||
 | 
					            self.settings_table.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def populate_advanced(self):
 | 
				
			||||||
 | 
					        """Populate the advanced tab with table format."""
 | 
				
			||||||
 | 
					        self.advanced_table.setRowCount(0)
 | 
				
			||||||
 | 
					        self.advanced_widgets.clear()
 | 
				
			||||||
 | 
					        self.original_display_values = {}
 | 
				
			||||||
 | 
					        self.advanced_table.verticalHeader().setVisible(False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        current = self.current_settings
 | 
				
			||||||
 | 
					        disabled_text = _('disabled')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Define advanced settings configuration
 | 
				
			||||||
 | 
					        advanced_settings = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 1. Windows version
 | 
				
			||||||
 | 
					        advanced_settings.append({
 | 
				
			||||||
 | 
					            'key': 'PW_WINDOWS_VER',
 | 
				
			||||||
 | 
					            'name': _("Windows version"),
 | 
				
			||||||
 | 
					            'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"),
 | 
				
			||||||
 | 
					            'type': 'combo',
 | 
				
			||||||
 | 
					            'options': ['11', '10', '7', 'XP'],
 | 
				
			||||||
 | 
					            'default': '10'
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 2. Forced to use/disable libraries
 | 
				
			||||||
 | 
					        advanced_settings.append({
 | 
				
			||||||
 | 
					            'key': 'WINEDLLOVERRIDES',
 | 
				
			||||||
 | 
					            'name': _("DLL Overrides"),
 | 
				
			||||||
 | 
					            'description': _("Forced to use/disable the library only for the given application.\n\nA brief instruction:\n* libraries are written WITHOUT the .dll file extension\n* libraries are separated by semicolons - ;\n* library=n - use the WINDOWS (third-party) library\n* library=b - use WINE (built-in) library\n* library=n,b - use WINDOWS library and then WINE\n* library=b,n - use WINE library and then WINDOWS\n* library= - disable the use of this library\n\nExample: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"),
 | 
				
			||||||
 | 
					            'type': 'text',
 | 
				
			||||||
 | 
					            'default': ''
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 3. Launch arguments
 | 
				
			||||||
 | 
					        advanced_settings.append({
 | 
				
			||||||
 | 
					            'key': 'LAUNCH_PARAMETERS',
 | 
				
			||||||
 | 
					            'name': _("Launch Arguments"),
 | 
				
			||||||
 | 
					            'description': _("Adding an argument after the .exe file, just like you would add an argument in a shortcut on a WINDOWS system.\n\nExample: -dx11 -skipintro 1"),
 | 
				
			||||||
 | 
					            'type': 'text',
 | 
				
			||||||
 | 
					            'default': ''
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 4. CPU cores limit
 | 
				
			||||||
 | 
					        advanced_settings.append({
 | 
				
			||||||
 | 
					            'key': 'PW_WINE_CPU_TOPOLOGY',
 | 
				
			||||||
 | 
					            'name': _("CPU Cores Limit"),
 | 
				
			||||||
 | 
					            'description': _("Limiting the number of CPU cores is useful for Unity games (It is recommended to set the value equal to 8)"),
 | 
				
			||||||
 | 
					            'type': 'combo',
 | 
				
			||||||
 | 
					            'options': [disabled_text] + self.logical_core_options,
 | 
				
			||||||
 | 
					            'default': disabled_text
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 5. OpenGL version
 | 
				
			||||||
 | 
					        advanced_settings.append({
 | 
				
			||||||
 | 
					            'key': 'PW_MESA_GL_VERSION_OVERRIDE',
 | 
				
			||||||
 | 
					            'name': _("OpenGL Version"),
 | 
				
			||||||
 | 
					            'description': _("You can select the required OpenGL version, some games require a forced Compatibility Profile (COMP)."),
 | 
				
			||||||
 | 
					            'type': 'combo',
 | 
				
			||||||
 | 
					            'options': [disabled_text, '4.6COMPAT', '4.5COMPAT', '4.3COMPAT', '4.1COMPAT', '3.3COMPAT', '3.2COMPAT'],
 | 
				
			||||||
 | 
					            'default': disabled_text
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 6. VKD3D feature level
 | 
				
			||||||
 | 
					        advanced_settings.append({
 | 
				
			||||||
 | 
					            'key': 'PW_VKD3D_FEATURE_LEVEL',
 | 
				
			||||||
 | 
					            'name': _("VKD3D Feature Level"),
 | 
				
			||||||
 | 
					            'description': _("You can set a forced feature level VKD3D for games on DirectX12"),
 | 
				
			||||||
 | 
					            'type': 'combo',
 | 
				
			||||||
 | 
					            'options': [disabled_text, '12_2', '12_1', '12_0', '11_1', '11_0'],
 | 
				
			||||||
 | 
					            'default': disabled_text
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 7. Locale
 | 
				
			||||||
 | 
					        advanced_settings.append({
 | 
				
			||||||
 | 
					            'key': 'PW_LOCALE_SELECT',
 | 
				
			||||||
 | 
					            'name': _("Locale"),
 | 
				
			||||||
 | 
					            'description': _("Force certain locale for an app. Fixes encoding issues in legacy software"),
 | 
				
			||||||
 | 
					            'type': 'combo',
 | 
				
			||||||
 | 
					            'options': [disabled_text] + self.locale_options,
 | 
				
			||||||
 | 
					            'default': disabled_text
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 8. Present mode
 | 
				
			||||||
 | 
					        advanced_settings.append({
 | 
				
			||||||
 | 
					            'key': 'PW_MESA_VK_WSI_PRESENT_MODE',
 | 
				
			||||||
 | 
					            'name': _("Window Mode"),
 | 
				
			||||||
 | 
					            'description': _("Window mode (for Vulkan and OpenGL):\nfifo - First in, first out. Limits the frame rate + no tearing. (VSync)\nimmediate - Unlimited frame rate + tearing.\nmailbox - Triple buffering. Unlimited frame rate + no tearing.\nrelaxed - Same as fifo but allows tearing when below the monitors refresh rate."),
 | 
				
			||||||
 | 
					            'type': 'combo',
 | 
				
			||||||
 | 
					            'options': [disabled_text, 'fifo', 'immediate', 'mailbox', 'relaxed'],
 | 
				
			||||||
 | 
					            'default': disabled_text
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 9. AMD Vulkan (always show, block if not applicable)
 | 
				
			||||||
 | 
					        amd_options = [disabled_text] + self.amd_vulkan_drivers if self.is_amd and self.amd_vulkan_drivers else [disabled_text]
 | 
				
			||||||
 | 
					        advanced_settings.append({
 | 
				
			||||||
 | 
					            'key': 'PW_AMD_VULKAN_USE',
 | 
				
			||||||
 | 
					            'name': _("AMD Vulkan Driver"),
 | 
				
			||||||
 | 
					            'description': _("Select needed AMD vulkan implementation. Choosing which implementation of vulkan will be used to run the game"),
 | 
				
			||||||
 | 
					            'type': 'combo',
 | 
				
			||||||
 | 
					            'options': amd_options,
 | 
				
			||||||
 | 
					            'default': disabled_text
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # 10. NUMA node (always show if numa_nodes exist, block if <=1)
 | 
				
			||||||
 | 
					        numa_ids = sorted(self.numa_nodes.keys())
 | 
				
			||||||
 | 
					        numa_options = [disabled_text] + numa_ids if len(numa_ids) > 1 else [disabled_text]
 | 
				
			||||||
 | 
					        advanced_settings.append({
 | 
				
			||||||
 | 
					            'key': 'PW_CPU_NUMA_NODE_INDEX',
 | 
				
			||||||
 | 
					            'name': _("NUMA Node"),
 | 
				
			||||||
 | 
					            'description': _("NUMA node for CPU affinity. In multi-core systems, CPUs are split into NUMA nodes, each with its own local memory and cores. Binding a game to a single node reduces memory-access latency and limits costly core-to-core switches."),
 | 
				
			||||||
 | 
					            'type': 'combo',
 | 
				
			||||||
 | 
					            'options': numa_options,
 | 
				
			||||||
 | 
					            'default': disabled_text
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Populate table
 | 
				
			||||||
 | 
					        for setting in advanced_settings:
 | 
				
			||||||
 | 
					            row = self.advanced_table.rowCount()
 | 
				
			||||||
 | 
					            self.advanced_table.insertRow(row)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Name column
 | 
				
			||||||
 | 
					            name_item = QTableWidgetItem(setting['name'])
 | 
				
			||||||
 | 
					            name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
 | 
				
			||||||
 | 
					            self.advanced_table.setItem(row, 0, name_item)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Value column (widget)
 | 
				
			||||||
 | 
					            if setting['type'] == 'combo':
 | 
				
			||||||
 | 
					                combo = QComboBox()
 | 
				
			||||||
 | 
					                combo.addItems(setting['options'])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                # Get current value
 | 
				
			||||||
 | 
					                current_raw = current.get(setting['key'], setting['default'])
 | 
				
			||||||
 | 
					                if setting['key'] == 'PW_WINE_CPU_TOPOLOGY':
 | 
				
			||||||
 | 
					                    current_val = disabled_text if current_raw == 'disabled' else (current_raw.split(':')[0] if isinstance(current_raw, str) and ':' in current_raw else current_raw)
 | 
				
			||||||
 | 
					                elif setting['key'] == 'PW_AMD_VULKAN_USE':
 | 
				
			||||||
 | 
					                    current_val = disabled_text if not current_raw or current_raw == '' else current_raw
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    current_val = disabled_text if current_raw == 'disabled' else current_raw
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                if current_val not in setting['options']:
 | 
				
			||||||
 | 
					                    combo.addItem(current_val)
 | 
				
			||||||
 | 
					                combo.setCurrentText(current_val)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                # Block if only disabled option
 | 
				
			||||||
 | 
					                if len(setting['options']) == 1:
 | 
				
			||||||
 | 
					                    combo.setEnabled(False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                self.advanced_table.setCellWidget(row, 1, combo)
 | 
				
			||||||
 | 
					                self.advanced_widgets[setting['key']] = combo
 | 
				
			||||||
 | 
					                self.original_display_values[setting['key']] = current_val
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            elif setting['type'] == 'text':
 | 
				
			||||||
 | 
					                text_edit = QTextEdit()
 | 
				
			||||||
 | 
					                current_val = current.get(setting['key'], setting['default'])
 | 
				
			||||||
 | 
					                text_edit.setPlainText(current_val)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                self.advanced_table.setCellWidget(row, 1, text_edit)
 | 
				
			||||||
 | 
					                self.advanced_widgets[setting['key']] = text_edit
 | 
				
			||||||
 | 
					                self.original_display_values[setting['key']] = current_val
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Description column
 | 
				
			||||||
 | 
					            desc_item = QTableWidgetItem(setting['description'])
 | 
				
			||||||
 | 
					            desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
 | 
				
			||||||
 | 
					            desc_item.setToolTip(setting['description'])
 | 
				
			||||||
 | 
					            desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
 | 
				
			||||||
 | 
					            self.advanced_table.setItem(row, 2, desc_item)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.advanced_table.resizeRowsToContents()
 | 
				
			||||||
 | 
					        if self.advanced_table.rowCount() > 0:
 | 
				
			||||||
 | 
					            self.advanced_table.setCurrentCell(0, 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def apply_changes(self):
 | 
				
			||||||
 | 
					        """Apply changes by collecting diffs from both main and advanced tabs."""
 | 
				
			||||||
 | 
					        changes = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- 1. Обычные (toggle) настройки ---
 | 
				
			||||||
 | 
					        for key, orig_val in self.original_values.items():
 | 
				
			||||||
 | 
					            if key in self.blocked_keys:
 | 
				
			||||||
 | 
					                continue  # Skip blocked keys
 | 
				
			||||||
 | 
					            row = -1
 | 
				
			||||||
 | 
					            for r in range(self.settings_table.rowCount()):
 | 
				
			||||||
 | 
					                item0 = self.settings_table.item(r, 0)
 | 
				
			||||||
 | 
					                if item0 and item0.text() == key:
 | 
				
			||||||
 | 
					                    row = r
 | 
				
			||||||
 | 
					                    break
 | 
				
			||||||
 | 
					            if row == -1:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            item = self.settings_table.item(row, 1)
 | 
				
			||||||
 | 
					            if not item:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            new_val = '1' if item.checkState() == Qt.CheckState.Checked else '0'
 | 
				
			||||||
 | 
					            if new_val != orig_val:
 | 
				
			||||||
 | 
					                changes.append(f"{key}={new_val}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- 2. Advanced настройки ---
 | 
				
			||||||
 | 
					        for key, widget in self.advanced_widgets.items():
 | 
				
			||||||
 | 
					            orig_val = self.original_display_values.get(key, '')
 | 
				
			||||||
 | 
					            if isinstance(widget, QComboBox):
 | 
				
			||||||
 | 
					                new_val = widget.currentText()
 | 
				
			||||||
 | 
					                # приведение disabled к 'disabled'
 | 
				
			||||||
 | 
					                if new_val.lower() == _('disabled').lower():
 | 
				
			||||||
 | 
					                    new_val = 'disabled'
 | 
				
			||||||
 | 
					            elif isinstance(widget, QTextEdit):
 | 
				
			||||||
 | 
					                new_val = widget.toPlainText().strip()
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                continue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if new_val != orig_val:
 | 
				
			||||||
 | 
					                changes.append(f"{key}={new_val}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- 3. Проверка на изменения ---
 | 
				
			||||||
 | 
					        if not changes:
 | 
				
			||||||
 | 
					            QMessageBox.information(self, _("Info"), _("No changes to apply."))
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # --- 4. Запуск процесса сохранения ---
 | 
				
			||||||
 | 
					        process = QProcess(self)
 | 
				
			||||||
 | 
					        process.finished.connect(self.on_edit_db_finished)
 | 
				
			||||||
 | 
					        args = ["cli", "--edit-db", self.exe_path] + changes
 | 
				
			||||||
 | 
					        process.start(self.start_sh[0], args)
 | 
				
			||||||
 | 
					        self.apply_button.setEnabled(False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def on_edit_db_finished(self, exit_code, exit_status):
 | 
				
			||||||
 | 
					        """Handle --edit-db output."""
 | 
				
			||||||
 | 
					        process = cast(QProcess, self.sender())
 | 
				
			||||||
 | 
					        self.apply_button.setEnabled(True)
 | 
				
			||||||
 | 
					        if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
 | 
				
			||||||
 | 
					            error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore')
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
 | 
				
			||||||
 | 
					            logger.error(f"Failed to apply changes: {error_output}")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            self.load_current_settings()
 | 
				
			||||||
 | 
					            QMessageBox.information(self, _("Success"), _("Settings updated successfully."))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def closeEvent(self, event):
 | 
				
			||||||
 | 
					        super().closeEvent(event)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def reject(self):
 | 
				
			||||||
 | 
					        super().reject()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,7 @@ from portprotonqt.localization import get_egs_language, _
 | 
				
			|||||||
from portprotonqt.logger import get_logger
 | 
					from portprotonqt.logger import get_logger
 | 
				
			||||||
from portprotonqt.image_utils import load_pixmap_async
 | 
					from portprotonqt.image_utils import load_pixmap_async
 | 
				
			||||||
from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
 | 
					from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
 | 
				
			||||||
from portprotonqt.config_utils import get_portproton_location
 | 
					from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
 | 
				
			||||||
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, call_steam_api
 | 
					    search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
 | 
				
			||||||
@@ -254,14 +254,7 @@ def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callba
 | 
				
			|||||||
        return
 | 
					        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Determine wrapper
 | 
					    # Determine wrapper
 | 
				
			||||||
    wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
					    wrapper = get_portproton_start_command()
 | 
				
			||||||
    start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
 | 
					 | 
				
			||||||
    if portproton_dir is not None and ".var" not in portproton_dir:
 | 
					 | 
				
			||||||
        wrapper = start_sh_path
 | 
					 | 
				
			||||||
        if not os.path.exists(start_sh_path):
 | 
					 | 
				
			||||||
            logger.error(f"start.sh not found at {start_sh_path}")
 | 
					 | 
				
			||||||
            callback((False, f"start.sh not found at {start_sh_path}"))
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # Create launch script
 | 
					    # Create launch script
 | 
				
			||||||
    steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
 | 
					    steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
from PySide6.QtGui import QPainter, QColor, QDesktopServices
 | 
					from PySide6.QtGui import QPainter, QColor, QDesktopServices
 | 
				
			||||||
from PySide6.QtCore import Signal, Property, Qt, QUrl
 | 
					from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
 | 
				
			||||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
 | 
					from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
 | 
				
			||||||
from collections.abc import Callable
 | 
					from collections.abc import Callable
 | 
				
			||||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
 | 
					from portprotonqt.image_utils import load_pixmap_async, round_corners
 | 
				
			||||||
@@ -404,6 +404,13 @@ class GameCard(QFrame):
 | 
				
			|||||||
            self.favoriteLabel.setText("☆")
 | 
					            self.favoriteLabel.setText("☆")
 | 
				
			||||||
        self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
 | 
					        self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        parent = self.parent()
 | 
				
			||||||
 | 
					        while parent:
 | 
				
			||||||
 | 
					            if hasattr(parent, 'game_library_manager'):
 | 
				
			||||||
 | 
					                QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined]
 | 
				
			||||||
 | 
					                break
 | 
				
			||||||
 | 
					            parent = parent.parent()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def toggle_favorite(self):
 | 
					    def toggle_favorite(self):
 | 
				
			||||||
        favorites = read_favorites()
 | 
					        favorites = read_favorites()
 | 
				
			||||||
        if self.is_favorite:
 | 
					        if self.is_favorite:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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:
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        Находит первый доступный геймпад.
 | 
					        Находит первый доступный геймпад.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,7 @@ import psutil
 | 
				
			|||||||
import re
 | 
					import re
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from portprotonqt.logger import get_logger
 | 
					from portprotonqt.logger import get_logger
 | 
				
			||||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog
 | 
					from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog
 | 
				
			||||||
from portprotonqt.game_card import GameCard
 | 
					from portprotonqt.game_card import GameCard
 | 
				
			||||||
from portprotonqt.animations import DetailPageAnimations
 | 
					from portprotonqt.animations import DetailPageAnimations
 | 
				
			||||||
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout
 | 
					from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout
 | 
				
			||||||
@@ -30,7 +30,7 @@ from portprotonqt.config_utils import (
 | 
				
			|||||||
    save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
 | 
					    save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
 | 
				
			||||||
    save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config,
 | 
					    save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config,
 | 
				
			||||||
    clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config, read_gamepad_type, save_gamepad_type, read_minimize_to_tray, save_minimize_to_tray,
 | 
					    clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config, read_gamepad_type, save_gamepad_type, read_minimize_to_tray, save_minimize_to_tray,
 | 
				
			||||||
    read_auto_card_size, save_auto_card_size
 | 
					    read_auto_card_size, save_auto_card_size, get_portproton_start_command
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
 | 
					from portprotonqt.localization import _, get_egs_language, read_metadata_translations
 | 
				
			||||||
from portprotonqt.howlongtobeat_api import HowLongToBeat
 | 
					from portprotonqt.howlongtobeat_api import HowLongToBeat
 | 
				
			||||||
@@ -74,6 +74,7 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        self.target_exe = None
 | 
					        self.target_exe = None
 | 
				
			||||||
        self.current_running_button = None
 | 
					        self.current_running_button = None
 | 
				
			||||||
        self.portproton_location = get_portproton_location()
 | 
					        self.portproton_location = get_portproton_location()
 | 
				
			||||||
 | 
					        self.start_sh = get_portproton_start_command()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.game_library_manager = GameLibraryManager(self, self.theme, None)
 | 
					        self.game_library_manager = GameLibraryManager(self, self.theme, None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -458,11 +459,11 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        self.current_install_script = script_name
 | 
					        self.current_install_script = script_name
 | 
				
			||||||
        self.seen_progress = False
 | 
					        self.seen_progress = False
 | 
				
			||||||
        self.current_percent = 0.0
 | 
					        self.current_percent = 0.0
 | 
				
			||||||
        start_sh = os.path.join(self.portproton_location or "", "data", "scripts", "start.sh") if self.portproton_location else ""
 | 
					        start_sh = self.start_sh
 | 
				
			||||||
        if not os.path.exists(start_sh):
 | 
					        if not start_sh:
 | 
				
			||||||
            self.installing = False
 | 
					            self.installing = False
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        cmd = [start_sh, "cli", "--autoinstall", script_name]
 | 
					        cmd = start_sh + ["cli", "--autoinstall", script_name]
 | 
				
			||||||
        self.install_process = QProcess(self)
 | 
					        self.install_process = QProcess(self)
 | 
				
			||||||
        self.install_process.finished.connect(self.on_install_finished)
 | 
					        self.install_process.finished.connect(self.on_install_finished)
 | 
				
			||||||
        self.install_process.errorOccurred.connect(self.on_install_error)
 | 
					        self.install_process.errorOccurred.connect(self.on_install_error)
 | 
				
			||||||
@@ -1253,7 +1254,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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1416,12 +1425,10 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        prefix = self.prefixCombo.currentText()
 | 
					        prefix = self.prefixCombo.currentText()
 | 
				
			||||||
        if not wine or not prefix:
 | 
					        if not wine or not prefix:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        if not self.portproton_location:
 | 
					        if not self.portproton_location or not self.start_sh:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
					        start_sh = self.start_sh
 | 
				
			||||||
        if not os.path.exists(start_sh):
 | 
					        cmd = start_sh + ["cli", cli_arg, wine, prefix]
 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
        cmd = [start_sh, "cli", cli_arg, wine, prefix]
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Показываем прогресс-бар перед запуском
 | 
					        # Показываем прогресс-бар перед запуском
 | 
				
			||||||
        self.wine_progress_bar.setVisible(True)
 | 
					        self.wine_progress_bar.setVisible(True)
 | 
				
			||||||
@@ -1500,12 +1507,13 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}")
 | 
					        QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def clear_prefix(self):
 | 
					    def clear_prefix(self):
 | 
				
			||||||
        """Очистка префикса (позже удалить)."""
 | 
					        """Очищает префикс"""
 | 
				
			||||||
        selected_prefix = self.prefixCombo.currentText()
 | 
					        selected_prefix = self.prefixCombo.currentText()
 | 
				
			||||||
        selected_wine = self.wineCombo.currentText()
 | 
					        selected_wine = self.wineCombo.currentText()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if not selected_prefix or not selected_wine:
 | 
					        if not selected_prefix or not selected_wine:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        if not self.portproton_location:
 | 
					        if not self.portproton_location or not self.start_sh:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        reply = QMessageBox.question(
 | 
					        reply = QMessageBox.question(
 | 
				
			||||||
@@ -1518,98 +1526,35 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        if reply != QMessageBox.StandardButton.Yes:
 | 
					        if reply != QMessageBox.StandardButton.Yes:
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        prefix_dir = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix)
 | 
					        start_sh = self.start_sh
 | 
				
			||||||
        if not os.path.exists(prefix_dir):
 | 
					
 | 
				
			||||||
 | 
					        self.wine_progress_bar.setVisible(True)
 | 
				
			||||||
 | 
					        self.update_status_message.emit(_("Clearing prefix..."), 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        self.clear_process = QProcess(self)
 | 
				
			||||||
 | 
					        self.clear_process.finished.connect(lambda exitCode, exitStatus: self._on_clear_prefix_finished(exitCode))
 | 
				
			||||||
 | 
					        self.clear_process.errorOccurred.connect(lambda error: self._on_clear_prefix_error(error))
 | 
				
			||||||
 | 
					        cmd = start_sh + ["cli", "--clear_pfx", selected_wine, selected_prefix]
 | 
				
			||||||
 | 
					        self.clear_process.start(cmd[0], cmd[1:])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if not self.clear_process.waitForStarted(5000):
 | 
				
			||||||
 | 
					            self.wine_progress_bar.setVisible(False)
 | 
				
			||||||
 | 
					            self.update_status_message.emit("", 0)
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, _("Error"), _("Failed to start prefix clear process."))
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        success = True
 | 
					    def _on_clear_prefix_finished(self, exitCode):
 | 
				
			||||||
        errors = []
 | 
					        self.wine_progress_bar.setVisible(False)
 | 
				
			||||||
 | 
					        self.update_status_message.emit("", 0)
 | 
				
			||||||
        # Удаление файлов
 | 
					        if exitCode == 0:
 | 
				
			||||||
        files_to_remove = [
 | 
					            QMessageBox.information(self, _("Success"), _("Prefix cleared successfully."))
 | 
				
			||||||
            os.path.join(prefix_dir, "*.dot*"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "*.prog*"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, ".wine_ver"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "system.reg"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "user.reg"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "userdef.reg"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "winetricks.log"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, ".update-timestamp"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", ".windows-serial"),
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        import glob
 | 
					 | 
				
			||||||
        for pattern in files_to_remove:
 | 
					 | 
				
			||||||
            if "*" in pattern:  # Глобальный паттерн
 | 
					 | 
				
			||||||
                matches = glob.glob(pattern)
 | 
					 | 
				
			||||||
                for file_path in matches:
 | 
					 | 
				
			||||||
                    try:
 | 
					 | 
				
			||||||
                        if os.path.exists(file_path):
 | 
					 | 
				
			||||||
                            os.remove(file_path)
 | 
					 | 
				
			||||||
                    except Exception as e:
 | 
					 | 
				
			||||||
                        success = False
 | 
					 | 
				
			||||||
                        errors.append(str(e))
 | 
					 | 
				
			||||||
            else:  # Конкретный файл
 | 
					 | 
				
			||||||
                try:
 | 
					 | 
				
			||||||
                    if os.path.exists(pattern):
 | 
					 | 
				
			||||||
                        os.remove(pattern)
 | 
					 | 
				
			||||||
                except Exception as e:
 | 
					 | 
				
			||||||
                    success = False
 | 
					 | 
				
			||||||
                    errors.append(str(e))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Удаление директорий
 | 
					 | 
				
			||||||
        dirs_to_remove = [
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "windows"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "ProgramData", "Setup"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "ProgramData", "Windows"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "ProgramData", "WindowsTask"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "ProgramData", "Package Cache"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Microsoft"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Temp"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Temporary Internet Files"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "Microsoft"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "wine_gecko"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "Public", "Temp"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Microsoft"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Temp"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Temporary Internet Files"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "Microsoft"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "wine_gecko"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Temp"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "Program Files", "Internet Explorer"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "Program Files", "Windows Media Player"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "Program Files", "Windows NT"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Internet Explorer"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows Media Player"),
 | 
					 | 
				
			||||||
            os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows NT"),
 | 
					 | 
				
			||||||
        ]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        import shutil
 | 
					 | 
				
			||||||
        for dir_path in dirs_to_remove:
 | 
					 | 
				
			||||||
            try:
 | 
					 | 
				
			||||||
                if os.path.exists(dir_path):
 | 
					 | 
				
			||||||
                    shutil.rmtree(dir_path)
 | 
					 | 
				
			||||||
            except Exception as e:
 | 
					 | 
				
			||||||
                success = False
 | 
					 | 
				
			||||||
                errors.append(str(e))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        tmp_path = os.path.join(self.portproton_location, "data", "tmp")
 | 
					 | 
				
			||||||
        if os.path.exists(tmp_path):
 | 
					 | 
				
			||||||
            import glob
 | 
					 | 
				
			||||||
            bin_files = glob.glob(os.path.join(tmp_path, "*.bin"))
 | 
					 | 
				
			||||||
            foz_files = glob.glob(os.path.join(tmp_path, "*.foz"))
 | 
					 | 
				
			||||||
            for file_path in bin_files + foz_files:
 | 
					 | 
				
			||||||
                try:
 | 
					 | 
				
			||||||
                    os.remove(file_path)
 | 
					 | 
				
			||||||
                except Exception as e:
 | 
					 | 
				
			||||||
                    success = False
 | 
					 | 
				
			||||||
                    errors.append(str(e))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if success:
 | 
					 | 
				
			||||||
            QMessageBox.information(self, _("Success"), _("Prefix '{}' cleared successfully.").format(selected_prefix))
 | 
					 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            error_msg = _("Prefix '{}' cleared with errors:\n{}").format(selected_prefix, "\n".join(errors[:5]))
 | 
					            QMessageBox.warning(self, _("Error"), _("Prefix clear failed with exit code {}.").format(exitCode))
 | 
				
			||||||
            QMessageBox.warning(self, _("Warning"), error_msg)
 | 
					
 | 
				
			||||||
 | 
					    def _on_clear_prefix_error(self, error):
 | 
				
			||||||
 | 
					        self.wine_progress_bar.setVisible(False)
 | 
				
			||||||
 | 
					        self.update_status_message.emit("", 0)
 | 
				
			||||||
 | 
					        QMessageBox.warning(self, _("Error"), _("Failed to run clear prefix command: {}").format(error))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def create_prefix_backup(self):
 | 
					    def create_prefix_backup(self):
 | 
				
			||||||
        selected_prefix = self.prefixCombo.currentText()
 | 
					        selected_prefix = self.prefixCombo.currentText()
 | 
				
			||||||
@@ -1621,14 +1566,12 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def _perform_backup(self, backup_dir, prefix_name):
 | 
					    def _perform_backup(self, backup_dir, prefix_name):
 | 
				
			||||||
        os.makedirs(backup_dir, exist_ok=True)
 | 
					        os.makedirs(backup_dir, exist_ok=True)
 | 
				
			||||||
        if not self.portproton_location:
 | 
					        if not self.portproton_location or not self.start_sh:
 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
        start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
					 | 
				
			||||||
        if not os.path.exists(start_sh):
 | 
					 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					        start_sh = self.start_sh
 | 
				
			||||||
        self.backup_process = QProcess(self)
 | 
					        self.backup_process = QProcess(self)
 | 
				
			||||||
        self.backup_process.finished.connect(lambda exitCode, exitStatus: self._on_backup_finished(exitCode))
 | 
					        self.backup_process.finished.connect(lambda exitCode, exitStatus: self._on_backup_finished(exitCode))
 | 
				
			||||||
        cmd = [start_sh, "--backup-prefix", prefix_name, backup_dir]
 | 
					        cmd = start_sh + ["--backup-prefix", prefix_name, backup_dir]
 | 
				
			||||||
        self.backup_process.start(cmd[0], cmd[1:])
 | 
					        self.backup_process.start(cmd[0], cmd[1:])
 | 
				
			||||||
        if not self.backup_process.waitForStarted():
 | 
					        if not self.backup_process.waitForStarted():
 | 
				
			||||||
            QMessageBox.warning(self, _("Error"), _("Failed to start backup process."))
 | 
					            QMessageBox.warning(self, _("Error"), _("Failed to start backup process."))
 | 
				
			||||||
@@ -1641,14 +1584,12 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
    def _perform_restore(self, file_path):
 | 
					    def _perform_restore(self, file_path):
 | 
				
			||||||
        if not file_path or not os.path.exists(file_path):
 | 
					        if not file_path or not os.path.exists(file_path):
 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
        if not self.portproton_location:
 | 
					        if not self.portproton_location or not self.start_sh:
 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
        start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
					 | 
				
			||||||
        if not os.path.exists(start_sh):
 | 
					 | 
				
			||||||
            return
 | 
					            return
 | 
				
			||||||
 | 
					        start_sh = self.start_sh
 | 
				
			||||||
        self.restore_process = QProcess(self)
 | 
					        self.restore_process = QProcess(self)
 | 
				
			||||||
        self.restore_process.finished.connect(lambda exitCode, exitStatus: self._on_restore_finished(exitCode))
 | 
					        self.restore_process.finished.connect(lambda exitCode, exitStatus: self._on_restore_finished(exitCode))
 | 
				
			||||||
        cmd = [start_sh, "--restore-prefix", file_path]
 | 
					        cmd = start_sh + ["--restore-prefix", file_path]
 | 
				
			||||||
        self.restore_process.start(cmd[0], cmd[1:])
 | 
					        self.restore_process.start(cmd[0], cmd[1:])
 | 
				
			||||||
        if not self.restore_process.waitForStarted():
 | 
					        if not self.restore_process.waitForStarted():
 | 
				
			||||||
            QMessageBox.warning(self, _("Error"), _("Failed to start restore process."))
 | 
					            QMessageBox.warning(self, _("Error"), _("Failed to start restore process."))
 | 
				
			||||||
@@ -2318,6 +2259,14 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
    def darkenColor(self, color, factor=200):
 | 
					    def darkenColor(self, color, factor=200):
 | 
				
			||||||
        return color.darker(factor)
 | 
					        return color.darker(factor)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def open_exe_settings(self, exe_path):
 | 
				
			||||||
 | 
					        """Open the ExeSettingsDialog for the given executable."""
 | 
				
			||||||
 | 
					        if not os.path.exists(exe_path):
 | 
				
			||||||
 | 
					            QMessageBox.warning(self, _("Error"), _("Executable not found: {0}").format(exe_path))
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        dialog = ExeSettingsDialog(self, self.theme, exe_path)
 | 
				
			||||||
 | 
					        dialog.exec()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""):
 | 
					    def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""):
 | 
				
			||||||
        detailPage = QWidget()
 | 
					        detailPage = QWidget()
 | 
				
			||||||
        self._animations = {}
 | 
					        self._animations = {}
 | 
				
			||||||
@@ -2620,8 +2569,6 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
                clear_layout(hltbLayout)
 | 
					                clear_layout(hltbLayout)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                has_data = False
 | 
					                has_data = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if main_story_time is not None:
 | 
					                if main_story_time is not None:
 | 
				
			||||||
@@ -2705,6 +2652,14 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        playButton.clicked.connect(lambda: self.toggleGame(exec_line, playButton))
 | 
					        playButton.clicked.connect(lambda: self.toggleGame(exec_line, playButton))
 | 
				
			||||||
        detailsLayout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
 | 
					        detailsLayout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Settings button
 | 
				
			||||||
 | 
					        settings_icon = self.theme_manager.get_icon("settings")
 | 
				
			||||||
 | 
					        settings_button = AutoSizeButton(_("Settings"), icon=settings_icon)
 | 
				
			||||||
 | 
					        settings_button.setFixedSize(120, 40)
 | 
				
			||||||
 | 
					        settings_button.setStyleSheet(self.theme.PLAY_BUTTON_STYLE)
 | 
				
			||||||
 | 
					        settings_button.clicked.connect(lambda: self.open_exe_settings(file_to_check))
 | 
				
			||||||
 | 
					        detailsLayout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        contentFrameLayout.addWidget(detailsWidget)
 | 
					        contentFrameLayout.addWidget(detailsWidget)
 | 
				
			||||||
        mainLayout.addStretch()
 | 
					        mainLayout.addStretch()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2927,10 +2882,7 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
                env_vars = os.environ.copy()
 | 
					                env_vars = os.environ.copy()
 | 
				
			||||||
                env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
 | 
					                env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
					                wrapper = self.start_sh or ""
 | 
				
			||||||
                if self.portproton_location is not None and ".var" not in self.portproton_location:
 | 
					 | 
				
			||||||
                    start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
					 | 
				
			||||||
                    wrapper = start_sh
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                cmd = [wrapper, game_exe]
 | 
					                cmd = [wrapper, game_exe]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -3024,13 +2976,6 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
            exe_name = os.path.splitext(current_exe)[0]
 | 
					            exe_name = os.path.splitext(current_exe)[0]
 | 
				
			||||||
            env_vars = os.environ.copy()
 | 
					            env_vars = os.environ.copy()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if entry_exec_split[0] == "env" and len(entry_exec_split) > 1 and 'data/scripts/start.sh' in entry_exec_split[1]:
 | 
					 | 
				
			||||||
                env_vars['START_FROM_STEAM'] = '1'
 | 
					 | 
				
			||||||
                env_vars['PROCESS_LOG'] = '1'
 | 
					 | 
				
			||||||
            elif entry_exec_split[0] == "flatpak":
 | 
					 | 
				
			||||||
                env_vars['START_FROM_STEAM'] = '1'
 | 
					 | 
				
			||||||
                env_vars['PROCESS_LOG'] = '1'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            # Запускаем игру
 | 
					            # Запускаем игру
 | 
				
			||||||
            try:
 | 
					            try:
 | 
				
			||||||
                process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
 | 
					                process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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."""
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,7 @@ from portprotonqt.logger import get_logger
 | 
				
			|||||||
from portprotonqt.localization import get_steam_language
 | 
					from portprotonqt.localization import get_steam_language
 | 
				
			||||||
from portprotonqt.downloader import Downloader
 | 
					from portprotonqt.downloader import Downloader
 | 
				
			||||||
from portprotonqt.dialogs import generate_thumbnail
 | 
					from portprotonqt.dialogs import generate_thumbnail
 | 
				
			||||||
from portprotonqt.config_utils import get_portproton_location
 | 
					from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
 | 
				
			||||||
from collections.abc import Callable
 | 
					from collections.abc import Callable
 | 
				
			||||||
import re
 | 
					import re
 | 
				
			||||||
import shutil
 | 
					import shutil
 | 
				
			||||||
@@ -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):
 | 
				
			||||||
@@ -957,7 +1004,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
 | 
				
			|||||||
        return (False, f"Executable file not found: {exe_path}")
 | 
					        return (False, f"Executable file not found: {exe_path}")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    portproton_dir = get_portproton_location()
 | 
					    portproton_dir = get_portproton_location()
 | 
				
			||||||
    if not portproton_dir:
 | 
					    start_sh = get_portproton_start_command()
 | 
				
			||||||
 | 
					    if not portproton_dir or not start_sh:
 | 
				
			||||||
        logger.error("PortProton directory not found")
 | 
					        logger.error("PortProton directory not found")
 | 
				
			||||||
        return (False, "PortProton directory not found")
 | 
					        return (False, "PortProton directory not found")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -966,17 +1014,12 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
 | 
					    safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
 | 
				
			||||||
    script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh")
 | 
					    script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh")
 | 
				
			||||||
    start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if not os.path.exists(start_sh_path):
 | 
					 | 
				
			||||||
        logger.error(f"start.sh not found at {start_sh_path}")
 | 
					 | 
				
			||||||
        return (False, f"start.sh not found at {start_sh_path}")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if not os.path.exists(script_path):
 | 
					    if not os.path.exists(script_path):
 | 
				
			||||||
        script_content = f"""#!/usr/bin/env bash
 | 
					        script_content = f"""#!/usr/bin/env bash
 | 
				
			||||||
export LD_PRELOAD=
 | 
					export LD_PRELOAD=
 | 
				
			||||||
export START_FROM_STEAM=1
 | 
					export START_FROM_STEAM=1
 | 
				
			||||||
"{start_sh_path}" "{exe_path}" "$@"
 | 
					"{start_sh}" "{exe_path}" "$@"
 | 
				
			||||||
"""
 | 
					"""
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            with open(script_path, "w", encoding="utf-8") as f:
 | 
					            with open(script_path, "w", encoding="utf-8") as f:
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/icons/settings.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								portprotonqt/themes/standart/images/icons/settings.svg
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0005 1c-0.38761 0-0.77522 0.0327-1.1588 0.0979-0.16351 0.0281-0.30273 0.13627-0.37209 0.28935l-0.39088 0.86264c-0.49378 0.16682-0.96454 0.39759-1.4007 0.68616 2.5e-4 0-0.90672-0.2272-0.90672-0.2272-0.161-0.0403-0.33098 3e-3 -0.45442 0.11569-0.57867 0.5285-1.0672 1.1514-1.4451 1.8432-0.0804 0.14721-0.0841 0.32549-0.01 0.47628l0.41938 0.84865c-0.17954 0.49666-0.29567 1.0147-0.346 1.5417l-0.73995 0.57946c-0.13121 0.10289-0.20407 0.26514-0.19431 0.4335 0.0453 0.78981 0.21961 1.5666 0.51558 2.2983 0.0631 0.15587 0.1978 0.27003 0.36005 0.30467l0.91397 0.19559c0.26993 0.45234 0.59572 0.86802 0.96931 1.2363l-0.0161 0.94973c-3e-3 0.16861 0.0766 0.32755 0.21183 0.42484 0.63551 0.45642 1.3414 0.80207 2.0884 1.0229 0.15926 0.0471 0.33077 0.0109 0.45872-0.0963l0.72016-0.60485c0.51582 0.0674 1.0384 0.0674 1.5544 0l0.72016 0.60485c0.12796 0.10722 0.29946 0.14343 0.45872 0.0963 0.74693-0.22083 1.4528-0.56648 2.0883-1.0229 0.13521-0.0973 0.21465-0.25623 0.21189-0.42484l-0.0161-0.94973c0.37359-0.36829 0.69939-0.78372 0.96932-1.2363l0.91396-0.19559c0.16226-0.0347 0.29695-0.1488 0.36005-0.30467 0.29597-0.73174 0.47026-1.5085 0.51558-2.2983 0.01-0.16836-0.0631-0.33061-0.1943-0.4335l-0.73996-0.57946c-0.0501-0.52671-0.16652-1.045-0.34606-1.5417l0.41944-0.84865c0.0746-0.15079 0.0709-0.32907-0.01-0.47628-0.37785-0.69176-0.86638-1.3147-1.445-1.8432-0.12345-0.11258-0.29343-0.15594-0.45443-0.11569l-0.90697 0.2272c-0.43594-0.28857-0.9067-0.51908-1.4005-0.68616l-0.39088-0.86264c-0.0694-0.15308-0.20858-0.26132-0.37209-0.28935-0.38361-0.0653-0.77121-0.0979-1.1588-0.0979zm0 4.1365a2.8152 2.8635 0 0 1 2.8152 2.8636 2.8152 2.8635 0 0 1-2.8152 2.8635 2.8152 2.8635 0 0 1-2.8152-2.8635 2.8152 2.8635 0 0 1 2.8152-2.8636z" fill="#fff" stroke-width=".25254"/></svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 1.8 KiB  | 
		Reference in New Issue
	
	Block a user