├── tests ├── cli │ ├── __init__.py │ ├── test_desktop_install.py │ ├── test_util.py │ └── test_launch.py ├── data │ └── appinfo_v29.vdf ├── test_winetricks.py ├── test_config.py ├── test_flatpak.py ├── test_util.py └── test_gui.py ├── src └── protontricks │ ├── cli │ ├── __init__.py │ ├── desktop_install.py │ ├── util.py │ ├── launch.py │ └── main.py │ ├── data │ ├── __init__.py │ ├── data │ │ ├── __init__.py │ │ └── icon_placeholder.png │ ├── scripts │ │ ├── __init__.py │ │ ├── wineserver_keepalive.bat │ │ ├── wineserver_keepalive.sh │ │ ├── bwrap_launcher.sh │ │ └── wine_launch.sh │ └── share │ │ └── applications │ │ ├── __init__.py │ │ ├── protontricks-launch.desktop │ │ └── protontricks.desktop │ ├── __init__.py │ ├── _vdf │ ├── LICENSE │ ├── README.rst │ ├── vdict.py │ └── __init__.py │ ├── winetricks.py │ ├── config.py │ ├── flatpak.py │ └── gui.py ├── requirements.txt ├── requirements_dev.txt ├── data ├── screenshot.png └── com.github.Matoking.protontricks.metainfo.xml ├── MANIFEST.in ├── pyproject.toml ├── Makefile ├── .github ├── workflows │ ├── appstream.yaml │ └── tests.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── setup.py ├── CONTRIBUTING.md ├── setup.cfg ├── .gitignore ├── TROUBLESHOOTING.md ├── README.md └── CHANGELOG.md /tests/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/protontricks/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/protontricks/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/protontricks/data/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | vdf==3.4 2 | Pillow 3 | -------------------------------------------------------------------------------- /src/protontricks/data/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/protontricks/data/share/applications/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest>=6.0 2 | pytest-cov>=2.10 3 | setuptools-scm 4 | -------------------------------------------------------------------------------- /data/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Matoking/protontricks/HEAD/data/screenshot.png -------------------------------------------------------------------------------- /tests/data/appinfo_v29.vdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Matoking/protontricks/HEAD/tests/data/appinfo_v29.vdf -------------------------------------------------------------------------------- /src/protontricks/data/data/icon_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Matoking/protontricks/HEAD/src/protontricks/data/data/icon_placeholder.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in LICENSE *.md 2 | 3 | graft src/protontricks 4 | graft data 5 | 6 | exclude *.yml 7 | 8 | global-exclude *.py[cod] 9 | global-exclude __pycache__ 10 | -------------------------------------------------------------------------------- /src/protontricks/__init__.py: -------------------------------------------------------------------------------- 1 | from .steam import * 2 | from .winetricks import * 3 | from .gui import * 4 | from .util import * 5 | 6 | try: 7 | from ._version import version as __version__ 8 | except ImportError: 9 | # Package not installed 10 | __version__ = "unknown" 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools", 4 | "wheel", 5 | "setuptools-scm" 6 | ] 7 | build-backend = "setuptools.build_meta" 8 | 9 | [tool.setuptools_scm] 10 | write_to = "src/protontricks/_version.py" 11 | 12 | [tool.coverage.report] 13 | omit = [ 14 | "*/protontricks/_vdf/*" 15 | ] 16 | -------------------------------------------------------------------------------- /src/protontricks/data/share/applications/protontricks-launch.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Exec=protontricks-launch --no-term %f 3 | Name=Protontricks Launcher 4 | Type=Application 5 | Terminal=false 6 | NoDisplay=true 7 | Categories=Utility 8 | Icon=wine 9 | MimeType=application/x-ms-dos-executable;application/x-msi;application/x-ms-shortcut; 10 | -------------------------------------------------------------------------------- /src/protontricks/data/share/applications/protontricks.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Exec=protontricks --no-term --gui 3 | Name=Protontricks 4 | Comment=A simple wrapper that does winetricks things for Proton enabled games 5 | Type=Application 6 | Terminal=false 7 | Categories=Utility; 8 | Icon=wine 9 | Keywords=Steam;Proton;Wine;Winetricks; 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /bin/sh 2 | PYTHON ?= python3 3 | ROOT ?= / 4 | PREFIX ?= /usr/local 5 | 6 | install: 7 | ${PYTHON} setup.py install --prefix="${DESTDIR}${PREFIX}" --root="${DESTDIR}${ROOT}" 8 | 9 | # Remove `protontricks-desktop-install`, since we already install 10 | # .desktop files properly 11 | rm "${DESTDIR}${PREFIX}/bin/protontricks-desktop-install" 12 | -------------------------------------------------------------------------------- /.github/workflows/appstream.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Validate AppStream 3 | 4 | on: [push, pull_request] 5 | 6 | permissions: read-all 7 | 8 | jobs: 9 | validate: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Install appstreamcli 16 | run: sudo apt install appstream 17 | 18 | - name: Validate AppStream metadata 19 | run: appstreamcli validate data/com.github.Matoking.protontricks.metainfo.xml 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | # This is considered deprecated since Python wheels don't provide a way 6 | # to install package-related files outside the package directory 7 | data_files=[ 8 | ( 9 | "share/applications", 10 | [ 11 | ("src/protontricks/data/share/applications/" 12 | "protontricks.desktop"), 13 | ("src/protontricks/data/share/applications/" 14 | "protontricks-launch.desktop") 15 | ] 16 | ) 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /tests/cli/test_desktop_install.py: -------------------------------------------------------------------------------- 1 | def test_run_desktop_install(home_dir, command_mock, desktop_install_cli): 2 | """ 3 | Ensure that `desktop-file-install` is called properly 4 | """ 5 | # `protontricks-desktop-install` takes no arguments 6 | desktop_install_cli([]) 7 | 8 | command = command_mock.commands[0] 9 | assert command.args[0:3] == [ 10 | "desktop-file-install", 11 | "--dir", 12 | str(home_dir / ".local" / "share" / "applications") 13 | ] 14 | assert command.args[3].endswith("/protontricks.desktop") 15 | assert command.args[4].endswith("/protontricks-launch.desktop") 16 | -------------------------------------------------------------------------------- /tests/test_winetricks.py: -------------------------------------------------------------------------------- 1 | from protontricks.winetricks import get_winetricks_path 2 | 3 | 4 | class TestGetWinetricksPath: 5 | def test_get_winetricks_env(self, monkeypatch, tmp_path): 6 | """ 7 | Use a custom Winetricks executable using an env var 8 | """ 9 | (tmp_path / "winetricks").touch() 10 | 11 | monkeypatch.setenv( 12 | "WINETRICKS", 13 | str(tmp_path / "winetricks") 14 | ) 15 | assert str(get_winetricks_path()) == str(tmp_path / "winetricks") 16 | 17 | def test_get_winetricks_env_not_found(self, monkeypatch): 18 | """ 19 | Try using a custom Winetricks with a non-existent path 20 | """ 21 | monkeypatch.setenv("WINETRICKS", "/invalid/path") 22 | assert not get_winetricks_path() 23 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from protontricks.config import get_config 2 | 3 | 4 | def test_config(home_dir): 5 | """ 6 | Test creating a configuration file, inserting a value into it and reading 7 | it back 8 | """ 9 | config = get_config() 10 | 11 | config.set("General", "test_field", "test_value") 12 | 13 | # Ensure the configuration file now exists 14 | config_path = home_dir / ".config/protontricks/config.ini" 15 | assert config_path.exists() 16 | assert "test_value" in config_path.read_text() 17 | 18 | # Open the configuration file again, we should be able to read the value 19 | # back 20 | config = get_config() 21 | 22 | assert config.get("General", "test_field") == "test_value" 23 | 24 | 25 | def test_config_default(): 26 | """ 27 | Test that a default value can be used if the field doesn't exist 28 | in the configuration file 29 | """ 30 | config = get_config() 31 | 32 | assert config.get( 33 | "General", "fake_field", "default_value" 34 | ) == "default_value" 35 | 36 | -------------------------------------------------------------------------------- /src/protontricks/_vdf/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Rossen Georgiev 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How can I contribute? 2 | Well, you can... 3 | * Report bugs 4 | * Add improvements 5 | * Fix bugs 6 | 7 | # Reporting bugs 8 | The best means of reporting bugs is by following these basic guidelines: 9 | 10 | * First describe in the title of the issue tracker what's gone wrong. 11 | * In the body, explain a basic synopsis of what exactly happens, explain how you got the bug one step at a time. If you're including script output, make sure you run the script with the verbose flag `-v`. 12 | * Explain what you had expected to occur, and what really occured. 13 | * Optionally, if you want, if you're a programmer, you can try to issue a pull request yourself that fixes the issue. 14 | 15 | # Adding improvements 16 | The way to go here is to ask yourself if the improvement would be useful for more than just a singular person, if it's for a certain use case then sure! 17 | 18 | * In any pull request, explain thoroughly what changes you made 19 | * Explain why you think these changes could be useful 20 | * If it fixes a bug, be sure to link to the issue itself. 21 | * Follow the [PEP 8](https://www.python.org/dev/peps/pep-0008/) code style to keep the code consistent. 22 | -------------------------------------------------------------------------------- /src/protontricks/winetricks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shutil 4 | from pathlib import Path 5 | 6 | __all__ = ("get_winetricks_path",) 7 | 8 | logger = logging.getLogger("protontricks") 9 | 10 | 11 | def get_winetricks_path(): 12 | """ 13 | Return to the path to 'winetricks' executable or return None if not found 14 | """ 15 | if os.environ.get('WINETRICKS'): 16 | path = Path(os.environ["WINETRICKS"]) 17 | logger.info( 18 | "Winetricks path is set to %s", str(path) 19 | ) 20 | if not path.is_file(): 21 | logger.error( 22 | "The WINETRICKS path is invalid, please make sure " 23 | "Winetricks is installed in that path!" 24 | ) 25 | return None 26 | 27 | return path 28 | 29 | logger.info( 30 | "WINETRICKS environment variable is not available. " 31 | "Searching from $PATH.") 32 | winetricks_path = shutil.which("winetricks") 33 | 34 | if winetricks_path: 35 | return Path(winetricks_path) 36 | 37 | logger.error( 38 | "'winetricks' executable could not be found automatically." 39 | ) 40 | return None 41 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Tests 5 | 6 | on: [push, pull_request] 7 | 8 | permissions: read-all 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-22.04 13 | strategy: 14 | matrix: 15 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | python -m pip install pytest-cov setuptools-scm 27 | pip install . 28 | - name: Test with pytest 29 | run: | 30 | pytest -vv --cov=protontricks --cov-report term --cov-report xml tests 31 | - name: Upload coverage 32 | uses: coverallsapp/github-action@v2 33 | with: 34 | format: cobertura 35 | file: coverage.xml 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Errors and crashes 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Run command `protontricks foo bar` 16 | 2. Command fails and error is displayed 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **System (please complete the following information):** 22 | - Distro: [e.g. Ubuntu 20.04, Arch Linux, ...] 23 | - Protontricks installation method: [e.g. community package, Flatpak, pipx or pip] 24 | - Protontricks version: run `protontricks --version` to print the version 25 | - Steam version: check if you're running Steam beta; this can be checked in _Steam_ -> _Settings_ -> _Interface_ -> _Client Beta Participation_ 26 | 27 | **Additional context** 28 | 29 | **If the error happens when trying to run a Protontricks command, run the command again using the `-vv` flag and copy the output!** 30 | 31 | For example, if the command that causes the error is `protontricks 42 faudio`, run `protontricks -vv 42 faudio` instead and copy the output here. 32 | 33 | If the output is very long, consider creating a gist using [gist.github.com](https://gist.github.com/). 34 | -------------------------------------------------------------------------------- /src/protontricks/data/scripts/wineserver_keepalive.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | Rem This is a simple Windows batch script, the sole purpose of which is to 3 | Rem indirectly create a wineserver process and keep it alive. 4 | Rem 5 | Rem This is necessary when running a lot of Wine commands in succession 6 | Rem in a sandbox (eg. Steam Runtime and Winetricks), since a wineserver 7 | Rem process is started and stopped repeatedly for each command unless one 8 | Rem is already available. 9 | Rem 10 | Rem Each Steam Runtime sandbox shares the same PID namespace, meaning Wine 11 | Rem commands in other sandboxes use it automatically without having to start 12 | Rem their own, reducing startup time dramatically. 13 | ECHO wineserver keepalive process started... 14 | :LOOP 15 | Rem Keep this process alive until the 'keepalive' file is deleted; this is 16 | Rem done by Protontricks when the underlying command is finished. 17 | Rem 18 | Rem If 'restart' file appears, stop this process and wait a moment before 19 | Rem starting it again; this is done by the Bash script. 20 | Rem 21 | Rem Batch doesn't have a sleep command, so ping an unreachable IP with 22 | Rem a 2s timeout repeatedly. This is stupid, but it appears to work. 23 | ping 192.0.2.1 -n 1 -w 2000 >nul 24 | IF EXIST restart ( 25 | ECHO stopping keepalive process temporarily... 26 | DEL restart 27 | EXIT /B 0 28 | ) 29 | IF EXIST keepalive ( 30 | goto LOOP 31 | ) ELSE ( 32 | ECHO keepalive file deleted, quitting... 33 | ) 34 | -------------------------------------------------------------------------------- /src/protontricks/config.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | import os 4 | from pathlib import Path 5 | 6 | logger = logging.getLogger("protontricks") 7 | 8 | 9 | class Config: 10 | def __init__(self): 11 | self._parser = configparser.ConfigParser() 12 | self._path = Path( 13 | os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") 14 | ) / "protontricks" / "config.ini" 15 | 16 | try: 17 | content = self._path.read_text(encoding="utf-8") 18 | self._parser.read_string(content) 19 | except FileNotFoundError: 20 | pass 21 | 22 | def get(self, section, option, default=None): 23 | """ 24 | Get the configuration value in the given section and its field 25 | """ 26 | self._parser.setdefault(section, {}) 27 | return self._parser[section].get(option, default) 28 | 29 | def set(self, section, option, value): 30 | """ 31 | Set the configuration value in the given section and its field, and 32 | save the configuration file 33 | """ 34 | logger.debug( 35 | "Setting configuration field [%s][%s] = %s", 36 | section, option, value 37 | ) 38 | self._parser.setdefault(section, {}) 39 | self._parser[section][option] = value 40 | 41 | # Ensure parent directories exist 42 | self._path.parent.mkdir(parents=True, exist_ok=True) 43 | 44 | with self._path.open("wt", encoding="utf-8") as file_: 45 | self._parser.write(file_) 46 | 47 | 48 | def get_config(): 49 | """ 50 | Retrieve the Protontricks configuration file 51 | """ 52 | return Config() 53 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = protontricks 3 | description = A simple wrapper for running Winetricks commands for Proton-enabled games. 4 | long_description = file: README.md 5 | long_description_content_type = text/markdown 6 | url = https://github.com/Matoking/protontricks 7 | author = Janne Pulkkinen 8 | author_email = janne.pulkkinen@protonmail.com 9 | license = GPL-3.0-only 10 | license_files = 11 | LICENSE 12 | platforms = linux 13 | classifiers = 14 | Topic :: Utilities 15 | Programming Language :: Python 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3.7 18 | Programming Language :: Python :: 3.8 19 | Programming Language :: Python :: 3.9 20 | Programming Language :: Python :: 3.10 21 | Programming Language :: Python :: 3.11 22 | Programming Language :: Python :: 3.12 23 | Programming Language :: Python :: 3.13 24 | Programming Language :: Python :: 3.14 25 | 26 | [options] 27 | packages = find_namespace: 28 | package_dir = 29 | = src 30 | include_package_data = True 31 | install_requires = 32 | vdf>=3.2 33 | Pillow 34 | setup_requires = 35 | setuptools-scm 36 | python_requires = >=3.7 37 | 38 | [options.packages.find] 39 | where = src 40 | 41 | [options.package_data] 42 | protontricks.data = 43 | * 44 | 45 | [options.entry_points] 46 | console_scripts = 47 | protontricks = protontricks.cli.main:cli 48 | protontricks-launch = protontricks.cli.launch:cli 49 | protontricks-desktop-install = protontricks.cli.desktop_install:cli 50 | 51 | [options.data_files] 52 | share/applications = 53 | src/protontricks/data/share/applications/protontricks.desktop 54 | src/protontricks/data/share/applications/protontricks-launch.desktop 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/python,virtualenv 2 | 3 | # Don't track setuptools-scm generated _version.py 4 | src/protontricks/_version.py 5 | 6 | ### Python ### 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | .pytest_cache/ 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule.* 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | ### VirtualEnv ### 107 | # Virtualenv 108 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 109 | [Bb]in 110 | [Ii]nclude 111 | [Ll]ib 112 | [Ll]ib64 113 | [Ll]ocal 114 | [Mm]an 115 | [Tt]cl 116 | pyvenv.cfg 117 | pip-selfcheck.json 118 | 119 | 120 | # End of https://www.gitignore.io/api/python,virtualenv 121 | -------------------------------------------------------------------------------- /src/protontricks/cli/desktop_install.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from pathlib import Path 4 | from subprocess import run 5 | 6 | import importlib.resources 7 | 8 | from .util import CustomArgumentParser 9 | 10 | 11 | def install_desktop_entries(): 12 | """ 13 | Install the desktop entry files for Protontricks. 14 | 15 | This should only be necessary when using an installation method that does 16 | not support .desktop files (eg. pip/pipx) 17 | 18 | :returns: Directory containing the installed .desktop files 19 | """ 20 | applications_dir = Path.home() / ".local" / "share" / "applications" 21 | applications_dir.mkdir(parents=True, exist_ok=True) 22 | 23 | desktop_path_resolver = importlib.resources.path( 24 | "protontricks.data.share.applications", "protontricks.desktop" 25 | ) 26 | launch_path_resolver = importlib.resources.path( 27 | "protontricks.data.share.applications", "protontricks-launch.desktop" 28 | ) 29 | 30 | with desktop_path_resolver as desktop_path, \ 31 | launch_path_resolver as launch_path: 32 | run([ 33 | "desktop-file-install", "--dir", str(applications_dir), 34 | str(desktop_path), str(launch_path) 35 | ], check=True) 36 | 37 | return applications_dir 38 | 39 | 40 | def cli(args=None): 41 | main(args) 42 | 43 | 44 | def main(args=None): 45 | """ 46 | 'protontricks-desktop-install' script entrypoint 47 | """ 48 | if args is None: 49 | args = sys.argv[1:] 50 | 51 | parser = CustomArgumentParser( 52 | description=( 53 | "Install Protontricks application shortcuts for the local user\n" 54 | ), 55 | formatter_class=argparse.RawTextHelpFormatter 56 | ) 57 | 58 | # This doesn't really do much except accept `--help` 59 | parser.parse_args(args) 60 | 61 | print("Installing .desktop files for the local user...") 62 | install_dir = install_desktop_entries() 63 | print(f"\nDone. Files have been installed under {install_dir}") 64 | print("The Protontricks shortcut and desktop integration should now work.") 65 | 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | Troubleshooting 2 | =============== 3 | 4 | You can [create an issue](https://github.com/Matoking/protontricks/issues/new/choose) on GitHub. Before doing so, please check if your issue is related to any of the following known issues. 5 | 6 | # Common issues and solutions 7 | 8 | ## "warning: You are using a 64-bit WINEPREFIX" 9 | 10 | > Whenever I run a Winetricks command, I see the warning `warning: You are using a 64-bit WINEPREFIX. Note that many verbs only install 32-bit versions of packages. If you encounter problems, please retest in a clean 32-bit WINEPREFIX before reporting a bug.`. 11 | > Is this a problem? 12 | 13 | Proton uses 64-bit Wine prefixes, which means you will see this warning with every game. You can safely ignore the message if the command otherwise works. 14 | 15 | ## "Unknown arg foobar" 16 | 17 | > When I'm trying to run a Protontricks command such as `protontricks foobar`, I get the error `Unknown arg foobar`. 18 | 19 | Your Winetricks installation might be outdated, which means your Winetricks installation doesn't support the verb you are trying to use (`foobar` in this example). Some distros such as Debian might ship very outdated versions of Winetricks. To ensure you have the latest version of Winetricks, [see the installation instructions](https://github.com/Winetricks/winetricks#installing) on the Winetricks repository. 20 | 21 | ## "Unknown option --foobar" 22 | 23 | > When I'm trying to run a Protontricks command such as `protontricks --no-bwrap foobar`, I get the error `Unknown option --no-bwrap`. 24 | 25 | You need to provide Protontricks specific options *before* the app ID. This is because all parameters after the app ID are passed directly to Winetricks; otherwise, Protontricks cannot tell which options are related to Winetricks and which are not. In this case, the correct command to run would be `protontricks --no-bwrap foobar`. 26 | 27 | ## "command cabextract ... returned status 1. Aborting." 28 | 29 | > When I'm trying to run a Winetricks command, I get the error `command cabextract ... returned status 1. Aborting.` 30 | 31 | This is a known issue with `cabextract`, which doesn't support symbolic links created by Proton 5.13 and newer. 32 | 33 | As a workaround, you can remove the problematic symbolic link in the failed command and run the command again. Repeat this until the command finishes successfully. 34 | 35 | You can also check [the Winetricks issue on GitHub](https://github.com/Winetricks/winetricks/issues/1648). 36 | -------------------------------------------------------------------------------- /src/protontricks/data/scripts/wineserver_keepalive.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # A simple keepalive script that will ensure a wineserver process is kept alive 3 | # for the duration of the Protontricks session. 4 | # This is accomplished by launching a simple Windows batch script that will 5 | # run until it is prompted to close itself at the end of the Protontricks 6 | # session. 7 | set -o errexit 8 | 9 | function log_info () { 10 | if [[ "$PROTONTRICKS_LOG_LEVEL" != "INFO" ]]; then 11 | return 12 | fi 13 | 14 | log "$@" 15 | } 16 | 17 | function log_warning () { 18 | if [[ "$PROTONTRICKS_LOG_LEVEL" = "INFO" || "$PROTONTRICKS_LOG_LEVEL" = "WARNING" ]]; then 19 | return 20 | fi 21 | 22 | log "$@" 23 | } 24 | 25 | function log () { 26 | >&2 echo "protontricks - $(basename "$0") $$: $*" 27 | } 28 | 29 | function cleanup () { 30 | # Remove the 'keepalive' file in the temp directory. This will prompt 31 | # the Wine process to stop execution. 32 | rm "$PROTONTRICKS_TEMP_PATH/keepalive" &>/dev/null || true 33 | log_info "Cleanup finished, goodbye!" 34 | } 35 | 36 | touch "$PROTONTRICKS_TEMP_PATH/keepalive" 37 | 38 | trap cleanup EXIT HUP INT QUIT ABRT 39 | 40 | cd "$PROTONTRICKS_TEMP_PATH" || exit 1 41 | 42 | while [[ -f "$PROTONTRICKS_TEMP_PATH/keepalive" ]]; do 43 | log_info "Starting wineserver-keepalive process..." 44 | 45 | wine cmd.exe /c "@@keepalive_bat_path@@" &>/dev/null 46 | if [[ -f "$PROTONTRICKS_TEMP_PATH/keepalive" ]]; then 47 | # If 'keepalive' still exists, someone called 'wineserver -w'. 48 | # To prevent that command from stalling indefinitely, we need to 49 | # shut down this process temporarily until the waiting command 50 | # has terminated. 51 | wineserver_finished=false 52 | 53 | log_info "'wineserver -w' was called, waiting until all processes are finished..." 54 | 55 | while [[ "$wineserver_finished" = false ]]; do 56 | wineserver_finished=true 57 | while read -r pid; do 58 | if [[ "$pid" = "$$" ]]; then 59 | continue 60 | fi 61 | 62 | if [[ $(pgrep -a "$pid" | grep -v -E '\/wineserver -w$') ]] &> /dev/null; then 63 | # Skip commands that do *not* end with 'wineserver -w' 64 | continue 65 | fi 66 | 67 | if [[ $(xargs -0 -L1 -a "/proc/${pid}/environ" | grep "^WINEPREFIX=${WINEPREFIX}") ]] &> /dev/null; then 68 | wineserver_finished=false 69 | fi 70 | done < <(pgrep wineserver) 71 | sleep 0.25 72 | done 73 | 74 | log_info "All wineserver processes finished, restarting keepalive process..." 75 | fi 76 | done 77 | -------------------------------------------------------------------------------- /src/protontricks/data/scripts/bwrap_launcher.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Helper script 3 | set -o errexit 4 | 5 | function log_debug () { 6 | if [[ "$PROTONTRICKS_LOG_LEVEL" != "DEBUG" ]]; then 7 | return 8 | fi 9 | 10 | log "$@" 11 | } 12 | 13 | function log_info () { 14 | if [[ "$PROTONTRICKS_LOG_LEVEL" = "WARNING" ]]; then 15 | return 16 | fi 17 | 18 | log "$@" 19 | } 20 | 21 | function log_warning () { 22 | if [[ "$PROTONTRICKS_LOG_LEVEL" = "INFO" || "$PROTONTRICKS_LOG_LEVEL" = "WARNING" ]]; then 23 | return 24 | fi 25 | 26 | log "$@" 27 | } 28 | 29 | function log () { 30 | >&2 echo "protontricks - $(basename "$0") $$: $*" 31 | } 32 | 33 | BLACKLISTED_ROOT_DIRS=( 34 | /bin /dev /lib /lib64 /proc /run /sys /var /usr 35 | ) 36 | 37 | ADDITIONAL_MOUNT_DIRS=( 38 | /run/media "$PROTON_PATH" "$WINEPREFIX" 39 | ) 40 | 41 | mount_dirs=() 42 | 43 | # Add any root directories that are not blacklisted 44 | for dir in /* ; do 45 | if [[ ! -d "$dir" ]]; then 46 | continue 47 | fi 48 | if [[ " ${BLACKLISTED_ROOT_DIRS[*]} " =~ " $dir " ]]; then 49 | continue 50 | fi 51 | mount_dirs+=("$dir") 52 | done 53 | 54 | # Add additional mount directories, including the Wine prefix and Proton 55 | # installation directory 56 | for dir in "${ADDITIONAL_MOUNT_DIRS[@]}"; do 57 | if [[ ! -d "$dir" ]]; then 58 | continue 59 | fi 60 | 61 | already_mounted=false 62 | # Check if the additional mount directory is already covered by one 63 | # of the existing root directories. 64 | # Most of the time this is the case, but if the user has placed the Proton 65 | # installation or prefix inside a blacklisted directory (eg. '/lib'), 66 | # we'll want to ensure it's mounted even if we're not mounting the entire 67 | # root directory. 68 | for mount_dir in "${mount_dirs[@]}"; do 69 | if [[ "$dir" =~ ^$mount_dir ]]; then 70 | # This directory is already covered by one of the existing mount 71 | # points 72 | already_mounted=true 73 | break 74 | fi 75 | done 76 | 77 | if [[ "$already_mounted" = false ]]; then 78 | mount_dirs+=("$dir") 79 | fi 80 | done 81 | 82 | mount_params=() 83 | 84 | for mount in "${mount_dirs[@]}"; do 85 | mount_params+=(--filesystem "${mount}") 86 | done 87 | 88 | log_info "Following directories will be mounted inside container: ${mount_dirs[*]}" 89 | log_info "Using temporary directory: $PROTONTRICKS_TEMP_PATH" 90 | 91 | # Protontricks will listen to this file descriptor. Once it's closed, 92 | # the launcher has finished starting up. 93 | status_fd="$1" 94 | 95 | exec "$STEAM_RUNTIME_PATH"/run --share-pid --launcher --pass-fd "$status_fd" \ 96 | "${mount_params[@]}" -- \ 97 | --info-fd "$status_fd" --bus-name="com.github.Matoking.protontricks.App${STEAM_APPID}_${PROTONTRICKS_SESSION_ID}" 98 | -------------------------------------------------------------------------------- /data/com.github.Matoking.protontricks.metainfo.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.github.Matoking.protontricks 4 | Protontricks 5 | Apps and fixes for Proton games 6 | wine 7 | 8 | protontricks 9 | protontricks-launch 10 | 11 | protontricks.desktop 12 | 13 | com.valvesoftware.Steam 14 | 15 | 16 | 17 | https://raw.githubusercontent.com/Matoking/protontricks/master/data/screenshot.png 18 | App selection screen 19 | 20 | 21 | 22 | pointing 23 | keyboard 24 | console 25 | 26 | 27 | 28 |

