├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── appstream.yaml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── TROUBLESHOOTING.md ├── data ├── com.github.Matoking.protontricks.metainfo.xml └── screenshot.png ├── pyproject.toml ├── requirements.txt ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── src └── protontricks │ ├── __init__.py │ ├── _vdf │ ├── LICENSE │ ├── README.rst │ ├── __init__.py │ └── vdict.py │ ├── cli │ ├── __init__.py │ ├── desktop_install.py │ ├── launch.py │ ├── main.py │ └── util.py │ ├── config.py │ ├── data │ ├── data │ │ └── icon_placeholder.png │ ├── scripts │ │ ├── bwrap_launcher.sh │ │ ├── wine_launch.sh │ │ ├── wineserver_keepalive.bat │ │ └── wineserver_keepalive.sh │ └── share │ │ └── applications │ │ ├── protontricks-launch.desktop │ │ └── protontricks.desktop │ ├── flatpak.py │ ├── gui.py │ ├── steam.py │ ├── util.py │ └── winetricks.py └── tests ├── cli ├── __init__.py ├── test_desktop_install.py ├── test_launch.py ├── test_main.py └── test_util.py ├── conftest.py ├── data └── appinfo_v29.vdf ├── test_config.py ├── test_flatpak.py ├── test_gui.py ├── test_steam.py ├── test_util.py └── test_winetricks.py /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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'] 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 coveralls 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 | run: | 33 | coveralls --service=github 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | COVERALLS_FLAG_NAME: python-${{ matrix.python-version }} 37 | COVERALLS_PARALLEL: true 38 | 39 | coveralls-finish: 40 | name: Finish Coveralls 41 | needs: test 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Set up Python 45 | uses: actions/setup-python@v2 46 | with: 47 | python-version: 3.8 48 | - name: Finished 49 | run: | 50 | python -m pip install --upgrade coveralls 51 | coveralls --finish 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | - Improve compatibility by setting additional Proton related environment variables when applicable 10 | 11 | ## [1.12.1] - 2025-03-08 12 | ### Fixed 13 | - Fix missing app icons for games installed using newer Steam client 14 | - Fix spurious "unknown file arch" Winetricks warnings (newer Winetricks required) 15 | 16 | ### Removed 17 | - Drop Python 3.6 support 18 | 19 | ## [1.12.0] - 2024-09-16 20 | ### Added 21 | - `--cwd-app` flag to set working directory to the game's installation directory 22 | - Add support for Snap Steam installations 23 | 24 | ### Changed 25 | - `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. 26 | - `protontricks` will now launch GUI if no arguments were provided 27 | 28 | ### Fixed 29 | - Fix crash when parsing appinfo.vdf V29 in new Steam client version 30 | - Fix Protontricks crash when `config.vdf` contains invalid Unicode characters 31 | 32 | > [!IMPORTANT] 33 | > This release bundles a patched version of `vdf` in case the system Python package doesn't have the required `appinfo.vdf` V29 support. 34 | > If you're a package maintainer, you will probably want to remove the corresponding 35 | > commit if the distro you're using already ships a version of `vdf` with the 36 | > required support. 37 | 38 | ## [1.11.1] - 2024-02-20 39 | ### Fixed 40 | - Fix Protontricks crash when custom Proton has an invalid or empty `compatibilitytool.vdf` manifest 41 | - Fix Protontricks GUI crash when Proton installation is incomplete 42 | - Check if Steam Runtime launcher service launched correctly instead of always assuming successful launch 43 | 44 | ## [1.11.0] - 2023-12-30 45 | ### Added 46 | - Show app icons for custom shortcuts in the app selector 47 | - Verbose flag can be enabled with `-vv` for additional debug logging 48 | 49 | ### Fixed 50 | - Fix Protontricks not recognizing supported Steam Runtime installation due to changed name 51 | - Fix Protontricks not recognizing default Proton installation for games with different Proton preselected by Valve testing 52 | - Fix Protontricks crash when app has an unidentifiable app icon 53 | 54 | ## [1.10.5] - 2023-09-05 55 | ### Fixed 56 | - Fix crash caused by custom app icons with non-RGB mode 57 | 58 | ## [1.10.4] - 2023-08-26 59 | ### Fixed 60 | - Fix crash caused by the Steam shortcut configuration file containing extra data after the VDF section 61 | - Fix differently sized custom app icons breaking the layout in the app selector 62 | 63 | ## [1.10.3] - 2023-05-06 64 | ### Added 65 | - Flatpak version of Steam is also detected with non-Flatpak installation of Protontricks 66 | 67 | ### Changed 68 | - `--background-wineserver` is now disabled by default due to problems with crashing graphical applications and broken console output 69 | 70 | ### Fixed 71 | - Fix detection of Steam library folders using non-standard capitalizations for `steamapps` 72 | - _Steam Linux Runtime - Sniper_ is no longer incorrectly reported as an unsupported runtime 73 | 74 | ## [1.10.2] - 2023-02-13 75 | ### Added 76 | - Launch application with fixed locale settings if Steam Deck is used and non-existent locales are configured 77 | 78 | ### Fixed 79 | - Fix crashes caused by missing permissions when checking for Steam apps 80 | 81 | ## [1.10.1]- 2022-12-10 82 | ### Fixed 83 | - Fix crash when unknown XDG Flatpak filesystem permissions are enabled 84 | - Fix crash when parsing appinfo.vdf V28 version introduced in Steam beta 85 | 86 | ## [1.10.0] - 2022-11-27 87 | ### Added 88 | - Prompt the user for a Steam installation if multiple installations are found 89 | 90 | ### Fixed 91 | - Detect XDG user directory permissions in Flatpak environment 92 | 93 | ## [1.9.2] - 2022-09-16 94 | ### Fixed 95 | - Fix random crashes when running Wine commands due to race condition in Wine launcher script 96 | 97 | ## [1.9.1] - 2022-08-28 98 | ### Added 99 | - Print a warning when multiple Steam directories are detected and `STEAM_DIR` is not used to specify the directory 100 | 101 | ### Changed 102 | - Launch Steam Runtime sandbox with `--bus-name` parameter instead of the now deprecated `--socket` 103 | 104 | ### Fixed 105 | - Fix various crashes due to Wine processes under Steam Runtime sandbox using the incorrect working directory 106 | 107 | ## [1.9.0] - 2022-07-02 108 | ### Added 109 | - Add `-l/--list` command to list all games 110 | 111 | ### Fixed 112 | - Fix `wineserver -w` calls hanging when legacy Steam Runtime and background wineserver are enabled 113 | - Do not attempt to launch bwrap-launcher if bwrap is not available 114 | 115 | ## [1.8.2] - 2022-05-16 116 | ### Fixed 117 | - Fix Wine crash on newer Steam Runtime installations due to renamed runtime executable 118 | - Fix graphical Wine applications crashing on Wayland 119 | - Fix Protontricks crash caused by Steam shortcuts created by 3rd party applications such as Lutris 120 | 121 | ## [1.8.1] - 2022-03-20 122 | ### Added 123 | - Prompt the user to update Flatpak permissions if inaccessible paths are detected 124 | 125 | ### Fixed 126 | - Fix Proton discovery on Steam Deck 127 | 128 | ### Removed 129 | - Drop Python 3.5 support 130 | 131 | ## [1.8.0] - 2022-02-26 132 | ### Added 133 | - fsync/esync is enabled by default 134 | - `PROTON_NO_FSYNC` and `PROTON_NO_ESYNC` environment variables are supported 135 | - 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`. 136 | - Improve Wine command startup time with bwrap by creating a single container and launching all Wine processes inside it. 137 | 138 | ### Fixed 139 | - Fix Wine crash when the Steam application and Protontricks are running at the same time 140 | - Fix Steam installation detection when both non-Flatpak and Flatpak versions of Steam are installed for the same user 141 | - Fix Protontricks crash when Proton installation is incomplete 142 | - Fix Protontricks crash when both Flatpak and non-Flatpak versions of Steam are installed 143 | - Fix duplicate log messages when using `protontricks-launch` 144 | - Fix error dialog not being displayed when using `protontricks-launch` 145 | 146 | ## [1.7.0] - 2022-01-08 147 | ### Changed 148 | - Enable usage of Flatpak Protontricks with non-Flatpak Steam. Flatpak Steam is prioritized if both are found. 149 | 150 | ### Fixed 151 | - bwrap is only disabled when the Flatpak installation is too old. Flatpak 1.12.1 and newer support sub-sandboxes. 152 | - Remove Proton installations from app listings 153 | 154 | ## [1.6.2] - 2021-11-28 155 | ### Changed 156 | - Return code is now returned from the executed user commands 157 | - Return code `1` is returned for most Protontricks errors instead of `-1` 158 | 159 | ## [1.6.1] - 2021-10-18 160 | ### Fixed 161 | - Fix duplicate Steam application entries 162 | - Fix crash on Python 3.5 163 | 164 | ## [1.6.0] - 2021-08-08 165 | ### Added 166 | - Add `protontricks-launch` script to launch Windows executables using Proton app specific Wine prefixes 167 | - Add desktop integration for Windows executables, which can now be launched using Protontricks 168 | - 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. 169 | - Add error dialog for displaying error information when Protontricks has been launched from desktop and no user-visible terminal is available. 170 | - Add YAD as GUI provider. YAD is automatically used instead of Zenity when available as it supports additional features. 171 | 172 | ### Changed 173 | - 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. 174 | 175 | ### Fixed 176 | - Display proper error messages in certain cases when corrupted VDF files are found 177 | - Fix crash caused by appmanifest files that can't be read due to insufficient permissions 178 | - Fix crash caused by non-Proton compatibility tool being enabled for the selected app 179 | - Fix erroneous warning when Steam library is inside a case-insensitive file system 180 | 181 | ## [1.5.2] - 2021-06-09 182 | ### Fixed 183 | - Custom Proton installations now use Steam Runtime installations when applicable 184 | - Fix crash caused by older Steam app installations using a different app manifest structure 185 | - Fix crash caused by change to lowercase field names in multiple VDF files 186 | - Fix crash caused by change in the Steam library folder configuration file 187 | 188 | ## [1.5.1] - 2021-05-10 189 | ### Fixed 190 | - bwrap containerization now tries to mount more root directories except those that have been blacklisted due to potential issues 191 | 192 | ## [1.5.0] - 2021-04-10 193 | ### Added 194 | - Use bwrap containerization with newer Steam Runtime installations. The old behavior can be enabled with `--no-bwrap` in case of problems. 195 | 196 | ### Fixed 197 | - User-provided `WINE` and `WINESERVER` environment variables are used when Steam Runtime is enabled 198 | - Fixed crash caused by changed directory name in Proton Experimental update 199 | 200 | ## [1.4.4] - 2021-02-03 201 | ### Fixed 202 | - Display a proper error message when Proton installation is incomplete due to missing Steam Runtime 203 | - Display a proper warning when a tool manifest is empty 204 | - Fix crash caused by changed directory structure in Steam Runtime update 205 | 206 | ## [1.4.3] - 2020-12-09 207 | ### Fixed 208 | - Add support for newer Steam Runtime versions 209 | 210 | ## [1.4.2] - 2020-09-19 211 | ### Fixed 212 | - Fix crash with newer Steam client beta caused by differently cased keys in `loginusers.vdf` 213 | 214 | ### Added 215 | - Print a warning if both `steamapps` and `SteamApps` directories are found inside the same library directory 216 | 217 | ### Changed 218 | - Print full help message when incorrect parameters are provided. 219 | 220 | ## [1.4.1] - 2020-02-17 221 | ### Fixed 222 | - Fixed crash caused by Steam library paths containing special characters 223 | - Fixed crash with Proton 5.0 caused by Steam Runtime being used unnecessarily with all binaries 224 | 225 | ## [1.4] - 2020-01-26 226 | ### Added 227 | - System-wide compatibility tool directories are now searched for Proton installations 228 | 229 | ### Changed 230 | - Drop Python 3.4 compatibility. Python 3.4 compatibility has been broken since 1.2.2. 231 | 232 | ### Fixed 233 | - Zenity no longer crashes the script if locale is incapable of processing the arguments. 234 | - Selecting "Cancel" in the GUI window now prints a proper message instead of an error. 235 | - Add workaround for Zenity crashes not handled by the previous fix 236 | 237 | ## [1.3.1] - 2019-11-21 238 | ### Fixed 239 | - Fix Proton prefix detection when the prefix directory is located inside a `SteamApps` directory instead of `steamapps` 240 | - Use the most recently used Proton prefix when multiple prefix directories are found for a single game 241 | - Fix Python 3.5 compatibility 242 | 243 | ## [1.3] - 2019-11-06 244 | ### Added 245 | - Non-Steam applications are now detected. 246 | 247 | ### Fixed 248 | - `STEAM_DIR` environment variable will no longer fallback to default path in some cases 249 | 250 | ## [1.2.5] - 2019-09-17 251 | ### Fixed 252 | - Fix regression in 1.2.3 that broke detection of custom Proton installations. 253 | - Proton prefix is detected correctly even if it exists in a different Steam library folder than the game installation. 254 | 255 | ## [1.2.4] - 2019-07-25 256 | ### Fixed 257 | - Add a workaround for a VDF parser bug that causes a crash when certain appinfo.vdf files are parsed. 258 | 259 | ## [1.2.3] - 2019-07-18 260 | ### Fixed 261 | - More robust parsing of appinfo.vdf. This fixes some cases where Protontricks was unable to detect Proton installations. 262 | 263 | ## [1.2.2] - 2019-06-05 264 | ### Fixed 265 | - Set `WINEDLLPATH` and `WINELOADER` environment variables. 266 | - Add a workaround for a Zenity bug that causes the GUI to crash when certain versions of Zenity are used. 267 | 268 | ## [1.2.1] - 2019-04-08 269 | ### Changed 270 | - Delay Proton detection until it's necessary. 271 | 272 | ### Fixed 273 | - Use the correct Proton installation when selecting a Steam app using the GUI. 274 | - Print a proper error message if Steam isn't found. 275 | - Print an error message when GUI is enabled and no games were found. 276 | - Support appmanifest files with mixed case field names. 277 | 278 | ## [1.2] - 2019-02-27 279 | ### Added 280 | - Add a `-c` parameter to run shell commands in the game's installation directory with relevant Wine environment variables. 281 | - Steam Runtime is now supported and used by default unless disabled with `--no-runtime` flag or `STEAM_RUNTIME` environment variable. 282 | 283 | ### Fixed 284 | - All arguments are now correctly passed to winetricks. 285 | - Games that haven't been launched at least once are now excluded properly. 286 | - Custom Proton versions with custom display names now work properly. 287 | - `PATH` environment variable is modified to prevent conflicts with system-wide Wine binaries. 288 | - Steam installation is handled correctly if `~/.steam/steam` and `~/.steam/root` point to different directories. 289 | 290 | ## [1.1.1] - 2019-01-20 291 | ### Added 292 | - Game-specific Proton installations are now detected. 293 | 294 | ### Fixed 295 | - Proton installations are now detected properly again in newer Steam Beta releases. 296 | 297 | ## [1.1] - 2019-01-20 298 | ### Added 299 | - Custom Proton installations in `STEAM_DIR/compatibilitytools.d` are now detected. See [Sirmentio/protontricks#31](https://github.com/Sirmentio/protontricks/issues/31). 300 | - Protontricks is now a Python package and can be installed using `pip`. 301 | 302 | ### Changed 303 | - Argument parsing has been refactored to use argparse. 304 | - `protontricks gui` is now `protontricks --gui`. 305 | - New `protontricks --version` command to print the version number. 306 | - Game names are now displayed in alphabetical order and filtered to exclude non-Proton games. 307 | - Protontricks no longer prints INFO messages by default. To restore previous behavior, use the `-v` flag. 308 | 309 | ### Fixed 310 | - More robust VDF parsing. 311 | - Corrupted appmanifest files are now skipped. See [Sirmentio/protontricks#36](https://github.com/Sirmentio/protontricks/pull/36). 312 | - 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). 313 | 314 | ## 1.0 - 2019-01-16 315 | ### Added 316 | - The last release of Protontricks maintained by [@Sirmentio](https://github.com/Sirmentio). 317 | 318 | [Unreleased]: https://github.com/Matoking/protontricks/compare/1.12.1...HEAD 319 | [1.12.1]: https://github.com/Matoking/protontricks/compare/1.12.0...1.12.1 320 | [1.12.0]: https://github.com/Matoking/protontricks/compare/1.11.1...1.12.0 321 | [1.11.1]: https://github.com/Matoking/protontricks/compare/1.11.0...1.11.1 322 | [1.11.0]: https://github.com/Matoking/protontricks/compare/1.10.5...1.11.0 323 | [1.10.5]: https://github.com/Matoking/protontricks/compare/1.10.4...1.10.5 324 | [1.10.4]: https://github.com/Matoking/protontricks/compare/1.10.3...1.10.4 325 | [1.10.3]: https://github.com/Matoking/protontricks/compare/1.10.2...1.10.3 326 | [1.10.2]: https://github.com/Matoking/protontricks/compare/1.10.1...1.10.2 327 | [1.10.1]: https://github.com/Matoking/protontricks/compare/1.10.0...1.10.1 328 | [1.10.0]: https://github.com/Matoking/protontricks/compare/1.9.2...1.10.0 329 | [1.9.2]: https://github.com/Matoking/protontricks/compare/1.9.1...1.9.2 330 | [1.9.1]: https://github.com/Matoking/protontricks/compare/1.9.0...1.9.1 331 | [1.9.0]: https://github.com/Matoking/protontricks/compare/1.8.2...1.9.0 332 | [1.8.2]: https://github.com/Matoking/protontricks/compare/1.8.1...1.8.2 333 | [1.8.1]: https://github.com/Matoking/protontricks/compare/1.8.0...1.8.1 334 | [1.8.0]: https://github.com/Matoking/protontricks/compare/1.7.0...1.8.0 335 | [1.7.0]: https://github.com/Matoking/protontricks/compare/1.6.2...1.7.0 336 | [1.6.2]: https://github.com/Matoking/protontricks/compare/1.6.1...1.6.2 337 | [1.6.1]: https://github.com/Matoking/protontricks/compare/1.6.0...1.6.1 338 | [1.6.0]: https://github.com/Matoking/protontricks/compare/1.5.2...1.6.0 339 | [1.5.2]: https://github.com/Matoking/protontricks/compare/1.5.1...1.5.2 340 | [1.5.1]: https://github.com/Matoking/protontricks/compare/1.5.0...1.5.1 341 | [1.5.0]: https://github.com/Matoking/protontricks/compare/1.4.4...1.5.0 342 | [1.4.4]: https://github.com/Matoking/protontricks/compare/1.4.3...1.4.4 343 | [1.4.3]: https://github.com/Matoking/protontricks/compare/1.4.2...1.4.3 344 | [1.4.2]: https://github.com/Matoking/protontricks/compare/1.4.1...1.4.2 345 | [1.4.1]: https://github.com/Matoking/protontricks/compare/1.4...1.4.1 346 | [1.4]: https://github.com/Matoking/protontricks/compare/1.3.1...1.4 347 | [1.3.1]: https://github.com/Matoking/protontricks/compare/1.3...1.3.1 348 | [1.3]: https://github.com/Matoking/protontricks/compare/1.2.5...1.3 349 | [1.2.5]: https://github.com/Matoking/protontricks/compare/1.2.4...1.2.5 350 | [1.2.4]: https://github.com/Matoking/protontricks/compare/1.2.3...1.2.4 351 | [1.2.3]: https://github.com/Matoking/protontricks/compare/1.2.2...1.2.3 352 | [1.2.2]: https://github.com/Matoking/protontricks/compare/1.2.1...1.2.2 353 | [1.2.1]: https://github.com/Matoking/protontricks/compare/1.2...1.2.1 354 | [1.2]: https://github.com/Matoking/protontricks/compare/1.1.1...1.2 355 | [1.1.1]: https://github.com/Matoking/protontricks/compare/1.1...1.1.1 356 | [1.1]: https://github.com/Matoking/protontricks/compare/1.0...1.1 357 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Protontricks 2 | ============ 3 | 4 | [![image](https://img.shields.io/pypi/v/protontricks.svg)](https://pypi.org/project/protontricks/) 5 | [![Coverage Status](https://coveralls.io/repos/github/Matoking/protontricks/badge.svg?branch=master)](https://coveralls.io/github/Matoking/protontricks?branch=master) 6 | [![Test Status](https://github.com/Matoking/protontricks/actions/workflows/tests.yml/badge.svg)](https://github.com/Matoking/protontricks/actions/workflows/tests.yml) 7 | 8 | [](https://flathub.org/apps/details/com.github.Matoking.protontricks) 9 | 10 | Run Winetricks commands for Steam Play/Proton games among other common Wine features, such as launching external Windows executables. 11 | 12 | This is a fork of the original project created by sirmentio. The original repository is available at [Sirmentio/protontricks](https://github.com/Sirmentio/protontricks). 13 | 14 | # What is it? 15 | 16 | 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. 17 | 18 | # Requirements 19 | 20 | * Python 3.7 or newer 21 | * Winetricks 22 | * Steam 23 | * YAD (recommended) **or** Zenity. Required for GUI. 24 | 25 | # Usage 26 | 27 | **Protontricks can be launched from desktop or using the `protontricks` command.** 28 | 29 | ## Command-line 30 | 31 | The basic command-line usage is as follows: 32 | 33 | ``` 34 | # Find your game's App ID by searching for it 35 | protontricks -s 36 | 37 | # or by listing all games 38 | protontricks -l 39 | 40 | # Run winetricks for the game. 41 | # Any parameters in are passed directly to Winetricks. 42 | # Parameters specific to Protontricks need to be placed *before* . 43 | protontricks 44 | 45 | # Run a custom command for selected game 46 | protontricks -c 47 | 48 | # Run the Protontricks GUI 49 | protontricks --gui 50 | 51 | # Launch a Windows executable using Protontricks 52 | protontricks-launch 53 | 54 | # Launch a Windows executable for a specific Steam app using Protontricks 55 | protontricks-launch --appid 56 | 57 | # Print the Protontricks help message 58 | protontricks --help 59 | ``` 60 | 61 | Since this is a wrapper, all commands that work for Winetricks will likely work for Protontricks as well. 62 | 63 | If you have a different Steam directory, you can export ``$STEAM_DIR`` to the directory where Steam is. 64 | 65 | If you'd like to use a local version of Winetricks, you can set ``$WINETRICKS`` to the location of your local winetricks installation. 66 | 67 | 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`. 68 | 69 | [Wanna see Protontricks in action?](https://asciinema.org/a/229323) 70 | 71 | ## Desktop 72 | 73 | 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. 74 | 75 | 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. 76 | 77 | # Troubleshooting 78 | 79 | For common issues and solutions, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md). 80 | 81 | # Installation 82 | 83 | 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. 84 | 85 | **If you're using a Steam Deck**, Flatpak is the recommended option. Open the **Discover** application store in desktop mode and search for **Protontricks**. 86 | 87 | **If you're using the Flatpak version of Steam**, follow the [Flatpak-specific installation instructions](https://github.com/flathub/com.github.Matoking.protontricks) instead. 88 | 89 | ## Community packages (recommended) 90 | 91 | 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**. 92 | 93 | Community packages are maintained by community members and might be out-of-date compared to releases on PyPI. 94 | Note that some distros such as **Debian** / **Ubuntu** often have outdated packages for either Protontricks **or** Winetricks. 95 | If so, install the Flatpak version instead as outdated releases may fail to work properly. 96 | 97 | [![Packaging status](https://repology.org/badge/vertical-allrepos/protontricks.svg)](https://repology.org/project/protontricks/versions) 98 | 99 | ## Flatpak (recommended) 100 | 101 | Protontricks is available on the Flathub app store: 102 | 103 | [](https://flathub.org/apps/details/com.github.Matoking.protontricks) 104 | 105 | To use Protontricks as a command-line application, add shell aliases by running the following commands: 106 | 107 | ``` 108 | echo "alias protontricks='flatpak run com.github.Matoking.protontricks'" >> ~/.bashrc 109 | echo "alias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'" >> ~/.bashrc 110 | ``` 111 | 112 | You will need to restart your terminal emulator for the aliases to take effect. 113 | 114 | The Flatpak installation is sandboxed and only has access to the Steam 115 | installation directory by default. **You will need to add filesystem permissions when 116 | using additional Steam library locations or running external Windows 117 | applications.** See 118 | [here](https://github.com/flathub/com.github.Matoking.protontricks#configuration) 119 | for instructions on changing the Flatpak permissions. 120 | 121 | ## pipx 122 | 123 | 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. 124 | 125 | **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. 126 | 127 | **pipx requires Python 3.7 or newer.** 128 | 129 | **You will need to install pip, setuptools and virtualenv first.** Install the correct packages depending on your distribution: 130 | 131 | * Arch Linux: `sudo pacman -S python-pip python-pipx python-setuptools python-virtualenv` 132 | * Debian-based (Ubuntu, Linux Mint): `sudo apt install python3-pip python3-setuptools python3-venv pipx` 133 | * Fedora: `sudo dnf install python3-pip python3-setuptools python3-libs pipx` 134 | * Gentoo: 135 | 136 | ```sh 137 | sudo emerge -av dev-python/pip dev-python/virtualenv dev-python/setuptools 138 | python3 -m pip install --user pipx 139 | ~/.local/bin/pipx ensurepath 140 | ``` 141 | 142 | Close and reopen your terminal. After that, you can install Protontricks. 143 | 144 | ```sh 145 | pipx install protontricks 146 | ``` 147 | 148 | To enable desktop integration as well, run the following command *after* installing Protontricks 149 | 150 | ```sh 151 | protontricks-desktop-install 152 | ``` 153 | 154 | To upgrade to the latest release: 155 | ```sh 156 | pipx upgrade protontricks 157 | ``` 158 | 159 | To install the latest development version (requires `git`): 160 | ```sh 161 | pipx install git+https://github.com/Matoking/protontricks.git 162 | # '--spec' is required for older versions of pipx 163 | pipx install --spec git+https://github.com/Matoking/protontricks.git protontricks 164 | ``` 165 | 166 | ## pip (not recommended) 167 | 168 | 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. 169 | 170 | **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.** 171 | 172 | **You will need to install pip and setuptools first.** Install the correct packages depending on your distribution: 173 | 174 | * Arch Linux: `sudo pacman -S python-pip python-setuptools` 175 | * Debian-based (Ubuntu, Linux Mint): `sudo apt install python3-pip python3-setuptools` 176 | * Fedora: `sudo dnf install python3-pip python3-setuptools` 177 | * Gentoo: `sudo emerge -av dev-python/pip dev-python/setuptools` 178 | 179 | To install the latest release using `pip`: 180 | ```sh 181 | sudo python3 -m pip install protontricks 182 | ``` 183 | 184 | To upgrade to the latest release: 185 | ```sh 186 | sudo python3 -m pip install --upgrade protontricks 187 | ``` 188 | 189 | To install Protontricks only for the current user: 190 | ```sh 191 | python3 -m pip install --user protontricks 192 | ``` 193 | 194 | To install the latest development version (requires `git`): 195 | ```sh 196 | sudo python3 -m pip install git+https://github.com/Matoking/protontricks.git 197 | ``` 198 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /data/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Matoking/protontricks/47257a8e2b7edccd680b7862140d6fdf7722cd6c/data/screenshot.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | vdf==3.4 2 | Pillow 3 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest>=6.0 2 | pytest-cov>=2.10 3 | setuptools-scm 4 | -------------------------------------------------------------------------------- /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 = GPL3 10 | license_files = 11 | LICENSE 12 | platforms = linux 13 | classifiers = 14 | License :: OSI Approved :: GNU General Public License v3 (GPLv3) 15 | Topic :: Utilities 16 | Programming Language :: Python 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3.7 19 | Programming Language :: Python :: 3.8 20 | Programming Language :: Python :: 3.9 21 | Programming Language :: Python :: 3.10 22 | Programming Language :: Python :: 3.11 23 | Programming Language :: Python :: 3.12 24 | 25 | [options] 26 | packages = find_namespace: 27 | package_dir = 28 | = src 29 | include_package_data = True 30 | install_requires = 31 | setuptools # Required for pkg_resources 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/protontricks/_vdf/README.rst: -------------------------------------------------------------------------------- 1 | | |pypi| |license| |coverage| |master_build| 2 | | |sonar_maintainability| |sonar_reliability| |sonar_security| 3 | 4 | Pure python module for (de)serialization to and from VDF that works just like ``json``. 5 | 6 | Tested and works on ``py2.7``, ``py3.3+``, ``pypy`` and ``pypy3``. 7 | 8 | VDF is Valve's KeyValue text file format 9 | 10 | https://developer.valvesoftware.com/wiki/KeyValues 11 | 12 | | Supported versions: ``kv1`` 13 | | Unsupported: ``kv2`` and ``kv3`` 14 | 15 | Install 16 | ------- 17 | 18 | You can grab the latest release from https://pypi.org/project/vdf/ or via ``pip`` 19 | 20 | .. code:: bash 21 | 22 | pip install vdf 23 | 24 | Install the current dev version from ``github`` 25 | 26 | .. code:: bash 27 | 28 | pip install git+https://github.com/ValvePython/vdf 29 | 30 | 31 | Problems & solutions 32 | -------------------- 33 | 34 | - There are known files that contain duplicate keys. This is supported the format and 35 | makes mapping to ``dict`` impossible. For this case the module provides ``vdf.VDFDict`` 36 | that can be used as mapper instead of ``dict``. See the example section for details. 37 | 38 | - By default de-serialization will return a ``dict``, which doesn't preserve nor guarantee 39 | key order on Python versions prior to 3.6, due to `hash randomization`_. If key order is 40 | important on old Pythons, I suggest using ``collections.OrderedDict``, or ``vdf.VDFDict``. 41 | 42 | Example usage 43 | ------------- 44 | 45 | For text representation 46 | 47 | .. code:: python 48 | 49 | import vdf 50 | 51 | # parsing vdf from file or string 52 | d = vdf.load(open('file.txt')) 53 | d = vdf.loads(vdf_text) 54 | d = vdf.parse(open('file.txt')) 55 | d = vdf.parse(vdf_text) 56 | 57 | # dumping dict as vdf to string 58 | vdf_text = vdf.dumps(d) 59 | indented_vdf = vdf.dumps(d, pretty=True) 60 | 61 | # dumping dict as vdf to file 62 | vdf.dump(d, open('file2.txt','w'), pretty=True) 63 | 64 | 65 | For binary representation 66 | 67 | .. code:: python 68 | 69 | d = vdf.binary_loads(vdf_bytes) 70 | b = vdf.binary_dumps(d) 71 | 72 | # alternative format - VBKV 73 | 74 | d = vdf.binary_loads(vdf_bytes, alt_format=True) 75 | b = vdf.binary_dumps(d, alt_format=True) 76 | 77 | # VBKV with header and CRC checking 78 | 79 | d = vdf.vbkv_loads(vbkv_bytes) 80 | b = vdf.vbkv_dumps(d) 81 | 82 | Using an alternative mapper 83 | 84 | .. code:: python 85 | 86 | d = vdf.loads(vdf_string, mapper=collections.OrderedDict) 87 | d = vdf.loads(vdf_string, mapper=vdf.VDFDict) 88 | 89 | ``VDFDict`` works much like the regular ``dict``, except it handles duplicates and remembers 90 | insert order. Additionally, keys can only be of type ``str``. The most important difference 91 | is that when trying to assigning a key that already exist it will create a duplicate instead 92 | of reassign the value to the existing key. 93 | 94 | .. code:: python 95 | 96 | >>> 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/_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('= 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/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Matoking/protontricks/47257a8e2b7edccd680b7862140d6fdf7722cd6c/src/protontricks/cli/__init__.py -------------------------------------------------------------------------------- /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 pkg_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 | run([ 24 | "desktop-file-install", "--dir", str(applications_dir), 25 | pkg_resources.resource_filename( 26 | "protontricks", "data/share/applications/protontricks.desktop" 27 | ), 28 | pkg_resources.resource_filename( 29 | "protontricks", 30 | "data/share/applications/protontricks-launch.desktop" 31 | ) 32 | ], check=True) 33 | 34 | return applications_dir 35 | 36 | 37 | def cli(args=None): 38 | main(args) 39 | 40 | 41 | def main(args=None): 42 | """ 43 | 'protontricks-desktop-install' script entrypoint 44 | """ 45 | if args is None: 46 | args = sys.argv[1:] 47 | 48 | parser = CustomArgumentParser( 49 | description=( 50 | "Install Protontricks application shortcuts for the local user\n" 51 | ), 52 | formatter_class=argparse.RawTextHelpFormatter 53 | ) 54 | 55 | # This doesn't really do much except accept `--help` 56 | parser.parse_args(args) 57 | 58 | print("Installing .desktop files for the local user...") 59 | install_dir = install_desktop_entries() 60 | print(f"\nDone. Files have been installed under {install_dir}") 61 | print("The Protontricks shortcut and desktop integration should now work.") 62 | 63 | 64 | if __name__ == "__main__": 65 | main() 66 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 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 | # Display an error dialog containing the message 142 | message = "".join([ 143 | "Protontricks was closed due to the following error:\n\n", 144 | f"{error}\n\n", 145 | "=============\n\n", 146 | "Please include this entire error message when making a bug report.\n", 147 | "Environment:\n\n", 148 | f"Protontricks version: {__version__}\n", 149 | f"Is Flatpak sandbox: {is_flatpak_sandbox_}\n", 150 | f"Is Steam Deck: {is_steam_deck_}\n\n", 151 | "Log messages:\n\n", 152 | f"{log_messages}" 153 | ]) 154 | 155 | show_text_dialog( 156 | title="Protontricks", 157 | text=message, 158 | window_icon=error 159 | ) 160 | sys.exit(1) 161 | 162 | 163 | def cli_error_handler(cli_func): 164 | """ 165 | Decorator for CLI entry points. 166 | 167 | If an unhandled exception is raised and Protontricks was launched from 168 | desktop, display an error dialog containing the stack trace instead 169 | of printing to stderr. 170 | """ 171 | @functools.wraps(cli_func) 172 | def wrapper(self, *args, **kwargs): 173 | try: 174 | wrapper.no_term = False 175 | return cli_func(self, *args, **kwargs) 176 | except Exception: # pylint: disable=broad-except 177 | if not wrapper.no_term: 178 | # If we weren't launched from desktop, handle it normally 179 | raise 180 | 181 | traceback_ = traceback.format_exc() 182 | exit_with_error(traceback_, desktop=True) 183 | 184 | return wrapper 185 | 186 | 187 | class CustomArgumentParser(argparse.ArgumentParser): 188 | """ 189 | Custom argument parser that prints the full help message 190 | when incorrect parameters are provided 191 | """ 192 | def error(self, message): 193 | self.print_help(sys.stderr) 194 | args = {'prog': self.prog, 'message': message} 195 | self.exit(2, '%(prog)s: error: %(message)s\n' % args) 196 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/protontricks/data/data/icon_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Matoking/protontricks/47257a8e2b7edccd680b7862140d6fdf7722cd6c/src/protontricks/data/data/icon_placeholder.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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'(?