Run Winetricks commands for Steam Play/Proton games among other common Wine features, 29 | such as launching external Windows executables. This is often useful when a game requires closed-source runtime libraries or applications 30 | that are not included with Proton.

31 |
32 | 33 | Utility 34 | 35 | 36 | Janne Pulkkinen 37 | 38 | https://github.com/Matoking/protontricks 39 | https://github.com/Matoking/protontricks#readme 40 | https://github.com/Matoking/protontricks/issues 41 | GPL-3.0 42 | CC0-1.0 43 | janne.pulkkinen@protonmail.com 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 |
69 | -------------------------------------------------------------------------------- /tests/cli/test_util.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from protontricks.cli.util import (_delete_log_file, _get_log_file_path, 6 | enable_logging, exit_with_error) 7 | 8 | 9 | @pytest.fixture(scope="function") 10 | def broken_appmanifest(monkeypatch): 11 | def _mock_from_appmanifest(*args, **kwargs): 12 | raise ValueError("Test appmanifest error") 13 | 14 | monkeypatch.setattr( 15 | "protontricks.steam.SteamApp.from_appmanifest", 16 | _mock_from_appmanifest 17 | ) 18 | 19 | 20 | def test_enable_logging(): 21 | """ 22 | Ensure that calling 'enable_logging' enables the logging only once 23 | """ 24 | logger = logging.getLogger("protontricks") 25 | 26 | assert len(logger.handlers) == 0 27 | 28 | enable_logging() 29 | assert len(logger.handlers) == 2 30 | 31 | # No more handlers are added 32 | enable_logging() 33 | assert len(logger.handlers) == 2 34 | 35 | 36 | def test_cli_error_handler_uncaught_exception( 37 | cli, default_proton, steam_app_factory, broken_appmanifest, 38 | gui_provider): 39 | """ 40 | Ensure that 'cli_error_handler' correctly catches any uncaught 41 | exception and includes a stack trace in the error dialog. 42 | """ 43 | steam_app_factory(name="Fake game", appid=10) 44 | 45 | cli(["--no-term", "-s", "Fake"], expect_returncode=1) 46 | 47 | assert gui_provider.args[0] == "yad" 48 | assert gui_provider.args[1] == "--text-info" 49 | 50 | message = gui_provider.kwargs["input"] 51 | 52 | # 'broken_appmanifest' will induce an error in 'SteamApp.from_appmanifest' 53 | assert b"Test appmanifest error" in message 54 | 55 | 56 | @pytest.mark.parametrize("gui_cmd", ["yad", "zenity"]) 57 | def test_cli_error_handler_gui_provider_env( 58 | cli, default_proton, steam_app_factory, monkeypatch, 59 | broken_appmanifest, gui_provider, gui_cmd): 60 | """ 61 | Ensure that correct GUI provider is used depending on 'PROTONTRICKS_GUI' 62 | environment variable 63 | """ 64 | monkeypatch.setenv("PROTONTRICKS_GUI", gui_cmd) 65 | 66 | steam_app_factory(name="Fake game", appid=10) 67 | 68 | cli(["--no-term", "-s", "Fake"], expect_returncode=1) 69 | 70 | message = gui_provider.kwargs["input"] 71 | 72 | assert b"Test appmanifest error" in message 73 | 74 | if gui_cmd == "yad": 75 | assert gui_provider.args[0] == "yad" 76 | # YAD has custom button declarations 77 | assert "--button=OK:0" in gui_provider.args 78 | elif gui_cmd == "zenity": 79 | assert gui_provider.args[0] == "zenity" 80 | # Zenity doesn't have custom button declarations 81 | assert "--button=OK:0" not in gui_provider.args 82 | 83 | 84 | def test_exit_with_error_no_log_file(gui_provider): 85 | """ 86 | Ensure that `exit_with_error` can show the error dialog even if 87 | the log file goes missing for some reason 88 | """ 89 | try: 90 | _get_log_file_path().unlink() 91 | except FileNotFoundError: 92 | pass 93 | 94 | with pytest.raises(SystemExit): 95 | exit_with_error("Test error", desktop=True) 96 | 97 | assert gui_provider.args[0] == "yad" 98 | assert gui_provider.args[1] == "--text-info" 99 | 100 | message = gui_provider.kwargs["input"] 101 | 102 | assert b"Test error" in message 103 | 104 | 105 | def test_log_file_cleanup(cli, steam_app_factory, gui_provider): 106 | """ 107 | Ensure that log file contains the log files generated during the 108 | CLI call and that it is cleared after running `_delete_log_file` 109 | """ 110 | steam_app_factory(name="Fake game", appid=10) 111 | cli(["--no-term", "-s", "Fake"]) 112 | 113 | assert "Found Steam directory" in _get_log_file_path().read_text() 114 | 115 | # This is called on shutdown by atexit, but call it here directly 116 | # since we can't test atexit. 117 | _delete_log_file() 118 | 119 | assert not _get_log_file_path().is_file() 120 | 121 | # Nothing happens if the file is already missing 122 | _delete_log_file() 123 | -------------------------------------------------------------------------------- /src/protontricks/flatpak.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import logging 3 | import os 4 | import re 5 | import subprocess 6 | from pathlib import Path 7 | 8 | __all__ = ( 9 | "FLATPAK_BWRAP_COMPATIBLE_VERSION", "FLATPAK_INFO_PATH", 10 | "is_flatpak_sandbox", "get_running_flatpak_version", 11 | "get_inaccessible_paths" 12 | ) 13 | 14 | logger = logging.getLogger("protontricks") 15 | 16 | # Flatpak minimum version required to enable bwrap. In other words, the first 17 | # Flatpak version with the necessary support for sub-sandboxes. 18 | FLATPAK_BWRAP_COMPATIBLE_VERSION = (1, 12, 1) 19 | 20 | FLATPAK_INFO_PATH = "/.flatpak-info" 21 | 22 | 23 | def is_flatpak_sandbox(): 24 | """ 25 | Check if we're running inside a Flatpak sandbox 26 | """ 27 | return bool(get_running_flatpak_version()) 28 | 29 | 30 | def _get_flatpak_config(): 31 | config = configparser.ConfigParser() 32 | 33 | try: 34 | config.read_string(Path(FLATPAK_INFO_PATH).read_text(encoding="utf-8")) 35 | except FileNotFoundError: 36 | return None 37 | 38 | return config 39 | 40 | 41 | _XDG_PERMISSIONS = { 42 | "xdg-desktop": "DESKTOP", 43 | "xdg-documents": "DOCUMENTS", 44 | "xdg-download": "DOWNLOAD", 45 | "xdg-music": "MUSIC", 46 | "xdg-pictures": "PICTURES", 47 | "xdg-public-share": "PUBLICSHARE", 48 | "xdg-videos": "VIDEOS", 49 | "xdg-templates": "TEMPLATES", 50 | } 51 | 52 | 53 | def _get_xdg_user_dir(permission): 54 | """ 55 | Get the XDG user directory corresponding to the given "xdg-" prefixed 56 | Flatpak permission and retrieve its absolute path using the `xdg-user-dir` 57 | command. 58 | """ 59 | if permission in _XDG_PERMISSIONS: 60 | # This will only be called in a Flatpak environment, and we can assume 61 | # 'xdg-user-dir' always exists in that environment. 62 | path = subprocess.check_output( 63 | ["xdg-user-dir", _XDG_PERMISSIONS[permission]] 64 | ) 65 | path = path.strip() 66 | path = os.fsdecode(path) 67 | logger.debug("XDG path for %s is %s", permission, path) 68 | return Path(path) 69 | 70 | return None 71 | 72 | 73 | def get_running_flatpak_version(): 74 | """ 75 | Get the running Flatpak version if running inside a Flatpak sandbox, 76 | or None if Flatpak sandbox isn't active 77 | """ 78 | config = _get_flatpak_config() 79 | 80 | if config is None: 81 | return None 82 | 83 | # If this fails it's because the Flatpak version is older than 0.6.10. 84 | # Since Steam Flatpak requires at least 1.0.0, we can fail here instead 85 | # of continuing on. It's also extremely unlikely, since even older distros 86 | # like CentOS 7 ship Flatpak releases newer than 1.0.0. 87 | version = config["Instance"]["flatpak-version"] 88 | 89 | # Remove non-numeric characters just in case (eg. if a suffix like '-pre' 90 | # is used). 91 | version = "".join([ch for ch in version if ch in ("0123456789.")]) 92 | 93 | # Convert version number into a tuple 94 | version = tuple([int(part) for part in version.split(".")]) 95 | return version 96 | 97 | 98 | def get_inaccessible_paths(paths): 99 | """ 100 | Check which given paths are inaccessible under Protontricks. 101 | 102 | Inaccessible paths are returned as a list. This has no effect in 103 | non-Flatpak environments, where an empty list is always returned. 104 | """ 105 | def _path_is_relative_to(a, b): 106 | try: 107 | a.relative_to(b) 108 | return True 109 | except ValueError: 110 | return False 111 | 112 | def _map_path(path): 113 | if path == "": 114 | return None 115 | 116 | if path.startswith("xdg-data/"): 117 | return ( 118 | Path("~/.local/share").expanduser() 119 | / path.split("xdg-data/")[1] 120 | ) 121 | 122 | if path.startswith("xdg-"): 123 | path_ = _get_xdg_user_dir(path) 124 | 125 | if path_: 126 | return path_ 127 | 128 | if path == "home": 129 | return Path.home() 130 | 131 | if path.startswith("/"): 132 | return Path(path).resolve() 133 | 134 | if path.startswith("~"): 135 | return Path(path).expanduser() 136 | 137 | logger.warning( 138 | "Unknown Flatpak file system permission '%s', ignoring.", 139 | path 140 | ) 141 | return None 142 | 143 | if not is_flatpak_sandbox(): 144 | return [] 145 | 146 | config = _get_flatpak_config() 147 | 148 | try: 149 | mounted_paths = \ 150 | re.split(r'(?>> d = vdf.VDFDict() 97 | >>> d['key'] = 111 98 | >>> d['key'] = 222 99 | >>> d 100 | VDFDict([('key', 111), ('key', 222)]) 101 | >>> d.items() 102 | [('key', 111), ('key', 222)] 103 | >>> d['key'] 104 | 111 105 | >>> d[(0, 'key')] # get the first duplicate 106 | 111 107 | >>> d[(1, 'key')] # get the second duplicate 108 | 222 109 | >>> d.get_all_for('key') 110 | [111, 222] 111 | 112 | >>> d[(1, 'key')] = 123 # reassign specific duplicate 113 | >>> d.get_all_for('key') 114 | [111, 123] 115 | 116 | >>> d['key'] = 333 117 | >>> d.get_all_for('key') 118 | [111, 123, 333] 119 | >>> del d[(1, 'key')] 120 | >>> d.get_all_for('key') 121 | [111, 333] 122 | >>> d[(1, 'key')] 123 | 333 124 | 125 | >>> print vdf.dumps(d) 126 | "key" "111" 127 | "key" "333" 128 | 129 | >>> d.has_duplicates() 130 | True 131 | >>> d.remove_all_for('key') 132 | >>> len(d) 133 | 0 134 | >>> d.has_duplicates() 135 | False 136 | 137 | 138 | .. |pypi| image:: https://img.shields.io/pypi/v/vdf.svg?style=flat&label=latest%20version 139 | :target: https://pypi.org/project/vdf/ 140 | :alt: Latest version released on PyPi 141 | 142 | .. |license| image:: https://img.shields.io/pypi/l/vdf.svg?style=flat&label=license 143 | :target: https://pypi.org/project/vdf/ 144 | :alt: MIT License 145 | 146 | .. |coverage| image:: https://img.shields.io/coveralls/ValvePython/vdf/master.svg?style=flat 147 | :target: https://coveralls.io/r/ValvePython/vdf?branch=master 148 | :alt: Test coverage 149 | 150 | .. |sonar_maintainability| image:: https://sonarcloud.io/api/project_badges/measure?project=ValvePython_vdf&metric=sqale_rating 151 | :target: https://sonarcloud.io/dashboard?id=ValvePython_vdf 152 | :alt: SonarCloud Rating 153 | 154 | .. |sonar_reliability| image:: https://sonarcloud.io/api/project_badges/measure?project=ValvePython_vdf&metric=reliability_rating 155 | :target: https://sonarcloud.io/dashboard?id=ValvePython_vdf 156 | :alt: SonarCloud Rating 157 | 158 | .. |sonar_security| image:: https://sonarcloud.io/api/project_badges/measure?project=ValvePython_vdf&metric=security_rating 159 | :target: https://sonarcloud.io/dashboard?id=ValvePython_vdf 160 | :alt: SonarCloud Rating 161 | 162 | .. |master_build| image:: https://github.com/ValvePython/vdf/workflows/Tests/badge.svg?branch=master 163 | :target: https://github.com/ValvePython/vdf/actions?query=workflow%3A%22Tests%22+branch%3Amaster 164 | :alt: Build status of master branch 165 | 166 | .. _DuplicateOrderedDict: https://github.com/rossengeorgiev/dota2_notebooks/blob/master/DuplicateOrderedDict_for_VDF.ipynb 167 | 168 | .. _hash randomization: https://docs.python.org/2/using/cmdline.html#envvar-PYTHONHASHSEED 169 | -------------------------------------------------------------------------------- /src/protontricks/cli/util.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import atexit 3 | import functools 4 | import logging 5 | import os 6 | import sys 7 | import tempfile 8 | import traceback 9 | import contextlib 10 | from pathlib import Path 11 | 12 | from ..gui import show_text_dialog 13 | from ..flatpak import is_flatpak_sandbox 14 | from ..util import is_steam_deck, is_steamos 15 | from .. import __version__ 16 | 17 | 18 | def _get_log_file_path(): 19 | """ 20 | Get the log file path to use for this Protontricks process. 21 | """ 22 | temp_dir = tempfile.gettempdir() 23 | 24 | pid = os.getpid() 25 | return Path(temp_dir) / f"protontricks{pid}.log" 26 | 27 | 28 | def _delete_log_file(): 29 | """ 30 | Delete the log file if one exists. 31 | 32 | This is usually executed before shutdown by registering this function 33 | using `atexit` 34 | """ 35 | try: 36 | _get_log_file_path().unlink() 37 | except FileNotFoundError: 38 | pass 39 | 40 | 41 | def enable_logging(level=0, record_to_file=True): 42 | """ 43 | Enables logging. 44 | 45 | :param int level: Level of logging. 0 = WARNING, 1 = INFO, 2 = DEBUG. 46 | :param bool record_to_file: Whether to log the generated log messages 47 | to a temporary file. 48 | This is used for the error dialog containing 49 | log records. 50 | """ 51 | if level >= 2: 52 | level = logging.DEBUG 53 | label = "DEBUG" 54 | elif level >= 1: 55 | level = logging.INFO 56 | label = "INFO" 57 | else: 58 | level = logging.WARNING 59 | label = "WARNING" 60 | 61 | # 'PROTONTRICKS_LOG_LEVEL' env var allows separate Bash scripts 62 | # to detect when logging is enabled. 63 | os.environ["PROTONTRICKS_LOG_LEVEL"] = label 64 | 65 | logger = logging.getLogger("protontricks") 66 | 67 | stream_handler_added = any( 68 | filter( 69 | lambda hndl: hndl.name == "protontricks-stream", logger.handlers 70 | ) 71 | ) 72 | 73 | if not stream_handler_added: 74 | # Logs printed to stderr will follow the log level 75 | stream_handler = logging.StreamHandler() 76 | stream_handler.name = "protontricks-stream" 77 | stream_handler.setLevel(level) 78 | stream_handler.setFormatter( 79 | logging.Formatter("%(name)s (%(levelname)s): %(message)s") 80 | ) 81 | 82 | logger.setLevel(logging.DEBUG) 83 | logger.addHandler(stream_handler) 84 | 85 | logger.debug("Stream log handler added") 86 | 87 | if not record_to_file: 88 | return 89 | 90 | file_handler_added = any( 91 | filter(lambda hndl: hndl.name == "protontricks-file", logger.handlers) 92 | ) 93 | 94 | if not file_handler_added: 95 | # Record log files to temporary file. This means log messages can be 96 | # printed at the end of the session in an error dialog. 97 | # INFO and WARNING log messages are written into this file whether 98 | # `--verbose` is enabled or not. 99 | log_file_path = _get_log_file_path() 100 | try: 101 | log_file_path.unlink() 102 | except FileNotFoundError: 103 | pass 104 | 105 | file_handler = logging.FileHandler(str(_get_log_file_path())) 106 | file_handler.name = "protontricks-file" 107 | file_handler.setLevel(logging.INFO) 108 | logger.addHandler(file_handler) 109 | 110 | # Ensure the log file is removed before the process exits 111 | atexit.register(_delete_log_file) 112 | 113 | logger.debug("File log handler added") 114 | 115 | 116 | def exit_with_error(error, desktop=False): 117 | """ 118 | Exit with an error, either by printing the error to stderr or displaying 119 | an error dialog. 120 | 121 | :param bool desktop: If enabled, display an error dialog containing 122 | the error itself and additional log messages. 123 | """ 124 | if not desktop: 125 | print(error) 126 | sys.exit(1) 127 | 128 | try: 129 | log_messages = _get_log_file_path().read_text() 130 | except FileNotFoundError: 131 | log_messages = "!! LOG FILE NOT FOUND !!" 132 | 133 | is_flatpak_sandbox_ = None 134 | with contextlib.suppress(Exception): 135 | is_flatpak_sandbox_ = is_flatpak_sandbox() 136 | 137 | is_steam_deck_ = None 138 | with contextlib.suppress(Exception): 139 | is_steam_deck_ = is_steam_deck() 140 | 141 | is_steamos_ = None 142 | with contextlib.suppress(Exception): 143 | is_steamos_ = is_steamos() 144 | 145 | # Display an error dialog containing the message 146 | message = "".join([ 147 | "Protontricks was closed due to the following error:\n\n", 148 | f"{error}\n\n", 149 | "=============\n\n", 150 | "Please include this entire error message when making a bug report.\n", 151 | "Environment:\n\n", 152 | f"Protontricks version: {__version__}\n", 153 | f"Is Flatpak sandbox: {is_flatpak_sandbox_}\n", 154 | f"Is Steam Deck: {is_steam_deck_}\n", 155 | f"Is SteamOS 3+: {is_steamos_}\n\n", 156 | "Log messages:\n\n", 157 | f"{log_messages}" 158 | ]) 159 | 160 | show_text_dialog( 161 | title="Protontricks", 162 | text=message, 163 | window_icon=error 164 | ) 165 | sys.exit(1) 166 | 167 | 168 | def cli_error_handler(cli_func): 169 | """ 170 | Decorator for CLI entry points. 171 | 172 | If an unhandled exception is raised and Protontricks was launched from 173 | desktop, display an error dialog containing the stack trace instead 174 | of printing to stderr. 175 | """ 176 | @functools.wraps(cli_func) 177 | def wrapper(self, *args, **kwargs): 178 | try: 179 | wrapper.no_term = False 180 | return cli_func(self, *args, **kwargs) 181 | except Exception: # pylint: disable=broad-except 182 | if not wrapper.no_term: 183 | # If we weren't launched from desktop, handle it normally 184 | raise 185 | 186 | traceback_ = traceback.format_exc() 187 | exit_with_error(traceback_, desktop=True) 188 | 189 | return wrapper 190 | 191 | 192 | class CustomArgumentParser(argparse.ArgumentParser): 193 | """ 194 | Custom argument parser that prints the full help message 195 | when incorrect parameters are provided 196 | """ 197 | def error(self, message): 198 | self.print_help(sys.stderr) 199 | args = {'prog': self.prog, 'message': message} 200 | self.exit(2, '%(prog)s: error: %(message)s\n' % args) 201 | -------------------------------------------------------------------------------- /src/protontricks/cli/launch.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import shlex 4 | import sys 5 | from pathlib import Path 6 | 7 | from ..gui import (prompt_filesystem_access, select_steam_app_with_gui, 8 | select_steam_installation) 9 | from ..steam import (find_steam_installations, get_steam_apps, 10 | get_steam_lib_paths) 11 | from .main import main as cli_main 12 | from .util import (CustomArgumentParser, cli_error_handler, enable_logging, 13 | exit_with_error) 14 | 15 | logger = logging.getLogger("protontricks") 16 | 17 | 18 | def cli(args=None): 19 | main(args) 20 | 21 | 22 | @cli_error_handler 23 | def main(args=None): 24 | """ 25 | 'protontricks-launch' script entrypoint 26 | """ 27 | if args is None: 28 | args = sys.argv[1:] 29 | 30 | parser = CustomArgumentParser( 31 | description=( 32 | "Utility for launching Windows executables using Protontricks\n" 33 | "\n" 34 | "Usage:\n" 35 | "\n" 36 | "Launch EXECUTABLE and pick the Steam app using a dialog.\n" 37 | "$ protontricks-launch EXECUTABLE [ARGS]\n" 38 | "\n" 39 | "Launch EXECUTABLE for Steam app APPID\n" 40 | "$ protontricks-launch --appid APPID EXECUTABLE [ARGS]\n" 41 | "\n" 42 | "Environment variables:\n" 43 | "\n" 44 | "PROTON_VERSION: name of the preferred Proton installation\n" 45 | "STEAM_DIR: path to custom Steam installation\n" 46 | "WINETRICKS: path to a custom 'winetricks' executable\n" 47 | "WINE: path to a custom 'wine' executable\n" 48 | "WINESERVER: path to a custom 'wineserver' executable\n" 49 | "STEAM_RUNTIME: 1 = enable Steam Runtime, 0 = disable Steam " 50 | "Runtime, valid path = custom Steam Runtime path, " 51 | "empty = enable automatically (default)" 52 | ), 53 | formatter_class=argparse.RawTextHelpFormatter 54 | ) 55 | parser.add_argument( 56 | "--no-term", action="store_true", 57 | help=( 58 | "Program was launched from desktop and no user-visible " 59 | "terminal is available. Error will be shown in a dialog instead " 60 | "of being printed." 61 | ) 62 | ) 63 | parser.add_argument( 64 | "--verbose", "-v", action="count", default=0, 65 | help=( 66 | "Increase log verbosity. Can be supplied twice for " 67 | "maximum verbosity." 68 | ) 69 | ) 70 | parser.add_argument( 71 | "--no-runtime", action="store_true", default=False, 72 | help="Disable Steam Runtime") 73 | parser.add_argument( 74 | "--no-bwrap", action="store_true", default=False, 75 | help="Disable bwrap containerization when using Steam Runtime" 76 | ) 77 | parser.add_argument( 78 | "--background-wineserver", 79 | dest="background_wineserver", 80 | action="store_true", 81 | help=( 82 | "Launch a background wineserver process to improve Wine command " 83 | "startup time. Disabled by default, as it can cause problems with " 84 | "some graphical applications." 85 | ) 86 | ) 87 | parser.add_argument( 88 | "--no-background-wineserver", 89 | dest="background_wineserver", 90 | action="store_false", 91 | help=( 92 | "Do not launch a background wineserver process to improve Wine " 93 | "command startup time." 94 | ) 95 | ) 96 | parser.add_argument( 97 | "--appid", type=int, nargs="?", default=None 98 | ) 99 | parser.add_argument( 100 | "--cwd-app", 101 | dest="cwd_app", 102 | default=False, 103 | action="store_true", 104 | help=( 105 | "Set the working directory of launched executable to the Steam " 106 | "app's installation directory." 107 | ) 108 | ) 109 | parser.add_argument("executable", type=str) 110 | parser.add_argument("exec_args", nargs=argparse.REMAINDER) 111 | parser.set_defaults(background_wineserver=False) 112 | 113 | args = parser.parse_args(args) 114 | 115 | # 'cli_error_handler' relies on this to know whether to use error dialog or 116 | # not 117 | main.no_term = args.no_term 118 | 119 | # Shorthand function for aborting with error message 120 | def exit_(error): 121 | exit_with_error(error, args.no_term) 122 | 123 | enable_logging(args.verbose, record_to_file=args.no_term) 124 | 125 | executable_path = Path(args.executable).resolve(strict=True) 126 | 127 | # 1. Find Steam path 128 | steam_installations = find_steam_installations() 129 | if not steam_installations: 130 | exit_("Steam installation directory could not be found.") 131 | 132 | steam_path, steam_root = select_steam_installation(steam_installations) 133 | if not steam_path: 134 | exit_("No Steam installation was selected.") 135 | 136 | # 2. Find any Steam library folders 137 | steam_lib_paths = get_steam_lib_paths(steam_path) 138 | 139 | # Check if Protontricks has access to all the required paths 140 | prompt_filesystem_access( 141 | paths=[steam_path, steam_root] + steam_lib_paths, 142 | show_dialog=args.no_term 143 | ) 144 | 145 | # 3. Find any Steam apps 146 | steam_apps = get_steam_apps( 147 | steam_root=steam_root, steam_path=steam_path, 148 | steam_lib_paths=steam_lib_paths 149 | ) 150 | steam_apps = [ 151 | app for app in steam_apps if app.prefix_path_exists and app.appid 152 | ] 153 | 154 | if not steam_apps: 155 | exit_( 156 | "No Proton enabled Steam apps were found. Have you launched one " 157 | "of the apps at least once?" 158 | ) 159 | 160 | if not args.appid: 161 | appid = select_steam_app_with_gui( 162 | steam_apps, 163 | title=f"Choose Wine prefix to run {executable_path.name}", 164 | steam_path=steam_path 165 | ).appid 166 | else: 167 | appid = args.appid 168 | 169 | # Build the command to pass to the main Protontricks CLI entrypoint 170 | cli_args = [] 171 | 172 | # Ensure each individual argument passed to the EXE is escaped 173 | exec_args = [shlex.quote(arg) for arg in args.exec_args] 174 | 175 | if args.verbose: 176 | cli_args += ["-" + ("v" * args.verbose)] 177 | 178 | if args.no_runtime: 179 | cli_args += ["--no-runtime"] 180 | 181 | if args.no_bwrap: 182 | cli_args += ["--no-bwrap"] 183 | 184 | if args.background_wineserver is True: 185 | cli_args += ["--background-wineserver"] 186 | elif args.background_wineserver is False: 187 | cli_args += ["--no-background-wineserver"] 188 | 189 | if args.no_term: 190 | cli_args += ["--no-term"] 191 | 192 | inner_args = " ".join( 193 | ["wine", shlex.quote(str(executable_path))] 194 | + exec_args 195 | ) 196 | 197 | if args.cwd_app: 198 | cli_args += ["--cwd-app"] 199 | 200 | cli_args += [ 201 | "-c", inner_args, str(appid) 202 | ] 203 | 204 | # Launch the main Protontricks CLI entrypoint 205 | logger.info( 206 | "Calling `protontricks` with the command: %s", cli_args 207 | ) 208 | cli_main(cli_args, steam_path=steam_path, steam_root=steam_root) 209 | 210 | 211 | if __name__ == "__main__": 212 | main() 213 | -------------------------------------------------------------------------------- /tests/test_flatpak.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pathlib import Path 4 | 5 | from protontricks.flatpak import (get_inaccessible_paths, 6 | get_running_flatpak_version) 7 | 8 | 9 | class TestGetRunningFlatpakVersion: 10 | def test_flatpak_not_active(self): 11 | """ 12 | Test Flatpak version detection when Flatpak is not active 13 | """ 14 | assert get_running_flatpak_version() is None 15 | 16 | def test_flatpak_active(self, monkeypatch, tmp_path): 17 | """ 18 | Test Flatpak version detection when Flatpak is active 19 | """ 20 | flatpak_info_path = tmp_path / "flatpak-info" 21 | 22 | flatpak_info_path.write_text( 23 | "[Application]\n" 24 | "name=fake.flatpak.Protontricks\n" 25 | "\n" 26 | "[Instance]\n" 27 | "flatpak-version=1.12.1" 28 | ) 29 | monkeypatch.setattr( 30 | "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) 31 | ) 32 | 33 | assert get_running_flatpak_version() == (1, 12, 1) 34 | 35 | 36 | class TestGetInaccessiblePaths: 37 | def test_flatpak_disabled(self): 38 | """ 39 | Test that an empty list is returned if Flatpak is not active 40 | """ 41 | assert get_inaccessible_paths(["/fake", "/fake_2"]) == [] 42 | 43 | def test_flatpak_active(self, monkeypatch, home_dir, tmp_path): 44 | """ 45 | Test that inaccessible paths are correctly detected when 46 | Flatpak is active 47 | """ 48 | flatpak_info_path = tmp_path / "flatpak-info" 49 | 50 | flatpak_info_path.write_text( 51 | "[Application]\n" 52 | "name=fake.flatpak.Protontricks\n" 53 | "\n" 54 | "[Instance]\n" 55 | "flatpak-version=1.12.1\n" 56 | "\n" 57 | "[Context]\n" 58 | "filesystems=/mnt/SSD_A;/mnt/SSD_B;xdg-data/Steam;" 59 | ) 60 | monkeypatch.setattr( 61 | "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) 62 | ) 63 | 64 | inaccessible_paths = get_inaccessible_paths([ 65 | "/mnt/SSD_A", "/mnt/SSD_C", 66 | str(home_dir / ".local/share/SteamOld"), 67 | str(home_dir / ".local/share/Steam") 68 | ]) 69 | assert len(inaccessible_paths) == 2 70 | assert str(inaccessible_paths[0]) == "/mnt/SSD_C" 71 | assert str(inaccessible_paths[1]) == \ 72 | str(Path("~/.local/share/SteamOld").expanduser()) 73 | 74 | def test_flatpak_home(self, monkeypatch, tmp_path, home_dir): 75 | """ 76 | Test that 'home' filesystem permission grants permission to the 77 | home directory 78 | """ 79 | flatpak_info_path = tmp_path / "flatpak-info" 80 | 81 | flatpak_info_path.write_text( 82 | "[Application]\n" 83 | "name=fake.flatpak.Protontricks\n" 84 | "\n" 85 | "[Instance]\n" 86 | "flatpak-version=1.12.1\n" 87 | "\n" 88 | "[Context]\n" 89 | "filesystems=home;" 90 | ) 91 | monkeypatch.setattr( 92 | "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) 93 | ) 94 | 95 | inaccessible_paths = get_inaccessible_paths([ 96 | "/mnt/SSD_A", "/var/fake_path", 97 | str(home_dir / "fake_path"), 98 | str(home_dir / ".local/share/FakePath") 99 | ]) 100 | 101 | assert len(inaccessible_paths) == 2 102 | assert str(inaccessible_paths[0]) == "/mnt/SSD_A" 103 | assert str(inaccessible_paths[1]) == "/var/fake_path" 104 | 105 | def test_flatpak_home_tilde(self, monkeypatch, tmp_path, home_dir): 106 | """ 107 | Test that tilde slash is expanded if included in the list of 108 | file systems 109 | """ 110 | flatpak_info_path = tmp_path / "flatpak-info" 111 | 112 | flatpak_info_path.write_text( 113 | "[Application]\n" 114 | "name=fake.flatpak.Protontricks\n" 115 | "\n" 116 | "[Instance]\n" 117 | "flatpak-version=1.12.1\n" 118 | "\n" 119 | "[Context]\n" 120 | "filesystems=~/fake_path" 121 | ) 122 | monkeypatch.setattr( 123 | "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) 124 | ) 125 | 126 | inaccessible_paths = get_inaccessible_paths([ 127 | str(home_dir / "fake_path"), 128 | str(home_dir / "fake_path_2") 129 | ]) 130 | 131 | assert len(inaccessible_paths) == 1 132 | assert str(inaccessible_paths[0]) == str(home_dir / "fake_path_2") 133 | 134 | def test_flatpak_host(self, monkeypatch, tmp_path, home_dir): 135 | """ 136 | Test that 'host' filesystem permission grants permission to the 137 | whole file system 138 | """ 139 | flatpak_info_path = tmp_path / "flatpak-info" 140 | 141 | flatpak_info_path.write_text( 142 | "[Application]\n" 143 | "name=fake.flatpak.Protontricks\n" 144 | "\n" 145 | "[Instance]\n" 146 | "flatpak-version=1.12.1\n" 147 | "\n" 148 | "[Context]\n" 149 | "filesystems=host;" 150 | ) 151 | monkeypatch.setattr( 152 | "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) 153 | ) 154 | 155 | inaccessible_paths = get_inaccessible_paths([ 156 | "/mnt/SSD_A", "/var/fake_path", 157 | str(home_dir / "fake_path"), 158 | ]) 159 | 160 | assert len(inaccessible_paths) == 0 161 | 162 | @pytest.mark.usefixtures("xdg_user_dir_bin") 163 | def test_flatpak_xdg_user_dir(self, monkeypatch, tmp_path, home_dir): 164 | """ 165 | Test that XDG filesystem permissions such as 'xdg-pictures' and 166 | 'xdg-download' are detected correctly 167 | """ 168 | flatpak_info_path = tmp_path / "flatpak-info" 169 | 170 | flatpak_info_path.write_text( 171 | "[Application]\n" 172 | "name=fake.flatpak.Protontricks\n" 173 | "\n" 174 | "[Instance]\n" 175 | "flatpak-version=1.12.1\n" 176 | "\n" 177 | "[Context]\n" 178 | "filesystems=xdg-pictures;" 179 | ) 180 | monkeypatch.setattr( 181 | "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) 182 | ) 183 | 184 | inaccessible_paths = get_inaccessible_paths([ 185 | str(home_dir / "Pictures"), 186 | str(home_dir / "Download") 187 | ]) 188 | 189 | assert len(inaccessible_paths) == 1 190 | assert str(inaccessible_paths[0]) == str(home_dir / "Download") 191 | 192 | def test_flatpak_unknown_permission(self, monkeypatch, tmp_path, caplog): 193 | """ 194 | Test that unknown filesystem permissions are ignored 195 | """ 196 | flatpak_info_path = tmp_path / "flatpak-info" 197 | 198 | flatpak_info_path.write_text( 199 | "[Application]\n" 200 | "name=fake.flatpak.Protontricks\n" 201 | "\n" 202 | "[Instance]\n" 203 | "flatpak-version=1.12.1\n" 204 | "\n" 205 | "[Context]\n" 206 | "filesystems=home;unknown-fs;" 207 | ) 208 | monkeypatch.setattr( 209 | "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) 210 | ) 211 | 212 | inaccessible_paths = get_inaccessible_paths([ 213 | "/mnt/SSD", 214 | ]) 215 | 216 | assert len(inaccessible_paths) == 1 217 | 218 | # Unknown filesystem permission is logged 219 | records = caplog.records 220 | 221 | assert len(records) == 1 222 | assert records[0].levelname == "WARNING" 223 | assert "Unknown Flatpak file system permission 'unknown-fs'" \ 224 | in records[0].message 225 | -------------------------------------------------------------------------------- /src/protontricks/_vdf/vdict.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from collections import Counter 3 | 4 | if sys.version_info[0] >= 3: 5 | _iter_values = 'values' 6 | _range = range 7 | _string_type = str 8 | import collections.abc as _c 9 | class _kView(_c.KeysView): 10 | def __iter__(self): 11 | return self._mapping.iterkeys() 12 | class _vView(_c.ValuesView): 13 | def __iter__(self): 14 | return self._mapping.itervalues() 15 | class _iView(_c.ItemsView): 16 | def __iter__(self): 17 | return self._mapping.iteritems() 18 | else: 19 | _iter_values = 'itervalues' 20 | _range = xrange 21 | _string_type = basestring 22 | _kView = lambda x: list(x.iterkeys()) 23 | _vView = lambda x: list(x.itervalues()) 24 | _iView = lambda x: list(x.iteritems()) 25 | 26 | 27 | class VDFDict(dict): 28 | def __init__(self, data=None): 29 | """ 30 | This is a dictionary that supports duplicate keys and preserves insert order 31 | 32 | ``data`` can be a ``dict``, or a sequence of key-value tuples. (e.g. ``[('key', 'value'),..]``) 33 | The only supported type for key is str. 34 | 35 | Get/set duplicates is done by tuples ``(index, key)``, where index is the duplicate index 36 | for the specified key. (e.g. ``(0, 'key')``, ``(1, 'key')``...) 37 | 38 | When the ``key`` is ``str``, instead of tuple, set will create a duplicate and get will look up ``(0, key)`` 39 | """ 40 | self.__omap = [] 41 | self.__kcount = Counter() 42 | 43 | if data is not None: 44 | if not isinstance(data, (list, dict)): 45 | raise ValueError("Expected data to be list of pairs or dict, got %s" % type(data)) 46 | self.update(data) 47 | 48 | def __repr__(self): 49 | out = "%s(" % self.__class__.__name__ 50 | out += "%s)" % repr(list(self.iteritems())) 51 | return out 52 | 53 | def __len__(self): 54 | return len(self.__omap) 55 | 56 | def _verify_key_tuple(self, key): 57 | if len(key) != 2: 58 | raise ValueError("Expected key tuple length to be 2, got %d" % len(key)) 59 | if not isinstance(key[0], int): 60 | raise TypeError("Key index should be an int") 61 | if not isinstance(key[1], _string_type): 62 | raise TypeError("Key value should be a str") 63 | 64 | def _normalize_key(self, key): 65 | if isinstance(key, _string_type): 66 | key = (0, key) 67 | elif isinstance(key, tuple): 68 | self._verify_key_tuple(key) 69 | else: 70 | raise TypeError("Expected key to be a str or tuple, got %s" % type(key)) 71 | return key 72 | 73 | def __setitem__(self, key, value): 74 | if isinstance(key, _string_type): 75 | key = (self.__kcount[key], key) 76 | self.__omap.append(key) 77 | elif isinstance(key, tuple): 78 | self._verify_key_tuple(key) 79 | if key not in self: 80 | raise KeyError("%s doesn't exist" % repr(key)) 81 | else: 82 | raise TypeError("Expected either a str or tuple for key") 83 | super(VDFDict, self).__setitem__(key, value) 84 | self.__kcount[key[1]] += 1 85 | 86 | def __getitem__(self, key): 87 | return super(VDFDict, self).__getitem__(self._normalize_key(key)) 88 | 89 | def __delitem__(self, key): 90 | key = self._normalize_key(key) 91 | result = super(VDFDict, self).__delitem__(key) 92 | 93 | start_idx = self.__omap.index(key) 94 | del self.__omap[start_idx] 95 | 96 | dup_idx, skey = key 97 | self.__kcount[skey] -= 1 98 | tail_count = self.__kcount[skey] - dup_idx 99 | 100 | if tail_count > 0: 101 | for idx in _range(start_idx, len(self.__omap)): 102 | if self.__omap[idx][1] == skey: 103 | oldkey = self.__omap[idx] 104 | newkey = (dup_idx, skey) 105 | super(VDFDict, self).__setitem__(newkey, self[oldkey]) 106 | super(VDFDict, self).__delitem__(oldkey) 107 | self.__omap[idx] = newkey 108 | 109 | dup_idx += 1 110 | tail_count -= 1 111 | if tail_count == 0: 112 | break 113 | 114 | if self.__kcount[skey] == 0: 115 | del self.__kcount[skey] 116 | 117 | return result 118 | 119 | def __iter__(self): 120 | return iter(self.iterkeys()) 121 | 122 | def __contains__(self, key): 123 | return super(VDFDict, self).__contains__(self._normalize_key(key)) 124 | 125 | def __eq__(self, other): 126 | if isinstance(other, VDFDict): 127 | return list(self.items()) == list(other.items()) 128 | else: 129 | return False 130 | 131 | def __ne__(self, other): 132 | return not self.__eq__(other) 133 | 134 | def clear(self): 135 | super(VDFDict, self).clear() 136 | self.__kcount.clear() 137 | self.__omap = list() 138 | 139 | def get(self, key, *args): 140 | return super(VDFDict, self).get(self._normalize_key(key), *args) 141 | 142 | def setdefault(self, key, default=None): 143 | if key not in self: 144 | self.__setitem__(key, default) 145 | return self.__getitem__(key) 146 | 147 | def pop(self, key): 148 | key = self._normalize_key(key) 149 | value = self.__getitem__(key) 150 | self.__delitem__(key) 151 | return value 152 | 153 | def popitem(self): 154 | if not self.__omap: 155 | raise KeyError("VDFDict is empty") 156 | key = self.__omap[-1] 157 | return key[1], self.pop(key) 158 | 159 | def update(self, data=None, **kwargs): 160 | if isinstance(data, dict): 161 | data = data.items() 162 | elif not isinstance(data, list): 163 | raise TypeError("Expected data to be a list or dict, got %s" % type(data)) 164 | 165 | for key, value in data: 166 | self.__setitem__(key, value) 167 | 168 | def iterkeys(self): 169 | return (key[1] for key in self.__omap) 170 | 171 | def keys(self): 172 | return _kView(self) 173 | 174 | def itervalues(self): 175 | return (self[key] for key in self.__omap) 176 | 177 | def values(self): 178 | return _vView(self) 179 | 180 | def iteritems(self): 181 | return ((key[1], self[key]) for key in self.__omap) 182 | 183 | def items(self): 184 | return _iView(self) 185 | 186 | def get_all_for(self, key): 187 | """ Returns all values of the given key """ 188 | if not isinstance(key, _string_type): 189 | raise TypeError("Key needs to be a string.") 190 | return [self[(idx, key)] for idx in _range(self.__kcount[key])] 191 | 192 | def remove_all_for(self, key): 193 | """ Removes all items with the given key """ 194 | if not isinstance(key, _string_type): 195 | raise TypeError("Key need to be a string.") 196 | 197 | for idx in _range(self.__kcount[key]): 198 | super(VDFDict, self).__delitem__((idx, key)) 199 | 200 | self.__omap = list(filter(lambda x: x[1] != key, self.__omap)) 201 | 202 | del self.__kcount[key] 203 | 204 | def has_duplicates(self): 205 | """ 206 | Returns ``True`` if the dict contains keys with duplicates. 207 | Recurses through any all keys with value that is ``VDFDict``. 208 | """ 209 | for n in getattr(self.__kcount, _iter_values)(): 210 | if n != 1: 211 | return True 212 | 213 | def dict_recurse(obj): 214 | for v in getattr(obj, _iter_values)(): 215 | if isinstance(v, VDFDict) and v.has_duplicates(): 216 | return True 217 | elif isinstance(v, dict): 218 | return dict_recurse(v) 219 | return False 220 | 221 | return dict_recurse(self) 222 | -------------------------------------------------------------------------------- /src/protontricks/data/scripts/wine_launch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Helper script created by Protontricks to run Wine binaries using Steam Runtime 3 | set -o errexit 4 | 5 | function log_debug () { 6 | if [[ "$PROTONTRICKS_LOG_LEVEL" != "DEBUG" ]]; then 7 | return 8 | fi 9 | } 10 | 11 | function log_info () { 12 | if [[ "$PROTONTRICKS_LOG_LEVEL" = "WARNING" ]]; then 13 | return 14 | fi 15 | 16 | log "$@" 17 | } 18 | 19 | function log_warning () { 20 | if [[ "$PROTONTRICKS_LOG_LEVEL" = "INFO" || "$PROTONTRICKS_LOG_LEVEL" = "WARNING" ]]; then 21 | return 22 | fi 23 | 24 | log "$@" 25 | } 26 | 27 | function log () { 28 | >&2 echo "protontricks - $(basename "$0") $$: $*" 29 | } 30 | 31 | PROTONTRICKS_PROXY_SCRIPT_PATH="@@script_path@@" 32 | 33 | BLACKLISTED_ROOT_DIRS=( 34 | /bin /dev /lib /lib64 /proc /run /sys /var /usr 35 | ) 36 | 37 | ADDITIONAL_MOUNT_DIRS=( 38 | /run/media "$PROTON_PATH" "$WINEPREFIX" 39 | ) 40 | 41 | WINESERVER_ENV_VARS_TO_COPY=( 42 | WINEESYNC WINEFSYNC 43 | ) 44 | 45 | if [[ -n "$PROTONTRICKS_BACKGROUND_WINESERVER" 46 | && "$0" = "@@script_path@@" 47 | ]]; then 48 | # Check if we're calling 'wineserver -w' when background wineserver is 49 | # enabled. 50 | # If so, prompt our keepalive wineserver to restart itself by creating 51 | # a 'restart' file inside the temporary directory 52 | if [[ "$(basename "$0")" = "wineserver" 53 | && "$1" = "-w" 54 | ]]; then 55 | log_info "Touching '$PROTONTRICKS_TEMP_PATH/restart' to restart wineserver." 56 | touch "$PROTONTRICKS_TEMP_PATH/restart" 57 | fi 58 | fi 59 | 60 | if [[ -z "$PROTONTRICKS_FIRST_START" ]]; then 61 | if [[ "$PROTONTRICKS_STEAM_RUNTIME" = "bwrap" ]]; then 62 | # Check if the launch script is named 'pressure-vessel-launch' or 63 | # 'steam-runtime-launch-client'. The latter name is newer and used 64 | # since steam-runtime-tools v0.20220420.0 65 | launch_script="" 66 | script_names=('pressure-vessel-launch' 'steam-runtime-launch-client') 67 | for name in "${script_names[@]}"; do 68 | if [[ -f "$STEAM_RUNTIME_PATH/pressure-vessel/bin/$name" ]]; then 69 | launch_script="$STEAM_RUNTIME_PATH/pressure-vessel/bin/$name" 70 | log_info "Found Steam Runtime launch client at $launch_script" 71 | fi 72 | done 73 | 74 | if [[ "$launch_script" = "" ]]; then 75 | echo "Launch script could not be found, aborting..." 76 | exit 1 77 | fi 78 | 79 | export STEAM_RUNTIME_LAUNCH_SCRIPT="$launch_script" 80 | fi 81 | 82 | # Try to detect if wineserver is already running, and if so, copy a few 83 | # environment variables from it to ensure our own Wine processes 84 | # are able to run at the same time without any issues. 85 | # This usually happens when the user is running the Steam app and 86 | # Protontricks at the same time. 87 | wineserver_found=false 88 | 89 | log_info "Checking for running wineserver instance" 90 | 91 | # Find the correct Wineserver that's using the same prefix 92 | while read -r pid; do 93 | if [[ $(xargs -0 -L1 -a "/proc/${pid}/environ" | grep "^WINEPREFIX=${WINEPREFIX}") ]] &> /dev/null; then 94 | if [[ "$pid" = "$$" ]]; then 95 | # Don't mistake this very script for a wineserver instance 96 | continue 97 | fi 98 | wineserver_found=true 99 | wineserver_pid="$pid" 100 | 101 | log_info "Found running wineserver instance with PID ${wineserver_pid}" 102 | fi 103 | done < <(pgrep "wineserver$") 104 | 105 | if [[ "$wineserver_found" = true ]]; then 106 | # wineserver found, retrieve its environment variables. 107 | # wineserver might disappear from under our foot especially if we're 108 | # in the middle of running a lot of Wine commands in succession, 109 | # so don't assume the wineserver still exists. 110 | wineserver_env_vars=$(xargs -0 -L1 -a "/proc/${wineserver_pid}/environ" 2> /dev/null || echo "") 111 | 112 | # Copy the required environment variables found in the 113 | # existing wineserver process 114 | for env_name in "${WINESERVER_ENV_VARS_TO_COPY[@]}"; do 115 | env_declr=$(echo "$wineserver_env_vars" | grep "^${env_name}=" || :) 116 | if [[ -n "$env_declr" ]]; then 117 | log_info "Copying env var from running wineserver: ${env_declr}" 118 | export "${env_declr?}" 119 | fi 120 | done 121 | fi 122 | 123 | # Enable fsync & esync by default 124 | if [[ "$wineserver_found" = false ]]; then 125 | if [[ -z "$WINEFSYNC" ]]; then 126 | if [[ -z "$PROTON_NO_FSYNC" || "$PROTON_NO_FSYNC" = "0" ]]; then 127 | log_info "Setting default env: WINEFSYNC=1" 128 | export WINEFSYNC=1 129 | fi 130 | fi 131 | 132 | if [[ -z "$WINEESYNC" ]]; then 133 | if [[ -z "$PROTON_NO_ESYNC" || "$PROTON_NO_ESYNC" = "0" ]]; then 134 | log_info "Setting default env: WINEESYNC=1" 135 | export WINEESYNC=1 136 | fi 137 | fi 138 | fi 139 | 140 | export PROTONTRICKS_FIRST_START=1 141 | fi 142 | 143 | # PROTONTRICKS_STEAM_RUNTIME values: 144 | # bwrap: Run Wine binaries inside Steam Runtime's bwrap sandbox, 145 | # modify LD_LIBRARY_PATH to include Proton libraries 146 | # 147 | # legacy: Modify LD_LIBRARY_PATH to include Steam Runtime *and* Proton 148 | # libraries. Host library order is adjusted as well. 149 | # 150 | # off: Just run the binaries as-is. 151 | if [[ -n "$PROTONTRICKS_INSIDE_STEAM_RUNTIME" 152 | || "$PROTONTRICKS_STEAM_RUNTIME" = "legacy" 153 | || "$PROTONTRICKS_STEAM_RUNTIME" = "off" 154 | ]]; then 155 | 156 | if [[ -n "$PROTONTRICKS_INSIDE_STEAM_RUNTIME" ]]; then 157 | log_info "Starting Wine process inside the container" 158 | else 159 | log_info "Starting Wine process directly, Steam runtime: $PROTONTRICKS_STEAM_RUNTIME" 160 | fi 161 | 162 | # If either Steam Runtime is enabled, change LD_LIBRARY_PATH 163 | if [[ "$PROTONTRICKS_STEAM_RUNTIME" = "bwrap" ]]; then 164 | export LD_LIBRARY_PATH="$LD_LIBRARY_PATH":"$PROTON_LD_LIBRARY_PATH" 165 | log_info "Appending to LD_LIBRARY_PATH: $PROTON_LD_LIBRARY_PATH" 166 | elif [[ "$PROTONTRICKS_STEAM_RUNTIME" = "legacy" ]]; then 167 | export LD_LIBRARY_PATH="$PROTON_LD_LIBRARY_PATH" 168 | log_info "LD_LIBRARY_PATH set to $LD_LIBRARY_PATH" 169 | fi 170 | exec "$PROTON_DIST_PATH"/bin/@@name@@ "$@" || : 171 | elif [[ "$PROTONTRICKS_STEAM_RUNTIME" = "bwrap" ]]; then 172 | # Command is being executed outside Steam Runtime and bwrap is enabled. 173 | # Use "pressure-vessel-launch" to launch it in the existing container. 174 | 175 | log_info "Starting Wine process using 'pressure-vessel-launch'" 176 | 177 | # It would be nicer to use the PID here, but that would break multiple 178 | # simultaneous Protontricks sessions inside Flatpak, which doesn't seem to 179 | # expose the unique host PID. 180 | bus_name="com.github.Matoking.protontricks.App${STEAM_APPID}_${PROTONTRICKS_SESSION_ID}" 181 | 182 | # Pass all environment variables to 'steam-runtime-launch-client' except 183 | # for problematic variables that should be determined by the launch command 184 | # instead. 185 | env_params=() 186 | for env_name in $(compgen -e); do 187 | # Skip vars that should be set by 'steam-runtime-launch-client' instead 188 | if [[ "$env_name" = "XAUTHORITY" 189 | || "$env_name" = "DISPLAY" 190 | || "$env_name" = "WAYLAND_DISPLAY" ]]; then 191 | continue 192 | fi 193 | 194 | env_params+=(--pass-env "${env_name}") 195 | done 196 | 197 | exec "$STEAM_RUNTIME_LAUNCH_SCRIPT" \ 198 | --share-pids --bus-name="$bus_name" \ 199 | --directory "$PWD" \ 200 | --env=PROTONTRICKS_INSIDE_STEAM_RUNTIME=1 \ 201 | "${env_params[@]}" -- "$PROTONTRICKS_PROXY_SCRIPT_PATH" "$@" 202 | else 203 | echo "Unknown PROTONTRICKS_STEAM_RUNTIME value $PROTONTRICKS_STEAM_RUNTIME" 204 | exit 1 205 | fi 206 | 207 | -------------------------------------------------------------------------------- /tests/cli/test_launch.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(scope="function", autouse=True) 5 | def home_cwd(home_dir, monkeypatch): 6 | """ 7 | Set the current working directory to the user's home directory and add 8 | an executable named "test.exe" 9 | """ 10 | monkeypatch.chdir(str(home_dir)) 11 | 12 | (home_dir / "test.exe").write_text("") 13 | 14 | 15 | class TestCLIRun: 16 | def test_run_executable( 17 | self, steam_app_factory, default_proton, 18 | command_mock, gui_provider, launch_cli): 19 | """ 20 | Run an EXE file by selecting using the GUI 21 | """ 22 | steam_app = steam_app_factory("Fake game", appid=10) 23 | 24 | # Fake the user selecting the game 25 | gui_provider.mock_stdout = "Fake game: 10" 26 | 27 | launch_cli(["test.exe"]) 28 | 29 | # 'test.exe' was executed 30 | command = command_mock.commands[-1] 31 | assert command.args.startswith("wine ") 32 | assert command.args.endswith("/test.exe") 33 | 34 | assert command.env["WINEPREFIX"] == str(steam_app.prefix_path) 35 | 36 | def test_run_executable_appid( 37 | self, default_proton, steam_app_factory, command_mock, launch_cli): 38 | """ 39 | Run an EXE file directly for a chosen game 40 | """ 41 | steam_app = steam_app_factory(name="Fake game 1", appid=10) 42 | 43 | launch_cli(["--appid", "10", "test.exe"]) 44 | 45 | # 'test.exe' was executed 46 | command = command_mock.commands[-1] 47 | assert command.args.startswith("wine ") 48 | assert command.args.endswith("/test.exe") 49 | 50 | assert command.env["WINEPREFIX"] == str(steam_app.prefix_path) 51 | 52 | def test_run_executable_no_selection( 53 | self, default_proton, steam_app_factory, gui_provider, 54 | launch_cli): 55 | """ 56 | Try running an EXE file but don't pick a Steam app 57 | """ 58 | steam_app_factory("Fake game", appid=10) 59 | 60 | # Fake the user closing the form 61 | gui_provider.mock_stdout = "" 62 | 63 | result = launch_cli(["test.exe"], expect_returncode=1) 64 | 65 | assert "No game was selected." in result 66 | 67 | def test_run_executable_no_apps(self, launch_cli): 68 | """ 69 | Try running an EXE file when no Proton enabled Steam apps are installed 70 | or ready 71 | """ 72 | result = launch_cli(["test.exe"], expect_returncode=1) 73 | 74 | assert "No Proton enabled Steam apps were found" in result 75 | 76 | def test_run_executable_no_apps_from_desktop( 77 | self, launch_cli, gui_provider): 78 | """ 79 | Try running an EXE file when no Proton enabled Steam apps are installed 80 | or ready, and ensure an error dialog is opened using `gui_provider`. 81 | """ 82 | launch_cli(["--no-term", "test.exe"], expect_returncode=1) 83 | 84 | assert gui_provider.args[0] == "yad" 85 | assert gui_provider.args[1] == "--text-info" 86 | 87 | message = gui_provider.kwargs["input"] 88 | 89 | assert b"No Proton enabled Steam apps were found." in message 90 | 91 | # Also ensure log messages are included in the error message 92 | assert b"Found Steam directory at" in message 93 | 94 | def test_run_executable_passthrough_arguments( 95 | self, default_proton, steam_app_factory, caplog, 96 | steam_dir, launch_cli, monkeypatch): 97 | """ 98 | Try running an EXE file and apply all arguments; those should 99 | also be passed to the main entrypoint 100 | """ 101 | cli_args = [] 102 | cli_kwargs = {} 103 | 104 | def _set_launch_args(*args, **kwargs): 105 | cli_args.extend(*args) 106 | cli_kwargs.update(kwargs) 107 | 108 | monkeypatch.setattr( 109 | "protontricks.cli.launch.cli_main", 110 | _set_launch_args 111 | ) 112 | 113 | steam_app_factory(name="Fake game", appid=10) 114 | 115 | launch_cli([ 116 | "--verbose", "--no-bwrap", "--no-runtime", "--no-term", 117 | "--cwd-app", "--appid", "10", "test.exe" 118 | ]) 119 | 120 | # CLI flags are passed through to the main CLI entrypoint 121 | assert cli_args[0:7] == [ 122 | "-v", "--no-runtime", "--no-bwrap", 123 | "--no-background-wineserver", "--no-term", "--cwd-app", "-c" 124 | ] 125 | assert cli_args[7].startswith("wine ") 126 | assert cli_args[7].endswith("test.exe") 127 | assert cli_args[8] == "10" 128 | 129 | # Steam installation was provided to the main entrypoint 130 | assert str(cli_kwargs["steam_path"]) == str(steam_dir) 131 | 132 | @pytest.mark.parametrize("argument", [ 133 | None, 134 | "--background-wineserver", 135 | "--no-background-wineserver" 136 | ]) 137 | def test_run_executable_passthrough_background_wineserver( 138 | self, launch_cli, monkeypatch, steam_app_factory, 139 | argument): 140 | """ 141 | Try running an EXE file and apply given wineserver argument. 142 | If the argument is set, it should also be passed to the main 143 | entrypoint. 144 | """ 145 | cli_args = [] 146 | 147 | def _set_launch_args(*args, **kwargs): 148 | cli_args.extend(*args) 149 | 150 | monkeypatch.setattr( 151 | "protontricks.cli.launch.cli_main", 152 | _set_launch_args 153 | ) 154 | 155 | steam_app_factory(name="Fake game", appid=10) 156 | 157 | extra_args = [argument] if argument else [] 158 | launch_cli(extra_args + ["--appid", "10", "test.exe"]) 159 | 160 | if argument: 161 | # Ensure the corresponding argument was passd to the main CLI 162 | # entrypoint 163 | assert argument in cli_args 164 | else: 165 | assert "--no-background-wineserver" in cli_args 166 | 167 | def test_cli_error_handler_uncaught_exception( 168 | self, launch_cli, default_proton, steam_app_factory, monkeypatch, 169 | gui_provider): 170 | """ 171 | Ensure that 'cli_error_handler' correctly catches any uncaught 172 | exception and includes a stack trace in the error dialog. 173 | """ 174 | def _mock_from_appmanifest(*args, **kwargs): 175 | raise ValueError("Test appmanifest error") 176 | 177 | steam_app_factory(name="Fake game", appid=10) 178 | 179 | monkeypatch.setattr( 180 | "protontricks.steam.SteamApp.from_appmanifest", 181 | _mock_from_appmanifest 182 | ) 183 | 184 | launch_cli( 185 | ["--no-term", "--appid", "10", "test.exe"], expect_returncode=1 186 | ) 187 | 188 | assert gui_provider.args[0] == "yad" 189 | assert gui_provider.args[1] == "--text-info" 190 | 191 | message = gui_provider.kwargs["input"] 192 | 193 | assert b"Test appmanifest error" in message 194 | 195 | @pytest.mark.usefixtures( 196 | "flatpak_sandbox", "default_proton", "command_mock" 197 | ) 198 | def test_run_filesystem_permission_missing( 199 | self, launch_cli, steam_library_factory, steam_app_factory, 200 | caplog): 201 | """ 202 | Try performing a launch command in a Flatpak sandbox where the user 203 | hasn't provided adequate fileystem permissions. Ensure warning is 204 | printed. 205 | """ 206 | steam_app_factory(name="Fake game 1", appid=10) 207 | path = steam_library_factory(name="GameDrive") 208 | 209 | launch_cli(["--appid", "10", "test.exe"]) 210 | 211 | record = next( 212 | record for record in caplog.records 213 | if "grant access to the required directories" in record.message 214 | ) 215 | assert record.levelname == "WARNING" 216 | assert str(path) in record.message 217 | 218 | @pytest.mark.usefixtures( 219 | "flatpak_sandbox", "steam_dir", "flatpak_steam_dir" 220 | ) 221 | def test_steam_installation_not_selected(self, launch_cli, gui_provider): 222 | """ 223 | Test that not selecting a Steam installation results in the correct 224 | exit message 225 | """ 226 | # Mock the user choosing the Flatpak installation. 227 | # Only the index is actually checked in the actual function. 228 | gui_provider.mock_stdout = "" 229 | gui_provider.mock_returncode = 1 230 | 231 | result = launch_cli(["test.exe"], expect_returncode=1) 232 | 233 | assert "No Steam installation was selected" in result 234 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!CAUTION] 2 | > **`hxxps[://]protontricks[.]com` IS A FAKE SITE** and not affiliated with any of the Protontricks developers. https://github.com/Matoking/protontricks is the only official page for Protontricks. 3 | 4 | Protontricks 5 | ============ 6 | 7 | [![image](https://img.shields.io/pypi/v/protontricks.svg)](https://pypi.org/project/protontricks/) 8 | [![Coverage Status](https://coveralls.io/repos/github/Matoking/protontricks/badge.svg?branch=master)](https://coveralls.io/github/Matoking/protontricks?branch=master) 9 | [![Test Status](https://github.com/Matoking/protontricks/actions/workflows/tests.yml/badge.svg)](https://github.com/Matoking/protontricks/actions/workflows/tests.yml) 10 | 11 | [](https://flathub.org/apps/details/com.github.Matoking.protontricks) 12 | 13 | Run Winetricks commands for Steam Play/Proton games among other common Wine features, such as launching external Windows executables. 14 | 15 | This is a fork of the original project created by sirmentio. The original repository is available at [Sirmentio/protontricks](https://github.com/Sirmentio/protontricks). 16 | 17 | # Table of Contents 18 | - [What is it](#what-is-it) 19 | - [Requirements](#requirements) 20 | - [Usage](#usage) 21 | - [Command-line](#command-line) 22 | - [Desktop](#desktop) 23 | - [Troubleshooting](#troubleshooting) 24 | - [Installation](#installation) 25 | - [Community packages](#community-packages-recommended) 26 | - [Flatpak](#flatpak-recommended) 27 | - [pipx](#pipx) 28 | - [pip](#pip-not-recommended) 29 | 30 | # What is it? 31 | 32 | This is a wrapper script that allows you to easily run Winetricks commands for Steam Play/Proton games among other common Wine features, such as launching external Windows executables. This is often useful when a game requires closed-source runtime libraries or applications that are not included with Proton. 33 | 34 | # Requirements 35 | 36 | * Python 3.7 or newer 37 | * Winetricks 38 | * Steam 39 | * YAD (recommended) **or** Zenity. Required for GUI. 40 | 41 | # Usage 42 | 43 | **Protontricks can be launched from desktop or using the `protontricks` command.** 44 | 45 | ## Command-line 46 | 47 | The basic command-line usage is as follows: 48 | 49 | ``` 50 | # Find your game's App ID by searching for it 51 | protontricks -s 52 | 53 | # or by listing all games 54 | protontricks -l 55 | 56 | # Run winetricks for the game. 57 | # Any parameters in are passed directly to Winetricks. 58 | # Parameters specific to Protontricks need to be placed *before* . 59 | protontricks 60 | 61 | # Run a custom command for selected game 62 | protontricks -c 63 | 64 | # Run the Protontricks GUI 65 | protontricks --gui 66 | 67 | # Launch a Windows executable using Protontricks 68 | protontricks-launch 69 | 70 | # Launch a Windows executable for a specific Steam app using Protontricks 71 | protontricks-launch --appid 72 | 73 | # Print the Protontricks help message 74 | protontricks --help 75 | ``` 76 | 77 | Since this is a wrapper, all commands that work for Winetricks will likely work for Protontricks as well. 78 | 79 | If you have a different Steam directory, you can export ``$STEAM_DIR`` to the directory where Steam is. 80 | 81 | If you'd like to use a local version of Winetricks, you can set ``$WINETRICKS`` to the location of your local winetricks installation. 82 | 83 | You can also set ``$PROTON_VERSION`` to a specific Proton version manually. This is usually the name of the Proton installation without the revision version number. For example, if Steam displays the name as `Proton 5.0-3`, use `Proton 5.0` as the value for `$PROTON_VERSION`. 84 | 85 | Protontricks supports `$STEAM_COMPAT_DATA_PATH` variable to set a custom path to a Wine prefix (`WINEPREFIX=$STEAM_COMPAT_DATA_PATH/pfx`). This is supported by Proton and can be set for the game itself under Steam (`STEAM_COMPAT_DATA_PATH= %command%` in game launch options). 86 | 87 | [Wanna see Protontricks in action?](https://asciinema.org/a/229323) 88 | 89 | ## Desktop 90 | 91 | Protontricks comes with desktop integration, adding the Protontricks app shortcut and the ability to launch external Windows executables for Proton apps. To run an executable for a Proton app, select **Protontricks Launcher** when opening a Windows executable (eg. **EXE**) in a file manager. 92 | 93 | The **Protontricks** app shortcut should be available automatically after installation. If not, you may need to run `protontricks-desktop-install` in a terminal to enable this functionality. 94 | 95 | # Troubleshooting 96 | 97 | For common issues and solutions, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md). 98 | 99 | # Installation 100 | 101 | You can install Protontricks using a community package, Flatpak or **pipx**. **pip** can also be used, but it is not recommended due to possible problems. 102 | 103 | **If you're using a Steam Deck**, Flatpak is the recommended option. Open the **Discover** application store in desktop mode and search for **Protontricks**. 104 | 105 | **If you're using the Flatpak version of Steam**, follow the [Flatpak-specific installation instructions](https://github.com/flathub/com.github.Matoking.protontricks) instead. 106 | 107 | ## Community packages (recommended) 108 | 109 | Community packages allow easier installation and updates using distro-specific package managers. They also take care of installing dependencies and desktop features out of the box, making them **the recommended option if available for your distribution**. 110 | 111 | Community packages are maintained by community members and might be out-of-date compared to releases on PyPI. 112 | Note that some distros such as **Debian** / **Ubuntu** often have outdated packages for either Protontricks **or** Winetricks. 113 | If so, install the Flatpak version instead as outdated releases may fail to work properly. 114 | 115 | [![Packaging status](https://repology.org/badge/vertical-allrepos/protontricks.svg)](https://repology.org/project/protontricks/versions) 116 | 117 | ## Flatpak (recommended) 118 | 119 | Protontricks is available on the Flathub app store: 120 | 121 | [](https://flathub.org/apps/details/com.github.Matoking.protontricks) 122 | 123 | To use Protontricks as a command-line application, add shell aliases by running the following commands: 124 | 125 | ``` 126 | echo "alias protontricks='flatpak run com.github.Matoking.protontricks'" >> ~/.bashrc 127 | echo "alias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'" >> ~/.bashrc 128 | ``` 129 | 130 | You will need to restart your terminal emulator for the aliases to take effect. 131 | 132 | The Flatpak installation is sandboxed and only has access to the Steam 133 | installation directory by default. **You will need to add filesystem permissions when 134 | using additional Steam library locations or running external Windows 135 | applications.** See 136 | [here](https://github.com/flathub/com.github.Matoking.protontricks#configuration) 137 | for instructions on changing the Flatpak permissions. 138 | 139 | ## pipx 140 | 141 | You can use pipx to install the latest version on PyPI or the git repository for the current user. Installing Protontricks using pipx is recommended if a community package doesn't exist for your Linux distro. 142 | 143 | **pipx does not install Winetricks and other dependencies out of the box.** You can install Winetricks using the [installation instructions](https://github.com/Winetricks/winetricks#installing) provided by the Winetricks project. 144 | 145 | **pipx requires Python 3.7 or newer.** 146 | 147 | **You will need to install pip, setuptools and virtualenv first.** Install the correct packages depending on your distribution: 148 | 149 | * Arch Linux: `sudo pacman -S python-pip python-pipx python-setuptools python-virtualenv` 150 | * Debian-based (Ubuntu, Linux Mint): `sudo apt install python3-pip python3-setuptools python3-venv pipx` 151 | * Fedora: `sudo dnf install python3-pip python3-setuptools python3-libs pipx` 152 | * Gentoo: 153 | 154 | ```sh 155 | sudo emerge -av dev-python/pip dev-python/virtualenv dev-python/setuptools 156 | python3 -m pip install --user pipx 157 | ~/.local/bin/pipx ensurepath 158 | ``` 159 | 160 | Close and reopen your terminal. After that, you can install Protontricks. 161 | 162 | ```sh 163 | pipx install protontricks 164 | ``` 165 | 166 | To enable desktop integration as well, run the following command *after* installing Protontricks 167 | 168 | ```sh 169 | protontricks-desktop-install 170 | ``` 171 | 172 | To upgrade to the latest release: 173 | ```sh 174 | pipx upgrade protontricks 175 | ``` 176 | 177 | To install the latest development version (requires `git`): 178 | ```sh 179 | pipx install git+https://github.com/Matoking/protontricks.git 180 | # '--spec' is required for older versions of pipx 181 | pipx install --spec git+https://github.com/Matoking/protontricks.git protontricks 182 | ``` 183 | 184 | ## pip (not recommended) 185 | 186 | You can use pip to install the latest version on PyPI or the git repository. This method should work in any system where Python 3 is available. 187 | 188 | **Note that this installation method might cause conflicts with your distro's package manager. To prevent this, consider using the pipx method or a community package instead.** 189 | 190 | **You will need to install pip and setuptools first.** Install the correct packages depending on your distribution: 191 | 192 | * Arch Linux: `sudo pacman -S python-pip python-setuptools` 193 | * Debian-based (Ubuntu, Linux Mint): `sudo apt install python3-pip python3-setuptools` 194 | * Fedora: `sudo dnf install python3-pip python3-setuptools` 195 | * Gentoo: `sudo emerge -av dev-python/pip dev-python/setuptools` 196 | 197 | To install the latest release using `pip`: 198 | ```sh 199 | sudo python3 -m pip install protontricks 200 | ``` 201 | 202 | To upgrade to the latest release: 203 | ```sh 204 | sudo python3 -m pip install --upgrade protontricks 205 | ``` 206 | 207 | To install Protontricks only for the current user: 208 | ```sh 209 | python3 -m pip install --user protontricks 210 | ``` 211 | 212 | To install the latest development version (requires `git`): 213 | ```sh 214 | sudo python3 -m pip install git+https://github.com/Matoking/protontricks.git 215 | ``` 216 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import stat 2 | import textwrap 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from protontricks.util import (create_wine_bin_dir, is_steam_deck, is_steamos, 8 | lower_dict, run_command) 9 | 10 | 11 | def get_files_in_dir(d): 12 | return {binary.name for binary in d.iterdir()} 13 | 14 | 15 | class TestCreateWineBinDir: 16 | def test_wine_bin_dir_updated(self, home_dir, default_proton): 17 | """ 18 | Test that the directory containing the helper scripts is kept 19 | up-to-date with the Proton installation's binaries 20 | """ 21 | create_wine_bin_dir(default_proton) 22 | 23 | # Check that the Wine binaries exist 24 | files = get_files_in_dir( 25 | home_dir / ".cache" / "protontricks" / "proton" / "Proton 4.20" 26 | / "bin" 27 | ) 28 | assert set([ 29 | "wine", "wineserver", "wineserver-keepalive", "bwrap-launcher", 30 | "wineserver-keepalive.bat" 31 | ]) == files 32 | 33 | # Create a new binary for the Proton installation and delete another 34 | # one 35 | proton_bin_path = Path(default_proton.install_path) / "dist" / "bin" 36 | 37 | (proton_bin_path / "winedine").touch() 38 | (proton_bin_path / "wineserver").unlink() 39 | 40 | # The old scripts will be deleted and regenerated now that the Proton 41 | # installation's contents changed 42 | create_wine_bin_dir(default_proton) 43 | 44 | files = get_files_in_dir( 45 | home_dir / ".cache" / "protontricks" / "proton" / "Proton 4.20" 46 | / "bin" 47 | ) 48 | # Scripts are regenerated 49 | assert set([ 50 | "wine", "winedine", "wineserver-keepalive", "bwrap-launcher", 51 | "wineserver-keepalive.bat" 52 | ]) == files 53 | 54 | 55 | class TestRunCommand: 56 | def test_user_environment_variables_used( 57 | self, default_proton, steam_runtime_dir, steam_app_factory, 58 | home_dir, command_mock, monkeypatch): 59 | """ 60 | Test that user-provided environment variables are used even when 61 | Steam Runtime is enabled 62 | """ 63 | steam_app = steam_app_factory(name="Fake game", appid=10) 64 | 65 | run_command( 66 | winetricks_path=Path("/usr/bin/winetricks"), 67 | proton_app=default_proton, 68 | steam_app=steam_app, 69 | command=["echo", "nothing"], 70 | use_steam_runtime=True, 71 | legacy_steam_runtime_path=steam_runtime_dir / "steam-runtime" 72 | ) 73 | 74 | # Proxy scripts are used if no environment variables are set by the 75 | # user 76 | wine_bin_dir = ( 77 | home_dir / ".cache" / "protontricks" / "proton" / "Proton 4.20" 78 | / "bin" 79 | ) 80 | 81 | command = command_mock.commands[-1] 82 | assert command.args == ["echo", "nothing"] 83 | assert command.env["WINE"] == str(wine_bin_dir / "wine") 84 | assert command.env["WINELOADER"] == str(wine_bin_dir / "wine") 85 | assert command.env["WINESERVER"] == str(wine_bin_dir / "wineserver") 86 | 87 | assert command.env["WINE_BIN"] == str( 88 | default_proton.proton_dist_path / "bin" / "wine" 89 | ) 90 | assert command.env["WINESERVER_BIN"] == str( 91 | default_proton.proton_dist_path / "bin" / "wineserver" 92 | ) 93 | 94 | monkeypatch.setenv("WINE", "/fake/wine") 95 | monkeypatch.setenv("WINESERVER", "/fake/wineserver") 96 | 97 | run_command( 98 | winetricks_path=Path("/usr/bin/winetricks"), 99 | proton_app=default_proton, 100 | steam_app=steam_app, 101 | command=["echo", "nothing"], 102 | use_steam_runtime=True, 103 | legacy_steam_runtime_path=steam_runtime_dir / "steam-runtime" 104 | ) 105 | 106 | # User provided Wine paths are used even when Steam Runtime is enabled 107 | command = command_mock.commands[-1] 108 | assert command.args == ["echo", "nothing"] 109 | assert command.env["WINE"] == "/fake/wine" 110 | assert command.env["WINELOADER"] == "/fake/wine" 111 | assert command.env["WINESERVER"] == "/fake/wineserver" 112 | 113 | @pytest.mark.usefixtures("command_mock") 114 | def test_unknown_steam_runtime_detected( 115 | self, home_dir, proton_factory, runtime_app_factory, 116 | steam_app_factory, caplog): 117 | """ 118 | Test that Protontricks will log a warning if it encounters a Steam 119 | Runtime it does not recognize 120 | """ 121 | steam_runtime_medic = runtime_app_factory( 122 | name="Steam Linux Runtime - Medic", 123 | appid=14242420, 124 | runtime_dir_name="medic" 125 | ) 126 | proton_app = proton_factory( 127 | name="Proton 5.20", appid=100, compat_tool_name="proton_520", 128 | is_default_proton=True, required_tool_app=steam_runtime_medic 129 | ) 130 | steam_app = steam_app_factory(name="Fake game", appid=10) 131 | 132 | run_command( 133 | winetricks_path=Path("/usr/bin/winetricks"), 134 | proton_app=proton_app, 135 | steam_app=steam_app, 136 | command=["echo", "nothing"], 137 | shell=True, 138 | use_steam_runtime=True 139 | ) 140 | 141 | # Warning will be logged since Protontricks does not recognize 142 | # Steam Runtime Medic and can't ensure it's being configured correctly 143 | warning = next( 144 | record for record in caplog.records 145 | if record.levelname == "WARNING" 146 | and "not recognized" in record.getMessage() 147 | ) 148 | assert warning.getMessage() == \ 149 | "Current Steam Runtime not recognized by Protontricks." 150 | 151 | @pytest.mark.usefixtures("steam_deck") 152 | def test_locale_fixed_on_steam_deck( 153 | self, proton_factory, default_proton, steam_app_factory, home_dir, 154 | command_mock, caplog): 155 | """ 156 | Test that Protontricks will fix locale settings if nonexistent locale 157 | settings are detected and Steam Deck is used to run Protontricks 158 | """ 159 | # Create binary to fake the 'locale' executable 160 | locale_script_path = home_dir / ".local" / "bin" / "locale" 161 | locale_script_path.write_text("""#!/bin/sh 162 | if [ "$1" = "-a" ]; then 163 | echo 'C' 164 | echo 'C.UTF-8' 165 | echo 'en_US' 166 | echo 'en_US.utf8' 167 | else 168 | echo 'LANG=fi_FI.UTF-8' 169 | echo 'LC_CTYPE=en_US.utf8' 170 | echo 'LC_TIME=en_US.UTF-8' 171 | echo 'LC_NUMERIC=D' 172 | fi 173 | """) 174 | locale_script_path.chmod( 175 | locale_script_path.stat().st_mode | stat.S_IEXEC 176 | ) 177 | 178 | steam_app = steam_app_factory(name="Fake game", appid=10) 179 | run_command( 180 | winetricks_path=Path("/usr/bin/winetricks"), 181 | proton_app=default_proton, 182 | steam_app=steam_app, 183 | command=["/bin/env"], 184 | env={ 185 | # Use same environment variables as in the mocked 'locale' 186 | # script 187 | "LANG": "fi_FI.UTF-8", 188 | "LC_CTYPE": "en_US.utf8", 189 | "LC_TIME": "en_US.UTF-8", 190 | "LC_NUMERIC": "D" 191 | } 192 | ) 193 | 194 | # Warning will be logged to indicate 'LANG' was changed 195 | warning = next( 196 | record for record in caplog.records 197 | if record.levelname == "WARNING" 198 | and "locale has been reset" in record.getMessage() 199 | ) 200 | assert warning.getMessage().endswith( 201 | "for the following categories: LANG, LC_NUMERIC" 202 | ) 203 | 204 | # Ensure the incorrect locale settings were changed for the command 205 | command = command_mock.commands[-1] 206 | assert command.env["LANG"] == "en_US.UTF-8" 207 | # LC_CTYPE was not changed as 'en_US.UTF-8' and 'en_US.utf8' 208 | # are identical after normalization. 209 | assert command.env["LC_CTYPE"] == "en_US.utf8" 210 | assert command.env["LC_TIME"] == "en_US.UTF-8" 211 | assert command.env["LC_NUMERIC"] == "en_US.UTF-8" 212 | 213 | def test_winedlloverrides_defaults_are_set( 214 | self, steam_app_factory, default_proton, command_mock, caplog): 215 | """ 216 | Test that Protontricks will automatically set WINEDLLOVERRIDES 217 | while skipping any DLLs that user has already configured 218 | """ 219 | dxvk_lib_path = \ 220 | default_proton.proton_dist_path / "lib" / "wine" / "dxvk" 221 | dxvk_lib_path.mkdir(parents=True) 222 | 223 | (dxvk_lib_path / "dxgi.dll").touch() 224 | (dxvk_lib_path / "d3d9.dll").touch() 225 | (dxvk_lib_path / "d3d11.dll").touch() 226 | 227 | steam_app = steam_app_factory(name="Fake game", appid=10) 228 | run_command( 229 | winetricks_path=Path("/usr/bin/winetricks"), 230 | proton_app=default_proton, 231 | steam_app=steam_app, 232 | command=["/bin/env"], 233 | env={ 234 | "WINEDLLOVERRIDES": "fakelibrary,anotherfakelibrary=b,n;dxgi=b" 235 | } 236 | ) 237 | 238 | command = command_mock.commands[-1] 239 | 240 | # User-provided environment variables are not overridden 241 | assert "dxgi=b" in command.env["WINEDLLOVERRIDES"] 242 | assert "fakelibrary=b,n" in command.env["WINEDLLOVERRIDES"] 243 | assert "anotherfakelibrary=b,n" in command.env["WINEDLLOVERRIDES"] 244 | 245 | # DXVK overrides are set if the corresponding DLL files exist in the 246 | # Proton installation 247 | assert "d3d9=n" in command.env["WINEDLLOVERRIDES"] 248 | assert "d3d11=n" in command.env["WINEDLLOVERRIDES"] 249 | 250 | assert "d3d10core" not in command.env["WINEDLLOVERRIDES"] 251 | 252 | def test_gstreamer_env_is_set( 253 | self, steam_app_factory, default_proton, command_mock): 254 | """ 255 | Test that Protontricks will automatically set GStreamer related 256 | environment variables if GStreamer appears to be installed for Proton 257 | """ 258 | (default_proton.proton_dist_path / "lib/gstreamer-1.0").mkdir( 259 | parents=True 260 | ) 261 | 262 | steam_app = steam_app_factory(name="Fake game", appid=10) 263 | 264 | run_command( 265 | winetricks_path=Path("/usr/bin/winetricks"), 266 | proton_app=default_proton, 267 | steam_app=steam_app, 268 | command=["/bin/env"], 269 | ) 270 | 271 | command = command_mock.commands[-1] 272 | 273 | assert str(default_proton.proton_dist_path / "lib/gstreamer-1.0") \ 274 | in command.env["GST_PLUGIN_SYSTEM_PATH_1_0"] 275 | assert str(steam_app.prefix_path.parent / "gstreamer-1.0") \ 276 | in command.env["WINE_GST_REGISTRY_DIR"] 277 | 278 | def test_default_proton_env_vars_set( 279 | self, steam_app_factory, default_proton, command_mock): 280 | """ 281 | Test that Protontricks will automatically set various Proton related 282 | environment variables, unless they're already set by the user 283 | """ 284 | steam_app = steam_app_factory(name="Fake game", appid=10) 285 | 286 | run_command( 287 | winetricks_path=Path("/usr/bin/winetricks"), 288 | proton_app=default_proton, 289 | steam_app=steam_app, 290 | command=["/bin/env"], 291 | env={ 292 | "WINE_LARGE_ADDRESS_AWARE": "2" 293 | } 294 | ) 295 | 296 | command = command_mock.commands[-1] 297 | 298 | # Default env var is set 299 | assert command.env["DXVK_ENABLE_NVAPI"] == "1" 300 | 301 | # User-set env var is not overridden 302 | assert command.env["WINE_LARGE_ADDRESS_AWARE"] == "2" 303 | 304 | 305 | 306 | def test_bwrap_launcher_crash_detected( 307 | self, default_new_proton, steam_app_factory, command_mock): 308 | """ 309 | Test that Protontricks will raise an exception if `bwrap-launcher` 310 | crashes unexpectedly 311 | """ 312 | steam_app = steam_app_factory(name="Fake game", appid=10) 313 | 314 | # Mock a crashing 'bwrap-launcher' 315 | command_mock.launcher_working = False 316 | 317 | with pytest.raises(RuntimeError) as exc: 318 | run_command( 319 | winetricks_path=Path("/usr/bin/winetricks"), 320 | proton_app=default_new_proton, 321 | steam_app=steam_app, 322 | command=["echo", "nothing"], 323 | shell=True, 324 | use_steam_runtime=True 325 | ) 326 | 327 | assert str(exc.value) == "bwrap launcher crashed, returncode: 1" 328 | 329 | def test_steam_compat_data_path_env_var( 330 | self, default_proton, steam_app_factory, monkeypatch, 331 | command_mock): 332 | """ 333 | Test that `STEAM_COMPAT_DATA_PATH` environment variable is used 334 | to set the Wine prefix path if set by user 335 | """ 336 | steam_app = steam_app_factory(name="Fake game", appid=10) 337 | 338 | monkeypatch.setenv("STEAM_COMPAT_DATA_PATH", "/custom/path") 339 | 340 | run_command( 341 | winetricks_path=Path("/usr/bin/winetricks"), 342 | proton_app=default_proton, 343 | steam_app=steam_app, 344 | command=["/bin/env"], 345 | ) 346 | 347 | command = command_mock.commands[-1] 348 | assert command.env["WINEPREFIX"] == "/custom/path/pfx" 349 | 350 | 351 | class TestLowerDict: 352 | def test_lower_nested_dict(self): 353 | """ 354 | Turn all keys in a nested dictionary to lowercase using `lower_dict` 355 | """ 356 | before = { 357 | "AppState": { 358 | "Name": "Blah", 359 | "appid": 123450, 360 | "userconfig": { 361 | "Language": "English" 362 | } 363 | } 364 | } 365 | 366 | after = { 367 | "appstate": { 368 | "name": "Blah", 369 | "appid": 123450, 370 | "userconfig": { 371 | "language": "English" 372 | } 373 | } 374 | } 375 | 376 | assert lower_dict(before) == after 377 | 378 | 379 | class TestIsSteamOSOrDeck: 380 | def test_not_steam_deck(self): 381 | """ 382 | Test that non-Steam Deck environment is detected correctly 383 | """ 384 | assert not is_steam_deck() 385 | 386 | @pytest.mark.usefixtures("steam_deck") 387 | def test_is_steam_deck(self): 388 | """ 389 | Test that Steam Deck environment is detected correctly 390 | """ 391 | assert is_steam_deck() 392 | 393 | def test_not_steamos(self): 394 | """ 395 | Test that non-SteamOS environment is detected correctly 396 | """ 397 | assert not is_steamos() 398 | 399 | @pytest.mark.usefixtures("steam_deck") 400 | def test_is_steamos(self): 401 | assert is_steamos() 402 | -------------------------------------------------------------------------------- /src/protontricks/gui.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import importlib.resources 3 | import itertools 4 | import json 5 | import logging 6 | import os 7 | import shlex 8 | import shutil 9 | import sys 10 | from pathlib import Path 11 | from subprocess import PIPE, CalledProcessError, run 12 | 13 | from PIL import Image 14 | 15 | from .config import get_config 16 | from .flatpak import get_inaccessible_paths 17 | from .steam import SNAP_STEAM_DIRS 18 | from .util import get_cache_dir 19 | 20 | APP_ICON_SIZE = (32, 32) 21 | 22 | 23 | __all__ = ( 24 | "LocaleError", "get_gui_provider", "select_steam_app_with_gui", 25 | "select_steam_installation", "show_text_dialog", "prompt_filesystem_access" 26 | ) 27 | 28 | logger = logging.getLogger("protontricks") 29 | 30 | 31 | class LocaleError(Exception): 32 | pass 33 | 34 | 35 | @functools.lru_cache(maxsize=1) 36 | def get_gui_provider(): 37 | """ 38 | Get the GUI provider used to display dialogs. 39 | Returns either 'yad' or 'zenity', preferring 'yad' if both exist. 40 | """ 41 | try: 42 | candidates = ["yad", "zenity"] 43 | # Allow overriding the GUI provider using an envvar 44 | if os.environ.get("PROTONTRICKS_GUI", "").lower() in candidates: 45 | candidates.insert(0, os.environ["PROTONTRICKS_GUI"].lower()) 46 | 47 | cmd = next(cmd for cmd in candidates if shutil.which(cmd)) 48 | logger.info("Using '%s' as GUI provider", cmd) 49 | 50 | return cmd 51 | except StopIteration as exc: 52 | raise FileNotFoundError( 53 | "'yad' or 'zenity' was not found. Either executable is required " 54 | "for Protontricks GUI." 55 | ) from exc 56 | 57 | 58 | def _get_appid2icon(steam_apps): 59 | """ 60 | Get icons for Steam apps to show in the app selection dialog. 61 | Return a {appid: icon_path} dict. 62 | """ 63 | protontricks_icon_dir = get_cache_dir() / "app_icons" 64 | protontricks_icon_dir.mkdir(exist_ok=True) 65 | 66 | # Write the placeholder from Python package into a more persistent 67 | # cache directory 68 | with importlib.resources.path( 69 | "protontricks.data.data", "icon_placeholder.png") as path: 70 | placeholder_path = protontricks_icon_dir / "icon_placeholder.png" 71 | placeholder_path.write_bytes(path.read_bytes()) 72 | 73 | appid2icon = {} 74 | 75 | for app in steam_apps: 76 | # Use library icon for Steam apps, fallback to placeholder icon 77 | # for non-Steam shortcuts and missing icons 78 | icon_cache_path = protontricks_icon_dir / f"{app.appid}.jpg" 79 | 80 | # What path to actually use for the app selector icon 81 | final_icon_path = placeholder_path 82 | 83 | if app.icon_path: 84 | # Resize icons that have a non-standard size to ensure they can be 85 | # displayed consistently in the app selector 86 | try: 87 | with Image.open(app.icon_path) as img: 88 | # Icon exists, so use the current icon instead of the 89 | # default placeholder. 90 | final_icon_path = app.icon_path 91 | 92 | resize_icon = img.size != APP_ICON_SIZE 93 | 94 | # Resize icons that have a non-standard size to ensure they can 95 | # be displayed consistently in the app selector 96 | if resize_icon: 97 | logger.info( 98 | "App icon %s has unusual size, resizing", 99 | app.icon_path 100 | ) 101 | resized_img = img.resize(APP_ICON_SIZE).convert("RGB") 102 | resized_img.save(icon_cache_path) 103 | final_icon_path = icon_cache_path 104 | except FileNotFoundError: 105 | # Icon does not exist, the placeholder will be used 106 | pass 107 | except Exception: 108 | # Multitude of reasons can cause image parsing or resizing 109 | # to fail. Instead of trying to catch everything, log the error 110 | # and move on. 111 | logger.warning( 112 | "Could not resize %s, ignoring", 113 | app.icon_path, 114 | exc_info=True 115 | ) 116 | 117 | appid2icon[app.appid] = final_icon_path 118 | 119 | return appid2icon 120 | 121 | 122 | def _run_gui(args, input_=None, strip_nonascii=False): 123 | """ 124 | Run YAD/Zenity with the given args. 125 | 126 | If 'strip_nonascii' is True, strip non-ASCII characters to workaround 127 | environments that can't handle all characters 128 | """ 129 | if strip_nonascii: 130 | # Convert to bytes and back to strings while stripping 131 | # non-ASCII characters 132 | args = [ 133 | arg.encode("ascii", "ignore").decode("ascii") for arg in args 134 | ] 135 | if input_: 136 | input_ = input_.encode("ascii", "ignore").decode("ascii") 137 | 138 | if input_: 139 | input_ = input_.encode("utf-8") 140 | 141 | try: 142 | return run( 143 | args, input=input_, check=True, stdout=PIPE, stderr=PIPE, 144 | ) 145 | except CalledProcessError as exc: 146 | if exc.returncode == 255 and not strip_nonascii: 147 | # User has weird locale settings. Log a warning and 148 | # rerun the command while stripping non-ASCII characters. 149 | logger.warning( 150 | "Your system locale is incapable of displaying all " 151 | "characters. Some app names may not show up correctly. " 152 | "Please use an UTF-8 locale to avoid this warning." 153 | ) 154 | return _run_gui(args, strip_nonascii=True) 155 | 156 | raise 157 | 158 | def show_text_dialog( 159 | title, 160 | text, 161 | window_icon, 162 | cancel_label=None, 163 | add_cancel_button=False, 164 | ok_label=None, 165 | width=600, 166 | height=600): 167 | """ 168 | Show a text dialog to the user 169 | 170 | :returns: True if user clicked OK, False otherwise 171 | """ 172 | if not ok_label: 173 | ok_label = "OK" 174 | 175 | if not cancel_label: 176 | cancel_label = "Cancel" 177 | 178 | def _get_yad_args(): 179 | args = [ 180 | "yad", "--text-info", "--window-icon", window_icon, 181 | "--title", title, "--width", str(width), "--height", str(height), 182 | f"--button={ok_label}:0", "--wrap", 183 | "--margins", "2", "--center" 184 | ] 185 | 186 | if add_cancel_button: 187 | args += [f"--button={cancel_label}:1"] 188 | 189 | return args 190 | 191 | def _get_zenity_args(): 192 | args = [ 193 | "zenity", "--text-info", "--window-icon", window_icon, 194 | "--title", title, "--width", str(width), "--height", 195 | str(height), "--cancel-label", cancel_label, "--ok-label", ok_label 196 | ] 197 | 198 | return args 199 | 200 | gui_provider = get_gui_provider() 201 | if gui_provider == "yad": 202 | args = _get_yad_args() 203 | else: 204 | args = _get_zenity_args() 205 | 206 | process = run(args, input=text.encode("utf-8"), check=False) 207 | 208 | return process.returncode == 0 209 | 210 | 211 | def select_steam_installation(steam_installations): 212 | """ 213 | Prompt the user to select a Steam installation if more than one 214 | installation is available 215 | 216 | Return the selected (steam_path, steam_root) installation, or None 217 | if the user picked nothing 218 | """ 219 | def _get_yad_args(): 220 | return [ 221 | "yad", "--list", "--no-headers", "--center", 222 | "--window-icon", "wine", 223 | # Disabling markup means we won't have to escape special characters 224 | "--no-markup", 225 | "--width", "600", "--height", "400", 226 | "--text", "Select Steam installation", 227 | "--title", "Protontricks", 228 | "--column", "Path" 229 | ] 230 | 231 | def _get_zenity_args(): 232 | return [ 233 | "zenity", "--list", "--hide-header", 234 | "--width", "600", 235 | "--height", "400", 236 | "--text", "Select Steam installation", 237 | "--title", "Protontricks", 238 | "--column", "Path" 239 | ] 240 | 241 | if len(steam_installations) == 1: 242 | return steam_installations[0] 243 | 244 | gui_provider = get_gui_provider() 245 | 246 | cmd_input = [] 247 | 248 | for i, installation in enumerate(steam_installations): 249 | steam_path, steam_root = installation 250 | 251 | is_flatpak = ( 252 | str(steam_path).endswith( 253 | "/com.valvesoftware.Steam/.local/share/Steam" 254 | ) 255 | ) 256 | is_snap = any( 257 | str(steam_path).endswith(snap_dir) 258 | for snap_dir in SNAP_STEAM_DIRS 259 | ) 260 | 261 | if is_flatpak: 262 | install_type = "Flatpak" 263 | elif is_snap: 264 | install_type = "Snap" 265 | else: 266 | install_type = "Native" 267 | 268 | cmd_input.append(f"{i+1}: {install_type} - {steam_path}") 269 | 270 | cmd_input = "\n".join(cmd_input) 271 | 272 | if gui_provider == "yad": 273 | args = _get_yad_args() 274 | elif gui_provider == "zenity": 275 | args = _get_zenity_args() 276 | 277 | try: 278 | result = _run_gui(args, input_=cmd_input) 279 | choice = result.stdout 280 | except CalledProcessError as exc: 281 | if exc.returncode in (1, 252): 282 | # YAD returns 252 when dialog is closed by pressing Esc 283 | # No installation was selected 284 | choice = b"" 285 | else: 286 | raise RuntimeError( 287 | f"{gui_provider} returned an error. Stderr: {exc.stderr}" 288 | ) 289 | 290 | if choice in (b"", b" \n"): 291 | return None, None 292 | 293 | choice = choice.decode("utf-8").split(":")[0] 294 | choice = int(choice) - 1 295 | 296 | return steam_installations[choice] 297 | 298 | 299 | def select_steam_app_with_gui(steam_apps, steam_path, title=None): 300 | """ 301 | Prompt the user to select a Proton-enabled Steam app from 302 | a dropdown list. 303 | 304 | Return the selected SteamApp 305 | """ 306 | def _get_yad_args(): 307 | return [ 308 | "yad", "--list", "--no-headers", "--center", 309 | "--window-icon", "wine", 310 | # Disabling markup means we won't have to escape special characters 311 | "--no-markup", 312 | "--search-column", "2", 313 | "--print-column", "2", 314 | "--width", "600", "--height", "400", 315 | "--text", title, 316 | "--title", "Protontricks", 317 | "--column", "Icon:IMG", 318 | "--column", "Steam app" 319 | ] 320 | 321 | def _get_zenity_args(): 322 | return [ 323 | "zenity", "--list", "--hide-header", 324 | "--width", "600", 325 | "--height", "400", 326 | "--text", title, 327 | "--title", "Protontricks", 328 | "--column", "Steam app" 329 | ] 330 | 331 | if not title: 332 | title = "Select Steam app" 333 | 334 | gui_provider = get_gui_provider() 335 | 336 | if gui_provider == "yad": 337 | args = _get_yad_args() 338 | 339 | # YAD implementation has icons for app selection 340 | appid2icon = _get_appid2icon(steam_apps) 341 | 342 | cmd_input = [ 343 | [ 344 | str(appid2icon[app.appid]), 345 | f"{app.name}: {app.appid}" 346 | ] 347 | for app in steam_apps if app.is_windows_app 348 | ] 349 | # Flatten the list 350 | cmd_input = list(itertools.chain.from_iterable(cmd_input)) 351 | else: 352 | args = _get_zenity_args() 353 | cmd_input = [ 354 | f'{app.name}: {app.appid}' for app in steam_apps 355 | if app.is_windows_app 356 | ] 357 | 358 | cmd_input = "\n".join(cmd_input) 359 | 360 | try: 361 | result = _run_gui(args, input_=cmd_input) 362 | choice = result.stdout 363 | except CalledProcessError as exc: 364 | # TODO: Remove this hack once the bug has been fixed upstream 365 | # Newer versions of zenity have a bug that causes long dropdown choice 366 | # lists to crash the command with a specific message. 367 | # Since stdout still prints the correct value, we can safely ignore 368 | # this error. 369 | # 370 | # The error is usually the message 371 | # 'free(): double free detected in tcache 2', but it can vary 372 | # depending on the environment. Instead, check if the returncode 373 | # is -6 374 | # 375 | # Related issues: 376 | # https://github.com/Matoking/protontricks/issues/20 377 | # https://gitlab.gnome.org/GNOME/zenity/issues/7 378 | if exc.returncode == -6: 379 | logger.info("Ignoring zenity crash bug") 380 | choice = exc.stdout 381 | elif exc.returncode in (1, 252): 382 | # YAD returns 252 when dialog is closed by pressing Esc 383 | # No game was selected 384 | choice = b"" 385 | else: 386 | raise RuntimeError( 387 | f"{gui_provider} returned an error. Stderr: {exc.stderr}" 388 | ) 389 | 390 | if choice in (b"", b" \n"): 391 | print("No game was selected. Quitting...") 392 | sys.exit(1) 393 | 394 | appid = str(choice).rsplit(':')[-1] 395 | appid = ''.join(x for x in appid if x.isdigit()) 396 | appid = int(appid) 397 | 398 | steam_app = next( 399 | app for app in steam_apps 400 | if app.appid == appid) 401 | return steam_app 402 | 403 | 404 | def prompt_filesystem_access(paths, show_dialog=False): 405 | """ 406 | Check whether Protontricks has access to the provided file system paths 407 | and prompt the user to grant access if necessary. 408 | 409 | :param show_dialog: Show a dialog. If disabled, just print the message 410 | instead. 411 | """ 412 | def _map_path(path): 413 | """ 414 | Map path to a path to be added into the `flatpak override` command. 415 | This means adding a tilde slash if the path is inside the home 416 | directory. 417 | """ 418 | home_dir = str(Path.home()) 419 | path = str(path) 420 | 421 | if path.startswith(home_dir): 422 | path = f"~/{path[len(home_dir)+1:]}" 423 | 424 | return path 425 | 426 | config = get_config() 427 | 428 | inaccessible_paths = get_inaccessible_paths(paths) 429 | inaccessible_paths = set(map(str, inaccessible_paths)) 430 | 431 | logger.debug( 432 | "Following inaccessible paths were found: %s", inaccessible_paths 433 | ) 434 | 435 | # Check what paths the user has ignored previously 436 | ignored_paths = set( 437 | json.loads(config.get("Dialog", "DismissedPaths", "[]")) 438 | ) 439 | 440 | logger.debug("Following paths have been ignored: %s", ignored_paths) 441 | 442 | # Remaining paths that are inaccessible and that haven't been dismissed 443 | # by the user 444 | remaining_paths = inaccessible_paths - ignored_paths 445 | 446 | if not remaining_paths: 447 | return None 448 | 449 | cmd_filesystem = " ".join([ 450 | "--filesystem={}".format(shlex.quote(_map_path(path))) 451 | for path in remaining_paths 452 | ]) 453 | 454 | # TODO: Showing a text dialog and asking user to manually run the command 455 | # is very janky. Replace this with a proper permission prompt when 456 | # Flatpak supports it. 457 | message = ( 458 | "Protontricks does not appear to have access to the following " 459 | "directories:\n" 460 | f" {' '.join(remaining_paths)}\n" 461 | "\n" 462 | "To fix this problem, grant access to the required directories by " 463 | "copying the following command and running it in a terminal:\n" 464 | "\n" 465 | f"flatpak override --user {cmd_filesystem} " 466 | "com.github.Matoking.protontricks\n" 467 | "\n" 468 | "You will need to restart Protontricks for the settings to take " 469 | "effect." 470 | ) 471 | 472 | if show_dialog: 473 | ignore = show_text_dialog( 474 | title="Protontricks", 475 | text=message, 476 | window_icon="wine", 477 | cancel_label="Close", 478 | ok_label="Ignore, don't ask again", 479 | add_cancel_button=True 480 | ) 481 | 482 | if ignore: 483 | # If user clicked "Don't ask again", store the paths to ensure the 484 | # user isn't prompted again for these directories 485 | ignored_paths |= inaccessible_paths 486 | 487 | config.set( 488 | "Dialog", "DismissedPaths", json.dumps(list(ignored_paths)) 489 | ) 490 | 491 | logger.warning(message) 492 | -------------------------------------------------------------------------------- /src/protontricks/cli/main.py: -------------------------------------------------------------------------------- 1 | # _____ _ _ _ _ 2 | # | _ |___ ___| |_ ___ ___| |_ ___|_|___| |_ ___ 3 | # | __| _| . | _| . | | _| _| | _| '_|_ -| 4 | # |__| |_| |___|_| |___|_|_|_| |_| |_|___|_,_|___| 5 | # A simple wrapper that makes it slightly painless to use winetricks with 6 | # Proton prefixes 7 | # 8 | # Script licensed under the GPLv3! 9 | 10 | import argparse 11 | import logging 12 | import os 13 | import sys 14 | 15 | from .. import __version__ 16 | from ..flatpak import (FLATPAK_BWRAP_COMPATIBLE_VERSION, 17 | get_running_flatpak_version) 18 | from ..gui import (prompt_filesystem_access, select_steam_app_with_gui, 19 | select_steam_installation) 20 | from ..steam import (find_legacy_steam_runtime_path, find_proton_app, 21 | find_steam_installations, get_steam_apps, 22 | get_steam_lib_paths) 23 | from ..util import run_command 24 | from ..winetricks import get_winetricks_path 25 | from .util import (CustomArgumentParser, cli_error_handler, enable_logging, 26 | exit_with_error) 27 | 28 | logger = logging.getLogger("protontricks") 29 | 30 | 31 | def cli(args=None): 32 | main(args) 33 | 34 | 35 | @cli_error_handler 36 | def main(args=None, steam_path=None, steam_root=None): 37 | """ 38 | 'protontricks' script entrypoint 39 | """ 40 | def _find_proton_app_or_exit(steam_path, steam_apps, appid): 41 | """ 42 | Attempt to find a Proton app. Fail with an appropriate CLI error 43 | message if one cannot be found. 44 | """ 45 | proton_app = find_proton_app( 46 | steam_path=steam_path, steam_apps=steam_apps, appid=appid 47 | ) 48 | 49 | if not proton_app: 50 | if os.environ.get("PROTON_VERSION"): 51 | # Print an error listing accepted values if PROTON_VERSION was 52 | # set, as the user is trying to use a certain Proton version 53 | proton_names = sorted(set([ 54 | app.name for app in steam_apps if app.is_proton 55 | ])) 56 | exit_( 57 | "Protontricks installation could not be found with given " 58 | "$PROTON_VERSION!\n\n" 59 | f"Valid values include: {', '.join(proton_names)}" 60 | ) 61 | else: 62 | exit_("Proton installation could not be found!") 63 | 64 | if not proton_app.is_proton_ready: 65 | exit_( 66 | "Proton installation is incomplete. Have you launched a Steam " 67 | "app using this Proton version at least once to finish the " 68 | "installation?" 69 | ) 70 | 71 | return proton_app 72 | 73 | if args is None: 74 | args = sys.argv[1:] 75 | 76 | parser = CustomArgumentParser( 77 | description=( 78 | "Wrapper for running Winetricks commands for " 79 | "Steam Play/Proton games.\n" 80 | "\n" 81 | "Usage:\n" 82 | "\n" 83 | "Run winetricks for game with APPID. " 84 | "COMMAND is passed directly to winetricks as-is. " 85 | "Any options specific to Protontricks need to be provided " 86 | "*before* APPID.\n" 87 | "$ protontricks APPID COMMAND\n" 88 | "\n" 89 | "Search installed games to find the APPID\n" 90 | "$ protontricks -s GAME_NAME\n" 91 | "\n" 92 | "List all installed games\n" 93 | "$ protontricks -l\n" 94 | "\n" 95 | "Use Protontricks GUI to select the game\n" 96 | "$ protontricks --gui\n" 97 | "\n" 98 | "Environment variables:\n" 99 | "\n" 100 | "PROTON_VERSION: name of the preferred Proton installation\n" 101 | "STEAM_DIR: path to custom Steam installation\n" 102 | "WINETRICKS: path to a custom 'winetricks' executable\n" 103 | "WINE: path to a custom 'wine' executable\n" 104 | "WINESERVER: path to a custom 'wineserver' executable\n" 105 | "STEAM_RUNTIME: 1 = enable Steam Runtime, 0 = disable Steam " 106 | "Runtime, valid path = custom Steam Runtime path, " 107 | "empty = enable automatically (default)\n" 108 | "PROTONTRICKS_GUI: GUI provider to use, accepts either 'yad' " 109 | "or 'zenity'\n" 110 | "\n" 111 | "Environment variables set automatically by Protontricks:\n" 112 | "STEAM_APP_PATH: path to the current game's installation directory\n" 113 | "STEAM_APPID: app ID of the current game\n" 114 | "PROTON_PATH: path to the currently used Proton installation" 115 | ), 116 | formatter_class=argparse.RawTextHelpFormatter 117 | ) 118 | parser.add_argument( 119 | "--verbose", "-v", action="count", default=0, 120 | help=( 121 | "Increase log verbosity. Can be supplied twice for " 122 | "maximum verbosity." 123 | ) 124 | ) 125 | parser.add_argument( 126 | "--no-term", action="store_true", 127 | help=( 128 | "Program was launched from desktop. This is used automatically " 129 | "when lauching Protontricks from desktop and no user-visible " 130 | "terminal is available." 131 | ) 132 | ) 133 | parser.add_argument( 134 | "-s", "--search", type=str, dest="search", nargs="+", 135 | required=False, help="Search for game(s) with the given name") 136 | parser.add_argument( 137 | "-l", "--list", action="store_true", dest="list", default=False, 138 | help="List all apps" 139 | ) 140 | parser.add_argument( 141 | "-c", "--command", type=str, dest="command", 142 | required=False, 143 | help="Run a command with Wine-related environment variables set. " 144 | "The command is passed to the shell as-is without being escaped.") 145 | parser.add_argument( 146 | "--gui", action="store_true", 147 | help="Launch the Protontricks GUI.") 148 | parser.add_argument( 149 | "--no-runtime", action="store_true", default=False, 150 | help="Disable Steam Runtime") 151 | parser.add_argument( 152 | "--no-bwrap", action="store_true", default=None, 153 | help="Disable bwrap containerization when using Steam Runtime" 154 | ) 155 | parser.add_argument( 156 | "--background-wineserver", 157 | dest="background_wineserver", 158 | action="store_true", 159 | help=( 160 | "Launch a background wineserver process to improve Wine command " 161 | "startup time. Disabled by default, as it can cause problems with " 162 | "some graphical applications." 163 | ) 164 | ) 165 | parser.add_argument( 166 | "--no-background-wineserver", 167 | dest="background_wineserver", 168 | action="store_false", 169 | help=( 170 | "Do not launch a background wineserver process to improve Wine " 171 | "command startup time." 172 | ) 173 | ) 174 | parser.add_argument( 175 | "--cwd-app", 176 | dest="cwd_app", 177 | default=False, 178 | action="store_true", 179 | help=( 180 | "Set the working directory of launched command to the Steam app's " 181 | "installation directory." 182 | ) 183 | ) 184 | parser.set_defaults(background_wineserver=False) 185 | 186 | parser.add_argument("appid", type=int, nargs="?", default=None) 187 | parser.add_argument("winetricks_command", nargs=argparse.REMAINDER) 188 | parser.add_argument( 189 | "-V", "--version", action="version", 190 | version=f"%(prog)s ({__version__})" 191 | ) 192 | 193 | if len(args) == 0: 194 | # No arguments were provided, default to GUI 195 | args = ["--gui"] 196 | 197 | args = parser.parse_args(args) 198 | 199 | # 'cli_error_handler' relies on this to know whether to use error dialog or 200 | # not 201 | main.no_term = args.no_term 202 | 203 | # Shorthand function for aborting with error message 204 | def exit_(error): 205 | exit_with_error(error, args.no_term) 206 | 207 | do_command = bool(args.command) 208 | do_list_apps = bool(args.search) or bool(args.list) 209 | do_gui = bool(args.gui) 210 | do_winetricks = bool(args.appid and args.winetricks_command) 211 | 212 | # Set 'use_bwrap' to opposite of args.no_bwrap if it was provided. 213 | # If not, keep it as None and determine the correct value to use later 214 | # once we've determined whether the selected Steam Runtime is a bwrap-based 215 | # one. 216 | use_bwrap = ( 217 | not bool(args.no_bwrap) if args.no_bwrap in (True, False) else None 218 | ) 219 | start_background_wineserver = ( 220 | args.background_wineserver 221 | if args.background_wineserver is not None 222 | else use_bwrap 223 | ) 224 | 225 | if not do_command and not do_list_apps and not do_gui and not do_winetricks: 226 | parser.print_help() 227 | return 228 | 229 | # Don't allow more than one action 230 | if sum([do_list_apps, do_gui, do_winetricks, do_command]) != 1: 231 | print("Only one action can be performed at a time.") 232 | parser.print_help() 233 | return 234 | 235 | enable_logging(args.verbose, record_to_file=args.no_term) 236 | 237 | flatpak_version = get_running_flatpak_version() 238 | if flatpak_version: 239 | logger.info( 240 | "Running inside Flatpak sandbox, version %s.", 241 | ".".join(map(str, flatpak_version)) 242 | ) 243 | if flatpak_version < FLATPAK_BWRAP_COMPATIBLE_VERSION: 244 | logger.warning( 245 | "Flatpak version is too old (<1.12.1) to support " 246 | "sub-sandboxes. Disabling bwrap. --no-bwrap will be ignored." 247 | ) 248 | use_bwrap = False 249 | 250 | # 1. Find Steam path 251 | # We can skip the Steam installation detection if the CLI entrypoint 252 | # has already been provided the path as a keyword argument. 253 | # This is the case when this entrypoint is being called by 254 | # 'protontricks-launch'. This prevents us from asking the user for 255 | # the Steam installation twice. 256 | if not steam_path: 257 | steam_installations = find_steam_installations() 258 | if not steam_installations: 259 | exit_("Steam installation directory could not be found.") 260 | 261 | steam_path, steam_root = select_steam_installation(steam_installations) 262 | if not steam_path: 263 | exit_("No Steam installation was selected.") 264 | 265 | # 2. Find the pre-installed legacy Steam Runtime if enabled 266 | legacy_steam_runtime_path = None 267 | use_steam_runtime = True 268 | 269 | if os.environ.get("STEAM_RUNTIME", "") != "0" and not args.no_runtime: 270 | legacy_steam_runtime_path = find_legacy_steam_runtime_path( 271 | steam_root=steam_root 272 | ) 273 | 274 | if not legacy_steam_runtime_path: 275 | exit_("Steam Runtime was enabled but couldn't be found!") 276 | else: 277 | use_steam_runtime = False 278 | logger.info("Steam Runtime disabled.") 279 | 280 | # 3. Find Winetricks 281 | winetricks_path = get_winetricks_path() 282 | if not winetricks_path: 283 | exit_( 284 | "Winetricks isn't installed, please install " 285 | "winetricks in order to use this script!" 286 | ) 287 | 288 | # 4. Find any Steam library folders 289 | steam_lib_paths = get_steam_lib_paths(steam_path) 290 | 291 | # Check if Protontricks has access to all the required paths 292 | prompt_filesystem_access( 293 | paths=[steam_path, steam_root] + steam_lib_paths, 294 | show_dialog=args.no_term 295 | ) 296 | 297 | # 5. Find any Steam apps 298 | steam_apps = get_steam_apps( 299 | steam_root=steam_root, steam_path=steam_path, 300 | steam_lib_paths=steam_lib_paths 301 | ) 302 | 303 | # It's too early to find Proton here, 304 | # as it cannot be found if no globally active Proton version is set. 305 | # Having no Proton at this point is no problem as: 306 | # 1. not all commands require Proton (search) 307 | # 2. a specific steam-app will be chosen in GUI mode, 308 | # which might use a different proton version than the one found here 309 | 310 | # Run the GUI 311 | if args.gui: 312 | has_installed_apps = any([ 313 | app for app in steam_apps if app.is_windows_app 314 | ]) 315 | 316 | if not has_installed_apps: 317 | exit_("Found no games. You need to launch a game at least once " 318 | "before Protontricks can find it.") 319 | 320 | try: 321 | steam_app = select_steam_app_with_gui( 322 | steam_apps=steam_apps, steam_path=steam_path 323 | ) 324 | except FileNotFoundError: 325 | exit_( 326 | "YAD or Zenity is not installed. Either executable is required for the " 327 | "Protontricks GUI." 328 | ) 329 | 330 | cwd = str(steam_app.install_path) if args.cwd_app else None 331 | 332 | # 6. Find Proton version of selected app 333 | proton_app = _find_proton_app_or_exit( 334 | steam_path=steam_path, steam_apps=steam_apps, appid=steam_app.appid 335 | ) 336 | 337 | run_command( 338 | winetricks_path=winetricks_path, 339 | proton_app=proton_app, 340 | steam_app=steam_app, 341 | use_steam_runtime=use_steam_runtime, 342 | legacy_steam_runtime_path=legacy_steam_runtime_path, 343 | command=[str(winetricks_path), "--gui"], 344 | use_bwrap=use_bwrap, 345 | start_wineserver=start_background_wineserver, 346 | cwd=cwd 347 | ) 348 | 349 | return 350 | # List apps (either all or using a search) 351 | elif do_list_apps: 352 | if args.list: 353 | matching_apps = [ 354 | app for app in steam_apps if app.is_windows_app 355 | ] 356 | else: 357 | # Search for games 358 | search_query = " ".join(args.search) 359 | matching_apps = [ 360 | app for app in steam_apps 361 | if app.is_windows_app and app.name_contains(search_query) 362 | ] 363 | 364 | if matching_apps: 365 | matching_games = "\n".join([ 366 | f"{app.name} ({app.appid})" for app in matching_apps 367 | ]) 368 | print( 369 | f"Found the following games:" 370 | f"\n{matching_games}\n" 371 | ) 372 | print( 373 | "To run Protontricks for the chosen game, run:\n" 374 | "$ protontricks APPID COMMAND" 375 | ) 376 | else: 377 | print("Found no games.") 378 | 379 | print( 380 | "\n" 381 | "NOTE: A game must be launched at least once before Protontricks " 382 | "can find the game." 383 | ) 384 | return 385 | 386 | # 6. Find globally active Proton version now 387 | proton_app = _find_proton_app_or_exit( 388 | steam_path=steam_path, steam_apps=steam_apps, appid=args.appid) 389 | 390 | # If neither search or GUI are set, do a normal Winetricks command 391 | # Find game by appid 392 | steam_appid = int(args.appid) 393 | try: 394 | steam_app = next( 395 | app for app in steam_apps 396 | if app.is_windows_app and app.appid == steam_appid 397 | ) 398 | except StopIteration: 399 | exit_( 400 | "Steam app with the given app ID could not be found. " 401 | "Is it installed, Proton compatible and have you launched it at " 402 | "least once? You can search for the app ID using the following " 403 | "command:\n" 404 | "$ protontricks -s " 405 | ) 406 | 407 | cwd = str(steam_app.install_path) if args.cwd_app else None 408 | 409 | if args.winetricks_command: 410 | returncode = run_command( 411 | winetricks_path=winetricks_path, 412 | proton_app=proton_app, 413 | steam_app=steam_app, 414 | use_steam_runtime=use_steam_runtime, 415 | legacy_steam_runtime_path=legacy_steam_runtime_path, 416 | use_bwrap=use_bwrap, 417 | start_wineserver=start_background_wineserver, 418 | command=[str(winetricks_path)] + args.winetricks_command, 419 | cwd=cwd 420 | ) 421 | elif args.command: 422 | returncode = run_command( 423 | winetricks_path=winetricks_path, 424 | proton_app=proton_app, 425 | steam_app=steam_app, 426 | command=args.command, 427 | use_steam_runtime=use_steam_runtime, 428 | legacy_steam_runtime_path=legacy_steam_runtime_path, 429 | use_bwrap=use_bwrap, 430 | start_wineserver=start_background_wineserver, 431 | # Pass the command directly into the shell *without* 432 | # escaping it 433 | shell=True, 434 | cwd=cwd, 435 | ) 436 | 437 | logger.info("Command returned %d", returncode) 438 | 439 | sys.exit(returncode) 440 | 441 | 442 | if __name__ == "__main__": 443 | main() 444 | -------------------------------------------------------------------------------- /tests/test_gui.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import shutil 3 | from subprocess import CalledProcessError 4 | 5 | import pytest 6 | from conftest import MockResult 7 | from PIL import Image 8 | 9 | from protontricks.gui import (prompt_filesystem_access, 10 | select_steam_app_with_gui, 11 | select_steam_installation) 12 | from protontricks.steam import SteamApp 13 | 14 | 15 | @pytest.fixture(scope="function") 16 | def broken_zenity(gui_provider, monkeypatch): 17 | """ 18 | Mock a broken Zenity executable that prints an error as described in 19 | the following GitHub issue: 20 | https://github.com/Matoking/protontricks/issues/20 21 | """ 22 | def mock_subprocess_run(args, **kwargs): 23 | gui_provider.args = args 24 | 25 | raise CalledProcessError( 26 | returncode=-6, 27 | cmd=args, 28 | output=gui_provider.mock_stdout, 29 | stderr=b"free(): double free detected in tcache 2\n" 30 | ) 31 | 32 | monkeypatch.setattr( 33 | "protontricks.gui.run", 34 | mock_subprocess_run 35 | ) 36 | 37 | yield gui_provider 38 | 39 | 40 | @pytest.fixture(scope="function") 41 | def locale_error_zenity(gui_provider, monkeypatch): 42 | """ 43 | Mock a Zenity executable returning a 255 error due to a locale issue 44 | on first run and working normally on second run 45 | """ 46 | def mock_subprocess_run(args, **kwargs): 47 | if not gui_provider.args: 48 | gui_provider.args = args 49 | raise CalledProcessError( 50 | returncode=255, 51 | cmd=args, 52 | output="", 53 | stderr=( 54 | b"This option is not available. " 55 | b"Please see --help for all possible usages." 56 | ) 57 | ) 58 | 59 | return MockResult(stdout=gui_provider.mock_stdout.encode("utf-8")) 60 | 61 | monkeypatch.setattr( 62 | "protontricks.gui.run", 63 | mock_subprocess_run 64 | ) 65 | monkeypatch.setenv("PROTONTRICKS_GUI", "zenity") 66 | 67 | yield gui_provider 68 | 69 | 70 | class TestSelectApp: 71 | def test_select_game(self, gui_provider, steam_app_factory, steam_dir): 72 | """ 73 | Select a game using the GUI 74 | """ 75 | steam_apps = [ 76 | steam_app_factory(name="Fake game 1", appid=10), 77 | steam_app_factory(name="Fake game 2", appid=20) 78 | ] 79 | 80 | # Fake user selecting 'Fake game 2' 81 | gui_provider.mock_stdout = "Fake game 2: 20" 82 | steam_app = select_steam_app_with_gui( 83 | steam_apps=steam_apps, steam_path=steam_dir 84 | ) 85 | 86 | assert steam_app == steam_apps[1] 87 | 88 | input_ = gui_provider.kwargs["input"] 89 | 90 | # Check that choices were displayed 91 | assert b"Fake game 1: 10\n" in input_ 92 | assert b"Fake game 2: 20" in input_ 93 | 94 | def test_select_game_icons( 95 | self, gui_provider, steam_app_factory, steam_dir): 96 | """ 97 | Select a game using the GUI. Ensure that icons are used in the dialog 98 | whenever available. 99 | """ 100 | steam_app_factory(name="Fake game 1", appid=10) 101 | steam_app_factory(name="Fake game 2", appid=20) 102 | steam_app_factory(name="Fake game 3", appid=30) 103 | 104 | # Create icons for game 1 and 3 105 | # Old location for 10 106 | Image.new("RGB", (32, 32)).save( 107 | steam_dir / "appcache" / "librarycache" / "10_icon.jpg" 108 | ) 109 | 110 | # New location for 30 111 | (steam_dir / "appcache" / "librarycache" / "30").mkdir() 112 | Image.new("RGB", (32, 32)).save( 113 | steam_dir / "appcache" / "librarycache" / "30" 114 | / "ffffffffffffffffffffffffffffffffffffffff.jpg" 115 | ) 116 | 117 | # Read Steam apps using `SteamApp.from_appmanifest` to ensure 118 | # icon paths are detected correctly 119 | steam_apps = [ 120 | SteamApp.from_appmanifest( 121 | steam_dir / "steamapps" / f"appmanifest_{appid}.acf", 122 | steam_path=steam_dir, 123 | steam_lib_paths=[steam_dir] 124 | ) 125 | for appid in (10, 20, 30) 126 | ] 127 | 128 | gui_provider.mock_stdout = "Fake game 2: 20" 129 | select_steam_app_with_gui(steam_apps=steam_apps, steam_path=steam_dir) 130 | 131 | input_ = gui_provider.kwargs["input"] 132 | 133 | assert b"librarycache/10_icon.jpg\nFake game 1" in input_ 134 | assert b"icon_placeholder.png\nFake game 2" in input_ 135 | assert b"librarycache/30/ffffffffffffffffffffffffffffffffffffffff.jpg\nFake game 3" \ 136 | in input_ 137 | 138 | def test_select_game_icons_ensure_resize( 139 | self, gui_provider, steam_app_factory, steam_dir, home_dir): 140 | """ 141 | Select a game using the GUI. Ensure custom icons with sizes other than 142 | 32x32 are resized. 143 | """ 144 | steam_apps = [ 145 | steam_app_factory(name="Fake game 1", appid=10) 146 | ] 147 | 148 | Image.new("RGB", (64, 64)).save( 149 | steam_dir / "appcache" / "librarycache" / "10_icon.jpg" 150 | ) 151 | 152 | gui_provider.mock_stdout = "Fake game 1: 10" 153 | select_steam_app_with_gui(steam_apps=steam_apps, steam_path=steam_dir) 154 | 155 | # Resized icon should have been created with the correct size and used 156 | resized_icon_path = \ 157 | home_dir / ".cache" / "protontricks" / "app_icons" / "10.jpg" 158 | assert resized_icon_path.is_file() 159 | with Image.open(resized_icon_path) as img: 160 | assert img.size == (32, 32) 161 | 162 | input_ = gui_provider.kwargs["input"] 163 | 164 | assert f"{resized_icon_path}\nFake game 1".encode("utf-8") in input_ 165 | 166 | # Any existing icon should be overwritten if it already exists 167 | resized_icon_path.write_bytes(b"not valid") 168 | select_steam_app_with_gui(steam_apps=steam_apps, steam_path=steam_dir) 169 | 170 | with Image.open(resized_icon_path) as img: 171 | assert img.size == (32, 32) 172 | 173 | def test_select_game_unidentifiable_icon_skipped( 174 | self, gui_provider, steam_app_factory, steam_dir, home_dir, caplog): 175 | """ 176 | Select a game using the GUI. Ensure a custom icon that's not 177 | identifiable by Pillow is skipped. 178 | """ 179 | steam_apps = [ 180 | steam_app_factory(name="Fake game 1", appid=10) 181 | ] 182 | 183 | icon_path = steam_dir / "appcache" / "librarycache" / "10_icon.jpg" 184 | icon_path.write_bytes(b"") 185 | 186 | gui_provider.mock_stdout = "Fake game 1: 10" 187 | selected_app = select_steam_app_with_gui( 188 | steam_apps=steam_apps, steam_path=steam_dir 189 | ) 190 | 191 | # Warning about icon was logged, but the app was selected successfully 192 | record = caplog.records[-1] 193 | assert record.message.startswith(f"Could not resize {icon_path}") 194 | 195 | assert selected_app.appid == 10 196 | 197 | def test_select_game_no_choice( 198 | self, gui_provider, steam_app_factory, steam_dir): 199 | """ 200 | Try choosing a game but make no choice 201 | """ 202 | steam_apps = [steam_app_factory(name="Fake game 1", appid=10)] 203 | 204 | # Fake user doesn't select any game 205 | gui_provider.mock_stdout = "" 206 | 207 | with pytest.raises(SystemExit) as exc: 208 | select_steam_app_with_gui( 209 | steam_apps=steam_apps, steam_path=steam_dir 210 | ) 211 | 212 | assert exc.value.code == 1 213 | 214 | def test_select_game_broken_zenity( 215 | self, broken_zenity, monkeypatch, steam_app_factory, steam_dir): 216 | """ 217 | Try choosing a game with a broken Zenity executable that 218 | prints a specific error message that Protontricks knows how to ignore 219 | """ 220 | monkeypatch.setenv("PROTONTRICKS_GUI", "zenity") 221 | 222 | steam_apps = [ 223 | steam_app_factory(name="Fake game 1", appid=10), 224 | steam_app_factory(name="Fake game 2", appid=20) 225 | ] 226 | 227 | # Fake user selecting 'Fake game 2' 228 | broken_zenity.mock_stdout = "Fake game 2: 20" 229 | steam_app = select_steam_app_with_gui( 230 | steam_apps=steam_apps, steam_path=steam_dir) 231 | 232 | assert steam_app == steam_apps[1] 233 | 234 | def test_select_game_locale_error( 235 | self, locale_error_zenity, steam_app_factory, steam_dir, caplog): 236 | """ 237 | Try choosing a game with an environment that can't handle non-ASCII 238 | characters 239 | """ 240 | steam_apps = [ 241 | steam_app_factory(name="Fäke game 1", appid=10), 242 | steam_app_factory(name="Fäke game 2", appid=20) 243 | ] 244 | 245 | # Fake user selecting 'Fäke game 2'. The non-ASCII character 'ä' 246 | # is stripped since Zenity wouldn't be able to display the character. 247 | locale_error_zenity.mock_stdout = "Fke game 2: 20" 248 | steam_app = select_steam_app_with_gui( 249 | steam_apps=steam_apps, steam_path=steam_dir 250 | ) 251 | 252 | assert steam_app == steam_apps[1] 253 | assert ( 254 | "Your system locale is incapable of displaying all characters" 255 | in caplog.records[-1].message 256 | ) 257 | 258 | @pytest.mark.parametrize("gui_cmd", ["yad", "zenity"]) 259 | def test_select_game_gui_provider_env( 260 | self, gui_provider, steam_app_factory, monkeypatch, gui_cmd, 261 | steam_dir): 262 | """ 263 | Test that the correct GUI provider is selected based on the 264 | `PROTONTRICKS_GUI` environment variable 265 | """ 266 | monkeypatch.setenv("PROTONTRICKS_GUI", gui_cmd) 267 | 268 | steam_apps = [ 269 | steam_app_factory(name="Fake game 1", appid=10), 270 | steam_app_factory(name="Fake game 2", appid=20) 271 | ] 272 | 273 | gui_provider.mock_stdout = "Fake game 2: 20" 274 | select_steam_app_with_gui( 275 | steam_apps=steam_apps, steam_path=steam_dir 276 | ) 277 | 278 | # The flags should differ slightly depending on which provider is in 279 | # use 280 | if gui_cmd == "yad": 281 | assert gui_provider.args[0] == "yad" 282 | assert gui_provider.args[2] == "--no-headers" 283 | elif gui_cmd == "zenity": 284 | assert gui_provider.args[0] == "zenity" 285 | assert gui_provider.args[2] == "--hide-header" 286 | 287 | 288 | class TestSelectSteamInstallation: 289 | @pytest.mark.usefixtures("flatpak_sandbox") 290 | @pytest.mark.parametrize("gui_cmd", ["yad", "zenity"]) 291 | def test_select_steam_gui_provider_env( 292 | self, gui_provider, monkeypatch, gui_cmd, steam_dir, 293 | flatpak_steam_dir): 294 | """ 295 | Test that the correct GUI provider is selected based on the 296 | `PROTONTRICKS_GUI` environment variable 297 | """ 298 | monkeypatch.setenv("PROTONTRICKS_GUI", gui_cmd) 299 | 300 | gui_provider.mock_stdout = "1: Flatpak - /foo/bar" 301 | select_steam_installation([ 302 | (steam_dir, steam_dir), 303 | (flatpak_steam_dir, flatpak_steam_dir) 304 | ]) 305 | 306 | # The flags should differ slightly depending on which provider is in 307 | # use 308 | if gui_cmd == "yad": 309 | assert gui_provider.args[0] == "yad" 310 | assert gui_provider.args[2] == "--no-headers" 311 | elif gui_cmd == "zenity": 312 | assert gui_provider.args[0] == "zenity" 313 | assert gui_provider.args[2] == "--hide-header" 314 | 315 | @pytest.mark.parametrize( 316 | "path,label", 317 | [ 318 | (".steam", "Native"), 319 | (".local/share/Steam", "Native"), 320 | (".var/app/com.valvesoftware.Steam/.local/share/Steam", "Flatpak"), 321 | ("snap/steam/common/.local/share/Steam", "Snap") 322 | ] 323 | ) 324 | def test_correct_labels_detected( 325 | self, gui_provider, steam_dir, home_dir, path, label): 326 | """ 327 | Test that the Steam installation selection dialog uses the correct 328 | label for each Steam installation depending on its type 329 | """ 330 | steam_new_dir = home_dir / path 331 | with contextlib.suppress(FileExistsError): 332 | # First test cases try copying against existing dirs, this can be 333 | # ignored 334 | shutil.copytree(steam_dir, steam_new_dir) 335 | 336 | select_steam_installation([ 337 | (steam_new_dir, steam_new_dir), 338 | # Use an additional nonsense path; there need to be at least 339 | # two paths or user won't be prompted as there is no need 340 | ("/mock-steam", "/mock-steam") 341 | ]) 342 | 343 | prompt_input = gui_provider.kwargs["input"].decode("utf-8") 344 | 345 | assert f"{label} - {steam_new_dir}" in prompt_input 346 | 347 | 348 | @pytest.mark.usefixtures("flatpak_sandbox") 349 | class TestPromptFilesystemAccess: 350 | def test_prompt_without_desktop(self, home_dir, caplog): 351 | """ 352 | Test that calling 'prompt_filesystem_access' without showing the dialog 353 | only generates a warning 354 | """ 355 | prompt_filesystem_access( 356 | [home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"], 357 | show_dialog=False 358 | ) 359 | 360 | assert len(caplog.records) == 1 361 | 362 | record = caplog.records[0] 363 | 364 | assert record.levelname == "WARNING" 365 | assert "Protontricks does not appear to have access" in record.message 366 | 367 | assert "--filesystem=/mnt/fake_SSD" in record.message 368 | assert "--filesystem=/mnt/fake_SSD_2" in record.message 369 | assert str(home_dir / "fake_path") not in record.message 370 | 371 | def test_prompt_home_dir(self, home_dir, tmp_path, caplog): 372 | """ 373 | Test that calling 'prompt_filesystem_access' with a path 374 | in the home directory will result in the command using a tilde slash 375 | as the shorthand instead 376 | """ 377 | flatpak_info_path = tmp_path / "flatpak-info" 378 | 379 | flatpak_info_path.write_text( 380 | "[Application]\n" 381 | "name=fake.flatpak.Protontricks\n" 382 | "\n" 383 | "[Instance]\n" 384 | "flatpak-version=1.12.1\n" 385 | "\n" 386 | "[Context]\n" 387 | "filesystems=/mnt/SSD_A" 388 | ) 389 | prompt_filesystem_access( 390 | [home_dir / "fake_path", "/mnt/SSD_A"], 391 | show_dialog=False 392 | ) 393 | 394 | assert len(caplog.records) == 1 395 | 396 | record = caplog.records[0] 397 | 398 | assert record.levelname == "WARNING" 399 | assert "Protontricks does not appear to have access" in record.message 400 | 401 | assert "--filesystem='~/fake_path'" in record.message 402 | assert "/mnt/SSD_A" not in record.message 403 | 404 | def test_prompt_with_desktop_no_dialog(self, home_dir, gui_provider): 405 | """ 406 | Test that calling 'prompt_filesystem_access' with 'show_dialog' 407 | displays a dialog 408 | """ 409 | prompt_filesystem_access( 410 | [home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"], 411 | show_dialog=True 412 | ) 413 | 414 | input_ = gui_provider.kwargs["input"].decode("utf-8") 415 | 416 | assert str(home_dir / "fake_path") not in input_ 417 | assert "--filesystem=/mnt/fake_SSD" in input_ 418 | assert "--filesystem=/mnt/fake_SSD_2" in input_ 419 | 420 | def test_prompt_with_desktop_dialog(self, home_dir, gui_provider): 421 | """ 422 | Test that calling 'prompt_filesystem_access' with 'show_dialog' 423 | displays a dialog 424 | """ 425 | # Mock the user closing the dialog without ignoring the messages 426 | gui_provider.returncode = 1 427 | 428 | prompt_filesystem_access( 429 | [home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"], 430 | show_dialog=True 431 | ) 432 | 433 | input_ = gui_provider.kwargs["input"].decode("utf-8") 434 | 435 | # Dialog was displayed 436 | assert "/mnt/fake_SSD" in input_ 437 | assert "/mnt/fake_SSD_2" in input_ 438 | 439 | # Mock the user selecting "Ignore, don't ask again" 440 | gui_provider.returncode = 0 441 | gui_provider.kwargs["input"] = None 442 | 443 | prompt_filesystem_access( 444 | [home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"], 445 | show_dialog=True 446 | ) 447 | 448 | # Dialog is still displayed, but it won't be the next time 449 | input_ = gui_provider.kwargs["input"].decode("utf-8") 450 | assert "/mnt/fake_SSD" in input_ 451 | assert "/mnt/fake_SSD_2" in input_ 452 | 453 | gui_provider.kwargs["input"] = None 454 | 455 | prompt_filesystem_access( 456 | [home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"], 457 | show_dialog=True 458 | ) 459 | 460 | # Dialog is not shown, since the user has opted to ignore the warning 461 | # for the current paths 462 | assert not gui_provider.kwargs["input"] 463 | 464 | # A new path makes the warning reappear 465 | prompt_filesystem_access( 466 | [ 467 | home_dir / "fake_path", 468 | "/mnt/fake_SSD", 469 | "/mnt/fake_SSD_2", 470 | "/mnt/fake_SSD_3" 471 | ], 472 | show_dialog=True 473 | ) 474 | 475 | input_ = gui_provider.kwargs["input"].decode("utf-8") 476 | assert "/mnt/fake_SSD " not in input_ 477 | assert "/mnt/fake_SSD_2" not in input_ 478 | assert "/mnt/fake_SSD_3" in input_ 479 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 | 7 | ## [Unreleased] 8 | ### Added 9 | - Use `$STEAM_COMPAT_DATA_PATH/pfx` as the game's prefix path if `STEAM_COMPAT_DATA_PATH` environment variable is set 10 | 11 | ## [1.13.1] - 2025-11-15 12 | ### Fixed 13 | - Fix Steam library folder discovery by using case-insensitive path matching 14 | - Fix default Proton version discovery. Protontricks will now use Proton Experimental as default per Steam client's hardcoded default setting, with stable Proton as fallback. 15 | 16 | ## [1.13.0] - 2025-08-11 17 | ### Added 18 | - Improve compatibility by setting additional Proton related environment variables when applicable 19 | 20 | ### Changed 21 | - Fixed locale is now used for SteamOS 3, not only Steam Deck 22 | 23 | ## [1.12.1] - 2025-03-08 24 | ### Fixed 25 | - Fix missing app icons for games installed using newer Steam client 26 | - Fix spurious "unknown file arch" Winetricks warnings (newer Winetricks required) 27 | 28 | ### Removed 29 | - Drop Python 3.6 support 30 | 31 | ## [1.12.0] - 2024-09-16 32 | ### Added 33 | - `--cwd-app` flag to set working directory to the game's installation directory 34 | - Add support for Snap Steam installations 35 | 36 | ### Changed 37 | - `protontricks -c` and `protontricks-launch` now use the current working directory instead of the game's installation directory. `--cwd-app` can be used to restore old behavior. Scripts can also `$STEAM_APP_PATH` environment variable to determine the game's installation directory; this has been supported (albeit undocumented) since 1.8.0. 38 | - `protontricks` will now launch GUI if no arguments were provided 39 | 40 | ### Fixed 41 | - Fix crash when parsing appinfo.vdf V29 in new Steam client version 42 | - Fix Protontricks crash when `config.vdf` contains invalid Unicode characters 43 | 44 | > [!IMPORTANT] 45 | > This release bundles a patched version of `vdf` in case the system Python package doesn't have the required `appinfo.vdf` V29 support. 46 | > If you're a package maintainer, you will probably want to remove the corresponding 47 | > commit if the distro you're using already ships a version of `vdf` with the 48 | > required support. 49 | 50 | ## [1.11.1] - 2024-02-20 51 | ### Fixed 52 | - Fix Protontricks crash when custom Proton has an invalid or empty `compatibilitytool.vdf` manifest 53 | - Fix Protontricks GUI crash when Proton installation is incomplete 54 | - Check if Steam Runtime launcher service launched correctly instead of always assuming successful launch 55 | 56 | ## [1.11.0] - 2023-12-30 57 | ### Added 58 | - Show app icons for custom shortcuts in the app selector 59 | - Verbose flag can be enabled with `-vv` for additional debug logging 60 | 61 | ### Fixed 62 | - Fix Protontricks not recognizing supported Steam Runtime installation due to changed name 63 | - Fix Protontricks not recognizing default Proton installation for games with different Proton preselected by Valve testing 64 | - Fix Protontricks crash when app has an unidentifiable app icon 65 | 66 | ## [1.10.5] - 2023-09-05 67 | ### Fixed 68 | - Fix crash caused by custom app icons with non-RGB mode 69 | 70 | ## [1.10.4] - 2023-08-26 71 | ### Fixed 72 | - Fix crash caused by the Steam shortcut configuration file containing extra data after the VDF section 73 | - Fix differently sized custom app icons breaking the layout in the app selector 74 | 75 | ## [1.10.3] - 2023-05-06 76 | ### Added 77 | - Flatpak version of Steam is also detected with non-Flatpak installation of Protontricks 78 | 79 | ### Changed 80 | - `--background-wineserver` is now disabled by default due to problems with crashing graphical applications and broken console output 81 | 82 | ### Fixed 83 | - Fix detection of Steam library folders using non-standard capitalizations for `steamapps` 84 | - _Steam Linux Runtime - Sniper_ is no longer incorrectly reported as an unsupported runtime 85 | 86 | ## [1.10.2] - 2023-02-13 87 | ### Added 88 | - Launch application with fixed locale settings if Steam Deck is used and non-existent locales are configured 89 | 90 | ### Fixed 91 | - Fix crashes caused by missing permissions when checking for Steam apps 92 | 93 | ## [1.10.1]- 2022-12-10 94 | ### Fixed 95 | - Fix crash when unknown XDG Flatpak filesystem permissions are enabled 96 | - Fix crash when parsing appinfo.vdf V28 version introduced in Steam beta 97 | 98 | ## [1.10.0] - 2022-11-27 99 | ### Added 100 | - Prompt the user for a Steam installation if multiple installations are found 101 | 102 | ### Fixed 103 | - Detect XDG user directory permissions in Flatpak environment 104 | 105 | ## [1.9.2] - 2022-09-16 106 | ### Fixed 107 | - Fix random crashes when running Wine commands due to race condition in Wine launcher script 108 | 109 | ## [1.9.1] - 2022-08-28 110 | ### Added 111 | - Print a warning when multiple Steam directories are detected and `STEAM_DIR` is not used to specify the directory 112 | 113 | ### Changed 114 | - Launch Steam Runtime sandbox with `--bus-name` parameter instead of the now deprecated `--socket` 115 | 116 | ### Fixed 117 | - Fix various crashes due to Wine processes under Steam Runtime sandbox using the incorrect working directory 118 | 119 | ## [1.9.0] - 2022-07-02 120 | ### Added 121 | - Add `-l/--list` command to list all games 122 | 123 | ### Fixed 124 | - Fix `wineserver -w` calls hanging when legacy Steam Runtime and background wineserver are enabled 125 | - Do not attempt to launch bwrap-launcher if bwrap is not available 126 | 127 | ## [1.8.2] - 2022-05-16 128 | ### Fixed 129 | - Fix Wine crash on newer Steam Runtime installations due to renamed runtime executable 130 | - Fix graphical Wine applications crashing on Wayland 131 | - Fix Protontricks crash caused by Steam shortcuts created by 3rd party applications such as Lutris 132 | 133 | ## [1.8.1] - 2022-03-20 134 | ### Added 135 | - Prompt the user to update Flatpak permissions if inaccessible paths are detected 136 | 137 | ### Fixed 138 | - Fix Proton discovery on Steam Deck 139 | 140 | ### Removed 141 | - Drop Python 3.5 support 142 | 143 | ## [1.8.0] - 2022-02-26 144 | ### Added 145 | - fsync/esync is enabled by default 146 | - `PROTON_NO_FSYNC` and `PROTON_NO_ESYNC` environment variables are supported 147 | - Improve Wine command startup time by launching a background wineserver for the duration of the Protontricks session. This is enabled by default for bwrap, and can also be toggled manually with `--background-wineserver/--no-background-wineserver`. 148 | - Improve Wine command startup time with bwrap by creating a single container and launching all Wine processes inside it. 149 | 150 | ### Fixed 151 | - Fix Wine crash when the Steam application and Protontricks are running at the same time 152 | - Fix Steam installation detection when both non-Flatpak and Flatpak versions of Steam are installed for the same user 153 | - Fix Protontricks crash when Proton installation is incomplete 154 | - Fix Protontricks crash when both Flatpak and non-Flatpak versions of Steam are installed 155 | - Fix duplicate log messages when using `protontricks-launch` 156 | - Fix error dialog not being displayed when using `protontricks-launch` 157 | 158 | ## [1.7.0] - 2022-01-08 159 | ### Changed 160 | - Enable usage of Flatpak Protontricks with non-Flatpak Steam. Flatpak Steam is prioritized if both are found. 161 | 162 | ### Fixed 163 | - bwrap is only disabled when the Flatpak installation is too old. Flatpak 1.12.1 and newer support sub-sandboxes. 164 | - Remove Proton installations from app listings 165 | 166 | ## [1.6.2] - 2021-11-28 167 | ### Changed 168 | - Return code is now returned from the executed user commands 169 | - Return code `1` is returned for most Protontricks errors instead of `-1` 170 | 171 | ## [1.6.1] - 2021-10-18 172 | ### Fixed 173 | - Fix duplicate Steam application entries 174 | - Fix crash on Python 3.5 175 | 176 | ## [1.6.0] - 2021-08-08 177 | ### Added 178 | - Add `protontricks-launch` script to launch Windows executables using Proton app specific Wine prefixes 179 | - Add desktop integration for Windows executables, which can now be launched using Protontricks 180 | - Add `protontricks-desktop-install` to install desktop integration for the local user. This is only necessary if the installation method doesn't do this automatically. 181 | - Add error dialog for displaying error information when Protontricks has been launched from desktop and no user-visible terminal is available. 182 | - Add YAD as GUI provider. YAD is automatically used instead of Zenity when available as it supports additional features. 183 | 184 | ### Changed 185 | - Improved GUI dialog. The prompt to select the Steam app now uses a list dialog with support for scrolling, search and app icons. App icons are only supported on YAD. 186 | 187 | ### Fixed 188 | - Display proper error messages in certain cases when corrupted VDF files are found 189 | - Fix crash caused by appmanifest files that can't be read due to insufficient permissions 190 | - Fix crash caused by non-Proton compatibility tool being enabled for the selected app 191 | - Fix erroneous warning when Steam library is inside a case-insensitive file system 192 | 193 | ## [1.5.2] - 2021-06-09 194 | ### Fixed 195 | - Custom Proton installations now use Steam Runtime installations when applicable 196 | - Fix crash caused by older Steam app installations using a different app manifest structure 197 | - Fix crash caused by change to lowercase field names in multiple VDF files 198 | - Fix crash caused by change in the Steam library folder configuration file 199 | 200 | ## [1.5.1] - 2021-05-10 201 | ### Fixed 202 | - bwrap containerization now tries to mount more root directories except those that have been blacklisted due to potential issues 203 | 204 | ## [1.5.0] - 2021-04-10 205 | ### Added 206 | - Use bwrap containerization with newer Steam Runtime installations. The old behavior can be enabled with `--no-bwrap` in case of problems. 207 | 208 | ### Fixed 209 | - User-provided `WINE` and `WINESERVER` environment variables are used when Steam Runtime is enabled 210 | - Fixed crash caused by changed directory name in Proton Experimental update 211 | 212 | ## [1.4.4] - 2021-02-03 213 | ### Fixed 214 | - Display a proper error message when Proton installation is incomplete due to missing Steam Runtime 215 | - Display a proper warning when a tool manifest is empty 216 | - Fix crash caused by changed directory structure in Steam Runtime update 217 | 218 | ## [1.4.3] - 2020-12-09 219 | ### Fixed 220 | - Add support for newer Steam Runtime versions 221 | 222 | ## [1.4.2] - 2020-09-19 223 | ### Fixed 224 | - Fix crash with newer Steam client beta caused by differently cased keys in `loginusers.vdf` 225 | 226 | ### Added 227 | - Print a warning if both `steamapps` and `SteamApps` directories are found inside the same library directory 228 | 229 | ### Changed 230 | - Print full help message when incorrect parameters are provided. 231 | 232 | ## [1.4.1] - 2020-02-17 233 | ### Fixed 234 | - Fixed crash caused by Steam library paths containing special characters 235 | - Fixed crash with Proton 5.0 caused by Steam Runtime being used unnecessarily with all binaries 236 | 237 | ## [1.4] - 2020-01-26 238 | ### Added 239 | - System-wide compatibility tool directories are now searched for Proton installations 240 | 241 | ### Changed 242 | - Drop Python 3.4 compatibility. Python 3.4 compatibility has been broken since 1.2.2. 243 | 244 | ### Fixed 245 | - Zenity no longer crashes the script if locale is incapable of processing the arguments. 246 | - Selecting "Cancel" in the GUI window now prints a proper message instead of an error. 247 | - Add workaround for Zenity crashes not handled by the previous fix 248 | 249 | ## [1.3.1] - 2019-11-21 250 | ### Fixed 251 | - Fix Proton prefix detection when the prefix directory is located inside a `SteamApps` directory instead of `steamapps` 252 | - Use the most recently used Proton prefix when multiple prefix directories are found for a single game 253 | - Fix Python 3.5 compatibility 254 | 255 | ## [1.3] - 2019-11-06 256 | ### Added 257 | - Non-Steam applications are now detected. 258 | 259 | ### Fixed 260 | - `STEAM_DIR` environment variable will no longer fallback to default path in some cases 261 | 262 | ## [1.2.5] - 2019-09-17 263 | ### Fixed 264 | - Fix regression in 1.2.3 that broke detection of custom Proton installations. 265 | - Proton prefix is detected correctly even if it exists in a different Steam library folder than the game installation. 266 | 267 | ## [1.2.4] - 2019-07-25 268 | ### Fixed 269 | - Add a workaround for a VDF parser bug that causes a crash when certain appinfo.vdf files are parsed. 270 | 271 | ## [1.2.3] - 2019-07-18 272 | ### Fixed 273 | - More robust parsing of appinfo.vdf. This fixes some cases where Protontricks was unable to detect Proton installations. 274 | 275 | ## [1.2.2] - 2019-06-05 276 | ### Fixed 277 | - Set `WINEDLLPATH` and `WINELOADER` environment variables. 278 | - Add a workaround for a Zenity bug that causes the GUI to crash when certain versions of Zenity are used. 279 | 280 | ## [1.2.1] - 2019-04-08 281 | ### Changed 282 | - Delay Proton detection until it's necessary. 283 | 284 | ### Fixed 285 | - Use the correct Proton installation when selecting a Steam app using the GUI. 286 | - Print a proper error message if Steam isn't found. 287 | - Print an error message when GUI is enabled and no games were found. 288 | - Support appmanifest files with mixed case field names. 289 | 290 | ## [1.2] - 2019-02-27 291 | ### Added 292 | - Add a `-c` parameter to run shell commands in the game's installation directory with relevant Wine environment variables. 293 | - Steam Runtime is now supported and used by default unless disabled with `--no-runtime` flag or `STEAM_RUNTIME` environment variable. 294 | 295 | ### Fixed 296 | - All arguments are now correctly passed to winetricks. 297 | - Games that haven't been launched at least once are now excluded properly. 298 | - Custom Proton versions with custom display names now work properly. 299 | - `PATH` environment variable is modified to prevent conflicts with system-wide Wine binaries. 300 | - Steam installation is handled correctly if `~/.steam/steam` and `~/.steam/root` point to different directories. 301 | 302 | ## [1.1.1] - 2019-01-20 303 | ### Added 304 | - Game-specific Proton installations are now detected. 305 | 306 | ### Fixed 307 | - Proton installations are now detected properly again in newer Steam Beta releases. 308 | 309 | ## [1.1] - 2019-01-20 310 | ### Added 311 | - Custom Proton installations in `STEAM_DIR/compatibilitytools.d` are now detected. See [Sirmentio/protontricks#31](https://github.com/Sirmentio/protontricks/issues/31). 312 | - Protontricks is now a Python package and can be installed using `pip`. 313 | 314 | ### Changed 315 | - Argument parsing has been refactored to use argparse. 316 | - `protontricks gui` is now `protontricks --gui`. 317 | - New `protontricks --version` command to print the version number. 318 | - Game names are now displayed in alphabetical order and filtered to exclude non-Proton games. 319 | - Protontricks no longer prints INFO messages by default. To restore previous behavior, use the `-v` flag. 320 | 321 | ### Fixed 322 | - More robust VDF parsing. 323 | - Corrupted appmanifest files are now skipped. See [Sirmentio/protontricks#36](https://github.com/Sirmentio/protontricks/pull/36). 324 | - Display a proper error message when $STEAM_DIR doesn't point to a valid Steam installation. See [Sirmentio/protontricks#46](https://github.com/Sirmentio/protontricks/issues/46). 325 | 326 | ## 1.0 - 2019-01-16 327 | ### Added 328 | - The last release of Protontricks maintained by [@Sirmentio](https://github.com/Sirmentio). 329 | 330 | [Unreleased]: https://github.com/Matoking/protontricks/compare/1.13.1...HEAD 331 | [1.13.1]: https://github.com/Matoking/protontricks/compare/1.13.0...1.13.1 332 | [1.13.0]: https://github.com/Matoking/protontricks/compare/1.12.1...1.13.0 333 | [1.12.1]: https://github.com/Matoking/protontricks/compare/1.12.0...1.12.1 334 | [1.12.0]: https://github.com/Matoking/protontricks/compare/1.11.1...1.12.0 335 | [1.11.1]: https://github.com/Matoking/protontricks/compare/1.11.0...1.11.1 336 | [1.11.0]: https://github.com/Matoking/protontricks/compare/1.10.5...1.11.0 337 | [1.10.5]: https://github.com/Matoking/protontricks/compare/1.10.4...1.10.5 338 | [1.10.4]: https://github.com/Matoking/protontricks/compare/1.10.3...1.10.4 339 | [1.10.3]: https://github.com/Matoking/protontricks/compare/1.10.2...1.10.3 340 | [1.10.2]: https://github.com/Matoking/protontricks/compare/1.10.1...1.10.2 341 | [1.10.1]: https://github.com/Matoking/protontricks/compare/1.10.0...1.10.1 342 | [1.10.0]: https://github.com/Matoking/protontricks/compare/1.9.2...1.10.0 343 | [1.9.2]: https://github.com/Matoking/protontricks/compare/1.9.1...1.9.2 344 | [1.9.1]: https://github.com/Matoking/protontricks/compare/1.9.0...1.9.1 345 | [1.9.0]: https://github.com/Matoking/protontricks/compare/1.8.2...1.9.0 346 | [1.8.2]: https://github.com/Matoking/protontricks/compare/1.8.1...1.8.2 347 | [1.8.1]: https://github.com/Matoking/protontricks/compare/1.8.0...1.8.1 348 | [1.8.0]: https://github.com/Matoking/protontricks/compare/1.7.0...1.8.0 349 | [1.7.0]: https://github.com/Matoking/protontricks/compare/1.6.2...1.7.0 350 | [1.6.2]: https://github.com/Matoking/protontricks/compare/1.6.1...1.6.2 351 | [1.6.1]: https://github.com/Matoking/protontricks/compare/1.6.0...1.6.1 352 | [1.6.0]: https://github.com/Matoking/protontricks/compare/1.5.2...1.6.0 353 | [1.5.2]: https://github.com/Matoking/protontricks/compare/1.5.1...1.5.2 354 | [1.5.1]: https://github.com/Matoking/protontricks/compare/1.5.0...1.5.1 355 | [1.5.0]: https://github.com/Matoking/protontricks/compare/1.4.4...1.5.0 356 | [1.4.4]: https://github.com/Matoking/protontricks/compare/1.4.3...1.4.4 357 | [1.4.3]: https://github.com/Matoking/protontricks/compare/1.4.2...1.4.3 358 | [1.4.2]: https://github.com/Matoking/protontricks/compare/1.4.1...1.4.2 359 | [1.4.1]: https://github.com/Matoking/protontricks/compare/1.4...1.4.1 360 | [1.4]: https://github.com/Matoking/protontricks/compare/1.3.1...1.4 361 | [1.3.1]: https://github.com/Matoking/protontricks/compare/1.3...1.3.1 362 | [1.3]: https://github.com/Matoking/protontricks/compare/1.2.5...1.3 363 | [1.2.5]: https://github.com/Matoking/protontricks/compare/1.2.4...1.2.5 364 | [1.2.4]: https://github.com/Matoking/protontricks/compare/1.2.3...1.2.4 365 | [1.2.3]: https://github.com/Matoking/protontricks/compare/1.2.2...1.2.3 366 | [1.2.2]: https://github.com/Matoking/protontricks/compare/1.2.1...1.2.2 367 | [1.2.1]: https://github.com/Matoking/protontricks/compare/1.2...1.2.1 368 | [1.2]: https://github.com/Matoking/protontricks/compare/1.1.1...1.2 369 | [1.1.1]: https://github.com/Matoking/protontricks/compare/1.1...1.1.1 370 | [1.1]: https://github.com/Matoking/protontricks/compare/1.0...1.1 371 | -------------------------------------------------------------------------------- /src/protontricks/_vdf/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module for deserializing/serializing to and from VDF 3 | """ 4 | __version__ = "3.4" 5 | __author__ = "Rossen Georgiev" 6 | 7 | import re 8 | import sys 9 | import struct 10 | from binascii import crc32 11 | from io import BytesIO 12 | from io import StringIO as unicodeIO 13 | 14 | try: 15 | from collections.abc import Mapping 16 | except: 17 | from collections import Mapping 18 | 19 | from vdf.vdict import VDFDict 20 | 21 | # Py2 & Py3 compatibility 22 | if sys.version_info[0] >= 3: 23 | string_type = str 24 | int_type = int 25 | BOMS = '\ufffe\ufeff' 26 | 27 | def strip_bom(line): 28 | return line.lstrip(BOMS) 29 | else: 30 | from StringIO import StringIO as strIO 31 | string_type = basestring 32 | int_type = long 33 | BOMS = '\xef\xbb\xbf\xff\xfe\xfe\xff' 34 | BOMS_UNICODE = '\\ufffe\\ufeff'.decode('unicode-escape') 35 | 36 | def strip_bom(line): 37 | return line.lstrip(BOMS if isinstance(line, str) else BOMS_UNICODE) 38 | 39 | # string escaping 40 | _unescape_char_map = { 41 | r"\n": "\n", 42 | r"\t": "\t", 43 | r"\v": "\v", 44 | r"\b": "\b", 45 | r"\r": "\r", 46 | r"\f": "\f", 47 | r"\a": "\a", 48 | r"\\": "\\", 49 | r"\?": "?", 50 | r"\"": "\"", 51 | r"\'": "\'", 52 | } 53 | _escape_char_map = {v: k for k, v in _unescape_char_map.items()} 54 | 55 | def _re_escape_match(m): 56 | return _escape_char_map[m.group()] 57 | 58 | def _re_unescape_match(m): 59 | return _unescape_char_map[m.group()] 60 | 61 | def _escape(text): 62 | return re.sub(r"[\n\t\v\b\r\f\a\\\?\"']", _re_escape_match, text) 63 | 64 | def _unescape(text): 65 | return re.sub(r"(\\n|\\t|\\v|\\b|\\r|\\f|\\a|\\\\|\\\?|\\\"|\\')", _re_unescape_match, text) 66 | 67 | # parsing and dumping for KV1 68 | def parse(fp, mapper=dict, merge_duplicate_keys=True, escaped=True): 69 | """ 70 | Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a VDF) 71 | to a Python object. 72 | 73 | ``mapper`` specifies the Python object used after deserializetion. ``dict` is 74 | used by default. Alternatively, ``collections.OrderedDict`` can be used if you 75 | wish to preserve key order. Or any object that acts like a ``dict``. 76 | 77 | ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the 78 | same key into one instead of overwriting. You can se this to ``False`` if you are 79 | using ``VDFDict`` and need to preserve the duplicates. 80 | """ 81 | if not issubclass(mapper, Mapping): 82 | raise TypeError("Expected mapper to be subclass of dict, got %s" % type(mapper)) 83 | if not hasattr(fp, 'readline'): 84 | raise TypeError("Expected fp to be a file-like object supporting line iteration") 85 | 86 | stack = [mapper()] 87 | expect_bracket = False 88 | 89 | re_keyvalue = re.compile(r'^("(?P(?:\\.|[^\\"])*)"|(?P#?[a-z0-9\-\_\\\?$%<>]+))' 90 | r'([ \t]*(' 91 | r'"(?P(?:\\.|[^\\"])*)(?P")?' 92 | r'|(?P(?:(? ])+)' 93 | r'|(?P{[ \t]*)(?P})?' 94 | r'))?', 95 | flags=re.I) 96 | 97 | for lineno, line in enumerate(fp, 1): 98 | if lineno == 1: 99 | line = strip_bom(line) 100 | 101 | line = line.lstrip() 102 | 103 | # skip empty and comment lines 104 | if line == "" or line[0] == '/': 105 | continue 106 | 107 | # one level deeper 108 | if line[0] == "{": 109 | expect_bracket = False 110 | continue 111 | 112 | if expect_bracket: 113 | raise SyntaxError("vdf.parse: expected openning bracket", 114 | (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 1, line)) 115 | 116 | # one level back 117 | if line[0] == "}": 118 | if len(stack) > 1: 119 | stack.pop() 120 | continue 121 | 122 | raise SyntaxError("vdf.parse: one too many closing parenthasis", 123 | (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) 124 | 125 | # parse keyvalue pairs 126 | while True: 127 | match = re_keyvalue.match(line) 128 | 129 | if not match: 130 | try: 131 | line += next(fp) 132 | continue 133 | except StopIteration: 134 | raise SyntaxError("vdf.parse: unexpected EOF (open key quote?)", 135 | (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) 136 | 137 | key = match.group('key') if match.group('qkey') is None else match.group('qkey') 138 | val = match.group('qval') 139 | if val is None: 140 | val = match.group('val') 141 | if val is not None: 142 | val = val.rstrip() 143 | if val == "": 144 | val = None 145 | 146 | if escaped: 147 | key = _unescape(key) 148 | 149 | # we have a key with value in parenthesis, so we make a new dict obj (level deeper) 150 | if val is None: 151 | if merge_duplicate_keys and key in stack[-1]: 152 | _m = stack[-1][key] 153 | # we've descended a level deeper, if value is str, we have to overwrite it to mapper 154 | if not isinstance(_m, mapper): 155 | _m = stack[-1][key] = mapper() 156 | else: 157 | _m = mapper() 158 | stack[-1][key] = _m 159 | 160 | if match.group('eblock') is None: 161 | # only expect a bracket if it's not already closed or on the same line 162 | stack.append(_m) 163 | if match.group('sblock') is None: 164 | expect_bracket = True 165 | 166 | # we've matched a simple keyvalue pair, map it to the last dict obj in the stack 167 | else: 168 | # if the value is line consume one more line and try to match again, 169 | # until we get the KeyValue pair 170 | if match.group('vq_end') is None and match.group('qval') is not None: 171 | try: 172 | line += next(fp) 173 | continue 174 | except StopIteration: 175 | raise SyntaxError("vdf.parse: unexpected EOF (open quote for value?)", 176 | (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) 177 | 178 | stack[-1][key] = _unescape(val) if escaped else val 179 | 180 | # exit the loop 181 | break 182 | 183 | if len(stack) != 1: 184 | raise SyntaxError("vdf.parse: unclosed parenthasis or quotes (EOF)", 185 | (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) 186 | 187 | return stack.pop() 188 | 189 | 190 | def loads(s, **kwargs): 191 | """ 192 | Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a JSON 193 | document) to a Python object. 194 | """ 195 | if not isinstance(s, string_type): 196 | raise TypeError("Expected s to be a str, got %s" % type(s)) 197 | 198 | try: 199 | fp = unicodeIO(s) 200 | except TypeError: 201 | fp = strIO(s) 202 | 203 | return parse(fp, **kwargs) 204 | 205 | 206 | def load(fp, **kwargs): 207 | """ 208 | Deserialize ``fp`` (a ``.readline()``-supporting file-like object containing 209 | a JSON document) to a Python object. 210 | """ 211 | return parse(fp, **kwargs) 212 | 213 | 214 | def dumps(obj, pretty=False, escaped=True): 215 | """ 216 | Serialize ``obj`` to a VDF formatted ``str``. 217 | """ 218 | if not isinstance(obj, Mapping): 219 | raise TypeError("Expected data to be an instance of``dict``") 220 | if not isinstance(pretty, bool): 221 | raise TypeError("Expected pretty to be of type bool") 222 | if not isinstance(escaped, bool): 223 | raise TypeError("Expected escaped to be of type bool") 224 | 225 | return ''.join(_dump_gen(obj, pretty, escaped)) 226 | 227 | 228 | def dump(obj, fp, pretty=False, escaped=True): 229 | """ 230 | Serialize ``obj`` as a VDF formatted stream to ``fp`` (a 231 | ``.write()``-supporting file-like object). 232 | """ 233 | if not isinstance(obj, Mapping): 234 | raise TypeError("Expected data to be an instance of``dict``") 235 | if not hasattr(fp, 'write'): 236 | raise TypeError("Expected fp to have write() method") 237 | if not isinstance(pretty, bool): 238 | raise TypeError("Expected pretty to be of type bool") 239 | if not isinstance(escaped, bool): 240 | raise TypeError("Expected escaped to be of type bool") 241 | 242 | for chunk in _dump_gen(obj, pretty, escaped): 243 | fp.write(chunk) 244 | 245 | 246 | def _dump_gen(data, pretty=False, escaped=True, level=0): 247 | indent = "\t" 248 | line_indent = "" 249 | 250 | if pretty: 251 | line_indent = indent * level 252 | 253 | for key, value in data.items(): 254 | if escaped and isinstance(key, string_type): 255 | key = _escape(key) 256 | 257 | if isinstance(value, Mapping): 258 | yield '%s"%s"\n%s{\n' % (line_indent, key, line_indent) 259 | for chunk in _dump_gen(value, pretty, escaped, level+1): 260 | yield chunk 261 | yield "%s}\n" % line_indent 262 | else: 263 | if escaped and isinstance(value, string_type): 264 | value = _escape(value) 265 | 266 | yield '%s"%s" "%s"\n' % (line_indent, key, value) 267 | 268 | 269 | # binary VDF 270 | class BASE_INT(int_type): 271 | def __repr__(self): 272 | return "%s(%d)" % (self.__class__.__name__, self) 273 | 274 | class UINT_64(BASE_INT): 275 | pass 276 | 277 | class INT_64(BASE_INT): 278 | pass 279 | 280 | class POINTER(BASE_INT): 281 | pass 282 | 283 | class COLOR(BASE_INT): 284 | pass 285 | 286 | BIN_NONE = b'\x00' 287 | BIN_STRING = b'\x01' 288 | BIN_INT32 = b'\x02' 289 | BIN_FLOAT32 = b'\x03' 290 | BIN_POINTER = b'\x04' 291 | BIN_WIDESTRING = b'\x05' 292 | BIN_COLOR = b'\x06' 293 | BIN_UINT64 = b'\x07' 294 | BIN_END = b'\x08' 295 | BIN_INT64 = b'\x0A' 296 | BIN_END_ALT = b'\x0B' 297 | 298 | def binary_loads(b, mapper=dict, merge_duplicate_keys=True, alt_format=False, key_table=None, raise_on_remaining=True): 299 | """ 300 | Deserialize ``b`` (``bytes`` containing a VDF in "binary form") 301 | to a Python object. 302 | 303 | ``mapper`` specifies the Python object used after deserializetion. ``dict` is 304 | used by default. Alternatively, ``collections.OrderedDict`` can be used if you 305 | wish to preserve key order. Or any object that acts like a ``dict``. 306 | 307 | ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the 308 | same key into one instead of overwriting. You can se this to ``False`` if you are 309 | using ``VDFDict`` and need to preserve the duplicates. 310 | 311 | ``key_table`` will be used to translate keys in binary VDF objects 312 | which do not encode strings directly but instead store them in an out-of-band 313 | table. Newer `appinfo.vdf` format stores this table the end of the file, 314 | and it is needed to deserialize the binary VDF objects in that file. 315 | """ 316 | if not isinstance(b, bytes): 317 | raise TypeError("Expected s to be bytes, got %s" % type(b)) 318 | 319 | return binary_load(BytesIO(b), mapper, merge_duplicate_keys, alt_format, key_table, raise_on_remaining) 320 | 321 | def binary_load(fp, mapper=dict, merge_duplicate_keys=True, alt_format=False, key_table=None, raise_on_remaining=False): 322 | """ 323 | Deserialize ``fp`` (a ``.read()``-supporting file-like object containing 324 | binary VDF) to a Python object. 325 | 326 | ``mapper`` specifies the Python object used after deserializetion. ``dict` is 327 | used by default. Alternatively, ``collections.OrderedDict`` can be used if you 328 | wish to preserve key order. Or any object that acts like a ``dict``. 329 | 330 | ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the 331 | same key into one instead of overwriting. You can se this to ``False`` if you are 332 | using ``VDFDict`` and need to preserve the duplicates. 333 | 334 | ``key_table`` will be used to translate keys in binary VDF objects 335 | which do not encode strings directly but instead store them in an out-of-band 336 | table. Newer `appinfo.vdf` format stores this table the end of the file, 337 | and it is needed to deserialize the binary VDF objects in that file. 338 | """ 339 | if not hasattr(fp, 'read') or not hasattr(fp, 'tell') or not hasattr(fp, 'seek'): 340 | raise TypeError("Expected fp to be a file-like object with tell()/seek() and read() returning bytes") 341 | if not issubclass(mapper, Mapping): 342 | raise TypeError("Expected mapper to be subclass of dict, got %s" % type(mapper)) 343 | 344 | # helpers 345 | int32 = struct.Struct(' 1: 391 | stack.pop() 392 | continue 393 | break 394 | 395 | if key_table: 396 | # If 'key_table' was provided, each key is an int32 value that 397 | # needs to be mapped to an actual field name using a key table. 398 | # Newer appinfo.vdf (V29+) stores this table at the end of the file. 399 | index = int32.unpack(fp.read(int32.size))[0] 400 | 401 | key = key_table[index] 402 | else: 403 | key = read_string(fp) 404 | 405 | if t == BIN_NONE: 406 | if merge_duplicate_keys and key in stack[-1]: 407 | _m = stack[-1][key] 408 | else: 409 | _m = mapper() 410 | stack[-1][key] = _m 411 | stack.append(_m) 412 | elif t == BIN_STRING: 413 | stack[-1][key] = read_string(fp) 414 | elif t == BIN_WIDESTRING: 415 | stack[-1][key] = read_string(fp, wide=True) 416 | elif t in (BIN_INT32, BIN_POINTER, BIN_COLOR): 417 | val = int32.unpack(fp.read(int32.size))[0] 418 | 419 | if t == BIN_POINTER: 420 | val = POINTER(val) 421 | elif t == BIN_COLOR: 422 | val = COLOR(val) 423 | 424 | stack[-1][key] = val 425 | elif t == BIN_UINT64: 426 | stack[-1][key] = UINT_64(uint64.unpack(fp.read(int64.size))[0]) 427 | elif t == BIN_INT64: 428 | stack[-1][key] = INT_64(int64.unpack(fp.read(int64.size))[0]) 429 | elif t == BIN_FLOAT32: 430 | stack[-1][key] = float32.unpack(fp.read(float32.size))[0] 431 | else: 432 | raise SyntaxError("Unknown data type at offset %d: %s" % (fp.tell() - 1, repr(t))) 433 | 434 | if len(stack) != 1: 435 | raise SyntaxError("Reached EOF, but Binary VDF is incomplete") 436 | if raise_on_remaining and fp.read(1) != b'': 437 | fp.seek(-1, 1) 438 | raise SyntaxError("Binary VDF ended at offset %d, but there is more data remaining" % (fp.tell() - 1)) 439 | 440 | return stack.pop() 441 | 442 | def binary_dumps(obj, alt_format=False): 443 | """ 444 | Serialize ``obj`` to a binary VDF formatted ``bytes``. 445 | """ 446 | buf = BytesIO() 447 | binary_dump(obj, buf, alt_format) 448 | return buf.getvalue() 449 | 450 | def binary_dump(obj, fp, alt_format=False): 451 | """ 452 | Serialize ``obj`` to a binary VDF formatted ``bytes`` and write it to ``fp`` filelike object 453 | """ 454 | if not isinstance(obj, Mapping): 455 | raise TypeError("Expected obj to be type of Mapping") 456 | if not hasattr(fp, 'write'): 457 | raise TypeError("Expected fp to have write() method") 458 | 459 | for chunk in _binary_dump_gen(obj, alt_format=alt_format): 460 | fp.write(chunk) 461 | 462 | def _binary_dump_gen(obj, level=0, alt_format=False): 463 | if level == 0 and len(obj) == 0: 464 | return 465 | 466 | int32 = struct.Struct('