pax_global_header00006660000000000000000000000064146717531750014532gustar00rootroot0000000000000052 comment=e6bb313797a1345770f6937a50803095a6a07173 protontricks-1.12.0/000077500000000000000000000000001467175317500143545ustar00rootroot00000000000000protontricks-1.12.0/.github/000077500000000000000000000000001467175317500157145ustar00rootroot00000000000000protontricks-1.12.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001467175317500200775ustar00rootroot00000000000000protontricks-1.12.0/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000022541467175317500225740ustar00rootroot00000000000000--- name: Bug report about: Errors and crashes title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Run command `protontricks foo bar` 2. Command fails and error is displayed **Expected behavior** A clear and concise description of what you expected to happen. **System (please complete the following information):** - Distro: [e.g. Ubuntu 20.04, Arch Linux, ...] - Protontricks installation method: [e.g. community package, Flatpak, pipx or pip] - Protontricks version: run `protontricks --version` to print the version - Steam version: check if you're running Steam beta; this can be checked in _Steam_ -> _Settings_ -> _Interface_ -> _Client Beta Participation_ **Additional context** **If the error happens when trying to run a Protontricks command, run the command again using the `-vv` flag and copy the output!** For example, if the command that causes the error is `protontricks 42 faudio`, run `protontricks -vv 42 faudio` instead and copy the output here. If the output is very long, consider creating a gist using [gist.github.com](https://gist.github.com/). protontricks-1.12.0/.github/workflows/000077500000000000000000000000001467175317500177515ustar00rootroot00000000000000protontricks-1.12.0/.github/workflows/appstream.yaml000066400000000000000000000005671467175317500226410ustar00rootroot00000000000000--- name: Validate AppStream on: [push, pull_request] permissions: read-all jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install appstreamcli run: sudo apt install appstream - name: Validate AppStream metadata run: appstreamcli validate data/com.github.Matoking.protontricks.metainfo.xml protontricks-1.12.0/.github/workflows/tests.yml000066400000000000000000000030341467175317500216360ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Tests on: [push, pull_request] permissions: read-all jobs: test: runs-on: ubuntu-20.04 strategy: matrix: python-version: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install pytest-cov setuptools-scm coveralls pip install . - name: Test with pytest run: | pytest -vv --cov=protontricks --cov-report term --cov-report xml tests - name: Upload coverage run: | coveralls --service=github env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} COVERALLS_FLAG_NAME: python-${{ matrix.python-version }} COVERALLS_PARALLEL: true coveralls-finish: name: Finish Coveralls needs: test runs-on: ubuntu-latest steps: - name: Set up Python uses: actions/setup-python@v2 with: python-version: 3.8 - name: Finished run: | python -m pip install --upgrade coveralls coveralls --finish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} protontricks-1.12.0/.gitignore000066400000000000000000000027761467175317500163600ustar00rootroot00000000000000# Created by https://www.gitignore.io/api/python,virtualenv # Don't track setuptools-scm generated _version.py src/protontricks/_version.py ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache .pytest_cache/ nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule.* # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ ### VirtualEnv ### # Virtualenv # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ [Bb]in [Ii]nclude [Ll]ib [Ll]ib64 [Ll]ocal [Mm]an [Tt]cl pyvenv.cfg pip-selfcheck.json # End of https://www.gitignore.io/api/python,virtualenv protontricks-1.12.0/CHANGELOG.md000066400000000000000000000375061467175317500162000ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [1.12.0] - 2024-09-16 ### Added - `--cwd-app` flag to set working directory to the game's installation directory - Add support for Snap Steam installations ### Changed - `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. - `protontricks` will now launch GUI if no arguments were provided ### Fixed - Fix crash when parsing appinfo.vdf V29 in new Steam client version - Fix Protontricks crash when `config.vdf` contains invalid Unicode characters > [!IMPORTANT] > This release bundles a patched version of `vdf` in case the system Python package doesn't have the required `appinfo.vdf` V29 support. > If you're a package maintainer, you will probably want to remove the corresponding > commit if the distro you're using already ships a version of `vdf` with the > required support. ## [1.11.1] - 2024-02-20 ### Fixed - Fix Protontricks crash when custom Proton has an invalid or empty `compatibilitytool.vdf` manifest - Fix Protontricks GUI crash when Proton installation is incomplete - Check if Steam Runtime launcher service launched correctly instead of always assuming successful launch ## [1.11.0] - 2023-12-30 ### Added - Show app icons for custom shortcuts in the app selector - Verbose flag can be enabled with `-vv` for additional debug logging ### Fixed - Fix Protontricks not recognizing supported Steam Runtime installation due to changed name - Fix Protontricks not recognizing default Proton installation for games with different Proton preselected by Valve testing - Fix Protontricks crash when app has an unidentifiable app icon ## [1.10.5] - 2023-09-05 ### Fixed - Fix crash caused by custom app icons with non-RGB mode ## [1.10.4] - 2023-08-26 ### Fixed - Fix crash caused by the Steam shortcut configuration file containing extra data after the VDF section - Fix differently sized custom app icons breaking the layout in the app selector ## [1.10.3] - 2023-05-06 ### Added - Flatpak version of Steam is also detected with non-Flatpak installation of Protontricks ### Changed - `--background-wineserver` is now disabled by default due to problems with crashing graphical applications and broken console output ### Fixed - Fix detection of Steam library folders using non-standard capitalizations for `steamapps` - _Steam Linux Runtime - Sniper_ is no longer incorrectly reported as an unsupported runtime ## [1.10.2] - 2023-02-13 ### Added - Launch application with fixed locale settings if Steam Deck is used and non-existent locales are configured ### Fixed - Fix crashes caused by missing permissions when checking for Steam apps ## [1.10.1]- 2022-12-10 ### Fixed - Fix crash when unknown XDG Flatpak filesystem permissions are enabled - Fix crash when parsing appinfo.vdf V28 version introduced in Steam beta ## [1.10.0] - 2022-11-27 ### Added - Prompt the user for a Steam installation if multiple installations are found ### Fixed - Detect XDG user directory permissions in Flatpak environment ## [1.9.2] - 2022-09-16 ### Fixed - Fix random crashes when running Wine commands due to race condition in Wine launcher script ## [1.9.1] - 2022-08-28 ### Added - Print a warning when multiple Steam directories are detected and `STEAM_DIR` is not used to specify the directory ### Changed - Launch Steam Runtime sandbox with `--bus-name` parameter instead of the now deprecated `--socket` ### Fixed - Fix various crashes due to Wine processes under Steam Runtime sandbox using the incorrect working directory ## [1.9.0] - 2022-07-02 ### Added - Add `-l/--list` command to list all games ### Fixed - Fix `wineserver -w` calls hanging when legacy Steam Runtime and background wineserver are enabled - Do not attempt to launch bwrap-launcher if bwrap is not available ## [1.8.2] - 2022-05-16 ### Fixed - Fix Wine crash on newer Steam Runtime installations due to renamed runtime executable - Fix graphical Wine applications crashing on Wayland - Fix Protontricks crash caused by Steam shortcuts created by 3rd party applications such as Lutris ## [1.8.1] - 2022-03-20 ### Added - Prompt the user to update Flatpak permissions if inaccessible paths are detected ### Fixed - Fix Proton discovery on Steam Deck ### Removed - Drop Python 3.5 support ## [1.8.0] - 2022-02-26 ### Added - fsync/esync is enabled by default - `PROTON_NO_FSYNC` and `PROTON_NO_ESYNC` environment variables are supported - 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`. - Improve Wine command startup time with bwrap by creating a single container and launching all Wine processes inside it. ### Fixed - Fix Wine crash when the Steam application and Protontricks are running at the same time - Fix Steam installation detection when both non-Flatpak and Flatpak versions of Steam are installed for the same user - Fix Protontricks crash when Proton installation is incomplete - Fix Protontricks crash when both Flatpak and non-Flatpak versions of Steam are installed - Fix duplicate log messages when using `protontricks-launch` - Fix error dialog not being displayed when using `protontricks-launch` ## [1.7.0] - 2022-01-08 ### Changed - Enable usage of Flatpak Protontricks with non-Flatpak Steam. Flatpak Steam is prioritized if both are found. ### Fixed - bwrap is only disabled when the Flatpak installation is too old. Flatpak 1.12.1 and newer support sub-sandboxes. - Remove Proton installations from app listings ## [1.6.2] - 2021-11-28 ### Changed - Return code is now returned from the executed user commands - Return code `1` is returned for most Protontricks errors instead of `-1` ## [1.6.1] - 2021-10-18 ### Fixed - Fix duplicate Steam application entries - Fix crash on Python 3.5 ## [1.6.0] - 2021-08-08 ### Added - Add `protontricks-launch` script to launch Windows executables using Proton app specific Wine prefixes - Add desktop integration for Windows executables, which can now be launched using Protontricks - 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. - Add error dialog for displaying error information when Protontricks has been launched from desktop and no user-visible terminal is available. - Add YAD as GUI provider. YAD is automatically used instead of Zenity when available as it supports additional features. ### Changed - 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. ### Fixed - Display proper error messages in certain cases when corrupted VDF files are found - Fix crash caused by appmanifest files that can't be read due to insufficient permissions - Fix crash caused by non-Proton compatibility tool being enabled for the selected app - Fix erroneous warning when Steam library is inside a case-insensitive file system ## [1.5.2] - 2021-06-09 ### Fixed - Custom Proton installations now use Steam Runtime installations when applicable - Fix crash caused by older Steam app installations using a different app manifest structure - Fix crash caused by change to lowercase field names in multiple VDF files - Fix crash caused by change in the Steam library folder configuration file ## [1.5.1] - 2021-05-10 ### Fixed - bwrap containerization now tries to mount more root directories except those that have been blacklisted due to potential issues ## [1.5.0] - 2021-04-10 ### Added - Use bwrap containerization with newer Steam Runtime installations. The old behavior can be enabled with `--no-bwrap` in case of problems. ### Fixed - User-provided `WINE` and `WINESERVER` environment variables are used when Steam Runtime is enabled - Fixed crash caused by changed directory name in Proton Experimental update ## [1.4.4] - 2021-02-03 ### Fixed - Display a proper error message when Proton installation is incomplete due to missing Steam Runtime - Display a proper warning when a tool manifest is empty - Fix crash caused by changed directory structure in Steam Runtime update ## [1.4.3] - 2020-12-09 ### Fixed - Add support for newer Steam Runtime versions ## [1.4.2] - 2020-09-19 ### Fixed - Fix crash with newer Steam client beta caused by differently cased keys in `loginusers.vdf` ### Added - Print a warning if both `steamapps` and `SteamApps` directories are found inside the same library directory ### Changed - Print full help message when incorrect parameters are provided. ## [1.4.1] - 2020-02-17 ### Fixed - Fixed crash caused by Steam library paths containing special characters - Fixed crash with Proton 5.0 caused by Steam Runtime being used unnecessarily with all binaries ## [1.4] - 2020-01-26 ### Added - System-wide compatibility tool directories are now searched for Proton installations ### Changed - Drop Python 3.4 compatibility. Python 3.4 compatibility has been broken since 1.2.2. ### Fixed - Zenity no longer crashes the script if locale is incapable of processing the arguments. - Selecting "Cancel" in the GUI window now prints a proper message instead of an error. - Add workaround for Zenity crashes not handled by the previous fix ## [1.3.1] - 2019-11-21 ### Fixed - Fix Proton prefix detection when the prefix directory is located inside a `SteamApps` directory instead of `steamapps` - Use the most recently used Proton prefix when multiple prefix directories are found for a single game - Fix Python 3.5 compatibility ## [1.3] - 2019-11-06 ### Added - Non-Steam applications are now detected. ### Fixed - `STEAM_DIR` environment variable will no longer fallback to default path in some cases ## [1.2.5] - 2019-09-17 ### Fixed - Fix regression in 1.2.3 that broke detection of custom Proton installations. - Proton prefix is detected correctly even if it exists in a different Steam library folder than the game installation. ## [1.2.4] - 2019-07-25 ### Fixed - Add a workaround for a VDF parser bug that causes a crash when certain appinfo.vdf files are parsed. ## [1.2.3] - 2019-07-18 ### Fixed - More robust parsing of appinfo.vdf. This fixes some cases where Protontricks was unable to detect Proton installations. ## [1.2.2] - 2019-06-05 ### Fixed - Set `WINEDLLPATH` and `WINELOADER` environment variables. - Add a workaround for a Zenity bug that causes the GUI to crash when certain versions of Zenity are used. ## [1.2.1] - 2019-04-08 ### Changed - Delay Proton detection until it's necessary. ### Fixed - Use the correct Proton installation when selecting a Steam app using the GUI. - Print a proper error message if Steam isn't found. - Print an error message when GUI is enabled and no games were found. - Support appmanifest files with mixed case field names. ## [1.2] - 2019-02-27 ### Added - Add a `-c` parameter to run shell commands in the game's installation directory with relevant Wine environment variables. - Steam Runtime is now supported and used by default unless disabled with `--no-runtime` flag or `STEAM_RUNTIME` environment variable. ### Fixed - All arguments are now correctly passed to winetricks. - Games that haven't been launched at least once are now excluded properly. - Custom Proton versions with custom display names now work properly. - `PATH` environment variable is modified to prevent conflicts with system-wide Wine binaries. - Steam installation is handled correctly if `~/.steam/steam` and `~/.steam/root` point to different directories. ## [1.1.1] - 2019-01-20 ### Added - Game-specific Proton installations are now detected. ### Fixed - Proton installations are now detected properly again in newer Steam Beta releases. ## [1.1] - 2019-01-20 ### Added - Custom Proton installations in `STEAM_DIR/compatibilitytools.d` are now detected. See [Sirmentio/protontricks#31](https://github.com/Sirmentio/protontricks/issues/31). - Protontricks is now a Python package and can be installed using `pip`. ### Changed - Argument parsing has been refactored to use argparse. - `protontricks gui` is now `protontricks --gui`. - New `protontricks --version` command to print the version number. - Game names are now displayed in alphabetical order and filtered to exclude non-Proton games. - Protontricks no longer prints INFO messages by default. To restore previous behavior, use the `-v` flag. ### Fixed - More robust VDF parsing. - Corrupted appmanifest files are now skipped. See [Sirmentio/protontricks#36](https://github.com/Sirmentio/protontricks/pull/36). - 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). ## 1.0 - 2019-01-16 ### Added - The last release of Protontricks maintained by [@Sirmentio](https://github.com/Sirmentio). [Unreleased]: https://github.com/Matoking/protontricks/compare/1.12.0...HEAD [1.12.0]: https://github.com/Matoking/protontricks/compare/1.11.1...1.12.0 [1.11.1]: https://github.com/Matoking/protontricks/compare/1.11.0...1.11.1 [1.11.0]: https://github.com/Matoking/protontricks/compare/1.10.5...1.11.0 [1.10.5]: https://github.com/Matoking/protontricks/compare/1.10.4...1.10.5 [1.10.4]: https://github.com/Matoking/protontricks/compare/1.10.3...1.10.4 [1.10.3]: https://github.com/Matoking/protontricks/compare/1.10.2...1.10.3 [1.10.2]: https://github.com/Matoking/protontricks/compare/1.10.1...1.10.2 [1.10.1]: https://github.com/Matoking/protontricks/compare/1.10.0...1.10.1 [1.10.0]: https://github.com/Matoking/protontricks/compare/1.9.2...1.10.0 [1.9.2]: https://github.com/Matoking/protontricks/compare/1.9.1...1.9.2 [1.9.1]: https://github.com/Matoking/protontricks/compare/1.9.0...1.9.1 [1.9.0]: https://github.com/Matoking/protontricks/compare/1.8.2...1.9.0 [1.8.2]: https://github.com/Matoking/protontricks/compare/1.8.1...1.8.2 [1.8.1]: https://github.com/Matoking/protontricks/compare/1.8.0...1.8.1 [1.8.0]: https://github.com/Matoking/protontricks/compare/1.7.0...1.8.0 [1.7.0]: https://github.com/Matoking/protontricks/compare/1.6.2...1.7.0 [1.6.2]: https://github.com/Matoking/protontricks/compare/1.6.1...1.6.2 [1.6.1]: https://github.com/Matoking/protontricks/compare/1.6.0...1.6.1 [1.6.0]: https://github.com/Matoking/protontricks/compare/1.5.2...1.6.0 [1.5.2]: https://github.com/Matoking/protontricks/compare/1.5.1...1.5.2 [1.5.1]: https://github.com/Matoking/protontricks/compare/1.5.0...1.5.1 [1.5.0]: https://github.com/Matoking/protontricks/compare/1.4.4...1.5.0 [1.4.4]: https://github.com/Matoking/protontricks/compare/1.4.3...1.4.4 [1.4.3]: https://github.com/Matoking/protontricks/compare/1.4.2...1.4.3 [1.4.2]: https://github.com/Matoking/protontricks/compare/1.4.1...1.4.2 [1.4.1]: https://github.com/Matoking/protontricks/compare/1.4...1.4.1 [1.4]: https://github.com/Matoking/protontricks/compare/1.3.1...1.4 [1.3.1]: https://github.com/Matoking/protontricks/compare/1.3...1.3.1 [1.3]: https://github.com/Matoking/protontricks/compare/1.2.5...1.3 [1.2.5]: https://github.com/Matoking/protontricks/compare/1.2.4...1.2.5 [1.2.4]: https://github.com/Matoking/protontricks/compare/1.2.3...1.2.4 [1.2.3]: https://github.com/Matoking/protontricks/compare/1.2.2...1.2.3 [1.2.2]: https://github.com/Matoking/protontricks/compare/1.2.1...1.2.2 [1.2.1]: https://github.com/Matoking/protontricks/compare/1.2...1.2.1 [1.2]: https://github.com/Matoking/protontricks/compare/1.1.1...1.2 [1.1.1]: https://github.com/Matoking/protontricks/compare/1.1...1.1.1 [1.1]: https://github.com/Matoking/protontricks/compare/1.0...1.1 protontricks-1.12.0/CONTRIBUTING.md000066400000000000000000000021061467175317500166040ustar00rootroot00000000000000# How can I contribute? Well, you can... * Report bugs * Add improvements * Fix bugs # Reporting bugs The best means of reporting bugs is by following these basic guidelines: * First describe in the title of the issue tracker what's gone wrong. * 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`. * Explain what you had expected to occur, and what really occured. * Optionally, if you want, if you're a programmer, you can try to issue a pull request yourself that fixes the issue. # Adding improvements 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! * In any pull request, explain thoroughly what changes you made * Explain why you think these changes could be useful * If it fixes a bug, be sure to link to the issue itself. * Follow the [PEP 8](https://www.python.org/dev/peps/pep-0008/) code style to keep the code consistent. protontricks-1.12.0/LICENSE000066400000000000000000001045131467175317500153650ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . protontricks-1.12.0/MANIFEST.in000066400000000000000000000002101467175317500161030ustar00rootroot00000000000000include MANIFEST.in LICENSE *.md graft src/protontricks graft data exclude *.yml global-exclude *.py[cod] global-exclude __pycache__ protontricks-1.12.0/Makefile000066400000000000000000000004771467175317500160240ustar00rootroot00000000000000SHELL = /bin/sh PYTHON ?= python3 ROOT ?= / PREFIX ?= /usr/local install: ${PYTHON} setup.py install --prefix="${DESTDIR}${PREFIX}" --root="${DESTDIR}${ROOT}" # Remove `protontricks-desktop-install`, since we already install # .desktop files properly rm "${DESTDIR}${PREFIX}/bin/protontricks-desktop-install" protontricks-1.12.0/README.md000066400000000000000000000213001467175317500156270ustar00rootroot00000000000000Protontricks ============ [![image](https://img.shields.io/pypi/v/protontricks.svg)](https://pypi.org/project/protontricks/) [![Coverage Status](https://coveralls.io/repos/github/Matoking/protontricks/badge.svg?branch=master)](https://coveralls.io/github/Matoking/protontricks?branch=master) [![Test Status](https://github.com/Matoking/protontricks/actions/workflows/tests.yml/badge.svg)](https://github.com/Matoking/protontricks/actions/workflows/tests.yml) [](https://flathub.org/apps/details/com.github.Matoking.protontricks) > [!IMPORTANT] > **Steam currently needs a beta version of Protontricks** to be installed and will otherwise fail with "Invalid file magic number" error. See [here](https://github.com/Matoking/protontricks/issues/304#issuecomment-2220599470) for installation instructions. Run Winetricks commands for Steam Play/Proton games among other common Wine features, such as launching external Windows executables. This is a fork of the original project created by sirmentio. The original repository is available at [Sirmentio/protontricks](https://github.com/Sirmentio/protontricks). # What is it? 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. # Requirements * Python 3.6 or newer * Winetricks * Steam * YAD (recommended) **or** Zenity. Required for GUI. # Usage **Protontricks can be launched from desktop or using the `protontricks` command.** ## Command-line The basic command-line usage is as follows: ``` # Find your game's App ID by searching for it protontricks -s # or by listing all games protontricks -l # Run winetricks for the game. # Any parameters in are passed directly to Winetricks. # Parameters specific to Protontricks need to be placed *before* . protontricks # Run a custom command for selected game protontricks -c # Run the Protontricks GUI protontricks --gui # Launch a Windows executable using Protontricks protontricks-launch # Launch a Windows executable for a specific Steam app using Protontricks protontricks-launch --appid # Print the Protontricks help message protontricks --help ``` Since this is a wrapper, all commands that work for Winetricks will likely work for Protontricks as well. If you have a different Steam directory, you can export ``$STEAM_DIR`` to the directory where Steam is. If you'd like to use a local version of Winetricks, you can set ``$WINETRICKS`` to the location of your local winetricks installation. 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`. [Wanna see Protontricks in action?](https://asciinema.org/a/229323) ## Desktop 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. 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. # Troubleshooting For common issues and solutions, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md). # Installation 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. **If you're using a Steam Deck**, Flatpak is the recommended option. Open the **Discover** application store in desktop mode and search for **Protontricks**. **If you're using the Flatpak version of Steam**, follow the [Flatpak-specific installation instructions](https://github.com/flathub/com.github.Matoking.protontricks) instead. ## Community packages (recommended) 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**. Community packages are maintained by community members and might be out-of-date compared to releases on PyPI. Note that some distros such as **Debian** / **Ubuntu** often have outdated packages for either Protontricks **or** Winetricks. If so, install the Flatpak version instead as outdated releases may fail to work properly. [![Packaging status](https://repology.org/badge/vertical-allrepos/protontricks.svg)](https://repology.org/project/protontricks/versions) ## Flatpak (recommended) Protontricks is available on the Flathub app store: [](https://flathub.org/apps/details/com.github.Matoking.protontricks) To use Protontricks as a command-line application, add shell aliases by running the following commands: ``` echo "alias protontricks='flatpak run com.github.Matoking.protontricks'" >> ~/.bashrc echo "alias protontricks-launch='flatpak run --command=protontricks-launch com.github.Matoking.protontricks'" >> ~/.bashrc ``` You will need to restart your terminal emulator for the aliases to take effect. The Flatpak installation is sandboxed and only has access to the Steam installation directory by default. **You will need to add filesystem permissions when using additional Steam library locations or running external Windows applications.** See [here](https://github.com/flathub/com.github.Matoking.protontricks#configuration) for instructions on changing the Flatpak permissions. ## pipx 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. **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. **pipx requires Python 3.6 or newer.** **You will need to install pip, setuptools and virtualenv first.** Install the correct packages depending on your distribution: * Arch Linux: `sudo pacman -S python-pip python-pipx python-setuptools python-virtualenv` * Debian-based (Ubuntu, Linux Mint): `sudo apt install python3-pip python3-setuptools python3-venv pipx` * Fedora: `sudo dnf install python3-pip python3-setuptools python3-libs pipx` * Gentoo: ```sh sudo emerge -av dev-python/pip dev-python/virtualenv dev-python/setuptools python3 -m pip install --user pipx ~/.local/bin/pipx ensurepath ``` Close and reopen your terminal. After that, you can install Protontricks. ```sh pipx install protontricks ``` To enable desktop integration as well, run the following command *after* installing Protontricks ```sh protontricks-desktop-install ``` To upgrade to the latest release: ```sh pipx upgrade protontricks ``` To install the latest development version (requires `git`): ```sh pipx install git+https://github.com/Matoking/protontricks.git # '--spec' is required for older versions of pipx pipx install --spec git+https://github.com/Matoking/protontricks.git protontricks ``` ## pip (not recommended) 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. **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.** **You will need to install pip and setuptools first.** Install the correct packages depending on your distribution: * Arch Linux: `sudo pacman -S python-pip python-setuptools` * Debian-based (Ubuntu, Linux Mint): `sudo apt install python3-pip python3-setuptools` * Fedora: `sudo dnf install python3-pip python3-setuptools` * Gentoo: `sudo emerge -av dev-python/pip dev-python/setuptools` To install the latest release using `pip`: ```sh sudo python3 -m pip install protontricks ``` To upgrade to the latest release: ```sh sudo python3 -m pip install --upgrade protontricks ``` To install Protontricks only for the current user: ```sh python3 -m pip install --user protontricks ``` To install the latest development version (requires `git`): ```sh sudo python3 -m pip install git+https://github.com/Matoking/protontricks.git ``` protontricks-1.12.0/TROUBLESHOOTING.md000066400000000000000000000045211467175317500171670ustar00rootroot00000000000000Troubleshooting =============== 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. # Common issues and solutions ## "warning: You are using a 64-bit WINEPREFIX" > 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.`. > Is this a problem? 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. ## "Unknown arg foobar" > When I'm trying to run a Protontricks command such as `protontricks foobar`, I get the error `Unknown arg foobar`. 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. ## "Unknown option --foobar" > When I'm trying to run a Protontricks command such as `protontricks --no-bwrap foobar`, I get the error `Unknown option --no-bwrap`. 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`. ## "command cabextract ... returned status 1. Aborting." > When I'm trying to run a Winetricks command, I get the error `command cabextract ... returned status 1. Aborting.` This is a known issue with `cabextract`, which doesn't support symbolic links created by Proton 5.13 and newer. 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. You can also check [the Winetricks issue on GitHub](https://github.com/Winetricks/winetricks/issues/1648). protontricks-1.12.0/data/000077500000000000000000000000001467175317500152655ustar00rootroot00000000000000protontricks-1.12.0/data/com.github.Matoking.protontricks.metainfo.xml000066400000000000000000000052601467175317500261620ustar00rootroot00000000000000 com.github.Matoking.protontricks Protontricks Apps and fixes for Proton games wine protontricks protontricks-launch protontricks.desktop com.valvesoftware.Steam https://raw.githubusercontent.com/Matoking/protontricks/master/data/screenshot.png App selection screen pointing keyboard console

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.

Utility Janne Pulkkinen https://github.com/Matoking/protontricks https://github.com/Matoking/protontricks#readme https://github.com/Matoking/protontricks/issues GPL-3.0 CC0-1.0 janne.pulkkinen@protonmail.com
protontricks-1.12.0/data/screenshot.png000066400000000000000000002200531467175317500201520ustar00rootroot00000000000000PNG  IHDR@ H>iCCPICC profile(}=H@_S*U;HuP,8jP :\MGbYWWAqssRtZxp܏ww^f1e11]^@}BL}.Ls|׻(>Ur&|", xxz9XQRω #e8xfHCb6fEC%"(FBeg\e{s2i#E,! 2( QZ5RLh?;$drȱ TH~wk''ܤ` |khlض'ZJ$"G@6pq=r|%Cr$?M!蛲-г7!0Zuw{L>r bKGDvlbm pHYs+tIME,.jtEXtCommentCreated with GIMPW IDATxw|lMo$tB "*EP"*zU,z+WTPP $'~,DB|ͮuϏtK־KVtӹw\u]wm1- 3[ 30ea&A3r$$RuL- }Bqh::veYL>=3ӞM(FU\.We!n7JYkN#{IJDt<~z޿}dgg0m1=2C.=[H4xrq!rrrD"YEVV?_`ow7{]Njmβ@ D0$ 56=HK1LgTdh%ĴXF^ob) ?Bq,_v`bZ&_~ӇʜUU)//0;9c(E& = u6MNZ4^}UmƬYoxӹ{}+\.L`Μ9ҢE rr62p `Yݻ5.X$dzpwTزe3?-[Vz**ʫק5In >۷mGu :6>5-JON{C~իӭ[F0U^Ju#(ާO:8 ;δlLT"iZD  QSԽ?=L|uB/=L駧*@S â,…\]wCzZS̘c3y1111x&woa4p@>t.njc'IwqO=mGs{&1[֭+6l`̙SQA>}8c !Q!ҵ#Jvuku>^][JV{uxPr3[OeZC8.KTU,FUT†s_ev{etY]1E.!mTTTVuiW, a&qq|<˲( )!٣ۨe5zHBi؎1^IP2\߇]D7`A+%gR1k1!POgƵw0TU%%9MLlTCB~ds+A1^W77YE$G{L;:mSe* Vs۞߭h:iB!Vm7gWo[u07b`cS3ت^T PT&5!B!ckTS%)c{>=4b::B!B:B!B @B!B!H!B!$ !B! !B!HNJVB!B#HB!BB!BHB!B @B!B!H!B!$ !B! !B!$B!tN*LKZY!B?A)+2X!œHv]p)w6XuNsuHB!, Y<4B}6]z:=߿Ϸa`y⃪_ !B!i -䣍yKA Vg;S޵-8'{m6>MYd|?BhTl+}+fs^!5.ܽh6,e Ru%3 V5kYt[+7Y 청TLbδψޯB!BCTws4Eaeavu c̝UW?ݑĆ+O%ӭٟp w U PP~ y>egN_45oT>zO6Tm akJ֑k 1\Jw^9XF Qoj*4y1틅w5^!B8 wʭ/'E\}:>&\ߕG?.aKH/)mC_q~uiLUWN V⧟V(+ Wt@ glɌՑ .ؾvꓑuB! s4fǞ=Ǟ-˸҉9}( ͭ%g#Tfh Gf宫~o^uycM%@c$1I>) #uDQn|B힜LFB!BGlˣwf+&|繝6SocK! YP=*r%]h{h޻lܘrfﰹ_t6zB!avD|KepFf~ZJ9-s\VtșWar֝4iykݣJ h(^Rpk߆aAPp{|R{YSioPG_֯bEõ^!BѰz{Uuխ.b6 g.h =Ŕ/$A¤fߞUqÜ4)`7;/'0lY Z]֡ka"gt!#hh3G?ȍ)\sYnU1vt:^!BѰgR$7_eA?meTY.S ⣸ 5-M9o+)Bw%rgX_ :o.}N B!B4N#TRHg W|2;`C$Iorp߾#>ɟkۀ&-}PB!I7B!Bш!pϖgf\AB!BHB!BZKA!B!H!B!$ !B! !B!VsX!-#B!Z޽ H,UrHPtd¤#Iq*Х:!6l؊ـ99z\l\F@*njݢ  B!?WspU*@D70LX$غ71%#uāP#I۴i: F\"5=3\E aYho++@!Bq2l T ZO>'9K|B2t#sM+.FT0L:Ǔ(l ,ӈLXS)N9B!BdTaC/SeWR%ovby.VJ^ :ӅSҢIO.ѳg4rsK1h=9"bp9B!B@t9.+@QI9kwaΏA8m%7˳q?Q7 .<wk~ViقJi޼ ŕhP4Q=N!Bw(dun®HN6@l?| ͙_9o.M <V6ؼ1NߊԎ`y,Ep1rB!/i7xTV]oμ%M :SeGAbbrJr}Y lɦ h؀nL !B!cnrSچQNq, m6AqFMtmb+ :w̢d;nO%۪`+~:AՔ/P51˙B!BZl=:.> ;DAAqɰ97 ΠV P9.Dz X !c^w: i"Kѡt.+gZ!Bqx*\z*UXˡ}V.S};18l \.ٰm,e+~#iq5Chl #&)!N;Fg!2?5oNio!B!6 $^Jb\?m'jVjcŬ[U i3r1t"/W:oɆlފҺN܅64+ќч: &Iּ˂%dʳ[ջgĪZ2{\n-%]/F_~.Z,&Ojfw.{HJ>y{KViɐc8e>e,}73񚮵5pչ.M|Jln\z%jB! $6A [qb+Mlʫ› 1*ZHiU: y?!3tlMIY>[X9i@gZeơ!b`NYmwBo38k2{κl^'D ecڠQqkoGkdMŨ9ynZۗ8k-[uB!/sz.=әDjj mEJ0%aS͖3h΋i9sг[[pP%.*e˦4kV1j }6lڴH `2]\WqK>A5T~Yź_+%'Q76kFRΧjdN3ڴ)(XO8ۧ1"h*unٚcOhM5fƠ {ìHl{}vs{+v\zLzη/ҥgR {S<|;w䇯e{UJH=oGjڤ. T/wB! 6mAf؀aftڊH0ga0H82 8`VG([Ǩg7U8I E` ϓ8u xG*)BVo.秙SxV J#:V/k;d2敝~niwڴ|w~bփ.vmsЃ)_a@U.lg2R`K8"v]&|%Y8k-SXAy !BR'ƈm%3~<\§Փe?e3yrkܝn/˯a[VB6FSMvl/"_(!Npj nFj+Z3um>=@ w\+?9oғ1qSEh1DNqs, IDATƣIjąnE\81Lːf1Iј|#\>W2)u#Qܒ \>Ӿd+K`[3ʕ|DO< m,+3!EuB!fE 羇&sC͆Mb|h4\8:ML 4KnE(\8LAiY!jSQw <:o|i珫磙׳32&wOl>w70jۦ7) +\1gO_RG &ykEN0V\FpU-=B嶏xyԡw)tEY]'B!2A=bN@LTG˾e@Ā9oax8N?\N+/˝]QLt3V4*}Xh hh8qT-=5޵m/;dƶ_a^kNಌ&ݡkܣQ]^M~5q<͍'PVVFYYV$߸~gSQyz[j'iV`5Lj2:9_̯yƮķ_o/omo,uoȻN!Be4ؚeDtbA1!1ڷMk!l*ؖ͢ñm{,a  :+ tEp\ݠ"X:XDp:̓sRIkw9vv3<G^8f?1LjNmy7Tzg{̶#fýƄ:ԪS`b=R5dNQ0֎I@f~=iJ:2^ 3Kn[^J4B!_F)+ɔW`Ѐ@1X"F:vrgM`[>#(7LH1xc2e+؊lpk0.~-`jz5CaKf@YY ݕDj J]1pWޔ&Ļ툳"> u4I@F)ͼu&RF STX31$vμEYQ14bB!aբӻ>7R%E0nD^ui7RS@TbrT[8ױr/KXND""&VEq8(`28S?uO ?΄uE#> M ?&M3a^bM%!B [iL~v'˖-ϢsO>q\EmmfՊ 6@St[$:ͶɉB!B9( iW3q9 I0e_4^A:uQqTNO=4'TVv:ٚ 21KpS˙B!BhjSqcagܨ(2Q c)nРl7>,a%?.efب(ĸ4T Jiٴ=]:v{KζB!phub&NDM8d8,pz8fYxBr,3DTE'DnOjZ:eXJδB! ,50=6ۑS#֓68T7^Crjz8lbba\pީ^bq qxaC47RTByB!0s]m(8‘0mRZTLUUݺv4 J6cZRVak\sɌ~NEJ+/A7(A|l">?Ш"֛eʭNB!BLCEUX($$ѻOW1=b0? u ?opa3Αd3 j+K(9mr9B!B7ζUl4]In-odeŲ%̶N22zjMv vh.tYDjUIަx!j%] hrB!Hu`+8^ ĶC(Nn8<8LOxR]:QN$-ҝٴh@8n\vlj>ÈʙB!BdlKP3b qTӫwT%ܭ,\+$]+*/߉i N"XJYa|JOf=UU;HJLj7mJa.9B!B7b6A14M4a/t`ؙF!WzE9+$5/#/mcNJKKA.͹Q@)VI3-B!8|=@>4Tvچm8sI3XK/n} !V+8.$е<=.zf.1t2t}}}g9m9B!BD""':zHMM&>6m=ٙ +Ԕp!( /pPE%?e;3Ce}f: UFCQK*޾GFS4ֿc[!6o#6 :gF)^H-fB!hҤ Ņ*%6֍E3۰e*PYna?@%ބx+ijiպ=cötnϖ9\?zbN۰jU&[5o`e #VwYgox83ֿl,+ٝ ^@8A">Y-;p%Ӣ}w L4k}lmKdƼIn  >b.\k?6x4vsfuj׿FKW]ӹ]ulh*ZsYv}4wk _+8\7+&_ Vl%E7.xeH!lPEq|N>GqѼ;t_E!!(%)#;y`x@th׃rдI|>P_Y3{HODu9~gpRe~ ;‹]˦9C#z"1+?ͻ5y|?=>>᧾ṽWߏQKW?ͽ/Vk#l6d8F5i5/e/cOM>b*v@OY0: wSrd߾$}Yn'h!ٶMFFPH_eKF˹#F,ZHL|xʷ:ٱ;SbǶx=.@U?N-86hIjjfp7;ws^Y-$ztkÈlugyܳ+uؖO\$dlN֭qZ~>|3֌ 4{gtpbv;|)яAȈ9s.E*,G&4 In p#n.'Cwڧy nկTq<3uoYF%\Jq(׎IH<Or~r۱8en.[Ƒl-yN>-Sxx0y7Qd2Wugɨ*6=sMxӫx'qkqCv}5fƠ{+v\zLz=@r6u"Ft:p9_j?9y7g%\>(D)mb}:.ӆul^?NY}@8|3b(sog[}ߥ(Zi|&NFbb MO\R")M滟4OŤrǠY{8cHEs5YY4sj[Nt?T,ѱzY!I7sK?ޘMvvz:0X;[-wY#Q @ޞoȹy$ FFo M8eӚ|˅ݑɉn6²v']78cΩnVaۓY4HESƤqEq+4 T:rj.7 @e,g^~%c?:vj9~9ճiAVnփ.u)_.cǃظu止 vn(Suk`S@!g0nP ˥qG OR;P6mmp{שo኏)ɮuS_E9X۞dvlTkiTzIǞ(`9h|uT-E#F9m{מؾ G>Ƙs,qqu+HPUGlSL}uûB8RS@!0`aZ*}%:IIMv`*?bŊ~iCtz0L?;w ;Ѝ yJ2+~o+$&SZ~R/滟?"uG3ɯgg\/le6Mb6}d )` P;@UG &ykEt$L~54ee\2hY.1.ۘ6hl}16Íis.?ՔP(u4C٦YئY=qzmt `X'wQRTǿU9LOfQ@0@EE (*W]5kČ$s^sDD$803=swAqv>s朙Uznw֝qQſlo=s"}vyb$_eS֤\̭x`3W@!d`mJKKm#t: riO1Yl&3NUQlڌv ֬YCii v 6h&ط˥,=hۿƯՍ_29m{smf=?=ԇ=r1mI#OTG3F ~׬Joj<9α7GG1?H4me){Mnm&mnKg>qooogw^91pT m'jb?`yUHj5W E0M n?|0q8#n% !c/ia1m$TW?QXVD.Bm}dʤېY(Dd{ IoyKVBaR`>8ӭ |}{@| GGs?G?t z@1}Njb'_Mcn8Gۦ@ǽi,Z 8h/gIqk8|E1zb[^@cjg$<~=c{!S:=M:׿ms x p iٲ[VӮm~XadMt=܉|x s +3)lNgV IDAT ӢM8t8 ysJfq{T03.9w}: Sǀ(*iW=h qr( 160ǽh4Z4 [u 2Joy!B]M_,L Qp_q_SQorQPP@ǎ lذqΜm(U 05R߇n%jIQTe\i!B!De:nK0Cee9%&_z &O݇b :NПJ Q6V*Ӌn??v(hM̢2B!BK~/mx<,_7^}393$j6}agЬd.]RPV]!qyexŗY9?ʵ6ǩj,BB!B4*pJ]7A4PnS_Jإw6[ //f-KP_! ۺ` RIpwiպ-S.B&,B!hȲLT<~/et~?3g[jxѾ}[:t"Ir \ 3\ 䧥+w&mw\= "Z!0p*NaX2B!B[!Y²,t] O;bUdT'@&my}9n 6J*꒤-l"$U$IS3 i!B!Dy<5, 4UCQ Mryq{g>uCOQ+Uko/9;EH N: fdiNNӰZUB!M94qb8Aqq)3~I~^vP[URi|9EDBb8\z|6*AtCDzLL+K eB!Muz kl$I 4Յ׫D^nX}ܜBlKQT0Y="' eDkq (XN:chG#/eB!M{m!B!-B!d3@' >峾&!ab*#v2%])-(lI4cmႮ~dZ9iY94#a$UK0< 2vaƫB!BbWdx8u /h!\ZlF-~iͪ$,CneK0iŕTdTt3g}UYm$L<$5ZMi!B!D%@[(Y/!OxZG L?69Tp}/i0r{|D9zB3vKl&J'}WzgOeCZFWNlKujB!įOkaT9n6G4yb v~Z= &TG b!ż1^9O®WW Y2Ժ|2B!B[C L͉@I^W@v"P m#-&:G.Zr~ RL~N5_l=V' F>uL-d D򠒔B!B4]RE&X}H8M'v2N饠ރG1BЫ&.;~"yEAҙǞ\?\_A%.AѴײ0e7d :*~ӒB!B4]Ԭ /LP6aay\$S,q^7+8-"r׍mm$X? ^%Og:@Q&f5-pVtH !B! (iJ]֙<+ϲII/bia oΔvnk\Uo7uo&qT<.wA'5)42a{ > B!B4P]N:(;v 5!Q:r 2mV \wt"XGu,d.rC yIo}~,8}r0G=N5%e<W!B!M8de4;e |)¾??8$ y_ձ|~+,b֙3 E;K )Z*p6mCƎ +p_Y2B!B&L\^Bmc*OD܅mģ9׌?}gv $k3{&ж] 㥭-W n<s \Gq B!BӼ9ejfZW%CgSsls~&<--HW㰜<18u.ҁ g)N;fuPoRt2dHM ߡO}[|\i[.=ESi ik^l6D>sFCovߛwteW悷gٟl~fw_~7bhk<&4O_֕F_@+&H!Bj oiGF~ . g*iهr\bJ%swʳ{CCˋt?/}zlg3%tݛ9IY#B!f '%:QP]Ab.>q#ywmhF:A5BaHarxE6|5g;gGI5n@rpEuhY8l½;T-=F#1gr S*6w%t?)b7t ں6>}$3Ν{/.c~N~"L;wٯ?s"cZZv_'FZƸ*Ft.u^%2ķmư0e#B!fn # 2dNշbͨ1Yn+uN6`)= ֭Ȥzr bYN%~y |NӁjXB(n^g&먊D T0ol+Ͱ^GpϪz~@+ X᭜2r}!A<ɒهܯlT/xfwdڣ;]?r=n1oadz) nn~}5k*~/Y ?>iNC7Xۑ_F"s+)B!o$@>=Dɭqxݬ\cllC"Vޞjnu vV|KR.SCP>G+ml;s <|,(&o夫7mW'\%8,ZXRFudϫʥj>`VTg۷p97٭ + 8r"$S-C=l۬X=UOƥv7&Q>IU!f5jl~f6xŁL8Ռ;<.?;DQvNTVJޑB!Fk9N0"jLP[50^ W\5T2Hh)ՌYl!k Vз!̜>w"($Ӭ0Yuʻz3xFQ^򬉻/5(Z_>}Uqʣ&e)=ql9JUfA5lޔl`9ࠃ4vQqǟ7h0wÄ_4}>YZv/]+jl'#'9)oGՁ[L}Ng*8CayG !BU!9nyDJ $2ԧH1L+ץ66,ME'ѥsgyN\:G~8 _zFURXB]v\.8‡ƒ7sFgeuܑG1V$ mLS`NJgzjC#΅j*p#p#d4F*u#̉yn6=s"xC]olwܹǂKن:H.aSvG̭fB!7 OuEY7//iMG3YiM 6Ǹ{[tlޜˆ5<6SNz L. #E0WpP7G·ߥ4QQK Nd1%hvom{BR1o*W7~;F 2ǢpqqlB.f-u>zɝhѨa|^ 7ŧҭ1<]_5Z1msw$!n~,swjlwN+ 03[ytY7lㆉyv+yG !BU]߰זp*j.d?V}CV'v׋&IF QXֆS? U+ijdYСTW7 qAvYMz}sn[,>xLq7<njGbsږIx(^3^$>NJ]l?.䱑8Gw}~y'Pس=Y1KF%ϭS 0dې4y5'ޥ3Y4z-^GG3q`Jc'ϥ\MI^&2#H!BRjwywܱ!Kg)(-@Bgw0}_}e-|=ECJӻg蚕hJ2)߸GF0ss/|LƉbcGs/aS$"hݕK~oP( f;KeE-p!A׮qV6FeNaaMmD%zݴi!ajњEfk)!o[VEpwnB!_N琞=wd3@84x_qAd4{=zfx,׎>Y=㷒X -DsqͣxS ߟ(;MsKaǗ5I *(ާ0E{L=]^o*tmj 6EU )B!L{Y?= ` 2lȇC #O|qNq8v&ޤ Ye(izu.? ]G0aaYU0NN/Qi!B!D%@$Q1 v K&@`xva/%(-|. W5[yr.Q֎; w֑J%4;6-x#RON֖B!B4]f(ZqcinBKG r/Q=%ƵbB⢫:'J6,yQ:Ń]]Ŧod░B!B4]v ylkpxU4=aWpf3Nr9<3֦] Υ iϻ3dUaTh,͒vd֫ghxq:,i!B!D%@ k~|pظ ~JP"y&|98⫨ó=;uE ĨC_VqXג2L S !B!DS$:~h;Mab9Îk;:kݱT %6k 5: ÝGXgV>{[P`gUS/#B!h| cyq!cA$7ֺe "XJHx;3_`Ӧ,ibYXN6:t2#-#-B!hP!Mj8Hi k>/Rt0:~L9I͏Z364l 3W̍ "qO̢nӻ,m̺4x) eB!M53}+5^ $YÁk⯨ꣻpgq`3:w܊9ӑ{QYeɪDfA]a)7[$L _ E`!#-B!hh+:B!B@!Bf >y~|K- .(bX:|N,lt.ŋC͋@m$ PT( G EK lӁBai3vB!5-`*lU6JlTI2Y ŴXCϦ}f||5c>k6$36:14͇dmPlPl͛ޖ !B!0TV4,P@ٶ׌zXC-H'8.B1lxUԥtfEubm6ʼnaTDdB!Mw9S!Q,L# \t{=OQ=dXJmOO:e6TaeP,`dB!M8E6>`5b>/vQ =7[7D7gM}}dŲ̚ZV/JNnk֓WߝOR706XXh<U!Bф hX I `c ́?A:U ?RIe:h شn5wL/rг1{>2~n T! md;!B!Mx e mb*ōf7c:ĥ'O1 vC8%ܹFhVXSh߮`Z.AA'#-B!h б1h)`e>sy9|84/D5Á\yյԬ"(֣jkbVCB9Xe)'B!h % PuB*G}T:%1~ŗ^-0%%a%6fm@qb l TGVFZ!Bф 8 NLll,,l裏f͊)ߺ嫖zhOb$7fy6Ӂ(Cnܤr9OW":,vOjCe{q6خ"ԙ. !B!;5 jiXńgglE-nnW7Zw` m׊h]GEd#%-J1ǟ8aTvYJ}[|\i[.=ESi iŧ<ݜk.?eK2v5E?O*ao7BKM5ڷ^9[dn6⛻cnկx{V PLyߥ߿Ƀwe;yu/t3OzItΓ r7^RpGH>=MG7鯊 IDAT]~|GLYͅaIa=w͸нwo>n$e#BtmOu*nq:yFvF"^G'7!uǶ,Z\)o)Ұ/P O]m\Q#yLݖJVHJͥ[v8E}ge,܅ǎ\sʚ@$)mͿ~aX_nFIAoY9CZw^QG2Kpy2`)PzF̂fm;LU9bѲEɎlFeo;#nJ<߶(S)oj_2[a|:LŦ9,rˎ8~o= G!z`"Cy 'd(/O>| yxwXl9vCNؾRM<Ԥg~>p`b`b6\geG?/m F>#:jmW~=~%o+awpjVkx'x\q9.>fv ǟ8GO\7A}jkyU/*4^;OxT~ g\8=߈e++#cO ם}vVY|!EWs6_fAMk[KN)ŌM{f2u 6nqsT$B *aӔu B!_Ħ?TQ`8 7MkFݬxx*73c<Na@*QlaE9e[2uTE"v i*N7i fX#gU=? ocExzVN9ӾѠ{dX|CLW}#qF啣Gre!7^x'o{Okؼps>G;?LK:xpN%ߌ vQq|!B/$@NՉ:,  L(CAY0獸s Ik~!TT4cyX_m(؆fC.VN~v|]=z'<-U3Zɢ%(ujTG2{'U:tܾ̀;ȹ'nMp]Y`ײ9lܶl|ו6+VDOՓF'q) M^`̠ vx }d =~.|}~]qǟ7Qèӳe7fr^W' qGL}Ng*8CaB!_H/dYPU, L<!c(.,]GtKfyt=/IuK׉f$m@]HGubo)KHTM'Э4o}[uan綷c3N۶mX48~eS;fcOnWjwlz?wȣ?uO6skq\x9mn6\0~u{oG,3ʦIm/M:ޒ-<5n8_!߼rz3qQſTGL_Kk$<9SCl\9{z F? āWٔ5ij8%s)L>uB!n_ޅb/}0Rye}^֭Z_O?2UAa΄̚n=$EhIC/AmN6n}{m{BR1o*W7~ ;F 21mXP..nXWŌ%=GBS `d+7L@l'<6ȳ[ɧB!P)P5-Kݢ뱜rHדUZBz#!#DT:a(3'Z_Mv(,L?,?_y`QY͍g1w귀eVt+;E':_vҜ){9Ɨ'p}Ztnw~A\۾x(^3^$>NJ]l?.䱑8Gw^@cj8cƿ:ʡ=LqJm M6r^R$ьcs<=V>sJ$UXfpŒw,KրH+:.^wQvʊZB]'l:0zt3g ^7E&ZCM /۳•W;]m=։TVcj~ 78h$VWC(,KV:9ҮEmUWn~*8B!M/sHϞlo +ƅ15@y_~A2vf:( NW/t $ȼf[S] L%׊ZG(q@,_<+! q`=.J;Cep”ŷjVױE qG柺=i4]68hK#WHppcWX$@B!%8 2*"\֖iSm6y6V^ sҡC;EK_BIS_]IN09C#?z[j> sPV$Y%Djגe ͽR_Mivb3ㅌ B!?`9p۳&JyÍ/dzq$dl:lIf=]Z >51 n&mc8A#JffrJYab;d)gcU;6mʶ=DU!B 8s 8~e lYx*5HqbP_҅ YaUƅ+#?bZ'nD$7YԆ%A3(B!HU5bı3UlݰW LYY|BZ{- qIp%_)89'w _6|c9}`k}bS֪M8V,C>-#-B!hKE&uŭd,$SHN/5\7L?Ŕw> Yxӏ~A~qiBQuCO):+:TtJFZ!Bt [Q0u;vjj7W߲%DRTPƮYїhMAi'OQNl`OЪbF-j:$*qYXJT+ !B!2 nUE\$&dPd1WlBVn7UTVEYr-ťE,_e琍E)P@2Ʀ$jbXJ*`8 !B!2jݪu0´͆4ڊj2nU3)zHGJ RzŠb+瀩8YrKWPkNLS[Amt|i!B!D%@vaXTV695,'e[xo Zg4vA" 4tn!L4l ƭ:pn4%[p!H !B!.zm!B!*!B!Bhy'mMAQllĩq8\ضih(Xm\Ķl@Q-6ڷ׭if-m!B!{wfGu>p;rm]f#WbHp(4i)BZ P A$B@וlݫ#džmy<ّ3g{ :g#W8BLtI 41MT҃+ [Wʃtܤn&4mC.R땚B!Btf>N֫5rx4wr\׵I$\LEM4:)0 ²ڃM44p qu'B!;@A[Q takJi(\RlWL5QO7jxl;8RB!B (M>ڒ~bb1I͠Ll ʃ0]Pׇ*IT&rp8:ض*i!B!DO. ܆梔gb*I0#EqiL UPWc"4 ~o8RB!B3@۹ !q'_rHQ~<}lMSD7\ڹ=Xt96 u74\$B!BN 4em棛)R '2>O cţ8ፑ#1x Ax/1"a233R <MjZ!BAqT ۵P*ןBMj+11p+ej=/cxxb'(W3Ynj0tX/:Cq !B!D'@@GI(! `[ ٕYJ[}>.te@);k(Wf-44֚47G0}1 }f%dgP1E>7muʐѰn-3_Mj2jٹWeQU]#Bx6n+Ԥ?No?ϧ$񤟬Bj0s Jrԕt-I}]'%֖u,[Md۬_F -kpR GZ`yǘlYFĮ?ylzl ;>,TCɿA.꙱L/d&rzPqQnK P:C쾧2ӹԅr6N߾S~Nicns!S9 ӱ)L]UXupD^x[ 9s`J}iŧmH9zޞ`\pt lޗ?'\{Z¤U[m> a?aż!{ZAD1Yy~?7giAX!c%;À!x<>,sYUB.]9N-*i=5j)..$;σe4׋Y?7!Lu|RVrɼ<0qbaǎ9[٭tj?[gDP0bJ{\(,#eQN0+HK1pd>N"a;Op8~3V*-Vͷ(>RrX< O47j {3{Uu銱>+69vWS8q:v+ .rL (ߝs$k rJ(J7wƋhu}Gq0ʹ~vy~** vv*hҒ]:&ɴY~w#^'a^%+Ql߾,].%ٱl2p% '˲7KcWxnQ^mwk*Y X6\,ϺCnQ7voZ?jghğׇʵPflĞ(^???Lp{m_Tyk`P؎om4 kX)o,wz]43htIVD/Jz[g$R<%GqF<,o6u\9Q./j/k94`ӂLp+/r?FbwЏH4/B!J~?J FA$]֗'\m@xtP>233kAV!TFμ7wNf/kW5b9TF?8מt<%^,.4׼9n>o[汧9gð/=DUc٫wviJrI<˧s9{JYCxa꽔 yuLY!t/,>-_ n'Z؇<#JE~ܼo6Ӻ)!NӟᕋtN¨Hpq|ׅ3 sޝu*KC,=ғ r<+8Ty6~{@BØ_ɥkN;9Z9rt.O/̙=w|ZvWgDO^er˾nMAxgJzv |l Wp S/q4~{f^^W>_.Uv2z^<qx ؋g_N }nqΥS?w 70=;vm%Էs8G(1`xmxC,͖_"jsFQ wS[9<ȤqW3^잾~u!'aQJ)uȽȱ%TƮƍ.~aqfܨu ۮRnR:|?UާL'{|;Yv\'9T]II 5a-+3Ԝ[Qvz壔R/Wrۛ?HU$이mlqٓaWU{-ݽQikRJ< ucKTi3jNVw[{mUoCƪYum~8qu!:ͿCv,󉛵eܢ\ZxIjđo*jjW6U+?[8mCVYKx{v!bOj[N=+?G{2hX mM-yMbQ_7IoɷGA9DZqhv9R٢_W+1}-˗ԓ[@@4i{H.јM^q Չxqi!*i? =숎o5w4O[-x_{y+R_%&s8W.dxPN DLkNqDz } })#xv>o#ΛC[HٺUθ_8$ в%?NEDzCO? M⸽[Sv\sw> oh= fS1hjH{2ϸG6 mvcYf;,;l;^mkx1I [:RV=9_5rIeiXJdcaYmVn8I0$o{me{1 c.Pޱ~smv?\A</ p8ubSmɌ{3.ԏnGyB!~j6)H^Q%]IXIZRXL2IaB(I8\ gZ.u$.4kyZ/SXA$bԄat|Lvw鳼gJ;$V!7 46kэt 6Yo؉kM(爃ow$ϣг{.<~??=s8X>3}8M,N#m-7w zpڭyi#)mwe WQo0K9Oh3ޞcLf3DM܍יִ燤Yodl=3bfzL>V_R%cD"sz^(^̯Y1i_7W=8o&i;m+IVl蛛|z/`pe޺q<5#.q%s*`y2{w[<~u;ʋB_<&xvID@aSΎEVF'7R&mI 7(-EV/b67нXf)m'/zێk[\);oU9l y0}pVN>vjXq*Mg@P6zmgXm cVV&vѿ9ʓ!ze<ߔ'c w9]pײr=ֶɟy^x.mַg6ׯw\ c蟷cI÷skgwlYff7ʉlb%cc/Qr(hv#ǖ^\sY:bqx|n|.Q^BZ %y/L~n!f",QcQ֣p$g g;C^ ztˍ{nJk_恌u~:*1\XğsfzS_oXc0MX$IZ48D#l?faY⊻&D hu[G%~7Ny}هst~kxc5LkK&0\~/qbWW':>?fi'W6;gfDnzj|9f,nŵqZͳ>|NJ.`EC= 3vd.v,[Y;;[>?ΦW~۲ǩ0e}'6WQ.ΤgVARrjcfoݦGo3vVşs$c3\? УmC'4Ҹi#kOZ7n}u{gfgHz3 AآGn͆hݎ"B:PiO̧H bnNc[D`z}VY`2ń1 ><3/(g}IIqma ʠgYɨ[~뺄Ҽ|T>iq ;G~^xLaM!t/^ )2≌z!t7Г~q- ;4r2kdm2]M/= QSpSS_|??JKd;__^v C ?ofZ[e!ٚ{ 1x2F=qyΝys Lqթhj F_ep}ב49 = ^㷜Tp=oG2H?ܣǡ@Wn1}y=u;9Qg\[/}s^ގoK̃FM{)w1[suksSocfU/Ȃ˺O BExm3雹px1tbc^ڱW=sK?n9'9>ϿgVr3'r?~Gwh^BKkn ooЃڳGB چRl<,>GA~dlbŨ1MaI6THX[ӗPw(ī1kF%Vh#mLㅗ4l"ʥov>W n!YDS&+?iu-9ohGDise4=]aV)'z3/DK"G&C[jYfl@hk1︝[?NoLPWJzA>]=rhkOh;e?47#OʋB6#@ΗO\+]wk u #\Ovv'i>N9}?,++3Ͽiݴl}J:|L|#-Q!BciC}3|/MfJ s]9yL0 WPIfT!Y>cѼѰ!քeҳz"sb4Uyu>$@(CGXuZ1vjaϾ"]Gq*Zڶ|nǍ B!7hWQܷ#ČGVWp%IihƒƆr虭QL\ZN_m#`ཷ|a3TqJ9S 'f0a-LzG&B!L4tA7򻗒]RH^I޾][]xhglDXIҷ rX#.Yj(Z@nF702mnחf0l@GMoA7呚B!Bt ES Ê%ieSһJ >xg..u~rEN3_.;4o~:| |}z&_ϞGÆ1fz,鎇˗v=|LT]+  |7O!B!(<$/m[A9B}Ny_/55Sp4rt2X]N^^u464,[G%-D@(ߛGyrַH|/YeQ.2 B![I%.WZ(+Hx],XU0 Ҳb2gXɲk!r ZIM]rߵ=X[E i0k=s97[IveO׊.C (?iΝ6a\|Jn9k/@ūxb3S+/?oշ?!JnA>tC'pxטq7a Kϥ['B!:] s R)x2HR75``*E0PAau]%عAY݋YXK 6U1G #Pu{| Ync%ԩaNG&$g^>=#W So \sډ闣v| ~1g]^L%ywV99[{ڭuo2)pA&m|yܢWwm0YɰDz~[Ei34MY !BQntXaقؤ]zO2V**k~Z㬡t3HY|҄/`` {--OXj5u0YGNKo_&JϏ,$Pns͝+OKxcN弍'ϯ=}n~(&}~9HS=zt.mN{qmfdml|vqY>}wOfVm]=c) }K: SIkpg\p<gq5zzc/^9fl@6ٕ?:}B!8R0ǀ]gm֐Qݗ?Ace5C8QhaOߟ|eG…'Ge>"niAyob*jrp tiڅtQZ#9AE:%cؐ$> c.Pޱ^玃DLkN>x=#}m0ܓ)Hŷ>en6Pv84_Lx_Ep=Ti ?~~,;qv(Ųe˱aX4T.Vqܸ>!BUJ2qie&>اa~j)^OK} ߭_d,`-H*_˜(20pxώRAi &lK֎.` 3XrNQ܏'Уvb9h>m9ۆ1)fxLDarbDwѐlo䤑cOiѺ7Χ& Q ,H5:0[lp]@oYq;l_'ltn6J F9Nǔߔ;" IDATVb`(m (_q3sh(Wz?]ãם%/5^\sYXs\s4|ce[Lٝ7`1~9;Ghݎ P M7 {C=E))@ ׊FG,Pͳ>˟s$c3\?aCv:3B\q4bv0VKhhn3vd.v9O/cp˯;3;>@zxt4a0Ǟ݃OXYH~LnJ1}z@ 8<55 E̋WPDYu:X68#gTp=oG2=lᾧo W0\]Nyi77L-#GOaNs7OO=BŸrsh+ޜCy\ujgB)_? j>ΛHEϢ>>Y _1wagO/]=\t/t<>!BVof==iiPr lrM,Ӌa%5ki"B+]/%-hcZh%r~uoģan:>\<8hU9ֆZ,ozCC]|Bm\K6'$ʢRޱjSDvښ9lvTii"mnAvp˨ӥoVA.=O!B}F u\YPh(@@sAp]fpc5}D&"'fHaGt--!#àMɡCy1P\=wy;XkWP}9gl+WXbOFI :K('6e|wrB!ߪ~wMfefi6y Z~ړcRdx>lf--1ze124ӏJRֻ!EPQz'XhRB!B !h' G`Է5M󳴺Bn>' 䭢1J7pQ !69b PvqPRB!B vǁjJN"`(241cc޴Xoaj^I!B!N)qqHƠK &7=&U#`+JMRb̮**5)B!)cxp-ڪ:zw/Fkk7GpicS[i^"B!Bvt<\VMIZF=գA$KI4&wp[jRB!BO\ ?ZQ/3֦zWeEhXB!B? $ zh K ' ?xx#+AAi`;?D">nt0ɔS T+vUjR!B)%/B!B{RB!BvSNaݺ*R)4&AquЕu* \\ W|X G3Pw4X.k5=˯Hm !B!P稪¶m^?@>h^ dx 0|ihF2'phs5") 4t.^a%S$,D2BMeԴB!D"1ihX6za D #afh.Ɵc*$ V%4KX6J)J~ȝ3p8勉YAqx;.OrQ]OoW,{I3Z> 0Gx{Z]r-`HOB!:P(w&Gy5ZLdx"A2Nňd\=d-l'뤸^1D$n%H$-)xfkNfw!oa?7njygNnkŌ=Oޜ3nwaVsnқX `D` Dbh1%"E7hXIw~\\ T~>繏{瞙3g{q@ꞔoU.$-^+f>œo0S7j rqI|SB!. ح\b)puPi< <{ RZ (`dY^^ݍ>UMY\<\(\ V[Ex+^ϲU5|ޫ6ZQԬ^CзA9zŁqW~{ج.mK>fY`{(7x ޼cҜg9qF.}z ׼X<mC"B!unkuF 4,rX6vrڹ q]̬:2LGx}˸ltGXes#Dn2/Pf3̼:9Lr헧^?}ͣLLuʼnmmvk[cG;䎩q,.,(,Ի/ׁ?S.6ݱ 軰 P'3-G9VMfIG]ɇc M3~Hn^t3nX&̳&s<9s40o>~s5i yb`6hU/!B!з<qt7v4\ByAF WxXE =<4P;= zEڥ,`brAKi!'0eҠ-f/~߼,]s>~Q{SZ6߅K8 G׌@tN/^,?8*)[(1r.DZoqL.+oB!H. I`=.$|<x.MjtcŲuv07 o|u$w^W#%m_ WnZ{Σs˶[i24eF2:U~@&Z-!B_FZ lea._^nd;pI%:^[zZG;1q~ a 4*R°GϘNigqV':CFE"a7rO?a7/g>~qNM1k&<{?0gx)Re*q[}vrY?gcAثe帅B!OZZ۶$9s8{DE(命WgZ}wd9-xE# tSjN*oo}<0'Nd:wJ1>/ .ܝ//,V.cܗK!/øA]Sxc/ߢ3f6%%MYu4T|G\Z65!B8o6Ç *Fl=- SGǟ{ n[Aq4/ y8ܗrmU16;ׇeQ6!c+ mnƪΥjʷF!B4Cg {i,J/)ڕW\(/|6XQ*}yiheRKO !B!p Ҩ`h~cgWx(rzl#CCu<][?NS0S^1nPU/JX5XA!B&xx4_|_9ac^x \q-7iW^-joz0rpΗs|1o%\B!B.22q<6cBɸRQè:c>|Ԡlj``(l<@WP`Awb84ɵٰvBnHO !B!.4ݰQضM*Oy_1 Ih@t4(W(CM<5CR xd'5MtA3+4dB!  P]â=9e?͏+8!1eaE\ƣ0GY(NA6-<+ 7h P 9l:B!Bb M3i>_0bл,J4/bҷ4L?[yhMJ`Z)2bicejQ:xBtO !B!D@Ŭ[(墡@9؆jGY0GARF?M8S[guK(/@neyp<PxGqqB!~)F<@y.TnvKaz W-5d5 Ϗ9/W6ťGzZ!BuߞAZ[!BJ&B!BH|) |h/c#B+|AS>EЧAF :Q]t* XA"j(zqؓB!]CkKMi(öm<.N: \r/mcXq/P Qqs ,l<Jpu |B!B I\\tdA<|n < a ( CzƏY[d+.v:)=-B!@Gay< <3`@? ޛҊBb:hBY CˠT2<\,<°\|R8n%xXxX@zZ!BuPJvl{MJ9>=*(9-5 SI6Y#kp&ק r,JB:F $\JMC3iGNS5B!B.|. ͦAѣVp[ -GeI?>yxJ'844e]5Au\]sY3*{HhB!Bt]zަ'5}z# ױ>!VPv#NV!MyA"dwg}A0O2벶!M(#behPKB!B 0q\ (GRܿvt84PPPEjRn+I2oy70 {=uu,Y8-M&=-B!eC0nBe G9x)}60j:t#Z&_̝-??&H.Y nFnQ0qZ3L^QpLfMm 2kkiuf%ԵeH{Hup< !BFl=(/._؉F PׁJGYP)pc,YFs`պ4p:ʴT n׿Zj2RUҸxkkڨkl$n 2NdȒ-Wa T.?X=7ᤖp1g*܋B;㯼Lyn>J~y'ݰgOҿ͑e:ZR[zy'ti뿧X>|6m: $|zJZݐ住U3_̯#/a}m:.a=~ָUk]oX{3`e{^ݓ'c1sq0ǸnbWQ=~L&Z.w_ׇdV}{{GZ>y?/ܥ}ө\B5W9ɤ\B!p87y5hn'kt- O{.ɒU #m5bY*D:ц]Gu0/HvzEeZz!^}0Ǭ@qjV!PREY'^ay竹|wmEW!2ó;m0:OےYm֣svҠ֐_i_.m ^ljԶ;W38`їiw w0yƖվ ,ˀҭ]i3./fPI'UϺF IDAT;`TTQdh;ҵt\tP;>k|Cpq,ykⶡrB!wq%4c)K]l#婗2:321˾8qݞ{?z6k[gp",nO``CQGnp1?e|x4o\M>OآՇҟ==TkNC s{qEӨ>pTϣ÷iyܿ9y^s6fҜ/2p91׽-_DOǘ.Oa C|o!? $B!p, ͵16b> kdP =3w= t׵Ѣ t4,;S$qdZXW CC[Mxq'3-T^͒*ǔfܼ?,_7; x_xmvf@މO9t95b'Ggs7RuSL?m +_?x}1Kg)rɟNΒ{lnY{?`ɀTCW_9 #9"OGa[6ͽk>o`swR_Zbͪ;B!Ŀ{\ V2CApl4aCk>̙WՓ#WH$XgB|?[f(Pzt6mm l+ N$w' "-l9Ec뙿r- *LƝ.gӫre ߭L7͖,wE DP+.wᷯߙcGMH&;g62&x&coy-zBE'Q>N g0:Wx?nI;5f*yW .{|΅?_[mJ `]N1qe'.~:ґבjq}Qn[:Z~v_$]rPͯ;B!Ŀ}懎 F2:DB~֠M˙&p2k;wٗ18-ԥ1crB!wpM uGcpuҎ¼JVZ8ۻ=Mt0oAzKPOHH5hpiiqXi`nWuٍ9ٟ9f\Sn.,i9'\㒃X=Wu@M\ag%ǃr;tzl~z=}(S}6^紳$isxsYq:ҫsăʘ*c"Udq/V<Ԏ#=/z2%IƟWȣmOh,vrk|s.rB!llµ2HfK6q}&zQZZ||z21,Tw;ܯY]k"Y&lhs47摻Y4}Y)@Q<ߴ"x{#4?HN߿Hr㮠•[뤖q9.9ZZZo<ϜGom\&LvRMt7.I2eC_1 iyrn(Wߔ×adBY@7V`g?YOt/.p?o`D*|N뻣˼# &|}UW=9_+;xjܹB!.2]T"ekdQ>^qM E5fEêEjQmz>E,Z,Q]Ճ H;zxk|x r]ApȨYc?Y0lE/i)3qͤ_wRpuO^[4}u+ooH½q{<?>u/JK1X1 v":ZN;LƇM 'P|YчdGn\AGpQ.=-B!E0~GCs,m|T/een=h~`H PBsRT ̬e| TFkahi+sH'TB!QlECs\t8Cs[+z,D;Zyݵ]atbeĊ'\Yٟт&[ vm+kW|8%7˭E{TAB!@ۓгXSضR6N}*dKtuy5) ] ba4 ;|:d7FYv= d0є2B!B 2*y(r8 ym2 lٴ4[xf -V\E͊5v\p=s#Q:h& G1 pgC~4#7fb*?*{0bHi!B!D=B!BҤ B!??G"$kQ64?: ]3m/e9 ,Ea7!I4idM tB) . (//B!BFΝKmm--mG=1?W_E6A[Kt&ڐJZ؎"Hd]<-ic)?i i Bs]{1$IZ[H$f i!B!D@j1M?H:m1ʫ[H)U H)h1R"ETUc)?X)hA0VH,Vښ?m݊@MzZ!BuP$!͒Τq" `Am +@A#D*"+ 1"<(zC3CcB ut±"ޛ~\r Cu\m*B!+O8~~>N C7KY[Hej(F @8`0g D"NQA%E,^t&KQi%mio?H:mch:7t].'=-B!U5Dp$>ϾLG> 0F0M(n6BC+(04`g5իV0|pJX~=HO !B!nҫ'1fZ"(#o 34LC8?{WU"MX** "\s/II4R ۶B!]8oe|D:[^yr ` L,O^4H$"c~"ʹSQ>[ygLWyP]]B!]AYYYŶ)IBPDYb5eZ躎y8.ұ]Lf8Ɗ1Igd2NB!BtqdLԬ%6z4dpl#A|LsSGQJa~$Jth05ôړ=EywMX[QVNmkB!uD"AKK Lu1x^?t|1amM,]4E#`v.2L PʪD"$| чrrJ*zrtÇa'2~d0o~cv/?X=7mmy{O1c~]x[pSh_W~eVe sotWƥ@a!>;)='Lc y|zYMj/~|YTu3!BH{ Yx_/Y\=wΛo[^RVVFyy#GKUU1|>pG*I&|~V,_nXTQ9_a=wVК}v9JZO3v4&?2{uOB{> z@NMտ1>YN>'&~ȶ̣C?4N>V (NL&(k[/2r.:w0 yUfLH.sԏ8|ŸE|:(`MrL(?{_<;)B!PJʺlw,_}9}o.'*++8p 83e9f64յM?]1|g =U3{ø׷Ph^)Ǯs:XbLVu58ŕnpRktѽ|SQSQQEvΕ}SϺ~4(5Tuk[üE bHl&̪9_08ddsc9ߪl|0Cn|Gu_q8^i9,Sʎtl{ 7-.fVyキlN>aiƏvuEyg)3Fa C߄K߷X !BH;,eXi/?? ?/>~}M<; K&ힼt`QPs7{He3~j޻~l9j-e]kdXp<~ĦG/^ 3ϑi[;>ǿ~aT+>>F{.w\{&4Cz}'V2K~UNp9?~Vg51ټA1f˝A!Bp(y8ynƣQSSa~,"͢:8Hbi0 ~?T 0ȏhISxaևTD*ӁeQos2 [,H5qNߙkTƃ*rwo?!Yˉ6>R ;߅OgßR:m<'_Ww`?C:HS,N[@M8Bδ=|)kQ!W==؞ǒ%KRmhY퍓޺!<+ 'sgӁI՝OXCc6\>z?kU#sOb.gn/gpk0A)si~ܔB!$=ҰfG!{ڛo1cyч $R ,~?a6HxD3cs#>\rA?xh.l1Azw %͉Tl{^| {h9g^o.}θsɘ#oFǚE2uiXB!Ku.v*tdR{`.YΪk(47ymۄB!4p0H&$ |>lh4J}s+oE`xן$ xN)3sY;bVxupa*sl_6E@\i͹=t8cW"`\'̦ٙQY^frtsZ˳=)N_2F&_-˴` rAIb'6~DrgB!hGa|02(4dYb|tVSvyrn(W\~˰b2,[?YOt/.=u^O·ㅭżyϺķ@84|U IDAT1|2*:l6۹e[{,oش(EYܱwa.󟸀},gL5qfsZf/\q?6ƞ 'YwpB<`F2:U~@&Z-w!BR]BkXy=٬K:Ձ tkut:뺄a,tnM0 lP(8A4]3I3TFXr%v RLIS:S/Ũ)=9;'A>W~V3#GOT^24U8Kʅ0j .^A߾}(H&.~bYV.8&L|tttupOmLG}hɴ4Q,[1w]K6GiaKu |%S~§ 擯nnVBtV|f9m^y$mnjw4Ӯbw=Fhn)--ܥ`7F|ńMM B!xmUz vuhomC9.xs4h~!&L3wޡu㸮ݛ?q1rHz~?Tt:M^^"d2{Z||ۚQTZF豻R^Z9eƪ(hA TH ~"%T|'5(()G!]\{{; L@KpYhgy&mhJo/s3N`ҥxC~~>+W#keє#SUQڊe[jmdSV>y\0Cb:Vw0_J!B +-`N?nÍCs(66{epaX5{Ye8U'B!Bf 1p5p%.ۢpN*KyILLW^8ΚiIIItԉ|B HkG1Uyix}IRVL,=pDB!YÕ0!32Xx ?3g Y:Ov !֬_I];3u-cGcSX{|>?@ͶQ,SaZ:NKZZ!BQ{s4nhJ;?XϨ td.h4eYtj~)w >B‘&N'$n#'z8iD+P1B!Bb@EU9p^/[!X[ɬp^shР>nDl4lmvlTV( a`QFU MZZ!BQ{P6<Jsv "TL3q:5nɴiOiG\\`Z^BE:V4lЌn)Ԝk}. YYצB!y#-B!CRB!B/jHP\h,61.F 냨08TLrl 005lEgM~(4 cXB!ZAU3@11quP n/h.Oq<.4L9݄17&>3fQDН(`)XHC !B!j1Rq*QŶ@@S5Upj@8lBS#((>NKOEhDq8ø*:NGMO4)#B!T( ՝8͍Dp@b  AFRZ*QnB^Ъi~XXv(q誎SQQ>n/`IK !B!j/\6(6n²*(!Oc  jˈ#PBsXu ,p;ܯlÌFQI6B!qBE#TaP-H c %bF0 xBtkU?'?8J \*@YO)l? B!@m40DUt@Ѯq{RNG vd<Ϯ߱9-(I!і/o9 g, bqE4B4D4$B!Bbqe3e7AN_yʘSWDرxIDK7:mڗ~$%*B;[mqJ&^Ջ pƴ-4ii!B!D@\xc>¾Z(׈ǘ*O=5|b1"Md hI4M)UBј\Xg&Y_l$|b QCEơ4HK !B!j/27nX,0}z| w( TwJbW bH8 WTa&%J VD_JcϏG]dh7a>Q\8MPB!9} AF$_e<`RKJB8C%=jG !h8Gֺ"ӨH) %jaZ Ă8 *ٽGfIB!(@#ĭX6D=niB UD"ٴV jO/eUJf4eU d'n> D1pإ\lޟ't||;ˑOQfyE?l" c{sߞ}KkP e3fN< ~ѥP7}16斫[3m[ysgt͕.ܗ_%x IjܖK&ǭt3|,XV bxeB!~ZDQ@vb:\XzXۃ&u{pZPÔF H #2p{ >\1PI\( w4G TlD7ʋ]!wΛɬ)9 3zP9]:g_ٻnZ^㕫q߂B:| 9JӹrKζCu/[B5LJa"ڜtmoѫϭ͚̘ӮkW?oq-)B{ ħvxpTfLPT+@ vg/"DM7gp l &`%8-`kg>RbFj/{e'R+zX[YhVǿܢ]$îɪ x3Ѻqudذz#Ֆm[y|j}jvWf5y=Z5YĖ$oS94*M-NpfTftWš^>jZه&IwҐKऱ2-lF|9uI`ԩ_InhwvdqdGp(-L&c_.Ģ~U~>:98.g5ڳ"u;Ҽл'kv]<4>"FkG5gHiyA7S1`@_uoYc?2Y\xbg\UCK!a'ck =.ĂX649,eAғQ\OrH#s_V [:@Ϡ4XBKxigcRMTpJިV6OeҿvdͲ՜tó<~mn߇Wyy4Ήcd{zPm^v7Vöd|Ygg0} QX"EeǦd\3/l||?Ciq\#(Yf=ߟJCb[ v7fiAvwer<.:gӎy7&s0,ۊpǧ9畛?4;&vKxLp}vMaFvr9*v;Y۫ xq\4l2t{T7NO, ͼ?r9ݙt\~=Wot/} 6%Zo·é91?{a&ɮCU+]MJs9s;ҹ ˷/Mch5k;H$B b(j',ې ,btnoLlacgꉷA̻AD\PYE;}|g7ѝ"FEDǞCRlS?sǮ ;4`M" r"mn2.kWsUL]:fwaJ&͋CN +Xx_x_Fnҧ U4y`yԺsS_ҙu<3?H1k tS_Ҍşvam y8b$47sIٸ: ϮI}=WK+%Fnmɤ_$VB!(Æ"/Qakw Glsӛ {g#Z"%/I7 i4 lձ(Dqܴbyf4HUևLkAS U2I"܋~1zl_~M?'=ċ9}_o}i,Y2k5~c; 7 #r)W4^~$Yg\Y8XmA85JKӿfqCթ)cL/E{޷?l3CTjw靯 Tt-{/fg72?+8BL_NƉwPvv&S'GCTW 3c~9hiϿ DkɝWXCr1`$Ch®_tMyuTVXB!JPS[EqEK$tc z;{b%;x{Hcly;zŬmO¦{5p*4O\Ȥ<"0BUUT#zbf|9wpAm\]&^Ԉ1/G~̿:?!*`qAhTkkm3EY 8i6pxɤtǤGM:O9d$';fhM΃͠h|<_sOoxH9}-QVCxeP'#Ը?-KFh}~sqQDb^et8cvw9a & `WdqYڃ/73!EB!Jxq u Zb`AKӡ/`ŁG&;;Gwl34Tj~Rra<'ydl. T*aܿK fe]Dz,(?ZEpÎN{l9&;j~  fe]SBs W_K_{|+͔%,],YE^?LT|4FӎלJ?Kh 8xzO:frN^9슙7GhR2{e˷B!v4n`k>, 15Hۃlj&K.!4i޸\ M<ǙT60Px0𐬔HJ"Vi8S(0K\ ]F?})<஝,EzMօX0w'e3S}O.όCaKIʚ:b"À{yf('lz{居;q'Ņt;-l镬8콏|r^)_bw6t!5 tIp{[OtlFѢhhrUvϟǑFȮ 8 m'߃7kV0Sr=Ic֚={X/,^lz]>[8ߋ7$x>[89-B߽?+1UkHfTKؖޛ8ؗz`m%B; ,EaJfa C3(;9-lC!m 3U\F"QR<5 *Er|T~C3#<{PmWҜSgl7Q;37⽓I~& 4F< -ǽ4pOVM$S?SwH@كqC\pp^O4D &PusヤiGpz4(/]s`ZQCx551s\_F <{ӈy&sb[ARFymʢ~.nȭ[~_[Ibt칷5j\ytŨ25%lB!CvX~ <7#C01vТ]ĖIJ#lDH&Q1ʖ6Ƿ7qb.`ƂXv դu0}ȟK"^*Pr'˱Fe&unA{\ҹ=I\UWU~eT.2R#ذuJJ182UU%{]I&~(`"3bѪ2BIxc%\UFeRRpt;Fr)Ļ|bU褧pUcѫ (qfQHC(E8H:_*PVLϯhF)* FpyYJJ#Ω7B!į_бCCZ+fa:VDbJ:V :Tv9>;ūQaBrEcAQJ6 8*0RJUvlH#/ϚS y5hdyOy㯟q؍AmP=dd_)d6tu:F iG_:.%KB u~{R5 vu{Q]de*DZ8r/Jpp~)fp4w7 M`ٸj|B!+L.:󲁒OZy))9ٲՈm^T#cFfӻKitq-qi^oTB!B9,7-k%~vt 2~4ݰc dWQL?6B!U_kބCLѣ q~]f 0#EP(q`@TZS!BpUapT[pWi85Ia7AG5˲شMšE U0,iI!B!_'.l ѕ8JV@P]D #ą H:P,)B!8?"!ӇIXhO8 b1/QKEN ~B!R ".@vbx8"vTOBB@mk}k_ !B!ğ$m[j[!BR B!_Zy}fjwEQp薎pؠ¶` bYXv*6N3׭b:.4ŠE\17nۍmKu}im!B!$+[NɞbXa`A *>h pɪIa&mNҜe|:t@ݰk2 vGDZZ!BQ{PE^lB4beX8¸t :N=F0KGQMv^dP]P 4jRZ6`)0L qn(ܾWZZ!BQ{$(JM8P ap˕i0О{S cIIӼSXm:FL/`O5/FQ,4M0`!zB!bQt  G&<`dw_4U!Ua\xUwdQ^^ŶY.U4؀@\)--B!Ullb{{ 8i3V;s.\z~O \1\|i>N ii!B!D@i(tb@Qi 6 T"aamN-"VKήOiE֕ٳbFa% FJ+ΒRb*6hNih!B!D-@nX@aHTn[^*׭f϶pm{^}n%h<ư|t 3ƊDU`ڊ_0:. %pZn]3kJ1r3\;9_Ƽ$f{sn^oן/+sfqaf㉝sU)iOB! Y-!Gq~@9,>=]7 #ϢcϺYT' .˕<8>ǟ}=?)Y HMb\u,TTWkc5q5-k%klgSIwҿMg v(`ٲ y!pDvN|k q 8vG 0 (a~Pricj2dj3d<M/=Rm^v7Vöd|Ygg0} o3qX:.y+~ρ+s3u ^߇Wyy4Ήcd{z0﹚8^bkWlFN89?5`.CPK,߾4mS׬oYv4$B!(Ķ ɠEv!kn­_!K˨*M,6LB1 f[)(*' |[Sn߉.bȯǰ{&>M" r"m_֖=6A7=|XFu:vU3A.@;0w!q̈́,3=/g)/̺[e95IK&Wxlvz Aޠ5-5c0yxhTm{O$7/|ѐ?@ Oݩָvt;zqu$]Qz~WJ0V @ʧ?ž7?bnzn]NG8yXq T/B9M!Bߢ*PZZL(@ 6 om^}8=_#PPR 7TG=LfC'/OxOL?iO F)??wcvssDPs5!n(oԤ5GMB!ⷨ TÄrӍk9a}SSr,=YI9XNe;uǨ./>@ Fh]?H@$J԰6hLxg+zl_˂x23B)ɮ/͑vhTi Yj.=.ųO }N&s?&=rm\ܖيIw,S4hl rpV"R\hYM/L|B lEGaÃ/C!S[f}.Y^knfe]SBs W_K_{HN{l9&e28_K2[&wNpAhT)~ ϛ&2Bqtl+̠|w=ՅV9cg״wP+/!U\Nxe(j/vL[~y)&B!oQkC"0Ϭ{bJխO3s^^{wRIЀ y)1 A$4bh^'!;FTH Xz{͛mȕ;,.!oU:Kdzp [Jjz4ouJռh-yZrYxLv5Z<}z?#_(7Byy9TL ocKPcn 0|p !B[8+?Nj{ػ=8(XFZЪs֮zI,eɂ7ywrMO?Nߐ_oDˋ/Cu4FYԢ܄Q:Lj~;)]ϦaqwQYwy7NSLT)/79YS֣QGy{inwTHR;y׾h-u(gq34xŏN{טOcԠ&-|ƯrO1 `[9VXU'70u`0ËQekKI5M!B_K)edQb1o@m%B!>D_wMmSO<Ή]N؟ֹS':wsOG̗dYj+#ĥ֡ H @LlnQxV/}ū~2 qN?gXݬ/%sWB(sB!nsq\<7o-?䜫NVf&o}m˰&WoNQȜ+rGt]g[Qɓ9$݌=$kF1w-fxCnzNr?x?.'t>K/ʔrх_lǜ=#^]Lގ]DDZlS9^TP@rvve>7ѐ# q&-rS' eTn&tW*mZ5ơhX,1p:%6mR7'Wؿ/ ~f#)cw`a{O=jAzB!WϞsƔMu CB!r1Bni˖/'  Yd =>[&.9_v0FaOӎ I,|YgaQ!$Pʍ"Us`ӳ   @ߒv?`H)yŝmcrKcESױs;u1-}nQeTUev@|)aq|6ȕ6yEQDYeWYI&@/xvx)հIENDB`protontricks-1.12.0/pyproject.toml000066400000000000000000000002731467175317500172720ustar00rootroot00000000000000[build-system] requires = [ "setuptools", "wheel", "setuptools-scm" ] build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "src/protontricks/_version.py" protontricks-1.12.0/requirements.txt000066400000000000000000000000201467175317500176300ustar00rootroot00000000000000vdf==3.4 Pillow protontricks-1.12.0/requirements_dev.txt000066400000000000000000000000541467175317500204750ustar00rootroot00000000000000pytest>=6.0 pytest-cov>=2.10 setuptools-scm protontricks-1.12.0/setup.cfg000066400000000000000000000030521467175317500161750ustar00rootroot00000000000000[metadata] name = protontricks description = A simple wrapper for running Winetricks commands for Proton-enabled games. long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/Matoking/protontricks author = Janne Pulkkinen author_email = janne.pulkkinen@protonmail.com license = GPL3 license_files = LICENSE platforms = linux classifiers = License :: OSI Approved :: GNU General Public License v3 (GPLv3) Topic :: Utilities Programming Language :: Python Programming Language :: Python :: 3 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 [options] packages = find_namespace: package_dir = = src include_package_data = True install_requires = setuptools # Required for pkg_resources vdf>=3.2 Pillow setup_requires = setuptools-scm python_requires = >=3.6 [options.packages.find] where = src [options.package_data] protontricks.data = * [options.entry_points] console_scripts = protontricks = protontricks.cli.main:cli protontricks-launch = protontricks.cli.launch:cli protontricks-desktop-install = protontricks.cli.desktop_install:cli [options.data_files] share/applications = src/protontricks/data/share/applications/protontricks.desktop src/protontricks/data/share/applications/protontricks-launch.desktop protontricks-1.12.0/setup.py000066400000000000000000000007661467175317500160770ustar00rootroot00000000000000from setuptools import setup setup( # This is considered deprecated since Python wheels don't provide a way # to install package-related files outside the package directory data_files=[ ( "share/applications", [ ("src/protontricks/data/share/applications/" "protontricks.desktop"), ("src/protontricks/data/share/applications/" "protontricks-launch.desktop") ] ) ] ) protontricks-1.12.0/src/000077500000000000000000000000001467175317500151435ustar00rootroot00000000000000protontricks-1.12.0/src/protontricks/000077500000000000000000000000001467175317500177045ustar00rootroot00000000000000protontricks-1.12.0/src/protontricks/__init__.py000066400000000000000000000003311467175317500220120ustar00rootroot00000000000000from .steam import * from .winetricks import * from .gui import * from .util import * try: from ._version import version as __version__ except ImportError: # Package not installed __version__ = "unknown" protontricks-1.12.0/src/protontricks/_vdf/000077500000000000000000000000001467175317500206225ustar00rootroot00000000000000protontricks-1.12.0/src/protontricks/_vdf/LICENSE000066400000000000000000000020631467175317500216300ustar00rootroot00000000000000Copyright (c) 2015 Rossen Georgiev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. protontricks-1.12.0/src/protontricks/_vdf/README.rst000066400000000000000000000115171467175317500223160ustar00rootroot00000000000000| |pypi| |license| |coverage| |master_build| | |sonar_maintainability| |sonar_reliability| |sonar_security| Pure python module for (de)serialization to and from VDF that works just like ``json``. Tested and works on ``py2.7``, ``py3.3+``, ``pypy`` and ``pypy3``. VDF is Valve's KeyValue text file format https://developer.valvesoftware.com/wiki/KeyValues | Supported versions: ``kv1`` | Unsupported: ``kv2`` and ``kv3`` Install ------- You can grab the latest release from https://pypi.org/project/vdf/ or via ``pip`` .. code:: bash pip install vdf Install the current dev version from ``github`` .. code:: bash pip install git+https://github.com/ValvePython/vdf Problems & solutions -------------------- - There are known files that contain duplicate keys. This is supported the format and makes mapping to ``dict`` impossible. For this case the module provides ``vdf.VDFDict`` that can be used as mapper instead of ``dict``. See the example section for details. - By default de-serialization will return a ``dict``, which doesn't preserve nor guarantee key order on Python versions prior to 3.6, due to `hash randomization`_. If key order is important on old Pythons, I suggest using ``collections.OrderedDict``, or ``vdf.VDFDict``. Example usage ------------- For text representation .. code:: python import vdf # parsing vdf from file or string d = vdf.load(open('file.txt')) d = vdf.loads(vdf_text) d = vdf.parse(open('file.txt')) d = vdf.parse(vdf_text) # dumping dict as vdf to string vdf_text = vdf.dumps(d) indented_vdf = vdf.dumps(d, pretty=True) # dumping dict as vdf to file vdf.dump(d, open('file2.txt','w'), pretty=True) For binary representation .. code:: python d = vdf.binary_loads(vdf_bytes) b = vdf.binary_dumps(d) # alternative format - VBKV d = vdf.binary_loads(vdf_bytes, alt_format=True) b = vdf.binary_dumps(d, alt_format=True) # VBKV with header and CRC checking d = vdf.vbkv_loads(vbkv_bytes) b = vdf.vbkv_dumps(d) Using an alternative mapper .. code:: python d = vdf.loads(vdf_string, mapper=collections.OrderedDict) d = vdf.loads(vdf_string, mapper=vdf.VDFDict) ``VDFDict`` works much like the regular ``dict``, except it handles duplicates and remembers insert order. Additionally, keys can only be of type ``str``. The most important difference is that when trying to assigning a key that already exist it will create a duplicate instead of reassign the value to the existing key. .. code:: python >>> d = vdf.VDFDict() >>> d['key'] = 111 >>> d['key'] = 222 >>> d VDFDict([('key', 111), ('key', 222)]) >>> d.items() [('key', 111), ('key', 222)] >>> d['key'] 111 >>> d[(0, 'key')] # get the first duplicate 111 >>> d[(1, 'key')] # get the second duplicate 222 >>> d.get_all_for('key') [111, 222] >>> d[(1, 'key')] = 123 # reassign specific duplicate >>> d.get_all_for('key') [111, 123] >>> d['key'] = 333 >>> d.get_all_for('key') [111, 123, 333] >>> del d[(1, 'key')] >>> d.get_all_for('key') [111, 333] >>> d[(1, 'key')] 333 >>> print vdf.dumps(d) "key" "111" "key" "333" >>> d.has_duplicates() True >>> d.remove_all_for('key') >>> len(d) 0 >>> d.has_duplicates() False .. |pypi| image:: https://img.shields.io/pypi/v/vdf.svg?style=flat&label=latest%20version :target: https://pypi.org/project/vdf/ :alt: Latest version released on PyPi .. |license| image:: https://img.shields.io/pypi/l/vdf.svg?style=flat&label=license :target: https://pypi.org/project/vdf/ :alt: MIT License .. |coverage| image:: https://img.shields.io/coveralls/ValvePython/vdf/master.svg?style=flat :target: https://coveralls.io/r/ValvePython/vdf?branch=master :alt: Test coverage .. |sonar_maintainability| image:: https://sonarcloud.io/api/project_badges/measure?project=ValvePython_vdf&metric=sqale_rating :target: https://sonarcloud.io/dashboard?id=ValvePython_vdf :alt: SonarCloud Rating .. |sonar_reliability| image:: https://sonarcloud.io/api/project_badges/measure?project=ValvePython_vdf&metric=reliability_rating :target: https://sonarcloud.io/dashboard?id=ValvePython_vdf :alt: SonarCloud Rating .. |sonar_security| image:: https://sonarcloud.io/api/project_badges/measure?project=ValvePython_vdf&metric=security_rating :target: https://sonarcloud.io/dashboard?id=ValvePython_vdf :alt: SonarCloud Rating .. |master_build| image:: https://github.com/ValvePython/vdf/workflows/Tests/badge.svg?branch=master :target: https://github.com/ValvePython/vdf/actions?query=workflow%3A%22Tests%22+branch%3Amaster :alt: Build status of master branch .. _DuplicateOrderedDict: https://github.com/rossengeorgiev/dota2_notebooks/blob/master/DuplicateOrderedDict_for_VDF.ipynb .. _hash randomization: https://docs.python.org/2/using/cmdline.html#envvar-PYTHONHASHSEED protontricks-1.12.0/src/protontricks/_vdf/__init__.py000066400000000000000000000441641467175317500227440ustar00rootroot00000000000000""" Module for deserializing/serializing to and from VDF """ __version__ = "3.4" __author__ = "Rossen Georgiev" import re import sys import struct from binascii import crc32 from io import BytesIO from io import StringIO as unicodeIO try: from collections.abc import Mapping except: from collections import Mapping from vdf.vdict import VDFDict # Py2 & Py3 compatibility if sys.version_info[0] >= 3: string_type = str int_type = int BOMS = '\ufffe\ufeff' def strip_bom(line): return line.lstrip(BOMS) else: from StringIO import StringIO as strIO string_type = basestring int_type = long BOMS = '\xef\xbb\xbf\xff\xfe\xfe\xff' BOMS_UNICODE = '\\ufffe\\ufeff'.decode('unicode-escape') def strip_bom(line): return line.lstrip(BOMS if isinstance(line, str) else BOMS_UNICODE) # string escaping _unescape_char_map = { r"\n": "\n", r"\t": "\t", r"\v": "\v", r"\b": "\b", r"\r": "\r", r"\f": "\f", r"\a": "\a", r"\\": "\\", r"\?": "?", r"\"": "\"", r"\'": "\'", } _escape_char_map = {v: k for k, v in _unescape_char_map.items()} def _re_escape_match(m): return _escape_char_map[m.group()] def _re_unescape_match(m): return _unescape_char_map[m.group()] def _escape(text): return re.sub(r"[\n\t\v\b\r\f\a\\\?\"']", _re_escape_match, text) def _unescape(text): return re.sub(r"(\\n|\\t|\\v|\\b|\\r|\\f|\\a|\\\\|\\\?|\\\"|\\')", _re_unescape_match, text) # parsing and dumping for KV1 def parse(fp, mapper=dict, merge_duplicate_keys=True, escaped=True): """ Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a VDF) to a Python object. ``mapper`` specifies the Python object used after deserializetion. ``dict` is used by default. Alternatively, ``collections.OrderedDict`` can be used if you wish to preserve key order. Or any object that acts like a ``dict``. ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the same key into one instead of overwriting. You can se this to ``False`` if you are using ``VDFDict`` and need to preserve the duplicates. """ if not issubclass(mapper, Mapping): raise TypeError("Expected mapper to be subclass of dict, got %s" % type(mapper)) if not hasattr(fp, 'readline'): raise TypeError("Expected fp to be a file-like object supporting line iteration") stack = [mapper()] expect_bracket = False re_keyvalue = re.compile(r'^("(?P(?:\\.|[^\\"])*)"|(?P#?[a-z0-9\-\_\\\?$%<>]+))' r'([ \t]*(' r'"(?P(?:\\.|[^\\"])*)(?P")?' r'|(?P(?:(? ])+)' r'|(?P{[ \t]*)(?P})?' r'))?', flags=re.I) for lineno, line in enumerate(fp, 1): if lineno == 1: line = strip_bom(line) line = line.lstrip() # skip empty and comment lines if line == "" or line[0] == '/': continue # one level deeper if line[0] == "{": expect_bracket = False continue if expect_bracket: raise SyntaxError("vdf.parse: expected openning bracket", (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 1, line)) # one level back if line[0] == "}": if len(stack) > 1: stack.pop() continue raise SyntaxError("vdf.parse: one too many closing parenthasis", (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) # parse keyvalue pairs while True: match = re_keyvalue.match(line) if not match: try: line += next(fp) continue except StopIteration: raise SyntaxError("vdf.parse: unexpected EOF (open key quote?)", (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) key = match.group('key') if match.group('qkey') is None else match.group('qkey') val = match.group('qval') if val is None: val = match.group('val') if val is not None: val = val.rstrip() if val == "": val = None if escaped: key = _unescape(key) # we have a key with value in parenthesis, so we make a new dict obj (level deeper) if val is None: if merge_duplicate_keys and key in stack[-1]: _m = stack[-1][key] # we've descended a level deeper, if value is str, we have to overwrite it to mapper if not isinstance(_m, mapper): _m = stack[-1][key] = mapper() else: _m = mapper() stack[-1][key] = _m if match.group('eblock') is None: # only expect a bracket if it's not already closed or on the same line stack.append(_m) if match.group('sblock') is None: expect_bracket = True # we've matched a simple keyvalue pair, map it to the last dict obj in the stack else: # if the value is line consume one more line and try to match again, # until we get the KeyValue pair if match.group('vq_end') is None and match.group('qval') is not None: try: line += next(fp) continue except StopIteration: raise SyntaxError("vdf.parse: unexpected EOF (open quote for value?)", (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) stack[-1][key] = _unescape(val) if escaped else val # exit the loop break if len(stack) != 1: raise SyntaxError("vdf.parse: unclosed parenthasis or quotes (EOF)", (getattr(fp, 'name', '<%s>' % fp.__class__.__name__), lineno, 0, line)) return stack.pop() def loads(s, **kwargs): """ Deserialize ``s`` (a ``str`` or ``unicode`` instance containing a JSON document) to a Python object. """ if not isinstance(s, string_type): raise TypeError("Expected s to be a str, got %s" % type(s)) try: fp = unicodeIO(s) except TypeError: fp = strIO(s) return parse(fp, **kwargs) def load(fp, **kwargs): """ Deserialize ``fp`` (a ``.readline()``-supporting file-like object containing a JSON document) to a Python object. """ return parse(fp, **kwargs) def dumps(obj, pretty=False, escaped=True): """ Serialize ``obj`` to a VDF formatted ``str``. """ if not isinstance(obj, Mapping): raise TypeError("Expected data to be an instance of``dict``") if not isinstance(pretty, bool): raise TypeError("Expected pretty to be of type bool") if not isinstance(escaped, bool): raise TypeError("Expected escaped to be of type bool") return ''.join(_dump_gen(obj, pretty, escaped)) def dump(obj, fp, pretty=False, escaped=True): """ Serialize ``obj`` as a VDF formatted stream to ``fp`` (a ``.write()``-supporting file-like object). """ if not isinstance(obj, Mapping): raise TypeError("Expected data to be an instance of``dict``") if not hasattr(fp, 'write'): raise TypeError("Expected fp to have write() method") if not isinstance(pretty, bool): raise TypeError("Expected pretty to be of type bool") if not isinstance(escaped, bool): raise TypeError("Expected escaped to be of type bool") for chunk in _dump_gen(obj, pretty, escaped): fp.write(chunk) def _dump_gen(data, pretty=False, escaped=True, level=0): indent = "\t" line_indent = "" if pretty: line_indent = indent * level for key, value in data.items(): if escaped and isinstance(key, string_type): key = _escape(key) if isinstance(value, Mapping): yield '%s"%s"\n%s{\n' % (line_indent, key, line_indent) for chunk in _dump_gen(value, pretty, escaped, level+1): yield chunk yield "%s}\n" % line_indent else: if escaped and isinstance(value, string_type): value = _escape(value) yield '%s"%s" "%s"\n' % (line_indent, key, value) # binary VDF class BASE_INT(int_type): def __repr__(self): return "%s(%d)" % (self.__class__.__name__, self) class UINT_64(BASE_INT): pass class INT_64(BASE_INT): pass class POINTER(BASE_INT): pass class COLOR(BASE_INT): pass BIN_NONE = b'\x00' BIN_STRING = b'\x01' BIN_INT32 = b'\x02' BIN_FLOAT32 = b'\x03' BIN_POINTER = b'\x04' BIN_WIDESTRING = b'\x05' BIN_COLOR = b'\x06' BIN_UINT64 = b'\x07' BIN_END = b'\x08' BIN_INT64 = b'\x0A' BIN_END_ALT = b'\x0B' def binary_loads(b, mapper=dict, merge_duplicate_keys=True, alt_format=False, key_table=None, raise_on_remaining=True): """ Deserialize ``b`` (``bytes`` containing a VDF in "binary form") to a Python object. ``mapper`` specifies the Python object used after deserializetion. ``dict` is used by default. Alternatively, ``collections.OrderedDict`` can be used if you wish to preserve key order. Or any object that acts like a ``dict``. ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the same key into one instead of overwriting. You can se this to ``False`` if you are using ``VDFDict`` and need to preserve the duplicates. ``key_table`` will be used to translate keys in binary VDF objects which do not encode strings directly but instead store them in an out-of-band table. Newer `appinfo.vdf` format stores this table the end of the file, and it is needed to deserialize the binary VDF objects in that file. """ if not isinstance(b, bytes): raise TypeError("Expected s to be bytes, got %s" % type(b)) return binary_load(BytesIO(b), mapper, merge_duplicate_keys, alt_format, key_table, raise_on_remaining) def binary_load(fp, mapper=dict, merge_duplicate_keys=True, alt_format=False, key_table=None, raise_on_remaining=False): """ Deserialize ``fp`` (a ``.read()``-supporting file-like object containing binary VDF) to a Python object. ``mapper`` specifies the Python object used after deserializetion. ``dict` is used by default. Alternatively, ``collections.OrderedDict`` can be used if you wish to preserve key order. Or any object that acts like a ``dict``. ``merge_duplicate_keys`` when ``True`` will merge multiple KeyValue lists with the same key into one instead of overwriting. You can se this to ``False`` if you are using ``VDFDict`` and need to preserve the duplicates. ``key_table`` will be used to translate keys in binary VDF objects which do not encode strings directly but instead store them in an out-of-band table. Newer `appinfo.vdf` format stores this table the end of the file, and it is needed to deserialize the binary VDF objects in that file. """ if not hasattr(fp, 'read') or not hasattr(fp, 'tell') or not hasattr(fp, 'seek'): raise TypeError("Expected fp to be a file-like object with tell()/seek() and read() returning bytes") if not issubclass(mapper, Mapping): raise TypeError("Expected mapper to be subclass of dict, got %s" % type(mapper)) # helpers int32 = struct.Struct(' 1: stack.pop() continue break if key_table: # If 'key_table' was provided, each key is an int32 value that # needs to be mapped to an actual field name using a key table. # Newer appinfo.vdf (V29+) stores this table at the end of the file. index = int32.unpack(fp.read(int32.size))[0] key = key_table[index] else: key = read_string(fp) if t == BIN_NONE: if merge_duplicate_keys and key in stack[-1]: _m = stack[-1][key] else: _m = mapper() stack[-1][key] = _m stack.append(_m) elif t == BIN_STRING: stack[-1][key] = read_string(fp) elif t == BIN_WIDESTRING: stack[-1][key] = read_string(fp, wide=True) elif t in (BIN_INT32, BIN_POINTER, BIN_COLOR): val = int32.unpack(fp.read(int32.size))[0] if t == BIN_POINTER: val = POINTER(val) elif t == BIN_COLOR: val = COLOR(val) stack[-1][key] = val elif t == BIN_UINT64: stack[-1][key] = UINT_64(uint64.unpack(fp.read(int64.size))[0]) elif t == BIN_INT64: stack[-1][key] = INT_64(int64.unpack(fp.read(int64.size))[0]) elif t == BIN_FLOAT32: stack[-1][key] = float32.unpack(fp.read(float32.size))[0] else: raise SyntaxError("Unknown data type at offset %d: %s" % (fp.tell() - 1, repr(t))) if len(stack) != 1: raise SyntaxError("Reached EOF, but Binary VDF is incomplete") if raise_on_remaining and fp.read(1) != b'': fp.seek(-1, 1) raise SyntaxError("Binary VDF ended at offset %d, but there is more data remaining" % (fp.tell() - 1)) return stack.pop() def binary_dumps(obj, alt_format=False): """ Serialize ``obj`` to a binary VDF formatted ``bytes``. """ buf = BytesIO() binary_dump(obj, buf, alt_format) return buf.getvalue() def binary_dump(obj, fp, alt_format=False): """ Serialize ``obj`` to a binary VDF formatted ``bytes`` and write it to ``fp`` filelike object """ if not isinstance(obj, Mapping): raise TypeError("Expected obj to be type of Mapping") if not hasattr(fp, 'write'): raise TypeError("Expected fp to have write() method") for chunk in _binary_dump_gen(obj, alt_format=alt_format): fp.write(chunk) def _binary_dump_gen(obj, level=0, alt_format=False): if level == 0 and len(obj) == 0: return int32 = struct.Struct('= 3: _iter_values = 'values' _range = range _string_type = str import collections.abc as _c class _kView(_c.KeysView): def __iter__(self): return self._mapping.iterkeys() class _vView(_c.ValuesView): def __iter__(self): return self._mapping.itervalues() class _iView(_c.ItemsView): def __iter__(self): return self._mapping.iteritems() else: _iter_values = 'itervalues' _range = xrange _string_type = basestring _kView = lambda x: list(x.iterkeys()) _vView = lambda x: list(x.itervalues()) _iView = lambda x: list(x.iteritems()) class VDFDict(dict): def __init__(self, data=None): """ This is a dictionary that supports duplicate keys and preserves insert order ``data`` can be a ``dict``, or a sequence of key-value tuples. (e.g. ``[('key', 'value'),..]``) The only supported type for key is str. Get/set duplicates is done by tuples ``(index, key)``, where index is the duplicate index for the specified key. (e.g. ``(0, 'key')``, ``(1, 'key')``...) When the ``key`` is ``str``, instead of tuple, set will create a duplicate and get will look up ``(0, key)`` """ self.__omap = [] self.__kcount = Counter() if data is not None: if not isinstance(data, (list, dict)): raise ValueError("Expected data to be list of pairs or dict, got %s" % type(data)) self.update(data) def __repr__(self): out = "%s(" % self.__class__.__name__ out += "%s)" % repr(list(self.iteritems())) return out def __len__(self): return len(self.__omap) def _verify_key_tuple(self, key): if len(key) != 2: raise ValueError("Expected key tuple length to be 2, got %d" % len(key)) if not isinstance(key[0], int): raise TypeError("Key index should be an int") if not isinstance(key[1], _string_type): raise TypeError("Key value should be a str") def _normalize_key(self, key): if isinstance(key, _string_type): key = (0, key) elif isinstance(key, tuple): self._verify_key_tuple(key) else: raise TypeError("Expected key to be a str or tuple, got %s" % type(key)) return key def __setitem__(self, key, value): if isinstance(key, _string_type): key = (self.__kcount[key], key) self.__omap.append(key) elif isinstance(key, tuple): self._verify_key_tuple(key) if key not in self: raise KeyError("%s doesn't exist" % repr(key)) else: raise TypeError("Expected either a str or tuple for key") super(VDFDict, self).__setitem__(key, value) self.__kcount[key[1]] += 1 def __getitem__(self, key): return super(VDFDict, self).__getitem__(self._normalize_key(key)) def __delitem__(self, key): key = self._normalize_key(key) result = super(VDFDict, self).__delitem__(key) start_idx = self.__omap.index(key) del self.__omap[start_idx] dup_idx, skey = key self.__kcount[skey] -= 1 tail_count = self.__kcount[skey] - dup_idx if tail_count > 0: for idx in _range(start_idx, len(self.__omap)): if self.__omap[idx][1] == skey: oldkey = self.__omap[idx] newkey = (dup_idx, skey) super(VDFDict, self).__setitem__(newkey, self[oldkey]) super(VDFDict, self).__delitem__(oldkey) self.__omap[idx] = newkey dup_idx += 1 tail_count -= 1 if tail_count == 0: break if self.__kcount[skey] == 0: del self.__kcount[skey] return result def __iter__(self): return iter(self.iterkeys()) def __contains__(self, key): return super(VDFDict, self).__contains__(self._normalize_key(key)) def __eq__(self, other): if isinstance(other, VDFDict): return list(self.items()) == list(other.items()) else: return False def __ne__(self, other): return not self.__eq__(other) def clear(self): super(VDFDict, self).clear() self.__kcount.clear() self.__omap = list() def get(self, key, *args): return super(VDFDict, self).get(self._normalize_key(key), *args) def setdefault(self, key, default=None): if key not in self: self.__setitem__(key, default) return self.__getitem__(key) def pop(self, key): key = self._normalize_key(key) value = self.__getitem__(key) self.__delitem__(key) return value def popitem(self): if not self.__omap: raise KeyError("VDFDict is empty") key = self.__omap[-1] return key[1], self.pop(key) def update(self, data=None, **kwargs): if isinstance(data, dict): data = data.items() elif not isinstance(data, list): raise TypeError("Expected data to be a list or dict, got %s" % type(data)) for key, value in data: self.__setitem__(key, value) def iterkeys(self): return (key[1] for key in self.__omap) def keys(self): return _kView(self) def itervalues(self): return (self[key] for key in self.__omap) def values(self): return _vView(self) def iteritems(self): return ((key[1], self[key]) for key in self.__omap) def items(self): return _iView(self) def get_all_for(self, key): """ Returns all values of the given key """ if not isinstance(key, _string_type): raise TypeError("Key needs to be a string.") return [self[(idx, key)] for idx in _range(self.__kcount[key])] def remove_all_for(self, key): """ Removes all items with the given key """ if not isinstance(key, _string_type): raise TypeError("Key need to be a string.") for idx in _range(self.__kcount[key]): super(VDFDict, self).__delitem__((idx, key)) self.__omap = list(filter(lambda x: x[1] != key, self.__omap)) del self.__kcount[key] def has_duplicates(self): """ Returns ``True`` if the dict contains keys with duplicates. Recurses through any all keys with value that is ``VDFDict``. """ for n in getattr(self.__kcount, _iter_values)(): if n != 1: return True def dict_recurse(obj): for v in getattr(obj, _iter_values)(): if isinstance(v, VDFDict) and v.has_duplicates(): return True elif isinstance(v, dict): return dict_recurse(v) return False return dict_recurse(self) protontricks-1.12.0/src/protontricks/cli/000077500000000000000000000000001467175317500204535ustar00rootroot00000000000000protontricks-1.12.0/src/protontricks/cli/__init__.py000066400000000000000000000000001467175317500225520ustar00rootroot00000000000000protontricks-1.12.0/src/protontricks/cli/desktop_install.py000066400000000000000000000031761467175317500242330ustar00rootroot00000000000000import argparse from pathlib import Path from subprocess import run import pkg_resources from .util import CustomArgumentParser def install_desktop_entries(): """ Install the desktop entry files for Protontricks. This should only be necessary when using an installation method that does not support .desktop files (eg. pip/pipx) :returns: Directory containing the installed .desktop files """ applications_dir = Path.home() / ".local" / "share" / "applications" applications_dir.mkdir(parents=True, exist_ok=True) run([ "desktop-file-install", "--dir", str(applications_dir), pkg_resources.resource_filename( "protontricks", "data/share/applications/protontricks.desktop" ), pkg_resources.resource_filename( "protontricks", "data/share/applications/protontricks-launch.desktop" ) ], check=True) return applications_dir def cli(args=None): main(args) def main(args=None): """ 'protontricks-desktop-install' script entrypoint """ parser = CustomArgumentParser( description=( "Install Protontricks application shortcuts for the local user\n" ), formatter_class=argparse.RawTextHelpFormatter ) # This doesn't really do much except accept `--help` parser.parse_args(args) print("Installing .desktop files for the local user...") install_dir = install_desktop_entries() print(f"\nDone. Files have been installed under {install_dir}") print("The Protontricks shortcut and desktop integration should now work.") if __name__ == "__main__": main() protontricks-1.12.0/src/protontricks/cli/launch.py000066400000000000000000000145561467175317500223120ustar00rootroot00000000000000import argparse import logging import shlex from pathlib import Path from ..gui import (prompt_filesystem_access, select_steam_app_with_gui, select_steam_installation) from ..steam import (find_steam_installations, get_steam_apps, get_steam_lib_paths) from .main import main as cli_main from .util import (CustomArgumentParser, cli_error_handler, enable_logging, exit_with_error) logger = logging.getLogger("protontricks") def cli(args=None): main(args) @cli_error_handler def main(args=None): """ 'protontricks-launch' script entrypoint """ parser = CustomArgumentParser( description=( "Utility for launching Windows executables using Protontricks\n" "\n" "Usage:\n" "\n" "Launch EXECUTABLE and pick the Steam app using a dialog.\n" "$ protontricks-launch EXECUTABLE [ARGS]\n" "\n" "Launch EXECUTABLE for Steam app APPID\n" "$ protontricks-launch --appid APPID EXECUTABLE [ARGS]\n" "\n" "Environment variables:\n" "\n" "PROTON_VERSION: name of the preferred Proton installation\n" "STEAM_DIR: path to custom Steam installation\n" "WINETRICKS: path to a custom 'winetricks' executable\n" "WINE: path to a custom 'wine' executable\n" "WINESERVER: path to a custom 'wineserver' executable\n" "STEAM_RUNTIME: 1 = enable Steam Runtime, 0 = disable Steam " "Runtime, valid path = custom Steam Runtime path, " "empty = enable automatically (default)" ), formatter_class=argparse.RawTextHelpFormatter ) parser.add_argument( "--no-term", action="store_true", help=( "Program was launched from desktop and no user-visible " "terminal is available. Error will be shown in a dialog instead " "of being printed." ) ) parser.add_argument( "--verbose", "-v", action="count", default=0, help=( "Increase log verbosity. Can be supplied twice for " "maximum verbosity." ) ) parser.add_argument( "--no-runtime", action="store_true", default=False, help="Disable Steam Runtime") parser.add_argument( "--no-bwrap", action="store_true", default=False, help="Disable bwrap containerization when using Steam Runtime" ) parser.add_argument( "--background-wineserver", dest="background_wineserver", action="store_true", help=( "Launch a background wineserver process to improve Wine command " "startup time. Disabled by default, as it can cause problems with " "some graphical applications." ) ) parser.add_argument( "--no-background-wineserver", dest="background_wineserver", action="store_false", help=( "Do not launch a background wineserver process to improve Wine " "command startup time." ) ) parser.add_argument( "--appid", type=int, nargs="?", default=None ) parser.add_argument( "--cwd-app", dest="cwd_app", default=False, action="store_true", help=( "Set the working directory of launched executable to the Steam " "app's installation directory." ) ) parser.add_argument("executable", type=str) parser.add_argument("exec_args", nargs=argparse.REMAINDER) parser.set_defaults(background_wineserver=False) args = parser.parse_args(args) # 'cli_error_handler' relies on this to know whether to use error dialog or # not main.no_term = args.no_term # Shorthand function for aborting with error message def exit_(error): exit_with_error(error, args.no_term) enable_logging(args.verbose, record_to_file=args.no_term) executable_path = Path(args.executable).resolve(strict=True) # 1. Find Steam path steam_installations = find_steam_installations() if not steam_installations: exit_("Steam installation directory could not be found.") steam_path, steam_root = select_steam_installation(steam_installations) if not steam_path: exit_("No Steam installation was selected.") # 2. Find any Steam library folders steam_lib_paths = get_steam_lib_paths(steam_path) # Check if Protontricks has access to all the required paths prompt_filesystem_access( paths=[steam_path, steam_root] + steam_lib_paths, show_dialog=args.no_term ) # 3. Find any Steam apps steam_apps = get_steam_apps( steam_root=steam_root, steam_path=steam_path, steam_lib_paths=steam_lib_paths ) steam_apps = [ app for app in steam_apps if app.prefix_path_exists and app.appid ] if not steam_apps: exit_( "No Proton enabled Steam apps were found. Have you launched one " "of the apps at least once?" ) if not args.appid: appid = select_steam_app_with_gui( steam_apps, title=f"Choose Wine prefix to run {executable_path.name}", steam_path=steam_path ).appid else: appid = args.appid # Build the command to pass to the main Protontricks CLI entrypoint cli_args = [] # Ensure each individual argument passed to the EXE is escaped exec_args = [shlex.quote(arg) for arg in args.exec_args] if args.verbose: cli_args += ["-" + ("v" * args.verbose)] if args.no_runtime: cli_args += ["--no-runtime"] if args.no_bwrap: cli_args += ["--no-bwrap"] if args.background_wineserver is True: cli_args += ["--background-wineserver"] elif args.background_wineserver is False: cli_args += ["--no-background-wineserver"] if args.no_term: cli_args += ["--no-term"] inner_args = " ".join( ["wine", shlex.quote(str(executable_path))] + exec_args ) if args.cwd_app: cli_args += ["--cwd-app"] cli_args += [ "-c", inner_args, str(appid) ] # Launch the main Protontricks CLI entrypoint logger.info( "Calling `protontricks` with the command: %s", cli_args ) cli_main(cli_args, steam_path=steam_path, steam_root=steam_root) if __name__ == "__main__": main() protontricks-1.12.0/src/protontricks/cli/main.py000077500000000000000000000352661467175317500217700ustar00rootroot00000000000000# _____ _ _ _ _ # | _ |___ ___| |_ ___ ___| |_ ___|_|___| |_ ___ # | __| _| . | _| . | | _| _| | _| '_|_ -| # |__| |_| |___|_| |___|_|_|_| |_| |_|___|_,_|___| # A simple wrapper that makes it slightly painless to use winetricks with # Proton prefixes # # Script licensed under the GPLv3! import argparse import logging import os import sys from .. import __version__ from ..flatpak import (FLATPAK_BWRAP_COMPATIBLE_VERSION, get_running_flatpak_version) from ..gui import (prompt_filesystem_access, select_steam_app_with_gui, select_steam_installation) from ..steam import (find_legacy_steam_runtime_path, find_proton_app, find_steam_installations, get_steam_apps, get_steam_lib_paths) from ..util import run_command from ..winetricks import get_winetricks_path from .util import (CustomArgumentParser, cli_error_handler, enable_logging, exit_with_error) logger = logging.getLogger("protontricks") def cli(args=None): main(args) @cli_error_handler def main(args=None, steam_path=None, steam_root=None): """ 'protontricks' script entrypoint """ parser = CustomArgumentParser( description=( "Wrapper for running Winetricks commands for " "Steam Play/Proton games.\n" "\n" "Usage:\n" "\n" "Run winetricks for game with APPID. " "COMMAND is passed directly to winetricks as-is. " "Any options specific to Protontricks need to be provided " "*before* APPID.\n" "$ protontricks APPID COMMAND\n" "\n" "Search installed games to find the APPID\n" "$ protontricks -s GAME_NAME\n" "\n" "List all installed games\n" "$ protontricks -l\n" "\n" "Use Protontricks GUI to select the game\n" "$ protontricks --gui\n" "\n" "Environment variables:\n" "\n" "PROTON_VERSION: name of the preferred Proton installation\n" "STEAM_DIR: path to custom Steam installation\n" "WINETRICKS: path to a custom 'winetricks' executable\n" "WINE: path to a custom 'wine' executable\n" "WINESERVER: path to a custom 'wineserver' executable\n" "STEAM_RUNTIME: 1 = enable Steam Runtime, 0 = disable Steam " "Runtime, valid path = custom Steam Runtime path, " "empty = enable automatically (default)\n" "PROTONTRICKS_GUI: GUI provider to use, accepts either 'yad' " "or 'zenity'\n" "\n" "Environment variables set automatically by Protontricks:\n" "STEAM_APP_PATH: path to the current game's installation directory\n" "STEAM_APPID: app ID of the current game\n" "PROTON_PATH: path to the currently used Proton installation" ), formatter_class=argparse.RawTextHelpFormatter ) parser.add_argument( "--verbose", "-v", action="count", default=0, help=( "Increase log verbosity. Can be supplied twice for " "maximum verbosity." ) ) parser.add_argument( "--no-term", action="store_true", help=( "Program was launched from desktop. This is used automatically " "when lauching Protontricks from desktop and no user-visible " "terminal is available." ) ) parser.add_argument( "-s", "--search", type=str, dest="search", nargs="+", required=False, help="Search for game(s) with the given name") parser.add_argument( "-l", "--list", action="store_true", dest="list", default=False, help="List all apps" ) parser.add_argument( "-c", "--command", type=str, dest="command", required=False, help="Run a command with Wine-related environment variables set. " "The command is passed to the shell as-is without being escaped.") parser.add_argument( "--gui", action="store_true", help="Launch the Protontricks GUI.") parser.add_argument( "--no-runtime", action="store_true", default=False, help="Disable Steam Runtime") parser.add_argument( "--no-bwrap", action="store_true", default=None, help="Disable bwrap containerization when using Steam Runtime" ) parser.add_argument( "--background-wineserver", dest="background_wineserver", action="store_true", help=( "Launch a background wineserver process to improve Wine command " "startup time. Disabled by default, as it can cause problems with " "some graphical applications." ) ) parser.add_argument( "--no-background-wineserver", dest="background_wineserver", action="store_false", help=( "Do not launch a background wineserver process to improve Wine " "command startup time." ) ) parser.add_argument( "--cwd-app", dest="cwd_app", default=False, action="store_true", help=( "Set the working directory of launched command to the Steam app's " "installation directory." ) ) parser.set_defaults(background_wineserver=False) parser.add_argument("appid", type=int, nargs="?", default=None) parser.add_argument("winetricks_command", nargs=argparse.REMAINDER) parser.add_argument( "-V", "--version", action="version", version=f"%(prog)s ({__version__})" ) args = parser.parse_args(args) if len(sys.argv) < 2: # No arguments were provided, default to GUI args.gui = True # 'cli_error_handler' relies on this to know whether to use error dialog or # not main.no_term = args.no_term # Shorthand function for aborting with error message def exit_(error): exit_with_error(error, args.no_term) do_command = bool(args.command) do_list_apps = bool(args.search) or bool(args.list) do_gui = bool(args.gui) do_winetricks = bool(args.appid and args.winetricks_command) # Set 'use_bwrap' to opposite of args.no_bwrap if it was provided. # If not, keep it as None and determine the correct value to use later # once we've determined whether the selected Steam Runtime is a bwrap-based # one. use_bwrap = ( not bool(args.no_bwrap) if args.no_bwrap in (True, False) else None ) start_background_wineserver = ( args.background_wineserver if args.background_wineserver is not None else use_bwrap ) if not do_command and not do_list_apps and not do_gui and not do_winetricks: parser.print_help() return # Don't allow more than one action if sum([do_list_apps, do_gui, do_winetricks, do_command]) != 1: print("Only one action can be performed at a time.") parser.print_help() return enable_logging(args.verbose, record_to_file=args.no_term) flatpak_version = get_running_flatpak_version() if flatpak_version: logger.info( "Running inside Flatpak sandbox, version %s.", ".".join(map(str, flatpak_version)) ) if flatpak_version < FLATPAK_BWRAP_COMPATIBLE_VERSION: logger.warning( "Flatpak version is too old (<1.12.1) to support " "sub-sandboxes. Disabling bwrap. --no-bwrap will be ignored." ) use_bwrap = False # 1. Find Steam path # We can skip the Steam installation detection if the CLI entrypoint # has already been provided the path as a keyword argument. # This is the case when this entrypoint is being called by # 'protontricks-launch'. This prevents us from asking the user for # the Steam installation twice. if not steam_path: steam_installations = find_steam_installations() if not steam_installations: exit_("Steam installation directory could not be found.") steam_path, steam_root = select_steam_installation(steam_installations) if not steam_path: exit_("No Steam installation was selected.") # 2. Find the pre-installed legacy Steam Runtime if enabled legacy_steam_runtime_path = None use_steam_runtime = True if os.environ.get("STEAM_RUNTIME", "") != "0" and not args.no_runtime: legacy_steam_runtime_path = find_legacy_steam_runtime_path( steam_root=steam_root ) if not legacy_steam_runtime_path: exit_("Steam Runtime was enabled but couldn't be found!") else: use_steam_runtime = False logger.info("Steam Runtime disabled.") # 3. Find Winetricks winetricks_path = get_winetricks_path() if not winetricks_path: exit_( "Winetricks isn't installed, please install " "winetricks in order to use this script!" ) # 4. Find any Steam library folders steam_lib_paths = get_steam_lib_paths(steam_path) # Check if Protontricks has access to all the required paths prompt_filesystem_access( paths=[steam_path, steam_root] + steam_lib_paths, show_dialog=args.no_term ) # 5. Find any Steam apps steam_apps = get_steam_apps( steam_root=steam_root, steam_path=steam_path, steam_lib_paths=steam_lib_paths ) # It's too early to find Proton here, # as it cannot be found if no globally active Proton version is set. # Having no Proton at this point is no problem as: # 1. not all commands require Proton (search) # 2. a specific steam-app will be chosen in GUI mode, # which might use a different proton version than the one found here # Run the GUI if args.gui: has_installed_apps = any([ app for app in steam_apps if app.is_windows_app ]) if not has_installed_apps: exit_("Found no games. You need to launch a game at least once " "before Protontricks can find it.") try: steam_app = select_steam_app_with_gui( steam_apps=steam_apps, steam_path=steam_path ) except FileNotFoundError: exit_( "YAD or Zenity is not installed. Either executable is required for the " "Protontricks GUI." ) cwd = str(steam_app.install_path) if args.cwd_app else None # 6. Find Proton version of selected app proton_app = find_proton_app( steam_path=steam_path, steam_apps=steam_apps, appid=steam_app.appid ) if not proton_app: exit_("Proton installation could not be found!") if not proton_app.is_proton_ready: exit_( "Proton installation is incomplete. Have you launched a Steam " "app using this Proton version at least once to finish the " "installation?" ) run_command( winetricks_path=winetricks_path, proton_app=proton_app, steam_app=steam_app, use_steam_runtime=use_steam_runtime, legacy_steam_runtime_path=legacy_steam_runtime_path, command=[str(winetricks_path), "--gui"], use_bwrap=use_bwrap, start_wineserver=start_background_wineserver, cwd=cwd ) return # List apps (either all or using a search) elif do_list_apps: if args.list: matching_apps = [ app for app in steam_apps if app.is_windows_app ] else: # Search for games search_query = " ".join(args.search) matching_apps = [ app for app in steam_apps if app.is_windows_app and app.name_contains(search_query) ] if matching_apps: matching_games = "\n".join([ f"{app.name} ({app.appid})" for app in matching_apps ]) print( f"Found the following games:" f"\n{matching_games}\n" ) print( "To run Protontricks for the chosen game, run:\n" "$ protontricks APPID COMMAND" ) else: print("Found no games.") print( "\n" "NOTE: A game must be launched at least once before Protontricks " "can find the game." ) return # 6. Find globally active Proton version now proton_app = find_proton_app( steam_path=steam_path, steam_apps=steam_apps, appid=args.appid) if not proton_app: exit_("Proton installation could not be found!") if not proton_app.is_proton_ready: exit_( "Proton installation is incomplete. Have you launched a Steam app " "using this Proton version at least once to finish the " "installation?" ) # If neither search or GUI are set, do a normal Winetricks command # Find game by appid steam_appid = int(args.appid) try: steam_app = next( app for app in steam_apps if app.is_windows_app and app.appid == steam_appid ) except StopIteration: exit_( "Steam app with the given app ID could not be found. " "Is it installed, Proton compatible and have you launched it at " "least once? You can search for the app ID using the following " "command:\n" "$ protontricks -s " ) cwd = str(steam_app.install_path) if args.cwd_app else None if args.winetricks_command: returncode = run_command( winetricks_path=winetricks_path, proton_app=proton_app, steam_app=steam_app, use_steam_runtime=use_steam_runtime, legacy_steam_runtime_path=legacy_steam_runtime_path, use_bwrap=use_bwrap, start_wineserver=start_background_wineserver, command=[str(winetricks_path)] + args.winetricks_command, cwd=cwd ) elif args.command: returncode = run_command( winetricks_path=winetricks_path, proton_app=proton_app, steam_app=steam_app, command=args.command, use_steam_runtime=use_steam_runtime, legacy_steam_runtime_path=legacy_steam_runtime_path, use_bwrap=use_bwrap, start_wineserver=start_background_wineserver, # Pass the command directly into the shell *without* # escaping it shell=True, cwd=cwd, ) logger.info("Command returned %d", returncode) sys.exit(returncode) if __name__ == "__main__": main() protontricks-1.12.0/src/protontricks/cli/util.py000066400000000000000000000120371467175317500220050ustar00rootroot00000000000000import argparse import atexit import functools import logging import os import sys import tempfile import traceback from pathlib import Path from ..gui import show_text_dialog def _get_log_file_path(): """ Get the log file path to use for this Protontricks process. """ temp_dir = tempfile.gettempdir() pid = os.getpid() return Path(temp_dir) / f"protontricks{pid}.log" def _delete_log_file(): """ Delete the log file if one exists. This is usually executed before shutdown by registering this function using `atexit` """ try: _get_log_file_path().unlink() except FileNotFoundError: pass def enable_logging(level=0, record_to_file=True): """ Enables logging. :param int level: Level of logging. 0 = WARNING, 1 = INFO, 2 = DEBUG. :param bool record_to_file: Whether to log the generated log messages to a temporary file. This is used for the error dialog containing log records. """ if level >= 2: level = logging.DEBUG label = "DEBUG" elif level >= 1: level = logging.INFO label = "INFO" else: level = logging.WARNING label = "WARNING" # 'PROTONTRICKS_LOG_LEVEL' env var allows separate Bash scripts # to detect when logging is enabled. os.environ["PROTONTRICKS_LOG_LEVEL"] = label logger = logging.getLogger("protontricks") stream_handler_added = any( filter( lambda hndl: hndl.name == "protontricks-stream", logger.handlers ) ) if not stream_handler_added: # Logs printed to stderr will follow the log level stream_handler = logging.StreamHandler() stream_handler.name = "protontricks-stream" stream_handler.setLevel(level) stream_handler.setFormatter( logging.Formatter("%(name)s (%(levelname)s): %(message)s") ) logger.setLevel(logging.DEBUG) logger.addHandler(stream_handler) logger.debug("Stream log handler added") if not record_to_file: return file_handler_added = any( filter(lambda hndl: hndl.name == "protontricks-file", logger.handlers) ) if not file_handler_added: # Record log files to temporary file. This means log messages can be # printed at the end of the session in an error dialog. # INFO and WARNING log messages are written into this file whether # `--verbose` is enabled or not. log_file_path = _get_log_file_path() try: log_file_path.unlink() except FileNotFoundError: pass file_handler = logging.FileHandler(str(_get_log_file_path())) file_handler.name = "protontricks-file" file_handler.setLevel(logging.INFO) logger.addHandler(file_handler) # Ensure the log file is removed before the process exits atexit.register(_delete_log_file) logger.debug("File log handler added") def exit_with_error(error, desktop=False): """ Exit with an error, either by printing the error to stderr or displaying an error dialog. :param bool desktop: If enabled, display an error dialog containing the error itself and additional log messages. """ if not desktop: print(error) sys.exit(1) try: log_messages = _get_log_file_path().read_text() except FileNotFoundError: log_messages = "!! LOG FILE NOT FOUND !!" # Display an error dialog containing the message message = "".join([ "Protontricks was closed due to the following error:\n\n", f"{error}\n\n", "=============\n\n", "Please include this entire error message when making a bug report.\n", "Log messages:\n\n", f"{log_messages}" ]) show_text_dialog( title="Protontricks", text=message, window_icon=error ) sys.exit(1) def cli_error_handler(cli_func): """ Decorator for CLI entry points. If an unhandled exception is raised and Protontricks was launched from desktop, display an error dialog containing the stack trace instead of printing to stderr. """ @functools.wraps(cli_func) def wrapper(self, *args, **kwargs): try: wrapper.no_term = False return cli_func(self, *args, **kwargs) except Exception: # pylint: disable=broad-except if not wrapper.no_term: # If we weren't launched from desktop, handle it normally raise traceback_ = traceback.format_exc() exit_with_error(traceback_, desktop=True) return wrapper class CustomArgumentParser(argparse.ArgumentParser): """ Custom argument parser that prints the full help message when incorrect parameters are provided """ def error(self, message): self.print_help(sys.stderr) args = {'prog': self.prog, 'message': message} self.exit(2, '%(prog)s: error: %(message)s\n' % args) protontricks-1.12.0/src/protontricks/config.py000066400000000000000000000027001467175317500215220ustar00rootroot00000000000000import configparser import logging import os from pathlib import Path logger = logging.getLogger("protontricks") class Config: def __init__(self): self._parser = configparser.ConfigParser() self._path = Path( os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") ) / "protontricks" / "config.ini" try: content = self._path.read_text(encoding="utf-8") self._parser.read_string(content) except FileNotFoundError: pass def get(self, section, option, default=None): """ Get the configuration value in the given section and its field """ self._parser.setdefault(section, {}) return self._parser[section].get(option, default) def set(self, section, option, value): """ Set the configuration value in the given section and its field, and save the configuration file """ logger.debug( "Setting configuration field [%s][%s] = %s", section, option, value ) self._parser.setdefault(section, {}) self._parser[section][option] = value # Ensure parent directories exist self._path.parent.mkdir(parents=True, exist_ok=True) with self._path.open("wt", encoding="utf-8") as file_: self._parser.write(file_) def get_config(): """ Retrieve the Protontricks configuration file """ return Config() protontricks-1.12.0/src/protontricks/data/000077500000000000000000000000001467175317500206155ustar00rootroot00000000000000protontricks-1.12.0/src/protontricks/data/data/000077500000000000000000000000001467175317500215265ustar00rootroot00000000000000protontricks-1.12.0/src/protontricks/data/data/icon_placeholder.png000066400000000000000000000112261467175317500255300ustar00rootroot00000000000000PNG  IHDR szz"zTXtRaw profile type exifxK "G@!q*7{$,d, }{p_GG9f.?[]#aW'0Dp.s뼍еRtM`&ly}׭U iVx{ult޶EbPNH#^5e UijOGG[p c Dpm;c/ڟkJڭ%?Kzhp}zI^6O2H6_mcmvĄ3}(kuFk`gt7Nw*EThб\|0\cSf`54 !W|Bn^v2A}\MAfݱ_<nLrs.c n@llAYa6zB ~AnҾ D-pDA(q4)ȕ\#/9UxGieS'@HHA X1 G9P QDd))Ya)iA&U5Z,X4djfJ>ӒQrΥh悷 vRTZ͵4OMZjڬV:: ztCtaG>@0␑yڦLѦ TpꥂD&3H '3o#OrU𻃪2t@02f Sn.ڧ+97 rn{!#PǠ-b ( +oEoEoEoEoEoEoE5@(]iCCPICC profilex}=HPO[E* ␡:Y+U(BP+`?hҐ8 .κ: x_Rh8}ffjI%\~U">D39QLs}8P &|, xxz9GYYRω joKyff扣B.feC%")F -j} 2שF" B:*Bv:OzH.\0r,߳5I7)z_lcmv<WZ_k37:Z.;\COdH}Sk8}4 pp({ݡ{=MTr1kQ iTXtXML:com.adobe.xmp YbKGDvlbm pHYs.#.#x?vtIME /5tEXtCommentCreated with GIMPWIDATX nH@ WIENDB`protontricks-1.12.0/src/protontricks/data/scripts/000077500000000000000000000000001467175317500223045ustar00rootroot00000000000000protontricks-1.12.0/src/protontricks/data/scripts/bwrap_launcher.sh000066400000000000000000000046331467175317500256420ustar00rootroot00000000000000#!/bin/bash # Helper script set -o errexit function log_debug () { if [[ "$PROTONTRICKS_LOG_LEVEL" != "DEBUG" ]]; then return fi log "$@" } function log_info () { if [[ "$PROTONTRICKS_LOG_LEVEL" = "WARNING" ]]; then return fi log "$@" } function log_warning () { if [[ "$PROTONTRICKS_LOG_LEVEL" = "INFO" || "$PROTONTRICKS_LOG_LEVEL" = "WARNING" ]]; then return fi log "$@" } function log () { >&2 echo "protontricks - $(basename "$0") $$: $*" } BLACKLISTED_ROOT_DIRS=( /bin /dev /lib /lib64 /proc /run /sys /var /usr ) ADDITIONAL_MOUNT_DIRS=( /run/media "$PROTON_PATH" "$WINEPREFIX" ) mount_dirs=() # Add any root directories that are not blacklisted for dir in /* ; do if [[ ! -d "$dir" ]]; then continue fi if [[ " ${BLACKLISTED_ROOT_DIRS[*]} " =~ " $dir " ]]; then continue fi mount_dirs+=("$dir") done # Add additional mount directories, including the Wine prefix and Proton # installation directory for dir in "${ADDITIONAL_MOUNT_DIRS[@]}"; do if [[ ! -d "$dir" ]]; then continue fi already_mounted=false # Check if the additional mount directory is already covered by one # of the existing root directories. # Most of the time this is the case, but if the user has placed the Proton # installation or prefix inside a blacklisted directory (eg. '/lib'), # we'll want to ensure it's mounted even if we're not mounting the entire # root directory. for mount_dir in "${mount_dirs[@]}"; do if [[ "$dir" =~ ^$mount_dir ]]; then # This directory is already covered by one of the existing mount # points already_mounted=true break fi done if [[ "$already_mounted" = false ]]; then mount_dirs+=("$dir") fi done mount_params=() for mount in "${mount_dirs[@]}"; do mount_params+=(--filesystem "${mount}") done log_info "Following directories will be mounted inside container: ${mount_dirs[*]}" log_info "Using temporary directory: $PROTONTRICKS_TEMP_PATH" # Protontricks will listen to this file descriptor. Once it's closed, # the launcher has finished starting up. status_fd="$1" exec "$STEAM_RUNTIME_PATH"/run --share-pid --launcher --pass-fd "$status_fd" \ "${mount_params[@]}" -- \ --info-fd "$status_fd" --bus-name="com.github.Matoking.protontricks.App${STEAM_APPID}_${PROTONTRICKS_SESSION_ID}" protontricks-1.12.0/src/protontricks/data/scripts/wine_launch.sh000066400000000000000000000163371467175317500251460ustar00rootroot00000000000000#!/bin/bash # Helper script created by Protontricks to run Wine binaries using Steam Runtime set -o errexit function log_debug () { if [[ "$PROTONTRICKS_LOG_LEVEL" != "DEBUG" ]]; then return fi } function log_info () { if [[ "$PROTONTRICKS_LOG_LEVEL" = "WARNING" ]]; then return fi log "$@" } function log_warning () { if [[ "$PROTONTRICKS_LOG_LEVEL" = "INFO" || "$PROTONTRICKS_LOG_LEVEL" = "WARNING" ]]; then return fi log "$@" } function log () { >&2 echo "protontricks - $(basename "$0") $$: $*" } PROTONTRICKS_PROXY_SCRIPT_PATH="@@script_path@@" BLACKLISTED_ROOT_DIRS=( /bin /dev /lib /lib64 /proc /run /sys /var /usr ) ADDITIONAL_MOUNT_DIRS=( /run/media "$PROTON_PATH" "$WINEPREFIX" ) WINESERVER_ENV_VARS_TO_COPY=( WINEESYNC WINEFSYNC ) if [[ -n "$PROTONTRICKS_BACKGROUND_WINESERVER" && "$0" = "@@script_path@@" ]]; then # Check if we're calling 'wineserver -w' when background wineserver is # enabled. # If so, prompt our keepalive wineserver to restart itself by creating # a 'restart' file inside the temporary directory if [[ "$(basename "$0")" = "wineserver" && "$1" = "-w" ]]; then log_info "Touching '$PROTONTRICKS_TEMP_PATH/restart' to restart wineserver." touch "$PROTONTRICKS_TEMP_PATH/restart" fi fi if [[ -z "$PROTONTRICKS_FIRST_START" ]]; then if [[ "$PROTONTRICKS_STEAM_RUNTIME" = "bwrap" ]]; then # Check if the launch script is named 'pressure-vessel-launch' or # 'steam-runtime-launch-client'. The latter name is newer and used # since steam-runtime-tools v0.20220420.0 launch_script="" script_names=('pressure-vessel-launch' 'steam-runtime-launch-client') for name in "${script_names[@]}"; do if [[ -f "$STEAM_RUNTIME_PATH/pressure-vessel/bin/$name" ]]; then launch_script="$STEAM_RUNTIME_PATH/pressure-vessel/bin/$name" log_info "Found Steam Runtime launch client at $launch_script" fi done if [[ "$launch_script" = "" ]]; then echo "Launch script could not be found, aborting..." exit 1 fi export STEAM_RUNTIME_LAUNCH_SCRIPT="$launch_script" fi # Try to detect if wineserver is already running, and if so, copy a few # environment variables from it to ensure our own Wine processes # are able to run at the same time without any issues. # This usually happens when the user is running the Steam app and # Protontricks at the same time. wineserver_found=false log_info "Checking for running wineserver instance" # Find the correct Wineserver that's using the same prefix while read -r pid; do if [[ $(xargs -0 -L1 -a "/proc/${pid}/environ" | grep "^WINEPREFIX=${WINEPREFIX}") ]] &> /dev/null; then if [[ "$pid" = "$$" ]]; then # Don't mistake this very script for a wineserver instance continue fi wineserver_found=true wineserver_pid="$pid" log_info "Found running wineserver instance with PID ${wineserver_pid}" fi done < <(pgrep "wineserver$") if [[ "$wineserver_found" = true ]]; then # wineserver found, retrieve its environment variables. # wineserver might disappear from under our foot especially if we're # in the middle of running a lot of Wine commands in succession, # so don't assume the wineserver still exists. wineserver_env_vars=$(xargs -0 -L1 -a "/proc/${wineserver_pid}/environ" 2> /dev/null || echo "") # Copy the required environment variables found in the # existing wineserver process for env_name in "${WINESERVER_ENV_VARS_TO_COPY[@]}"; do env_declr=$(echo "$wineserver_env_vars" | grep "^${env_name}=" || :) if [[ -n "$env_declr" ]]; then log_info "Copying env var from running wineserver: ${env_declr}" export "${env_declr?}" fi done fi # Enable fsync & esync by default if [[ "$wineserver_found" = false ]]; then if [[ -z "$WINEFSYNC" ]]; then if [[ -z "$PROTON_NO_FSYNC" || "$PROTON_NO_FSYNC" = "0" ]]; then log_info "Setting default env: WINEFSYNC=1" export WINEFSYNC=1 fi fi if [[ -z "$WINEESYNC" ]]; then if [[ -z "$PROTON_NO_ESYNC" || "$PROTON_NO_ESYNC" = "0" ]]; then log_info "Setting default env: WINEESYNC=1" export WINEESYNC=1 fi fi fi export PROTONTRICKS_FIRST_START=1 fi # PROTONTRICKS_STEAM_RUNTIME values: # bwrap: Run Wine binaries inside Steam Runtime's bwrap sandbox, # modify LD_LIBRARY_PATH to include Proton libraries # # legacy: Modify LD_LIBRARY_PATH to include Steam Runtime *and* Proton # libraries. Host library order is adjusted as well. # # off: Just run the binaries as-is. if [[ -n "$PROTONTRICKS_INSIDE_STEAM_RUNTIME" || "$PROTONTRICKS_STEAM_RUNTIME" = "legacy" || "$PROTONTRICKS_STEAM_RUNTIME" = "off" ]]; then if [[ -n "$PROTONTRICKS_INSIDE_STEAM_RUNTIME" ]]; then log_info "Starting Wine process inside the container" else log_info "Starting Wine process directly, Steam runtime: $PROTONTRICKS_STEAM_RUNTIME" fi # If either Steam Runtime is enabled, change LD_LIBRARY_PATH if [[ "$PROTONTRICKS_STEAM_RUNTIME" = "bwrap" ]]; then export LD_LIBRARY_PATH="$LD_LIBRARY_PATH":"$PROTON_LD_LIBRARY_PATH" log_info "Appending to LD_LIBRARY_PATH: $PROTON_LD_LIBRARY_PATH" elif [[ "$PROTONTRICKS_STEAM_RUNTIME" = "legacy" ]]; then export LD_LIBRARY_PATH="$PROTON_LD_LIBRARY_PATH" log_info "LD_LIBRARY_PATH set to $LD_LIBRARY_PATH" fi exec "$PROTON_DIST_PATH"/bin/@@name@@ "$@" || : elif [[ "$PROTONTRICKS_STEAM_RUNTIME" = "bwrap" ]]; then # Command is being executed outside Steam Runtime and bwrap is enabled. # Use "pressure-vessel-launch" to launch it in the existing container. log_info "Starting Wine process using 'pressure-vessel-launch'" # It would be nicer to use the PID here, but that would break multiple # simultaneous Protontricks sessions inside Flatpak, which doesn't seem to # expose the unique host PID. bus_name="com.github.Matoking.protontricks.App${STEAM_APPID}_${PROTONTRICKS_SESSION_ID}" # Pass all environment variables to 'steam-runtime-launch-client' except # for problematic variables that should be determined by the launch command # instead. env_params=() for env_name in $(compgen -e); do # Skip vars that should be set by 'steam-runtime-launch-client' instead if [[ "$env_name" = "XAUTHORITY" || "$env_name" = "DISPLAY" || "$env_name" = "WAYLAND_DISPLAY" ]]; then continue fi env_params+=(--pass-env "${env_name}") done exec "$STEAM_RUNTIME_LAUNCH_SCRIPT" \ --share-pids --bus-name="$bus_name" \ --directory "$PWD" \ --env=PROTONTRICKS_INSIDE_STEAM_RUNTIME=1 \ "${env_params[@]}" -- "$PROTONTRICKS_PROXY_SCRIPT_PATH" "$@" else echo "Unknown PROTONTRICKS_STEAM_RUNTIME value $PROTONTRICKS_STEAM_RUNTIME" exit 1 fi protontricks-1.12.0/src/protontricks/data/scripts/wineserver_keepalive.bat000066400000000000000000000024401467175317500272120ustar00rootroot00000000000000@ECHO OFF Rem This is a simple Windows batch script, the sole purpose of which is to Rem indirectly create a wineserver process and keep it alive. Rem Rem This is necessary when running a lot of Wine commands in succession Rem in a sandbox (eg. Steam Runtime and Winetricks), since a wineserver Rem process is started and stopped repeatedly for each command unless one Rem is already available. Rem Rem Each Steam Runtime sandbox shares the same PID namespace, meaning Wine Rem commands in other sandboxes use it automatically without having to start Rem their own, reducing startup time dramatically. ECHO wineserver keepalive process started... :LOOP Rem Keep this process alive until the 'keepalive' file is deleted; this is Rem done by Protontricks when the underlying command is finished. Rem Rem If 'restart' file appears, stop this process and wait a moment before Rem starting it again; this is done by the Bash script. Rem Rem Batch doesn't have a sleep command, so ping an unreachable IP with Rem a 2s timeout repeatedly. This is stupid, but it appears to work. ping 192.0.2.1 -n 1 -w 2000 >nul IF EXIST restart ( ECHO stopping keepalive process temporarily... DEL restart EXIT /B 0 ) IF EXIST keepalive ( goto LOOP ) ELSE ( ECHO keepalive file deleted, quitting... ) protontricks-1.12.0/src/protontricks/data/scripts/wineserver_keepalive.sh000066400000000000000000000045751467175317500270710ustar00rootroot00000000000000#!/bin/bash # A simple keepalive script that will ensure a wineserver process is kept alive # for the duration of the Protontricks session. # This is accomplished by launching a simple Windows batch script that will # run until it is prompted to close itself at the end of the Protontricks # session. set -o errexit function log_info () { if [[ "$PROTONTRICKS_LOG_LEVEL" != "INFO" ]]; then return fi log "$@" } function log_warning () { if [[ "$PROTONTRICKS_LOG_LEVEL" = "INFO" || "$PROTONTRICKS_LOG_LEVEL" = "WARNING" ]]; then return fi log "$@" } function log () { >&2 echo "protontricks - $(basename "$0") $$: $*" } function cleanup () { # Remove the 'keepalive' file in the temp directory. This will prompt # the Wine process to stop execution. rm "$PROTONTRICKS_TEMP_PATH/keepalive" &>/dev/null || true log_info "Cleanup finished, goodbye!" } touch "$PROTONTRICKS_TEMP_PATH/keepalive" trap cleanup EXIT HUP INT QUIT ABRT cd "$PROTONTRICKS_TEMP_PATH" || exit 1 while [[ -f "$PROTONTRICKS_TEMP_PATH/keepalive" ]]; do log_info "Starting wineserver-keepalive process..." wine cmd.exe /c "@@keepalive_bat_path@@" &>/dev/null if [[ -f "$PROTONTRICKS_TEMP_PATH/keepalive" ]]; then # If 'keepalive' still exists, someone called 'wineserver -w'. # To prevent that command from stalling indefinitely, we need to # shut down this process temporarily until the waiting command # has terminated. wineserver_finished=false log_info "'wineserver -w' was called, waiting until all processes are finished..." while [[ "$wineserver_finished" = false ]]; do wineserver_finished=true while read -r pid; do if [[ "$pid" = "$$" ]]; then continue fi if [[ $(pgrep -a "$pid" | grep -v -E '\/wineserver -w$') ]] &> /dev/null; then # Skip commands that do *not* end with 'wineserver -w' continue fi if [[ $(xargs -0 -L1 -a "/proc/${pid}/environ" | grep "^WINEPREFIX=${WINEPREFIX}") ]] &> /dev/null; then wineserver_finished=false fi done < <(pgrep wineserver) sleep 0.25 done log_info "All wineserver processes finished, restarting keepalive process..." fi done protontricks-1.12.0/src/protontricks/data/share/000077500000000000000000000000001467175317500217175ustar00rootroot00000000000000protontricks-1.12.0/src/protontricks/data/share/applications/000077500000000000000000000000001467175317500244055ustar00rootroot00000000000000protontricks-1.12.0/src/protontricks/data/share/applications/protontricks-launch.desktop000066400000000000000000000003631467175317500320130ustar00rootroot00000000000000[Desktop Entry] Exec=protontricks-launch --no-term %f Name=Protontricks Launcher Type=Application Terminal=false NoDisplay=true Categories=Utility Icon=wine MimeType=application/x-ms-dos-executable;application/x-msi;application/x-ms-shortcut; protontricks-1.12.0/src/protontricks/data/share/applications/protontricks.desktop000066400000000000000000000003671467175317500305470ustar00rootroot00000000000000[Desktop Entry] Exec=protontricks --no-term --gui Name=Protontricks Comment=A simple wrapper that does winetricks things for Proton enabled games Type=Application Terminal=false Categories=Utility; Icon=wine Keywords=Steam;Proton;Wine;Winetricks; protontricks-1.12.0/src/protontricks/flatpak.py000066400000000000000000000113301467175317500216760ustar00rootroot00000000000000import configparser import logging import os import re import subprocess from pathlib import Path __all__ = ( "FLATPAK_BWRAP_COMPATIBLE_VERSION", "FLATPAK_INFO_PATH", "is_flatpak_sandbox", "get_running_flatpak_version", "get_inaccessible_paths" ) logger = logging.getLogger("protontricks") # Flatpak minimum version required to enable bwrap. In other words, the first # Flatpak version with the necessary support for sub-sandboxes. FLATPAK_BWRAP_COMPATIBLE_VERSION = (1, 12, 1) FLATPAK_INFO_PATH = "/.flatpak-info" def is_flatpak_sandbox(): """ Check if we're running inside a Flatpak sandbox """ return bool(get_running_flatpak_version()) def _get_flatpak_config(): config = configparser.ConfigParser() try: config.read_string(Path(FLATPAK_INFO_PATH).read_text(encoding="utf-8")) except FileNotFoundError: return None return config _XDG_PERMISSIONS = { "xdg-desktop": "DESKTOP", "xdg-documents": "DOCUMENTS", "xdg-download": "DOWNLOAD", "xdg-music": "MUSIC", "xdg-pictures": "PICTURES", "xdg-public-share": "PUBLICSHARE", "xdg-videos": "VIDEOS", "xdg-templates": "TEMPLATES", } def _get_xdg_user_dir(permission): """ Get the XDG user directory corresponding to the given "xdg-" prefixed Flatpak permission and retrieve its absolute path using the `xdg-user-dir` command. """ if permission in _XDG_PERMISSIONS: # This will only be called in a Flatpak environment, and we can assume # 'xdg-user-dir' always exists in that environment. path = subprocess.check_output( ["xdg-user-dir", _XDG_PERMISSIONS[permission]] ) path = path.strip() path = os.fsdecode(path) logger.debug("XDG path for %s is %s", permission, path) return Path(path) return None def get_running_flatpak_version(): """ Get the running Flatpak version if running inside a Flatpak sandbox, or None if Flatpak sandbox isn't active """ config = _get_flatpak_config() if config is None: return None # If this fails it's because the Flatpak version is older than 0.6.10. # Since Steam Flatpak requires at least 1.0.0, we can fail here instead # of continuing on. It's also extremely unlikely, since even older distros # like CentOS 7 ship Flatpak releases newer than 1.0.0. version = config["Instance"]["flatpak-version"] # Remove non-numeric characters just in case (eg. if a suffix like '-pre' # is used). version = "".join([ch for ch in version if ch in ("0123456789.")]) # Convert version number into a tuple version = tuple([int(part) for part in version.split(".")]) return version def get_inaccessible_paths(paths): """ Check which given paths are inaccessible under Protontricks. Inaccessible paths are returned as a list. This has no effect in non-Flatpak environments, where an empty list is always returned. """ def _path_is_relative_to(a, b): try: a.relative_to(b) return True except ValueError: return False def _map_path(path): if path == "": return None if path.startswith("xdg-data/"): return ( Path("~/.local/share").expanduser() / path.split("xdg-data/")[1] ) if path.startswith("xdg-"): path_ = _get_xdg_user_dir(path) if path_: return path_ if path == "home": return Path.home() if path.startswith("/"): return Path(path).resolve() if path.startswith("~"): return Path(path).expanduser() logger.warning( "Unknown Flatpak file system permission '%s', ignoring.", path ) return None if not is_flatpak_sandbox(): return [] config = _get_flatpak_config() try: mounted_paths = \ re.split(r'(? app ID" mappings with this app ID STEAM_PLAY_MANIFESTS_APPID = 891390 logger = logging.getLogger("protontricks") class SteamApp(object): """ SteamApp represents an installed Steam app or whatever is close enough to one (eg. a custom Proton installation or a Windows shortcut with its own Proton prefix) """ __slots__ = ( "appid", "name", "prefix_path", "install_path", "icon_path", "required_tool_appid", "required_tool_app" ) def __init__( self, name, install_path, icon_path=None, prefix_path=None, appid=None, required_tool_appid=None): """ :appid: App's appid :name: The app's human-readable name :prefix_path: Absolute path to where the app's Wine prefix *might* exist. :app_path: Absolute path to app's installation directory :icon_path: Absolute path to app's icon, whether Steam's own or custom one configured by user :required_tool_appid: App ID required to run this application. Usually corresponds to a Steam Runtime for Proton installations. """ self.appid = int(appid) if appid else None self.required_tool_appid = \ int(required_tool_appid) if required_tool_appid else None self.name = name if prefix_path: self.prefix_path = Path(prefix_path) else: self.prefix_path = None if icon_path: self.icon_path = Path(icon_path) else: self.icon_path = None self.install_path = Path(install_path) # Reference to another SteamApp will be added later if necessary, # once we have the full list of Steam apps self.required_tool_app = None @property def prefix_path_exists(self): """ Returns True if the app has a Wine prefix directory that has been launched at least once """ if not self.prefix_path: return False # 'pfx' directory is incomplete until the game has been launched # once, so check for 'pfx.lock' as well return ( self.prefix_path.is_dir() and (self.prefix_path.parent / "pfx.lock").is_file() ) def name_contains(self, s): """ Returns True if the name contains the given substring. Both strings are normalized for easier searching before comparison. """ def normalize_str(s): """ Normalize the string to make it easier for human to perform a search by removing all symbols except ASCII digits and letters and turning it into lowercase """ printable = set(string.printable) - set(string.punctuation) s = "".join([c for c in s if c in printable]) s = s.lower() s = s.replace(" ", "") return s return normalize_str(s) in normalize_str(self.name) @property def is_proton(self): """ Return True if this app is a Proton installation """ # If the installation directory contains a file named "proton", # it's a Proton installation return (self.install_path / "proton").is_file() @property def is_tool(self): """ Return True if this app is a tool rather an app. This is true for Proton and Steam Runtime installations. """ return (self.install_path / "toolmanifest.vdf").is_file() @property def is_windows_app(self): """ Return True if this app is a Windows app that's launched using Proton """ return not self.is_proton and self.prefix_path_exists and self.appid @property def is_proton_ready(self): """ Return True if the Proton installation is ready for use. Proton installation might be incomplete if it hasn't been launched yet, in which case the Proton binaries don't exist yet. """ return bool(self.proton_dist_path) @property def proton_dist_path(self): """ Return path to the directory containing Proton binaries and libraries. None is returned if this app isn't a Proton installation or either directory doesn't exist. The directory is named either 'dist' or 'files'. 'dist' is used by older Proton releases, and it is extracted from a separate 'proton_dist.tar' archive during first launch. 'files' is used by newer Proton releases, and it already exists after the Steam app has been installed, requiring no first launch. """ if not self.is_proton: return None try: # Prioritize 'files' directory if it exists. # If both directories exist, 'dist' is likely a leftover that # wasn't removed by Steam. return next( (self.install_path / name) for name in ("files", "dist") if (self.install_path / name).is_dir() ) except StopIteration: return None @classmethod def from_appmanifest(cls, path, steam_lib_paths, steam_path=None): """ Parse appmanifest_X.acf file containing Steam app installation metadata and return a SteamApp object If 'steam_path' is provided, icon path is also populated """ logger.debug( "Creating SteamApp from manifest file in %s", path ) try: content = path.read_text(encoding="utf-8") except UnicodeDecodeError: # This might occur if the appmanifest becomes corrupted # eg. due to running a Linux filesystem under Windows # In that case just skip it logger.warning( "Skipping malformed appmanifest %s", path ) return None except PermissionError: # Skip the appmanifest if we can't read it. # Steam also seems to ignore unreadable app manifests, so do the # same here. logger.warning( "Skipping appmanifest %s due to insufficient permissions", path ) return None try: vdf_data = lower_dict(vdf.loads(content)) except SyntaxError: logger.warning("Skipping malformed appmanifest %s", path) return None try: app_state = vdf_data["appstate"] except KeyError: # Some appmanifest files may be empty. Ignore those. logger.info("Skipping empty appmanifest %s", path) return None # The app ID field can be named 'appID' or 'appid'. # 'appid' is more common, but certain appmanifest # files (created by old Steam clients?) also use 'appID'. # # Use case-insensitive field names to deal with these. app_state = lower_dict(app_state) appid = int(app_state["appid"]) try: name = app_state["name"] except KeyError: # Older app installations also use `userconfig/name` name = app_state["userconfig"]["name"] # Proton prefix may exist on a different library prefix_path = find_appid_proton_prefix( appid=appid, steam_lib_paths=steam_lib_paths ) install_path = Path(path).parent / "common" / app_state["installdir"] icon_path = None # If Steam path was provided, also populate the icon path if steam_path: icon_path = \ steam_path / "appcache" / "librarycache" / f"{appid}_icon.jpg" # Check if the app requires another app. This is the case with # newer versions of Proton, which use Steam Runtimes installed as # normal Steam apps try: required_tool_appid = _get_required_tool_appid(install_path) except (ValueError, SyntaxError): logger.warning( "Tool manifest for %s is empty or corrupted. You may need to " "reinstall the application.", name ) return None return cls( appid=appid, name=name, prefix_path=prefix_path, install_path=install_path, icon_path=icon_path, required_tool_appid=required_tool_appid ) def _get_required_tool_appid(path): """ Get the required tool app ID for the Proton installation at the given path :raises ValueError: Tool manifest is empty :raises SyntaxError: Tool manifest is corrupted """ tool_manifest_path = path / "toolmanifest.vdf" try: tool_manifest_content = tool_manifest_path.read_text() if tool_manifest_content == "": raise ValueError("Tool manifest is empty") tool_manifest = lower_dict(vdf.loads(tool_manifest_content)) return tool_manifest["manifest"].get("require_tool_appid", None) except FileNotFoundError: return None def _get_steamapps_subdirs(path): """ Get all directories under the given path that match 'steamapps' in a case-insensitive manner """ try: dirs = list( entry for entry in path.iterdir() if entry.name.lower() == "steamapps" and entry.is_dir() ) except FileNotFoundError: # Directory does not exist return [] # Sort entries so that 'steamapps' is listed first, as it's the default # directory name that Steam uses and should thus be prioritized dirs = list(reversed(sorted(dirs, key=lambda dir_: dir_.name))) return dirs def find_steam_installations(): """ Find all Steam installations and return them as a list of (steam_path, steam_root) tuples """ def has_steamapps_dir(path): """ Return True if the path either has a 'steamapps' subdirectory, False otherwise """ # Steam doesn't care about case-insensitivity and considers even # names like 'SteaMAPps' valid try: return any( path.name.lower() == "steamapps" and path.is_dir() for path in path.iterdir() ) except FileNotFoundError: # Directory does not exist return False def has_runtime_dir(path): return (path / "ubuntu12_32").is_dir() # as far as @admalledd can tell, # this should always be correct for the tools root: steam_root = (Path.home() / ".steam" / "root").resolve() if not (steam_root / "ubuntu12_32").is_dir(): # Check that runtime dir exists, if not make root=path and hope steam_root = None if os.environ.get("STEAM_DIR"): steam_path = Path(os.environ.get("STEAM_DIR")) if has_steamapps_dir(steam_path) and has_runtime_dir(steam_path): logger.info( "Found a valid Steam installation at %s.", steam_path ) return [ (Path(steam_path), Path(steam_path)) ] logger.error( "$STEAM_DIR was provided but didn't point to a valid Steam " "installation." ) return [] # Track the found Steam directory candidates using an ordered dict, # ensuring that any duplicates are eliminated and that we pick the first # candidate we find. # We essentially use this as an ordered set. candidates = OrderedDict() for steam_path in COMMON_STEAM_DIRS: # The common Steam directories are found inside the home directory steam_path = (Path.home() / steam_path).resolve() if has_steamapps_dir(steam_path): if not steam_root: steam_root_ = steam_path else: steam_root_ = steam_root candidates[(str(steam_path), str(steam_root_))] = True # Check for Flatpak and Snap Steam separately and ensure we don't mix steam_root # and steam_path from Flatpak/Snap and native installations of Steam. steam_path = \ Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam" steam_path = steam_path.resolve() if has_steamapps_dir(steam_path): candidates[(str(steam_path), str(steam_path))] = True for steam_dir in SNAP_STEAM_DIRS: steam_path = (Path.home() / steam_dir).resolve() if has_steamapps_dir(steam_path): candidates[(str(steam_path), str(steam_path))] = True for steam_path, _ in candidates.keys(): logger.info("Found Steam directory at %s", steam_path) return [ [Path(steam_path), Path(steam_root)] for steam_path, steam_root in candidates.keys() ] def find_steam_path(): """ Try to discover default Steam dir using common locations and return the first one that matches Return (steam_path, steam_root), where steam_path points to "~/.steam/steam" (contains "appcache", "config" and "steamapps") and "~/.steam/root" (contains "ubuntu12_32" and "compatibilitytools.d") """ candidates = find_steam_installations() if len(candidates) > 1: logger.warning( "Found multiple Steam directories. If you want to select a " "specific installation, use STEAM_DIR environment variable to set " "the correct directory:" ) logger.warning("$ STEAM_DIR= protontricks ") logger.warning("The following Steam directories were found:") for steam_path, _ in candidates: logger.warning(" %s", str(steam_path)) try: steam_path, steam_root = candidates[0] logger.info( "Using Steam directory at %s. You can also define Steam directory " "manually using $STEAM_DIR", str(steam_path) ) return Path(steam_path), Path(steam_root) except IndexError: return None, None def find_legacy_steam_runtime_path(steam_root): """ Find the legacy Steam Runtime either using the STEAM_RUNTIME env or steam_root """ env_steam_runtime = os.environ.get("STEAM_RUNTIME", "") if env_steam_runtime == "0": # User has disabled Steam Runtime logger.info("STEAM_RUNTIME is 0. Disabling Steam Runtime.") return None elif env_steam_runtime and Path(env_steam_runtime).is_dir(): # User has a custom Steam Runtime logger.info( "Using custom Steam Runtime at %s", env_steam_runtime) return Path(env_steam_runtime) elif env_steam_runtime in ["1", ""]: # User has enabled Steam Runtime or doesn't have STEAM_RUNTIME set; # default to enabled Steam Runtime in either case steam_runtime_path = steam_root / "ubuntu12_32" / "steam-runtime" logger.info( "Using default Steam Runtime at %s", str(steam_runtime_path)) return steam_runtime_path logger.error( "Path in STEAM_RUNTIME doesn't point to a valid Steam Runtime!") return None APPINFO_STRUCT_HEADER = "<4sL" APPINFO_V28_STRUCT_SECTION = "/config/config.vdf` and `/appcache/appinfo.vdf`. # Multiple configuration values might be found. In such case, select the # one with the highest priority. config_vdf_path = steam_path / "config" / "config.vdf" # config.vdf might contain invalid UTF-8 characters in the # `SDL_GamepadBind` field. We don't use that in any way, so we can deal # with the invalid characters by just ignoring them. content = config_vdf_path.read_text(errors="replace") vdf_data = lower_dict(vdf.loads(content)) appinfo_path = steam_path / "appcache" / "appinfo.vdf" appinfo_sections = [ section for section in iter_appinfo_sections(appinfo_path) if section["appinfo"]["appid"] in (STEAM_PLAY_MANIFESTS_APPID, appid) ] steam_play_manifest = next( section for section in appinfo_sections if section["appinfo"]["appid"] == STEAM_PLAY_MANIFESTS_APPID ) try: app_section = next( section for section in appinfo_sections if section["appinfo"]["appid"] == appid ) except StopIteration: # App ID was most likely not provided app_section = None try: manifest_app_compat_section = next( entry for mapping_appid, entry in steam_play_manifest["appinfo"]["extended"]["app_mappings"].items() if int(mapping_appid) == appid ) except StopIteration: # App doesn't have a default compatibility tool mapping manifest_app_compat_section = None # ToolMapping seems to be used in older Steam beta releases try: tool_mapping = ( vdf_data["installconfigstore"]["software"]["valve"]["steam"] ["toolmapping"] ) logger.debug("Found ToolMapping entry") except KeyError: tool_mapping = {} # CompatToolMapping seems to be the name used in newer Steam releases # We'll prioritize this if it exists try: compat_tool_mapping = ( vdf_data["installconfigstore"]["software"]["valve"]["steam"] ["compattoolmapping"] ) logger.debug("Found CompatToolMapping entry") except KeyError: compat_tool_mapping = {} # The name of potential names in order of priority potential_names = [] # Game specific user settings have the 1st priority, if they exist if compat_tool_mapping.get(str(appid), {}).get("name"): tool_name = compat_tool_mapping[str(appid)]["name"] logger.info( "User has configured app Proton version (CompatToolMapping): %s", tool_name ) potential_names.append(tool_name) # Steam Deck compatibility profile has the 2nd highest priority if app_section and is_steam_deck(): logger.info( "We're on a Steam Deck, checking if compatibility profile is " "available for the app" ) recommended_runtime = ( app_section["appinfo"] .get("common", {}) .get("steam_deck_compatibility", {}) .get("configuration", {}) .get("recommended_runtime", None) ) if recommended_runtime not in (None, "native"): logger.info( "App has Steam Deck compatibility profile with Proton " "version: %s", recommended_runtime ) potential_names.append(recommended_runtime) # Game specific default compatibility tool mapping in Steam Play 2.0 # manifest has the 3rd highest priority if manifest_app_compat_section: if "tool" in manifest_app_compat_section: tool = manifest_app_compat_section["tool"] logger.info( "App has default compatibility tool mapping in the Steam Play " "manifest: %s", tool ) potential_names.append(tool) # Global user settings have the 4th highest priority if compat_tool_mapping.get("0", {}).get("name"): tool_name = compat_tool_mapping["0"]["name"] logger.info( "User has configured default Proton version (CompatToolMapping): " "%s", tool_name ) potential_names.append(tool_name) # Legacy user settings (ToolMapping) have the 5th highest priority if tool_mapping.get(str(appid), {}).get("name", {}): tool_name = tool_mapping[str(appid)]["name"] logger.info( "User has configured app Proton version (ToolMapping): %s", tool_name ) potential_names.append(tool_name) if tool_mapping.get("0", {}).get("name", {}): tool_name = tool_mapping["0"]["name"] logger.info( "User has configured default Proton version (ToolMapping): %s", tool_name ) potential_names.append(tool_name) # Get the first name that was valid, or use stable Proton as fallback try: compat_tool_name = next(name for name in potential_names if name) except StopIteration: logger.info( "No compatibility tool found by reading Steam configuration. " "Using stable version of Proton as fallback." ) compat_tool_name = "proton-stable" # We've got a compatibility tool name, # now there are two possible ways to find the installation # 1. It's a custom compatibility tool, and we simply need to find # a SteamApp by its internal name # 2. It's a production Proton installation, in which case we need # to parse a binary configuration file to find the App ID # Let's try option 1 first try: app = next( app for app in steam_apps if app.name == compat_tool_name ) logger.info( "Found active custom compatibility tool: %s", app.name ) return app except StopIteration: pass # Try option 2: # Find the corresponding App ID from /appcache/appinfo.vdf tool_app = _get_tool_app( compat_tool_name=compat_tool_name, steam_apps=steam_apps, steam_play_manifest=steam_play_manifest ) if tool_app: logger.info( "Found active compatibility tool: %s", tool_app.name ) return tool_app logger.error("Could not find configured Proton installation!") def find_appid_proton_prefix(appid, steam_lib_paths): """ Find the Proton prefix for the app by its App ID Proton prefix and the game installation itself can exist on different Steam libraries, making a search necessary """ def get_prefix_modify_time(prefix_path): """ Get the prefix modification time for sorting purposes. The newest modification time corresponds to the most recently used Proton prefix """ try: # 'pfx.lock' is modified on game launch return (prefix_path.parent / "pfx.lock").stat().st_mtime except FileNotFoundError: return 0 candidates = [] for path in steam_lib_paths: steamapps_dirs = _get_steamapps_subdirs(path) for steamapps_path in steamapps_dirs: prefix_path = steamapps_path / "compatdata" / str(appid) / "pfx" if prefix_path.is_dir(): candidates.append(prefix_path) logger.debug( "Found compatdata directories for app %s: %s", appid, candidates ) if len(candidates) > 1: # If we have more than one possible prefix path, use the one # with the most recent modification date logger.info( "Multiple compatdata directories found for app %s", appid ) candidates.sort(key=get_prefix_modify_time) candidates.reverse() if candidates: return candidates[0] return None def find_proton_app(steam_path, steam_apps, appid=None): """ Find the Proton app, using either $PROTON_VERSION or the one currently configured in Steam If 'appid' is provided, use it to find the app-specific Proton installation if one is configured """ if os.environ.get("PROTON_VERSION"): proton_version = os.environ.get("PROTON_VERSION") try: proton_app = next( app for app in steam_apps if app.name == proton_version) logger.info( "Found requested Proton version: %s", proton_app.name ) return proton_app except StopIteration: logger.error( "$PROTON_VERSION was set but matching Proton installation " "could not be found." ) return None tool_app = find_steam_compat_tool_app( steam_path=steam_path, steam_apps=steam_apps, appid=appid) if not tool_app: logger.error( "Active Proton installation could not be found automatically." ) return None # Check that it's actually a Proton app; Protontricks doesn't handle # other compatibility tools. if not tool_app.is_proton: logger.error( "Active compatibility tool was found, but it's not a Proton " "installation supported by Protontricks." ) return None logger.info("Active compatibility tool is a Proton installation") return tool_app def get_steam_lib_paths(steam_path): """ Return a list of any Steam directories including any user-added Steam library folders """ def parse_library_folders(data): """ Parse the Steam library folders in the VDF file using the given data """ vdf_data = lower_dict(vdf.loads(data)) # Library folders have integer field names in ascending order library_entries = [ value for key, value in vdf_data["libraryfolders"].items() if key.isdigit() ] library_folders = [] for value in library_entries: if isinstance(value, dict): # Library data is stored in a dict in newer Steam releases path = Path(value["path"]) else: # Older releases just store the library path as a string # and nothing else path = Path(value) xdg_steam_path = Path.home() / ".local/share/Steam" flatpak_steam_path = \ Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam" is_library_folder_xdg_steam = str(path) == str(xdg_steam_path) is_flatpak_steam = str(steam_path) == str(flatpak_steam_path) # Adjust the path if the library folder is "~/.local/share/Steam" # and we're looking for library folders in # "~/.var/app/com.valvesoftware.Steam" # This is because ~/.local/share/Steam inside a Steam Flatpak # sandbox corresponds to a different location, and we need to # adjust for that. if is_library_folder_xdg_steam and is_flatpak_steam: path = flatpak_steam_path logger.debug( "Found Steam library folder %s. Is Flatpak path: %s, " "Is XDG Steam path: %s.", path, is_flatpak_steam, is_library_folder_xdg_steam ) library_folders.append(path) logger.info( "Found %d Steam library folders", len(library_folders) ) return library_folders # Try finding Steam library folders using libraryfolders.vdf in Steam root try: steamapps_path = _get_steamapps_subdirs(steam_path)[0] folders_vdf_path = steamapps_path / "libraryfolders.vdf" except IndexError as exc: raise ValueError( "Steam installation directory does not contain a 'steamapps' " "directory. Installation may be incomplete or broken." ) from exc try: library_folders = parse_library_folders(folders_vdf_path.read_text()) except OSError: # libraryfolders.vdf doesn't exist; maybe no Steam library folders # are set? library_folders = [] except SyntaxError as exc: raise ValueError( f"Library folder configuration file {folders_vdf_path} is corrupted" ) from exc paths = [steam_path] + library_folders # Get rid of duplicate paths by fully resolving them and turning them into # a set and back paths = [path.resolve() for path in paths] paths = list(set(paths)) return paths def get_compat_tool_dirs(steam_root): """ Return a list of compatibility tool directories in order from directories with lowest precedence """ # The path list is ordered by priority, starting from Proton apps # with the lowest precedence ('/usr/share/steam/compatibilitytools.d') paths = [ Path("/usr/share/steam/compatibilitytools.d"), Path("/usr/local/share/steam/compatibilitytools.d"), ] extra_ct_paths_env = os.getenv("STEAM_EXTRA_COMPAT_TOOLS_PATHS") if extra_ct_paths_env: logger.debug( "Including extra compat tool paths provided via env var: %s", extra_ct_paths_env ) paths += [Path(path) for path in extra_ct_paths_env.split(os.pathsep)] paths += [steam_root / "compatibilitytools.d"] return paths def get_custom_compat_tool_installations_in_dir(compat_tool_dir): """ Return a list of custom compatibility tools in the given directory as a list of SteamApp objects """ if not compat_tool_dir.is_dir(): return [] comptool_files = list(compat_tool_dir.glob("*/compatibilitytool.vdf")) comptool_files += list(compat_tool_dir.glob("compatibilitytool.vdf")) custom_tool_apps = [] for vdf_path in comptool_files: content = vdf_path.read_text() logger.debug( "Parsing custom compatibility tool manifest at %s", vdf_path ) try: vdf_data = vdf.loads(content) except SyntaxError: logger.warning( "Compatibility tool declaration at %s is corrupted. You may " "need to reinstall the application.", vdf_path ) continue if not vdf_data: logger.warning( "Compatibility tool declaration at %s is empty. You may need " "to reinstall the application.", vdf_path ) continue # Traverse to 'compatibilitytools/compat_tools' in a case-insensitive # way. This is done because we can't turn all keys recursively to # lowercase from the get-go; the app name is stored as a key. try: compat_tools = {k.lower(): v for k, v in vdf_data.items()} compat_tools = compat_tools["compatibilitytools"] compat_tools = { k.lower(): v for k, v in compat_tools.items() } compat_tools = compat_tools["compat_tools"] internal_name = list(compat_tools.keys())[0] tool_info = compat_tools[internal_name] # We can now convert the remainder into lowercase tool_info = lower_dict(tool_info) install_path_name = tool_info["install_path"] from_oslist = tool_info["from_oslist"] to_oslist = tool_info["to_oslist"] except KeyError: logger.warning( "Compatibility tool declaration at %s is incomplete. You may " "need to reinstall the application", vdf_path ) continue if from_oslist != "windows" or to_oslist != "linux": continue # Installation path can be relative if the VDF was in # 'compatibilitytools.d/' # or '.' if the VDF was in 'compatibilitytools.d/TOOL_NAME' if install_path_name == ".": install_path = vdf_path.parent else: install_path = compat_tool_dir / install_path_name # Check if the app requires another app. This is the case with # newer versions of Proton, which use Steam Runtimes installed as # normal Steam apps try: required_tool_appid = _get_required_tool_appid(install_path) except (ValueError, SyntaxError): logger.warning( "Tool manifest for %s is empty or corrupted. You may need to " "reinstall the application.", install_path.name ) continue logger.debug( "Found custom compatibility tool %s at %s", internal_name, install_path ) custom_tool_apps.append( SteamApp( name=internal_name, install_path=install_path, required_tool_appid=required_tool_appid ) ) return custom_tool_apps def get_custom_compat_tool_installations(steam_root): """ Get a list of all custom compatibility tools as a list of SteamApp objects """ custom_tool_apps = {} for dir_ in get_compat_tool_dirs(steam_root=steam_root): for tool_app in get_custom_compat_tool_installations_in_dir(dir_): # If another tool app exists with the same name, it will # be replaced with an installation that has higher precedence # here custom_tool_apps[tool_app.name] = tool_app # Return the list of tool apps as a list custom_tool_apps = list(custom_tool_apps.values()) return custom_tool_apps def find_current_steamid3(steam_path): """ Find the SteamID3 of the currently logged in Steam user """ def to_steamid3(steamid64): """Convert a SteamID64 into the SteamID3 format""" return int(steamid64) & 0xffffffff loginusers_path = steam_path / "config" / "loginusers.vdf" try: content = loginusers_path.read_text() vdf_data = lower_dict(vdf.loads(content)) except IOError: logger.warning( "Couldn't determine the currently logged-in Steam user. Custom " "shortcuts won't be detected." ) return None user_datas = [ (user_id, lower_dict(user_data)) for user_id, user_data in vdf_data["users"].items() ] users = [ { "steamid3": to_steamid3(user_id), "account_name": user_data["accountname"], "timestamp": user_data.get("timestamp", 0) } for user_id, user_data in user_datas ] logger.debug("Found Steam user entries: %s", users) # Return the user with the highest timestamp, as that's likely to be the # currently logged-in user if users: user = max(users, key=lambda u: u["timestamp"]) logger.info( "Currently logged-in Steam user: %s", user["account_name"] ) return user["steamid3"] return None def get_appid_from_shortcut(target, name): """ Get the identifier used for the Proton prefix from a shortcut's target and name """ # First, calculate the screenshot ID Steam uses for shortcuts data = b"".join([ target.encode("utf-8"), name.encode("utf-8") ]) result = zlib.crc32(data) & 0xffffffff result = result | 0x80000000 result = (result << 32) | 0x02000000 # Derive the prefix ID from the screenshot ID return result >> 32 def get_custom_windows_shortcuts(steam_path, steam_lib_paths): """ Get a list of custom shortcuts for Windows applications as a list of SteamApp objects """ # Get the Steam ID3 for the currently logged-in user steamid3 = find_current_steamid3(steam_path) shortcuts_path = \ steam_path / "userdata" / str(steamid3) / "config" / "shortcuts.vdf" try: content = shortcuts_path.read_bytes() # Tolerate VDF files that have extra data after the binary VDF section. # Steam itself can supposedly create such files in some situations. vdf_data = lower_dict( vdf.binary_loads(content, raise_on_remaining=False) ) except IOError: logger.info( "Couldn't find custom shortcuts. Maybe none have been created yet?" ) return [] steam_apps = [] for shortcut_id, shortcut_data in vdf_data["shortcuts"].items(): # The "exe" field can also be "Exe". Account for this by making # all field names lowercase shortcut_data = lower_dict(shortcut_data) shortcut_id = int(shortcut_id) if "appid" in shortcut_data: try: appid = shortcut_data["appid"] & 0xffffffff except TypeError: logger.info( "Skipping unrecognized non-Steam shortcut with app ID " "'%s'", shortcut_data["appid"] ) continue else: appid = get_appid_from_shortcut( target=shortcut_data["exe"], name=shortcut_data["appname"] ) prefix_path = find_appid_proton_prefix( appid=appid, steam_lib_paths=steam_lib_paths ) if not prefix_path: logger.info( "Shortcut %s (%s) does not have a prefix. " "It's either not a Proton app or it hasn't been launched yet.", shortcut_data["appname"], appid ) continue install_path = Path(shortcut_data["startdir"].strip('"')) try: # Check that we have permission to access the installation # directory, whether it exists or not. Any attempts to check # any files will fail later if this isn't done. install_path.is_dir() if not prefix_path.is_dir(): continue except PermissionError as exc: logger.warning( "Skipping shortcut %s due to insufficient permissions. " "Error: %s", shortcut_data["appname"], str(exc) ) continue icon_path = None if shortcut_data.get("icon", None): icon_path = Path(shortcut_data["icon"]) logger.debug( "Creating SteamApp from non-Steam shortcut in %s", install_path ) steam_apps.append( SteamApp( appid=appid, name=f"Non-Steam shortcut: {shortcut_data['appname']}", prefix_path=prefix_path, install_path=install_path, icon_path=icon_path ) ) logger.info( "Found %d Steam shortcuts running using Steam compatibility tools", len(steam_apps) ) return steam_apps def _link_tool_apps(steam_apps): """ Check which Steam apps require other Steam apps and add the corresponding references """ appid2steam_app = {steam_app.appid: steam_app for steam_app in steam_apps} for steam_app in steam_apps: if steam_app.required_tool_appid: steam_app.required_tool_app = \ appid2steam_app.get(steam_app.required_tool_appid) def get_steam_apps(steam_root, steam_path, steam_lib_paths): """ Find all the installed Steam apps and return them as a list of SteamApp objects """ steam_apps = [] for path in steam_lib_paths: try: if not path.is_dir(): logger.warning( "Steam library folder %s not found. Protontricks " "might not have access to the directory.", str(path) ) continue except PermissionError: logger.warning( "Skipping library folder %s due to insufficient permissions", str(path) ) continue appmanifest_paths = [] steamapps_dirs = _get_steamapps_subdirs(path) try: appmanifest_paths = steamapps_dirs[0].glob("appmanifest_*.acf") except IndexError: logger.warning( "No 'steamapps' directory was found at %s", str(path) ) if len(steamapps_dirs) > 1: # Log a warning if multiple 'steamapps' directories with different # cases exist, as both Protontricks and Steam client have problems # dealing with them (see issue #51) logger.warning( "Multiple 'steamapps' directories were found " "at %s. Only one directory should exist to prevent issues " "with app and Proton discovery.", str(path) ) for manifest_path in appmanifest_paths: try: steam_app = SteamApp.from_appmanifest( manifest_path, steam_lib_paths=steam_lib_paths, steam_path=steam_path ) except PermissionError as exc: logger.warning( "Could not load manifest %s due to insufficient " "permissions. Error: %s", str(manifest_path), str(exc) ) steam_app = None if steam_app: steam_apps.append(steam_app) # Get the custom compatibility tools and non-Steam shortcuts as well steam_apps += get_custom_compat_tool_installations(steam_root=steam_root) steam_apps += get_custom_windows_shortcuts( steam_path=steam_path, steam_lib_paths=steam_lib_paths ) # Exclude games that haven't been launched yet steam_apps = [ app for app in steam_apps if app.prefix_path_exists or app.is_proton or app.is_tool ] # Populate the `SteamApp.required_tool_app` parameter for Steam apps # which rely on other Steam apps _link_tool_apps(steam_apps) # Sort the apps by their names steam_apps.sort(key=lambda app: app.name) return steam_apps protontricks-1.12.0/src/protontricks/util.py000066400000000000000000000454211467175317500212410ustar00rootroot00000000000000import locale import logging import os import shlex import shutil import stat import tempfile from pathlib import Path from subprocess import DEVNULL, PIPE, Popen, TimeoutExpired, check_output, run import pkg_resources __all__ = ( "SUPPORTED_STEAM_RUNTIMES", "OS_RELEASE_PATHS", "lower_dict", "is_steam_deck", "get_legacy_runtime_library_paths", "get_host_library_paths", "RUNTIME_ROOT_GLOB_PATTERNS", "get_runtime_library_paths", "WINE_SCRIPT_TEMPLATE", "get_cache_dir", "create_wine_bin_dir", "run_command" ) logger = logging.getLogger("protontricks") SUPPORTED_STEAM_RUNTIMES = [ # Old names "Steam Linux Runtime - Soldier", "Steam Linux Runtime - Sniper", # New names "Steam Linux Runtime 2.0 (soldier)", "Steam Linux Runtime 3.0 (sniper)" ] OS_RELEASE_PATHS = [ "/run/host/os-release", # The host file if we're inside a Flatpak sandbox "/etc/os-release" ] def lower_dict(d): """ Return a copy of the dictionary with all keys recursively converted to lowercase. This is mainly used when dealing with Steam VDF files, as those tend to have either CamelCase or lowercase keys depending on the version. """ def _lower_value(value): if not isinstance(value, dict): return value return {k.lower(): _lower_value(v) for k, v in value.items()} return {k.lower(): _lower_value(v) for k, v in d.items()} def is_steam_deck(): """ Check if we're running on a Steam Deck """ for path in OS_RELEASE_PATHS: try: lines = Path(path).read_text("utf-8").split("\n") except FileNotFoundError: continue if "ID=steamos" in lines and "VARIANT_ID=steamdeck" in lines: logger.info("The current device is a Steam Deck") return True return False def get_legacy_runtime_library_paths(legacy_steam_runtime_path, proton_app): """ Get LD_LIBRARY_PATH value to use when running a command using Steam Runtime """ steam_runtime_paths = check_output([ str(legacy_steam_runtime_path / "run.sh"), "--print-steam-runtime-library-paths" ]) steam_runtime_paths = str(steam_runtime_paths, "utf-8") # Add Proton installation directory first into LD_LIBRARY_PATH # so that libwine.so.1 is picked up correctly (see issue #3) return "".join([ str(proton_app.proton_dist_path / "lib"), os.pathsep, str(proton_app.proton_dist_path / "lib64"), os.pathsep, steam_runtime_paths ]) def get_host_library_paths(): """ Get host library paths to use when creating the LD_LIBRARY_PATH environment variable for use with newer Steam Runtime installations when *not* using bwrap """ # The traditional Steam Runtime does the following when running the # `run.sh --print-steam-runtime-library-paths` command. # Since that command is unavailable with newer Steam Runtime releases, # do it ourselves here. result = run( ["/sbin/ldconfig", "-XNv"], check=True, stdout=PIPE, stderr=PIPE ) lines = result.stdout.decode("utf-8").split("\n") paths = [ line.split(":")[0] for line in lines if line.startswith("/") and ":" in line ] return ":".join(paths) RUNTIME_ROOT_GLOB_PATTERNS = ( "var/*/files/", "*/files/" ) def get_runtime_library_paths(proton_app, use_bwrap=True): """ Get LD_LIBRARY_PATH value to use when running a command using Steam Runtime """ def find_runtime_app_root(runtime_app): """ Find the runtime root (the directory containing the root fileystem used for the container) for separately installed Steam Runtime app """ for pattern in RUNTIME_ROOT_GLOB_PATTERNS: try: return next( runtime_app.install_path.glob(pattern) ) except StopIteration: pass raise RuntimeError( f"Could not find Steam Runtime runtime root for {runtime_app.name}" ) if use_bwrap: return "".join([ str(proton_app.proton_dist_path / "lib"), os.pathsep, str(proton_app.proton_dist_path / "lib64"), os.pathsep ]) runtime_root = find_runtime_app_root(proton_app.required_tool_app) return "".join([ str(proton_app.proton_dist_path / "lib"), os.pathsep, str(proton_app.proton_dist_path / "lib64"), os.pathsep, get_host_library_paths(), os.pathsep, str(runtime_root / "lib" / "i386-linux-gnu"), os.pathsep, str(runtime_root / "lib" / "x86_64-linux-gnu") ]) WINE_SCRIPT_TEMPLATE = Path( pkg_resources.resource_filename( "protontricks", "data/scripts/wine_launch.sh" ) ).read_text(encoding="utf-8") WINESERVER_KEEPALIVE_SH_SCRIPT = Path( pkg_resources.resource_filename( "protontricks", "data/scripts/wineserver_keepalive.sh" ) ).read_text(encoding="utf-8") WINESERVER_KEEPALIVE_BATCH_SCRIPT = Path( pkg_resources.resource_filename( "protontricks", "data/scripts/wineserver_keepalive.bat" ) ).read_text(encoding="utf-8") BWRAP_LAUNCHER_SH_SCRIPT = Path( pkg_resources.resource_filename( "protontricks", "data/scripts/bwrap_launcher.sh" ) ).read_text(encoding="utf-8") def get_cache_dir(): """ Get Protontricks' cache directory, creating it first if it does not exist """ xdg_cache_dir = os.environ.get( "XDG_CACHE_HOME", os.path.expanduser("~/.cache") ) base_path = Path(xdg_cache_dir) / "protontricks" os.makedirs(str(base_path), exist_ok=True) return base_path def create_wine_bin_dir(proton_app, use_bwrap=True): """ Create a directory with "proxy" executables that load shared libraries using Steam Runtime and Proton's own libraries instead of the system libraries """ binaries = list((proton_app.proton_dist_path / "bin").iterdir()) # Create the base directory containing files for every Proton installation base_path = get_cache_dir() / "proton" os.makedirs(str(base_path), exist_ok=True) # Create a directory to hold the new executables for the specific # Proton installation bin_path = base_path / proton_app.name / "bin" bin_path.mkdir(parents=True, exist_ok=True) logger.info( "Created Steam Runtime Wine binary directory at %s", str(bin_path) ) # Delete the directory and rewrite the scripts. Some binaries may no # longer exist in the Proton installation, so we'll also get rid of # scripts that point to non-existing files shutil.rmtree(str(bin_path)) bin_path.mkdir(parents=True) for binary in binaries: proxy_script_path = bin_path / binary.name content = WINE_SCRIPT_TEMPLATE.replace( "@@name@@", shlex.quote(binary.name), ) content = content.replace( "@@script_path@@", str(proxy_script_path) ) proxy_script_path.write_text(content, encoding="utf-8") script_stat = proxy_script_path.stat() # Make the helper script executable proxy_script_path.chmod(script_stat.st_mode | stat.S_IEXEC) # Create the wineserver keepalive batch script (bin_path / "wineserver-keepalive.bat").write_text( WINESERVER_KEEPALIVE_BATCH_SCRIPT ) keepalive_shell_script = bin_path / "wineserver-keepalive" keepalive_shell_script.write_text( WINESERVER_KEEPALIVE_SH_SCRIPT.replace( "@@keepalive_bat_path@@", str(bin_path / "wineserver-keepalive.bat") ) ) keepalive_shell_script.chmod( keepalive_shell_script.stat().st_mode | stat.S_IEXEC ) launcher_script = bin_path / "bwrap-launcher" launcher_script.write_text(BWRAP_LAUNCHER_SH_SCRIPT) launcher_script.chmod(launcher_script.stat().st_mode | stat.S_IEXEC) return bin_path def _get_fixed_locale_env(): """Return a dictionary of fixed locale environment variables if Steam Deck is in use and some of the selected locales haven't actually been generated by the system. If the locale settings require no changes, an empty dict will be returned instead. """ # We can assume the 'en_US.UTF-8' locale always exists on Steam Deck, but # we can't assume the same about other distros. Therefore, only attempt # fixing the locale when running on a Steam Deck. if not is_steam_deck(): return {} supported_locales = run( ["locale", "-a"], check=True, stdout=PIPE, stderr=DEVNULL ).stdout.decode("utf-8").splitlines() supported_locales = [ locale.normalize(locale_) for locale_ in supported_locales ] locale_output = run( ["locale"], check=True, stdout=PIPE, stderr=DEVNULL ).stdout.decode("utf-8").splitlines() locale_output = [value.split("=") for value in locale_output] locale_settings = { value[0]: value[1].strip('"') for value in locale_output } fixed_env = {} # Check if any of the locales don't actually exist for category, locale_ in locale_settings.items(): if locale_.strip() == "": continue # Normalize the locale name locale_ = locale.normalize(locale_) if locale_ not in supported_locales: # Locale does not exist fixed_env[category] = "en_US.UTF-8" if fixed_env: logger.warning( "Found locale categories configured with missing locales. " "The locale has been reset to 'en_US.UTF-8' for the " "following categories: %s", ", ".join(fixed_env.keys()) ) return fixed_env def _start_process(args, wait=False, **kwargs): """Start a new process and return a Popen instance """ process = Popen(args=args, **kwargs) if wait: process.wait() return process def run_command( winetricks_path, proton_app, steam_app, command, use_steam_runtime=False, legacy_steam_runtime_path=None, use_bwrap=None, start_wineserver=None, env=None, **kwargs): """Run an arbitrary command with the correct environment variables for the given Proton app The environment variables are set for the duration of the call and restored afterwards If 'use_steam_runtime' is True, run the command using Steam Runtime using either 'legacy_steam_runtime_path' or the Proton app's specific Steam Runtime installation, depending on which one is required. If 'use_bwrap' is True, run newer Steam Runtime installations using bwrap based containerization. If None, determine whether bwrap is available and use it if so. If 'start_wineserver' is True, launch a background wineserver and keep it alive for the duration of the Protontricks call. If None, launch background wineserver if bwrap can be enabled. :returns: Return code of the executed command """ # Check for incomplete Steam Runtime installation runtime_install_incomplete = \ proton_app.required_tool_appid and not proton_app.required_tool_app if use_steam_runtime and runtime_install_incomplete: raise RuntimeError( f"{proton_app.name} is missing the required Steam Runtime. " "You may need to launch a Steam app using this Proton version " "to finish the installation." ) # Make a copy of the environment variables to use for the subprocesses. # Include any additional environment variables if provided. if env is None: env = {} wine_environ = os.environ.copy() wine_environ.update(env) user_provided_wine = os.environ.get("WINE", False) user_provided_wineserver = os.environ.get("WINESERVER", False) wine_environ["WINETRICKS"] = str(winetricks_path) wine_environ["WINEPREFIX"] = str(steam_app.prefix_path) wine_environ["WINEDLLPATH"] = "".join([ str(proton_app.proton_dist_path / "lib64" / "wine"), os.pathsep, str(proton_app.proton_dist_path / "lib" / "wine") ]) wine_environ["PATH"] = "".join([ str(proton_app.proton_dist_path / "bin"), os.pathsep, wine_environ["PATH"] ]) # Expose the path to Proton installation. This is mainly used for # Wine helper scripts, but other scripts could use it as well. wine_environ["PROTON_PATH"] = str(proton_app.install_path) wine_environ["PROTON_DIST_PATH"] = str(proton_app.proton_dist_path) wine_environ["STEAM_APP_PATH"] = str(steam_app.install_path) wine_environ["STEAM_APPID"] = str(steam_app.appid) # Unset WINEARCH, which might be set for another Wine installation wine_environ.pop("WINEARCH", "") # Fix the locale for Steam Deck, if necessary wine_environ.update(_get_fixed_locale_env()) wine_bin_dir = None wine_environ["PROTONTRICKS_STEAM_RUNTIME"] = "off" if use_steam_runtime: if use_bwrap is None: use_bwrap = bool(proton_app.required_tool_app) logger.info("Using 'bwrap = %s' as default value", use_bwrap) if start_wineserver is None: start_wineserver = use_bwrap logger.info( "Using 'background-wineserver = %s' as default value", start_wineserver ) if proton_app.required_tool_app: wine_environ["STEAM_RUNTIME_PATH"] = \ str(proton_app.required_tool_app.install_path) wine_environ["PROTON_LD_LIBRARY_PATH"] = \ get_runtime_library_paths(proton_app, use_bwrap=use_bwrap) runtime_name = proton_app.required_tool_app.name logger.info( "Using separately installed Steam Runtime: %s", runtime_name ) if use_bwrap: wine_environ["PROTONTRICKS_STEAM_RUNTIME"] = "bwrap" logger.info( "Running Steam Runtime using bwrap containerization.\n" "If any problems arise, please try running the command " "again using the `--no-bwrap` flag and make an issue " "report if the problem only occurs when bwrap is in use." ) else: wine_environ["PROTONTRICKS_STEAM_RUNTIME"] = "legacy" if runtime_name not in SUPPORTED_STEAM_RUNTIMES: logger.warning( "Current Steam Runtime not recognized by Protontricks." ) else: # Legacy Steam Runtime requires a different LD_LIBRARY_PATH # that is produced by a script. wine_environ["PROTONTRICKS_STEAM_RUNTIME"] = "legacy" wine_environ["PROTON_LD_LIBRARY_PATH"] = \ get_legacy_runtime_library_paths( legacy_steam_runtime_path, proton_app ) # bwrap is not available, so ensure it is not launched even if the # user configured it so use_bwrap = False # Configure the environment to use launch scripts that take care of # configuring the environment and Wine before launching the underlying # Wine binaries. wine_bin_dir = create_wine_bin_dir(proton_app) wine_environ["LEGACY_STEAM_RUNTIME_PATH"] = str(legacy_steam_runtime_path) wine_environ["PATH"] = os.pathsep.join( [str(wine_bin_dir), wine_environ["PATH"]] ) if not user_provided_wine: logger.info( "WINE environment variable is not available. " "Setting WINE environment variable to Proton bundled version." ) wine_environ["WINE"] = str(wine_bin_dir / "wine") wine_environ["WINELOADER"] = wine_environ["WINE"] if not user_provided_wineserver: logger.info( "WINESERVER environment variable is not available. " "Setting WINESERVER environment variable to Proton bundled version" ) wine_environ["WINESERVER"] = str(wine_bin_dir / "wineserver") temp_dir = Path(tempfile.mkdtemp(prefix="protontricks-")) wine_environ["PROTONTRICKS_TEMP_PATH"] = str(temp_dir) wine_environ["PROTONTRICKS_SESSION_ID"] = temp_dir.name.split("-")[1] if start_wineserver: wine_environ["PROTONTRICKS_BACKGROUND_WINESERVER"] = "1" launcher_process = None keepalive_process = None try: if use_bwrap: logger.info( "Starting bwrap launcher process: %s", str(wine_bin_dir / "bwrap-launcher") ) # TODO: Waiting for launcher to start can be simplified once # ValveSoftware/steam-runtime#593 has been fixed and stdout can # be used instead. launcher_read_fd, launcher_write_fd = os.pipe2(os.O_CLOEXEC) launcher_process = _start_process( [str(wine_bin_dir / "bwrap-launcher"), str(launcher_write_fd)], wait=False, pass_fds=[launcher_write_fd], env=wine_environ ) # The Steam Runtime launcher service will write to the given # file descriptor and then close it to indicate the launcher is # ready or about to exit (i.e. due to wrong CLI parameters). os.close(launcher_write_fd) with open(launcher_read_fd, "rb") as reader: reader.read() # Check if the launcher actually started up and is still running. try: launcher_process.wait(timeout=0.1) # Launcher process crashed, bail out raise RuntimeError( f"bwrap launcher crashed, " f"returncode: {launcher_process.returncode}" ) except TimeoutExpired: # Launcher is running as expected pass logger.info("bwrap launcher started") if start_wineserver: logger.info( "Starting wineserver keepalive process: %s", str(wine_bin_dir / "wineserver-keepalive") ) keepalive_process = _start_process( str(wine_bin_dir / "wineserver-keepalive"), wait=False, env=wine_environ, stdout=DEVNULL, ) logger.info("Attempting to run command %s", command) kwargs = kwargs.copy() kwargs["env"] = wine_environ process = _start_process( command, wait=True, **kwargs ) return process.returncode finally: shutil.rmtree(str(temp_dir), ignore_errors=True) if keepalive_process: logger.info( "Terminating wineserver keepalive process %d", keepalive_process.pid ) keepalive_process.terminate() if launcher_process: logger.info( "Terminating launcher process %d", launcher_process.pid ) launcher_process.terminate() launcher_process.wait() logger.info("Launcher process terminated") protontricks-1.12.0/src/protontricks/winetricks.py000066400000000000000000000017571467175317500224520ustar00rootroot00000000000000import logging import os import shutil from pathlib import Path __all__ = ("get_winetricks_path",) logger = logging.getLogger("protontricks") def get_winetricks_path(): """ Return to the path to 'winetricks' executable or return None if not found """ if os.environ.get('WINETRICKS'): path = Path(os.environ["WINETRICKS"]) logger.info( "Winetricks path is set to %s", str(path) ) if not path.is_file(): logger.error( "The WINETRICKS path is invalid, please make sure " "Winetricks is installed in that path!" ) return None return path logger.info( "WINETRICKS environment variable is not available. " "Searching from $PATH.") winetricks_path = shutil.which("winetricks") if winetricks_path: return Path(winetricks_path) logger.error( "'winetricks' executable could not be found automatically." ) return None protontricks-1.12.0/tests/000077500000000000000000000000001467175317500155165ustar00rootroot00000000000000protontricks-1.12.0/tests/cli/000077500000000000000000000000001467175317500162655ustar00rootroot00000000000000protontricks-1.12.0/tests/cli/__init__.py000066400000000000000000000000001467175317500203640ustar00rootroot00000000000000protontricks-1.12.0/tests/cli/test_desktop_install.py000066400000000000000000000010471467175317500230770ustar00rootroot00000000000000def test_run_desktop_install(home_dir, command_mock, desktop_install_cli): """ Ensure that `desktop-file-install` is called properly """ # `protontricks-desktop-install` takes no arguments desktop_install_cli([]) command = command_mock.commands[0] assert command.args[0:3] == [ "desktop-file-install", "--dir", str(home_dir / ".local" / "share" / "applications") ] assert command.args[3].endswith("/protontricks.desktop") assert command.args[4].endswith("/protontricks-launch.desktop") protontricks-1.12.0/tests/cli/test_launch.py000066400000000000000000000171221467175317500211530ustar00rootroot00000000000000import pytest @pytest.fixture(scope="function", autouse=True) def home_cwd(home_dir, monkeypatch): """ Set the current working directory to the user's home directory and add an executable named "test.exe" """ monkeypatch.chdir(str(home_dir)) (home_dir / "test.exe").write_text("") class TestCLIRun: def test_run_executable( self, steam_app_factory, default_proton, command_mock, gui_provider, launch_cli): """ Run an EXE file by selecting using the GUI """ steam_app = steam_app_factory("Fake game", appid=10) # Fake the user selecting the game gui_provider.mock_stdout = "Fake game: 10" launch_cli(["test.exe"]) # 'test.exe' was executed command = command_mock.commands[-1] assert command.args.startswith("wine ") assert command.args.endswith("/test.exe") assert command.env["WINEPREFIX"] == str(steam_app.prefix_path) def test_run_executable_appid( self, default_proton, steam_app_factory, command_mock, launch_cli): """ Run an EXE file directly for a chosen game """ steam_app = steam_app_factory(name="Fake game 1", appid=10) launch_cli(["--appid", "10", "test.exe"]) # 'test.exe' was executed command = command_mock.commands[-1] assert command.args.startswith("wine ") assert command.args.endswith("/test.exe") assert command.env["WINEPREFIX"] == str(steam_app.prefix_path) def test_run_executable_no_selection( self, default_proton, steam_app_factory, gui_provider, launch_cli): """ Try running an EXE file but don't pick a Steam app """ steam_app_factory("Fake game", appid=10) # Fake the user closing the form gui_provider.mock_stdout = "" result = launch_cli(["test.exe"], expect_returncode=1) assert "No game was selected." in result def test_run_executable_no_apps(self, launch_cli): """ Try running an EXE file when no Proton enabled Steam apps are installed or ready """ result = launch_cli(["test.exe"], expect_returncode=1) assert "No Proton enabled Steam apps were found" in result def test_run_executable_no_apps_from_desktop( self, launch_cli, gui_provider): """ Try running an EXE file when no Proton enabled Steam apps are installed or ready, and ensure an error dialog is opened using `gui_provider`. """ launch_cli(["--no-term", "test.exe"], expect_returncode=1) assert gui_provider.args[0] == "yad" assert gui_provider.args[1] == "--text-info" message = gui_provider.kwargs["input"] assert b"No Proton enabled Steam apps were found." in message # Also ensure log messages are included in the error message assert b"Found Steam directory at" in message def test_run_executable_passthrough_arguments( self, default_proton, steam_app_factory, caplog, steam_dir, launch_cli, monkeypatch): """ Try running an EXE file and apply all arguments; those should also be passed to the main entrypoint """ cli_args = [] cli_kwargs = {} def _set_launch_args(*args, **kwargs): cli_args.extend(*args) cli_kwargs.update(kwargs) monkeypatch.setattr( "protontricks.cli.launch.cli_main", _set_launch_args ) steam_app_factory(name="Fake game", appid=10) launch_cli([ "--verbose", "--no-bwrap", "--no-runtime", "--no-term", "--cwd-app", "--appid", "10", "test.exe" ]) # CLI flags are passed through to the main CLI entrypoint assert cli_args[0:7] == [ "-v", "--no-runtime", "--no-bwrap", "--no-background-wineserver", "--no-term", "--cwd-app", "-c" ] assert cli_args[7].startswith("wine ") assert cli_args[7].endswith("test.exe") assert cli_args[8] == "10" # Steam installation was provided to the main entrypoint assert str(cli_kwargs["steam_path"]) == str(steam_dir) @pytest.mark.parametrize("argument", [ None, "--background-wineserver", "--no-background-wineserver" ]) def test_run_executable_passthrough_background_wineserver( self, launch_cli, monkeypatch, steam_app_factory, argument): """ Try running an EXE file and apply given wineserver argument. If the argument is set, it should also be passed to the main entrypoint. """ cli_args = [] def _set_launch_args(*args, **kwargs): cli_args.extend(*args) monkeypatch.setattr( "protontricks.cli.launch.cli_main", _set_launch_args ) steam_app_factory(name="Fake game", appid=10) extra_args = [argument] if argument else [] launch_cli(extra_args + ["--appid", "10", "test.exe"]) if argument: # Ensure the corresponding argument was passd to the main CLI # entrypoint assert argument in cli_args else: assert "--no-background-wineserver" in cli_args def test_cli_error_handler_uncaught_exception( self, launch_cli, default_proton, steam_app_factory, monkeypatch, gui_provider): """ Ensure that 'cli_error_handler' correctly catches any uncaught exception and includes a stack trace in the error dialog. """ def _mock_from_appmanifest(*args, **kwargs): raise ValueError("Test appmanifest error") steam_app_factory(name="Fake game", appid=10) monkeypatch.setattr( "protontricks.steam.SteamApp.from_appmanifest", _mock_from_appmanifest ) launch_cli( ["--no-term", "--appid", "10", "test.exe"], expect_returncode=1 ) assert gui_provider.args[0] == "yad" assert gui_provider.args[1] == "--text-info" message = gui_provider.kwargs["input"] assert b"Test appmanifest error" in message @pytest.mark.usefixtures( "flatpak_sandbox", "default_proton", "command_mock" ) def test_run_filesystem_permission_missing( self, launch_cli, steam_library_factory, steam_app_factory, caplog): """ Try performing a launch command in a Flatpak sandbox where the user hasn't provided adequate fileystem permissions. Ensure warning is printed. """ steam_app_factory(name="Fake game 1", appid=10) path = steam_library_factory(name="GameDrive") launch_cli(["--appid", "10", "test.exe"]) record = next( record for record in caplog.records if "grant access to the required directories" in record.message ) assert record.levelname == "WARNING" assert str(path) in record.message @pytest.mark.usefixtures( "flatpak_sandbox", "steam_dir", "flatpak_steam_dir" ) def test_steam_installation_not_selected(self, launch_cli, gui_provider): """ Test that not selecting a Steam installation results in the correct exit message """ # Mock the user choosing the Flatpak installation. # Only the index is actually checked in the actual function. gui_provider.mock_stdout = "" gui_provider.mock_returncode = 1 result = launch_cli(["test.exe"], expect_returncode=1) assert "No Steam installation was selected" in result protontricks-1.12.0/tests/cli/test_main.py000066400000000000000000001065771467175317500206420ustar00rootroot00000000000000import os import sys import shutil from pathlib import Path import pytest class TestCLIRun: def test_run_winetricks( self, cli, steam_app_factory, default_proton, command_mock, home_dir): """ Perform a Protontricks command directly for a certain game """ proton_install_path = Path(default_proton.install_path) steam_app = steam_app_factory(name="Fake game 1", appid=10) cli(["10", "winecfg"], env={"STEAM_RUNTIME": "0"}) # winecfg was actually run command = command_mock.commands[-1] assert str(command.args[0]).endswith(".local/bin/winetricks") assert command.args[1] == "winecfg" # Correct environment vars were set assert command.env["PROTON_PATH"] == str(proton_install_path) assert command.env["PROTON_DIST_PATH"] == \ str(proton_install_path / "dist") assert command.env["WINETRICKS"] == str( home_dir / ".local" / "bin" / "winetricks") assert command.env["WINEPREFIX"] == str(steam_app.prefix_path) assert command.env["WINELOADER"] == command.env["WINE"] assert command.env["WINEDLLPATH"] == "{}{}{}".format( str(proton_install_path / "dist" / "lib64" / "wine"), os.pathsep, str(proton_install_path / "dist" / "lib" / "wine") ) def test_run_winetricks_shortcut( self, cli, shortcut_factory, default_proton, command_mock, steam_dir): """ Perform a Protontricks command for a non-Steam shortcut """ proton_install_path = Path(default_proton.install_path) shortcut_factory(install_dir="fake/path/", name="fakegame.exe") cli(["4149337689", "winecfg"]) # Default Proton is used command = command_mock.commands[-1] assert command.env["PROTON_PATH"] == str(proton_install_path) assert command.env["WINEPREFIX"] == str( steam_dir / "steamapps" / "compatdata" / "4149337689" / "pfx") def test_run_winetricks_select_proton( self, cli, steam_app_factory, default_proton, custom_proton_factory, command_mock, home_dir): """ Perform a Protontricks command while selecting a specific Proton version using PROTON_VERSION env var """ steam_app_factory(name="Fake game", appid=10) custom_proton = custom_proton_factory(name="Custom Proton") cli(["10", "winecfg"], env={"PROTON_VERSION": "Custom Proton"}) assert command_mock.commands[-1].env["PROTON_PATH"] \ == str(custom_proton.install_path) def test_run_winetricks_select_steam( self, cli, steam_app_factory, default_proton, command_mock, home_dir): """ Perform a Protontricks command while selecting a specific Steam installation directory """ steam_app_factory(name="Fake game", appid=10) os.rename( str(home_dir / ".steam" / "steam"), str(home_dir / ".steam_new") ) os.rename( str(home_dir / ".steam" / "root" / "ubuntu12_32"), str(home_dir / ".steam_new" / "ubuntu12_32") ) cli( ["10", "winecfg"], env={"STEAM_DIR": str(home_dir / ".steam_new")} ) command = command_mock.commands[-1] assert command.env["WINE"] == str( home_dir / ".cache" / "protontricks" / "proton" / "Proton 4.20" / "bin" / "wine" ) assert command.env["PROTON_PATH"] == str( home_dir / ".steam_new" / "steamapps" / "common" / "Proton 4.20" ) def test_run_winetricks_steam_runtime_v1( self, cli, steam_app_factory, steam_runtime_dir, default_proton, command_mock, home_dir): """ Perform a Protontricks command using the older Steam Runtime bundled with Steam """ steam_app_factory(name="Fake game 1", appid=10) cli(["10", "winecfg"], env={"STEAM_RUNTIME": "1"}) wine_bin_dir = ( home_dir / ".cache" / "protontricks" / "proton" / "Proton 4.20" / "bin" ) # winecfg was actually run command = command_mock.commands[-1] assert str(command.args[0]).endswith(".local/bin/winetricks") assert command.args[1] == "winecfg" assert command.env["PATH"].startswith(str(wine_bin_dir)) assert ( "fake_steam_runtime/lib64" in command.env["PROTON_LD_LIBRARY_PATH"] ) assert command.env["WINE"] == str(wine_bin_dir / "wine") assert command.env["WINELOADER"] == str(wine_bin_dir / "wine") assert command.env["WINESERVER"] == str(wine_bin_dir / "wineserver") assert command.env["LEGACY_STEAM_RUNTIME_PATH"] == \ str(steam_runtime_dir / "steam-runtime") assert command.env["PROTONTRICKS_STEAM_RUNTIME"] == "legacy" assert "STEAM_RUNTIME_PATH" not in command.env for name in ("wine", "wineserver"): # The helper scripts are created that point towards the real # Wine binaries path = wine_bin_dir / name assert path.is_file() content = path.read_text() # Correct binary names used in the scripts assert f"\"$PROTON_DIST_PATH\"/bin/{name}" in content def test_run_winetricks_steam_runtime_v2( self, cli, home_dir, steam_app_factory, steam_runtime_dir, steam_runtime_soldier, command_mock, proton_factory, caplog): """ Perform a Protontricks command using a newer Steam Runtime that is installed as its own application """ proton_app = proton_factory( name="Proton 5.13", appid=10, compat_tool_name="proton_513", is_default_proton=True, required_tool_app=steam_runtime_soldier ) steam_app_factory(name="Fake game 1", appid=20) cli(["20", "winecfg"], env={"STEAM_RUNTIME": "1"}) wine_bin_dir = ( home_dir / ".cache" / "protontricks" / "proton" / "Proton 5.13" / "bin" ) # Launcher process was launched to handle launching processes # inside the sandbox assert command_mock.commands[0].args[0] \ == str(wine_bin_dir / "bwrap-launcher") # winecfg was run command = command_mock.commands[-1] assert str(command.args[0]).endswith(".local/bin/winetricks") assert command.args[1] == "winecfg" assert command.env["PATH"].startswith(str(wine_bin_dir)) # Compared to the traditional Steam Runtime, PROTON_LD_LIBRARY_PATH # will be different proton_install_path = Path(proton_app.install_path) assert command.env["PROTON_LD_LIBRARY_PATH"] == "".join([ str(proton_install_path / "dist" / "lib"), os.pathsep, str(proton_install_path / "dist" / "lib64"), os.pathsep ]) # Environment variables for both legacy and new Steam Runtime exist assert command.env["LEGACY_STEAM_RUNTIME_PATH"] == \ str(steam_runtime_dir / "steam-runtime") assert command.env["STEAM_RUNTIME_PATH"] == \ str(steam_runtime_soldier.install_path) assert command.env["PROTONTRICKS_STEAM_RUNTIME"] == "bwrap" # No warning will be created since Steam Runtime Soldier is recognized # by Protontricks assert len([ record for record in caplog.records if record.levelname == "WARNING" and "Steam Runtime not recognized" in record.message ]) == 0 for name in ("wine", "wineserver"): # The helper scripts are created that point towards the real # Wine binaries path = wine_bin_dir / name assert path.is_file() content = path.read_text() # Correct binary names used in the scripts assert f"\"$PROTON_DIST_PATH\"/bin/{name}" in content def test_run_winetricks_steam_runtime_v2_no_bwrap( self, cli, home_dir, steam_app_factory, steam_runtime_dir, steam_runtime_soldier, command_mock, proton_factory, caplog): """ Perform a Protontricks command using a newer Steam Runtime *without* bwrap that is installed as its own application """ proton_app = proton_factory( name="Proton 5.13", appid=10, compat_tool_name="proton_513", is_default_proton=True, required_tool_app=steam_runtime_soldier ) steam_app_factory(name="Fake game 1", appid=20) cli(["--no-bwrap", "20", "winecfg"], env={"STEAM_RUNTIME": "1"}) wine_bin_dir = ( home_dir / ".cache" / "protontricks" / "proton" / "Proton 5.13" / "bin" ) command = command_mock.commands[-1] # winecfg was run assert str(command.args[0]).endswith(".local/bin/winetricks") assert command.args[1] == "winecfg" assert command.env["PATH"].startswith(str(wine_bin_dir)) # Compared to the traditional Steam Runtime, PROTON_LD_LIBRARY_PATH # will be different proton_install_path = Path(proton_app.install_path) assert command.env["PROTON_LD_LIBRARY_PATH"].startswith("".join([ str(proton_install_path / "dist" / "lib"), os.pathsep, str(proton_install_path / "dist" / "lib64"), os.pathsep ])) runtime_root = \ steam_runtime_soldier.install_path / "soldier" / "files" assert command.env["PROTON_LD_LIBRARY_PATH"].endswith("".join([ str(runtime_root / "lib" / "i386-linux-gnu"), os.pathsep, str(runtime_root / "lib" / "x86_64-linux-gnu") ])) # Environment variables for both legacy and new Steam Runtime exist assert command.env["LEGACY_STEAM_RUNTIME_PATH"] == \ str(steam_runtime_dir / "steam-runtime") assert command.env["STEAM_RUNTIME_PATH"] == \ str(steam_runtime_soldier.install_path) assert command.env["PROTONTRICKS_STEAM_RUNTIME"] == "legacy" # No warning will be created since Steam Runtime Soldier is recognized # by Protontricks assert len([ record for record in caplog.records if record.levelname == "WARNING" and "Steam Runtime not recognized" in record.getMessage() ]) == 0 for name in ("wine", "wineserver"): # The helper scripts are created that point towards the real # Wine binaries path = wine_bin_dir / name assert path.is_file() content = path.read_text() assert f"\"$PROTON_DIST_PATH\"/bin/{name}" in content @pytest.mark.parametrize( "args,wineserver_launched", [ # background wineserver disabled for bwrap by default (["-c", "'echo nothing'", "20"], False), # background wineserver also disabled by default for everything # else (["--no-bwrap", "-c", "'echo nothing'", "20"], False), # Manually disable background wineserver ( ["--no-background-wineserver", "-c", "'echo nothing'", "20"], False ), # Manually enable background wineserver ( [ "--background-wineserver", "--no-bwrap", "-c", "'echo nothing'", "20" ], True ) ] ) def test_run_background_wineserver_toggle( self, cli, steam_app_factory, default_new_proton, command_mock, args, wineserver_launched, home_dir): """ Try running a Protontricks command with different arguments and ensure background wineserver is (not) launched depending on the scenario """ steam_app_factory(name="Fake game 1", appid=20) cli(args) wineserver_found = any( True for command in command_mock.commands if isinstance(command.args, str) and command.args == str( home_dir / ".cache/protontricks/proton/Proton 7.0/bin" / "wineserver-keepalive" ) ) assert wineserver_found == wineserver_launched def test_run_winetricks_game_not_found( self, cli, steam_app_factory, default_proton): """ Try running a Protontricks command for a non-existing app """ result = cli(["100", "winecfg"], expect_returncode=1) assert "Steam app with the given app ID could not be found" in result @pytest.mark.usefixtures("default_proton") def test_run_returncode_passed(self, cli, steam_app_factory): """ Run a command that returns a specific exit code and ensure it is returned """ steam_app_factory(name="Fake game", appid=10) cli(["-c", "exit 5", "10"], expect_returncode=5) def test_run_multiple_command_mock(self, cli): """ Try performing multiple command_mock at once """ result = cli(["--gui", "-s", "game"]) assert "Only one action can be performed" in result def test_run_steam_not_found(self, cli, steam_dir): """ Try performing a command with a missing Steam directory """ shutil.rmtree(str(steam_dir)) result = cli(["10", "winecfg"], expect_returncode=1) assert "Steam installation directory could not be found" in result def test_run_winetricks_not_found( self, cli, default_proton, home_dir, steam_app_factory): """ Try performing a command with missing Winetricks executable """ steam_app_factory(name="Fake game 1", appid=10) (home_dir / ".local" / "bin" / "winetricks").unlink() result = cli(["10", "winecfg"], expect_returncode=1) assert "Winetricks isn't installed" in result def test_run_winetricks_from_desktop( self, cli, default_proton, home_dir, steam_app_factory, monkeypatch, gui_provider): """ Try performing a command with missing Winetricks executable. Run command using --no-term and ensure error dialog is shown with the expected error message """ steam_app_factory(name="Fake game 1", appid=10) (home_dir / ".local" / "bin" / "winetricks").unlink() cli(["--no-term", "10", "winecfg"], expect_returncode=1) assert gui_provider.args[0] == "yad" assert gui_provider.args[1] == "--text-info" message = gui_provider.kwargs["input"] assert b"Winetricks isn't installed" in message # Also ensure log messages are included in the error message assert b"Found Steam directory at" in message assert b"Using default Steam Runtime" in message def test_run_gui_provider_not_found(self, cli, home_dir, steam_app_factory): """ Try performing a command with missing YAD or Zenity executable """ steam_app_factory(name="Fake game 1", appid=10) (home_dir / ".local" / "bin" / "yad").unlink() (home_dir / ".local" / "bin" / "zenity").unlink() result = cli(["--gui"], expect_returncode=1) assert "YAD or Zenity is not installed" in result def test_run_steam_runtime_not_found( self, cli, steam_dir, steam_app_factory): """ Try performing a command with Steam Runtime enabled but no available Steam Runtime installation """ steam_app_factory(name="Fake game 1", appid=10) result = cli( ["10", "winecfg"], env={"STEAM_RUNTIME": "invalid/path"}, expect_returncode=1 ) assert "Steam Runtime was enabled but couldn't be found" in result def test_run_proton_not_found(self, cli, steam_dir, steam_app_factory): steam_app_factory(name="Fake game 1", appid=10) result = cli(["10", "winecfg"], expect_returncode=1) assert "Proton installation could not be found" in result def test_run_compat_tool_not_proton( self, cli, steam_dir, default_proton, custom_proton_factory, steam_app_factory, caplog): """ Try performing a Protontricks command for a Steam app that uses a compatibility tool that isn't Proton. Regression test for https://github.com/Matoking/protontricks/issues/113 """ # Create a compatibility tool that isn't actually Proton tool_app = custom_proton_factory(name="Not Proton") (tool_app.install_path / "proton").unlink() steam_app_factory( name="Fake game", appid=10, compat_tool_name="Not Proton" ) result = cli(["10", "winecfg"], expect_returncode=1) assert "Proton installation could not be found" in result record = caplog.records[-1] assert ( "Active compatibility tool was found, but it's not a Proton" in record.getMessage() ) def test_run_command_proton_incomplete( self, cli, steam_app_factory, default_proton): """ Try performing a Protontricks command using a Proton installation that is incomplete because it hasn't been launched yet. Regression test for https://github.com/flathub/com.github.Matoking.protontricks/issues/10 """ # Remove the 'dist' directory to make the Proton installation # incomplete shutil.rmtree(str(default_proton.install_path / "dist")) steam_app_factory(name="Fake game", appid=10) result = cli(["10", "winecfg"], expect_returncode=1) assert "Proton installation is incomplete" in result def test_run_command_runtime_incomplete( self, cli, steam_app_factory, steam_runtime_soldier, proton_factory, steam_dir): """ Try performing a Protontricks command using a Proton installation that is still missing a Steam Runtime installation. Regression test for https://github.com/Matoking/protontricks/issues/75 """ proton_factory( name="Proton 5.13", appid=10, compat_tool_name="proton_513", is_default_proton=True, required_tool_app=steam_runtime_soldier ) steam_app_factory(name="Fake game 1", appid=20) # Delete the Steam Runtime installation to simulate an incomplete # Proton installation that's missing the required Steam Runtime shutil.rmtree(str(steam_runtime_soldier.install_path)) (steam_dir / "steamapps" / "appmanifest_1391110.acf").unlink() with pytest.raises(RuntimeError) as exc: cli(["20", "winecfg"]) assert "Proton 5.13 is missing the required Steam Runtime" \ in str(exc.value) def test_old_flatpak_detected(self, cli, monkeypatch, caplog): """ Try performing a Protontricks command when running inside an older Flatpak environment and ensure bwrap is disabled. """ cli(["-s", "nothing"]) # No warning is printed since we're not running inside Flatpak assert len([ record for record in caplog.records if record.levelname == "WARNING" ]) == 0 # Fake a Flatpak environment monkeypatch.setattr( "protontricks.cli.main.get_running_flatpak_version", # Mock version 1.12.0. 1.12.1 is new enough to not require # disabling bwrap. lambda: (1, 12, 0) ) cli(["-s", "nothing"]) assert len([ record for record in caplog.records if record.levelname == "WARNING" ]) == 1 record = next( record for record in caplog.records if record.levelname == "WARNING" ) assert record.levelname == "WARNING" assert "Flatpak version is too old" \ in record.message def test_new_flatpak_detected(self, cli, monkeypatch, caplog): """ Try performing a Protontricks command when running inside a newer Flatpak environment and ensure Flatpak is detected correctly. """ # Fake a newer Flatpak environment monkeypatch.setattr( "protontricks.cli.main.get_running_flatpak_version", lambda: (1, 12, 1) ) cli(["-s", "nothing"]) # Flatpak is new enough not to generate a warning. assert len([ record for record in caplog.records if record.levelname == "WARNING" ]) == 0 assert any([ record for record in caplog.records if record.levelname == "INFO" and "Running inside Flatpak sandbox, version 1.12.1" in record.message ]) def test_cli_error_handler_uncaught_exception( self, cli, default_proton, steam_app_factory, monkeypatch, gui_provider): """ Ensure that 'cli_error_handler' correctly catches any uncaught exception and includes a stack trace in the error dialog. """ def _mock_from_appmanifest(*args, **kwargs): raise ValueError("Test appmanifest error") steam_app_factory(name="Fake game", appid=10) monkeypatch.setattr( "protontricks.steam.SteamApp.from_appmanifest", _mock_from_appmanifest ) cli(["--no-term", "-s", "Fake"], expect_returncode=1) assert gui_provider.args[0] == "yad" assert gui_provider.args[1] == "--text-info" message = gui_provider.kwargs["input"] assert b"Test appmanifest error" in message @pytest.mark.usefixtures("flatpak_sandbox") def test_run_filesystem_permission_missing( self, cli, steam_library_factory, caplog): """ Try performing a command in a Flatpak sandbox where the user hasn't provided adequate fileystem permissions. Ensure warning is printed. """ path = steam_library_factory(name="GameDrive") cli(["-s", "fake"]) record = next( record for record in caplog.records if "grant access to the required directories" in record.message ) assert record.levelname == "WARNING" assert str(path) in record.message @pytest.mark.usefixtures("command_mock") def test_run_bwrap_default( self, cli, steam_app_factory, steam_runtime_soldier, proton_factory, command_mock, caplog): """ Perform command_mock for two Proton apps, one using a Proton version using the legacy Steam Runtime and another app using newer Steam Runtime with bwrap. Ensure that the correct default for `use_bwrap` is used in both cases. Regression test for #150 """ proton_factory( name="Old Proton", appid=123450, compat_tool_name="old_proton", ) proton_factory( name="New Proton", appid=543210, compat_tool_name="new_proton", required_tool_app=steam_runtime_soldier ) steam_app_factory( name="Fake game", appid=10, compat_tool_name="old_proton" ) steam_app_factory( name="Fake game 2", appid=20, compat_tool_name="new_proton" ) # bwrap is disabled for the old app by default cli(["-v", "-c", "bash", "10"]) assert any( filter(lambda msg: "Using 'bwrap = False'" in msg, caplog.messages) ) caplog.clear() # bwrap is enabled for the new app by default. cli(["-v", "-c", "bash", "20"]) assert any( filter(lambda msg: "Using 'bwrap = True'" in msg, caplog.messages) ) @pytest.mark.usefixtures("flatpak_sandbox") def test_select_steam_installation( self, cli, steam_dir, flatpak_steam_dir, steam_app_factory, proton_factory, gui_provider): """ Test that the user is prompted to select the Steam installation, and that the correct Steam installation is used in both cases """ # Only the Flatpak installation has an app steam_app_factory( name="Native Steam app", appid=10 ) proton_factory( name="Flatpak Proton", appid=123450, compat_tool_name="flatpak_proton" ) steam_app_factory( name="Flatpak Steam app", appid=10, compat_tool_name="flatpak_proton", library_dir=flatpak_steam_dir, ) # Mock the user choosing the Flatpak installation. # Only the index is actually checked in the actual function. gui_provider.mock_stdout = "1: Native - /home/fake/.steam" result = cli(["-s", "app"]) assert "Native Steam app (10)" in result assert "Flatpak Steam app (10)" not in result # This time mock the Flatpak installation gui_provider.mock_stdout = "2: Flatpak - /home/fake/.var/app/something" result = cli(["-s", "app"]) assert "Flatpak Steam app (10)" in result assert "Native Steam app (10)" not in result @pytest.mark.usefixtures( "flatpak_sandbox", "steam_dir", "flatpak_steam_dir" ) def test_steam_installation_not_selected(self, cli, gui_provider): """ Test that not selecting a Steam installation results in the correct exit message """ # Mock the user choosing the Flatpak installation. # Only the index is actually checked in the actual function. gui_provider.mock_stdout = "" gui_provider.mock_returncode = 1 result = cli(["-s", "app"], expect_returncode=1) assert "No Steam installation was selected" in result class TestCLIGUI: def test_run_gui( self, cli, default_proton, steam_app_factory, gui_provider, command_mock, home_dir): """ Start the GUI and fake selecting a game """ steam_app = steam_app_factory(name="Fake game 1", appid=10) proton_install_path = Path(default_proton.install_path) # Fake the user selecting the game gui_provider.mock_stdout = "Fake game 1: 10" cli(["--gui"]) command = command_mock.commands[-1] # 'winetricks --gui' was run for the game selected by user assert str(command.args[0]) == \ str(home_dir / ".local" / "bin" / "winetricks") assert command.args[1] == "--gui" # Correct environment vars were set assert command.env["WINE"] == str( home_dir / ".cache" / "protontricks" / "proton" / "Proton 4.20" / "bin" / "wine" ) assert command.env["PROTON_PATH"] == str(proton_install_path) assert command.env["WINETRICKS"] == str( home_dir / ".local" / "bin" / "winetricks") assert command.env["WINEPREFIX"] == str(steam_app.prefix_path) assert command.env["WINELOADER"] == command.env["WINE"] assert command.env["WINEDLLPATH"] == "{}{}{}".format( str(proton_install_path / "dist" / "lib64" / "wine"), os.pathsep, str(proton_install_path / "dist" / "lib" / "wine") ) def test_run_gui_no_games(self, cli, default_proton): """ Try starting the GUI when no games are installed """ result = cli(["--gui"], expect_returncode=1) assert "Found no games" in result def test_run_gui_proton_incomplete( self, cli, steam_app_factory, default_proton, gui_provider): """ Try running Protontricks GUI using a Proton installation that is incomplete because it hasn't been launched yet. """ # Remove the 'dist' directory to make the Proton installation # incomplete shutil.rmtree(str(default_proton.install_path / "dist")) steam_app_factory(name="Fake game", appid=10) # Fake the user selecting the game gui_provider.mock_stdout = "Fake game 1: 10" result = cli(["--gui"], expect_returncode=1) assert "Proton installation is incomplete" in result @pytest.mark.usefixtures("default_proton", "gui_provider") def test_run_no_args( self, cli, steam_app_factory, command_mock, gui_provider, monkeypatch): """ Run only the 'protontricks' command. This will default to GUI. """ # Monkeypatch 'sys.argv', as that seems to be the only way to determine # whether no arguments were provided monkeypatch.setattr(sys, "argv", ["protontricks"]) steam_app_factory(name="Fake game", appid=10) result = cli([], expect_returncode=1) # Help will be printed if no specific command is given assert "No game was selected" in result class TestCLICommand: def test_run_command( self, cli, default_proton, steam_app_factory, gui_provider, command_mock, home_dir): """ Run a shell command for a given game """ steam_app = steam_app_factory(name="Fake game", appid=10) proton_install_path = default_proton.install_path cli(["-c", "bash", "10"]) command = command_mock.commands[-1] # The command is just 'bash' assert command.args == "bash" assert command.cwd is None assert command.shell is True # Correct environment vars were set assert command.env["WINE"] == str( home_dir / ".cache" / "protontricks" / "proton" / "Proton 4.20" / "bin" / "wine" ) assert command.env["PROTON_PATH"] == str(proton_install_path) assert command.env["WINETRICKS"] == str( home_dir / ".local" / "bin" / "winetricks") assert command.env["WINEPREFIX"] == str(steam_app.prefix_path) assert command.env["WINELOADER"] == command.env["WINE"] assert command.env["WINEDLLPATH"] == "{}{}{}".format( str(proton_install_path / "dist" / "lib64" / "wine"), os.pathsep, str(proton_install_path / "dist" / "lib" / "wine") ) @pytest.mark.usefixtures("default_proton") def test_run_command_cwd_app(self, cli, steam_app_factory, command_mock): """ Run a shell command for a given game using `--cwd-app` flag and ensure the working directory was set to the game's installation directory """ steam_app = steam_app_factory(name="Fake game", appid=10) cli(["--cwd-app", "-c", "bash", "10"]) command = command_mock.commands[-1] assert command.args == "bash" assert command.cwd == str(steam_app.install_path) class TestCLISearch: def test_search_case_insensitive(self, cli, steam_app_factory): """ Do a case-insensitive search """ steam_app_factory(name="FaKe GaMe 1", appid=10) steam_app_factory(name="FAKE GAME 2", appid=20) # Search is case-insensitive stdout = cli(["-s", "game"]) assert "FaKe GaMe 1 (10)" in stdout assert "FAKE GAME 2 (20)" in stdout def test_search_pfx_lock_required(self, cli, steam_app_factory): """ Do a search for a game that doesn't have a complete prefix yet """ steam_app = steam_app_factory(name="Fake game", appid=10) # Delete the pfx.lock file that signifies that the game has been # launched at least once. Protontricks requires that this file # exists (Path(steam_app.prefix_path).parent / "pfx.lock").unlink() stdout = cli(["-s", "game"]) assert "Found no games" in stdout assert "Fake game" not in stdout def test_search_multiple_keywords(self, cli, steam_app_factory): """ Do a search for games with multiple subsequent words from the entire name """ steam_app_factory(name="Apple banana cinnamon", appid=10) steam_app_factory(name="Apple banana", appid=20) stdout = cli(["-s", "apple", "banana"]) # First game is found, second is not assert "Apple banana cinnamon (10)" in stdout assert "Apple banana (20)" in stdout # Having the keywords in one parameter is also valid stdout = cli(["-s", "apple banana"]) assert "Apple banana cinnamon (10)" in stdout assert "Apple banana (20)" in stdout def test_search_strip_non_ascii(self, cli, steam_app_factory): """ Do a search for a game with various symbols that are ignored when doing the search """ steam_app_factory( name="Frog™ Simulator®: Year of the 🐸 Edition", appid=10 ) # Non-ASCII symbols are not checked for when doing the search stdout = cli([ "-s", "frog", "simulator", "year", "of", "the", "edition" ]) assert "Frog™ Simulator®: Year of the 🐸 Edition (10)" in stdout def test_search_multiple_library_folders( self, cli, steam_app_factory, steam_library_factory): """ Create three games in three different locations and ensure all are found when searched for """ library_dir_a = steam_library_factory("LibraryA") library_dir_b = steam_library_factory("LibraryB") steam_app_factory(name="Fake game 1", appid=10) steam_app_factory( name="Fake game 2", appid=20, library_dir=library_dir_a ) steam_app_factory( name="Fake game 3", appid=30, library_dir=library_dir_b ) # All three games should be found automatically result = cli(["-s", "game"]) assert "Fake game 1" in result assert "Fake game 2" in result assert "Fake game 3" in result def test_search_shortcut( self, cli, shortcut_factory): """ Create two non-Steam shortcut and ensure they can be found """ shortcut_factory(install_dir="fake/path/", name="fakegame.exe") shortcut_factory(install_dir="fake/path2/", name="fakegame.exe") result = cli(["-v", "-s", "steam"]) assert "Non-Steam shortcut: fakegame.exe (4149337689)" in result assert "Non-Steam shortcut: fakegame.exe (4136117770)" in result def test_list_all_apps(self, cli, steam_app_factory): """ List all apps using `-l` CLI flag """ steam_app_factory(name="Game number one", appid=10) steam_app_factory(name="Fake game", appid=20) result = cli(["-l"]) assert "Game number one" in result assert "Fake game" in result def test_cli_error_help(cli): """ Ensure that the full help message is printed when an incorrect argument is provided """ _, stderr = cli( ["--nothing"], expect_returncode=2, # Returned for CLI syntax error include_stderr=True ) # Usage message assert "[-h] [--verbose]" in stderr # Help message assert "positional arguments:" in stderr @pytest.mark.parametrize( "parameter,log_levels", [ (None, []), ("-v", ["INFO"]), ("-vv", ["INFO", "DEBUG"]) ] ) def test_cli_enable_logging(cli, parameter, log_levels): """ Run the CLI interface with different logging levels and ensure that log messages with corresponding log levels are printed """ if parameter: _, stderr = cli( [parameter, "-s", "nothing"], expect_returncode=1, # We don't care whether the command succeeds include_stderr=True ) for log_level in log_levels: assert log_level in stderr elif not parameter: _, stderr = cli( ["-s", "nothing"], expect_returncode=1, include_stderr=True ) assert "DEBUG" not in stderr assert "INFO" not in stderr protontricks-1.12.0/tests/cli/test_util.py000066400000000000000000000070031467175317500206530ustar00rootroot00000000000000import logging import pytest from protontricks.cli.util import (_delete_log_file, _get_log_file_path, enable_logging, exit_with_error) @pytest.fixture(scope="function") def broken_appmanifest(monkeypatch): def _mock_from_appmanifest(*args, **kwargs): raise ValueError("Test appmanifest error") monkeypatch.setattr( "protontricks.steam.SteamApp.from_appmanifest", _mock_from_appmanifest ) def test_enable_logging(): """ Ensure that calling 'enable_logging' enables the logging only once """ logger = logging.getLogger("protontricks") assert len(logger.handlers) == 0 enable_logging() assert len(logger.handlers) == 2 # No more handlers are added enable_logging() assert len(logger.handlers) == 2 def test_cli_error_handler_uncaught_exception( cli, default_proton, steam_app_factory, broken_appmanifest, gui_provider): """ Ensure that 'cli_error_handler' correctly catches any uncaught exception and includes a stack trace in the error dialog. """ steam_app_factory(name="Fake game", appid=10) cli(["--no-term", "-s", "Fake"], expect_returncode=1) assert gui_provider.args[0] == "yad" assert gui_provider.args[1] == "--text-info" message = gui_provider.kwargs["input"] # 'broken_appmanifest' will induce an error in 'SteamApp.from_appmanifest' assert b"Test appmanifest error" in message @pytest.mark.parametrize("gui_cmd", ["yad", "zenity"]) def test_cli_error_handler_gui_provider_env( cli, default_proton, steam_app_factory, monkeypatch, broken_appmanifest, gui_provider, gui_cmd): """ Ensure that correct GUI provider is used depending on 'PROTONTRICKS_GUI' environment variable """ monkeypatch.setenv("PROTONTRICKS_GUI", gui_cmd) steam_app_factory(name="Fake game", appid=10) cli(["--no-term", "-s", "Fake"], expect_returncode=1) message = gui_provider.kwargs["input"] assert b"Test appmanifest error" in message if gui_cmd == "yad": assert gui_provider.args[0] == "yad" # YAD has custom button declarations assert "--button=OK:0" in gui_provider.args elif gui_cmd == "zenity": assert gui_provider.args[0] == "zenity" # Zenity doesn't have custom button declarations assert "--button=OK:0" not in gui_provider.args def test_exit_with_error_no_log_file(gui_provider): """ Ensure that `exit_with_error` can show the error dialog even if the log file goes missing for some reason """ try: _get_log_file_path().unlink() except FileNotFoundError: pass with pytest.raises(SystemExit): exit_with_error("Test error", desktop=True) assert gui_provider.args[0] == "yad" assert gui_provider.args[1] == "--text-info" message = gui_provider.kwargs["input"] assert b"Test error" in message def test_log_file_cleanup(cli, steam_app_factory, gui_provider): """ Ensure that log file contains the log files generated during the CLI call and that it is cleared after running `_delete_log_file` """ steam_app_factory(name="Fake game", appid=10) cli(["--no-term", "-s", "Fake"]) assert "Found Steam directory" in _get_log_file_path().read_text() # This is called on shutdown by atexit, but call it here directly # since we can't test atexit. _delete_log_file() assert not _get_log_file_path().is_file() # Nothing happens if the file is already missing _delete_log_file() protontricks-1.12.0/tests/conftest.py000066400000000000000000000723231467175317500177240ustar00rootroot00000000000000import logging import random import shutil import struct import zlib from collections import defaultdict from pathlib import Path from subprocess import run, TimeoutExpired import pytest import vdf from protontricks.cli.desktop_install import \ cli as desktop_install_cli_entrypoint from protontricks.cli.launch import cli as launch_cli_entrypoint from protontricks.cli.main import cli as main_cli_entrypoint from protontricks.cli.util import enable_logging from protontricks.gui import get_gui_provider from protontricks.steam import (APPINFO_STRUCT_HEADER, APPINFO_V28_STRUCT_SECTION, SteamApp, get_appid_from_shortcut) from protontricks.steam import iter_appinfo_sections @pytest.fixture(scope="function", autouse=True) def env_vars(monkeypatch): """ Set default environment variables to prevent user's env vars from intefering with tests """ monkeypatch.setenv("STEAM_RUNTIME", "") monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) @pytest.fixture(scope="function", autouse=True) def cleanup(): """ Miscellaneous cleanup tasks that need to be done before each test """ # 'get_gui_provider' uses functools.lru_cache and needs to be cleared # between tests get_gui_provider.cache_clear() # Clear log handlers logging.getLogger("protontricks").handlers.clear() @pytest.fixture(scope="function", autouse=True) def default_caplog(caplog): caplog.set_level(logging.INFO) @pytest.fixture(scope="function") def info_logging(): """ Enable logging to ensure INFO messages are captured by the caplog fixture as well """ enable_logging(level=1) @pytest.fixture(scope="function", autouse=True) def home_dir(monkeypatch, tmp_path): """ Fake home directory """ home_dir_ = Path(str(tmp_path)) / "home" / "fakeuser" home_dir_.mkdir(parents=True) # Create fake Winetricks executable (home_dir_ / ".local" / "bin").mkdir(parents=True) (home_dir_ / ".local" / "bin" / "winetricks").touch() (home_dir_ / ".local" / "bin" / "winetricks").chmod(0o744) # Create fake YAD and Zenity executable (home_dir_ / ".local" / "bin" / "zenity").touch() (home_dir_ / ".local" / "bin" / "zenity").chmod(0o744) (home_dir_ / ".local" / "bin" / "yad").touch() (home_dir_ / ".local" / "bin" / "yad").chmod(0o744) monkeypatch.setenv("HOME", str(home_dir_)) # Set PATH to point only towards the fake home directory # This helps prevent the system-wide binaries from messing with tests # where we test for absence of executables such as 'winetricks' monkeypatch.setenv("PATH", str(home_dir_ / ".local" / "bin")) yield home_dir_ @pytest.fixture(scope="function", autouse=True) def steam_dir_factory(): """ Factory for creating a fake Steam directory """ def func(path): path.mkdir(parents=True) (path / "root" / "compatibilitytools.d").mkdir(parents=True) (path / "steam" / "appcache" / "librarycache").mkdir(parents=True) (path / "steam" / "config").mkdir(parents=True) (path / "steam" / "steamapps").mkdir(parents=True) return path / "steam" return func @pytest.fixture(scope="function", autouse=True) def steam_dir(steam_dir_factory, home_dir): """ Fake Steam directory """ return steam_dir_factory(home_dir / ".steam") @pytest.fixture(scope="function") def flatpak_steam_dir(steam_dir_factory, home_dir): """ Fake Flatpak Steam directory """ flatpak_steam_dir = \ home_dir / ".var/app/com.valvesoftware.Steam/data" steam_dir_factory(flatpak_steam_dir) # Rename the created directory to match the real directory hierarchy (flatpak_steam_dir / "steam").rename(flatpak_steam_dir / "Steam") (flatpak_steam_dir / "root" / "compatibilitytools.d").rmdir() (flatpak_steam_dir / "root").rmdir() (flatpak_steam_dir / "Steam" / "compatibilitytools.d").mkdir() return flatpak_steam_dir / "Steam" @pytest.fixture(scope="function") def steam_root(steam_dir): """ Fake Steam directory. Compared to "steam_dir", it points to "~/.steam/root" instead of "~/.steam/steam" """ yield steam_dir.parent / "root" @pytest.fixture(scope="function") def flatpak_sandbox(monkeypatch, tmp_path): """ Fake Flatpak sandbox running under Flatpak 1.12.1, with access to the home directory """ flatpak_info_path = tmp_path / "flatpak-info" flatpak_info_path.write_text( "[Application]\n" "name=fake.flatpak.Protontricks\n" "\n" "[Instance]\n" "flatpak-version=1.12.1\n" "\n" "[Context]\n" "filesystems=home" ) monkeypatch.setattr( "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) ) @pytest.fixture(scope="function", autouse=True) def steam_runtime_dir(steam_dir): """ Fake Steam Runtime installation """ (steam_dir.parent / "root" / "ubuntu12_32" / "steam-runtime").mkdir(parents=True) (steam_dir.parent / "root" / "ubuntu12_32" / "steam-runtime" / "run.sh").write_text( "#!/bin/bash\n" """if [ "$1" = "--print-steam-runtime-library-paths" ]; then\n""" " echo 'fake_steam_runtime/lib:fake_steam_runtime/lib64'\n" "fi" ) (steam_dir.parent / "root" / "ubuntu12_32" / "steam-runtime" / "run.sh").chmod( 0o744 ) yield steam_dir.parent / "root" / "ubuntu12_32" @pytest.fixture(scope="function") def steam_user_factory(steam_dir): """ Factory function for creating fake Steam users """ steam_users = [] def func(name, steamid64=None): if not steamid64: steamid64 = random.randint((2**32), (2**64)-1) steam_users.append({ "name": name, "steamid64": steamid64 }) loginusers_path = steam_dir / "config" / "loginusers.vdf" data = {"users": {}} for i, user in enumerate(steam_users): data["users"][str(user["steamid64"])] = { "AccountName": user["name"], # This ensures the newest Steam user is assumed to be logged-in "Timestamp": i } loginusers_path.write_text(vdf.dumps(data)) return steamid64 return func @pytest.fixture(scope="function", autouse=True) def steam_user(steam_user_factory): return steam_user_factory(name="TestUser", steamid64=(2**32)+42) @pytest.fixture(scope="function") def shortcut_factory(steam_dir, steam_user): """ Factory function for creating fake shortcuts """ shortcuts_by_user = defaultdict(list) def func( install_dir, name, steamid64=None, appid_in_vdf=False, appid=None, icon_path=None): if not steamid64: steamid64 = steam_user # Update shortcuts.vdf first steamid3 = int(steamid64) & 0xffffffff shortcuts_by_user[steamid3].append({ "install_dir": install_dir, "name": name, "appid": appid }) shortcut_path = ( steam_dir / "userdata" / str(steamid3) / "config" / "shortcuts.vdf" ) shortcut_path.parent.mkdir(parents=True, exist_ok=True) data = {"shortcuts": {}} for shortcut_data in shortcuts_by_user[steamid3]: install_dir_ = shortcut_data["install_dir"] name_ = shortcut_data["name"] appid_ = shortcut_data["appid"] entry = { "AppName": name_, "StartDir": install_dir_, "exe": str(Path(install_dir_) / name_) } # Derive the shortcut ID like Steam would crc_data = b"".join([ entry["exe"].encode("utf-8"), entry["AppName"].encode("utf-8") ]) result = zlib.crc32(crc_data) & 0xffffffff result = result | 0x80000000 shortcut_id = (result << 32) | 0x02000000 if appid_in_vdf: # Store the app ID in `shortcuts.vdf`. This is similar # in behavior to newer Steam releases. if appid_ is None: entry["appid"] = ~(result ^ 0xffffffff) else: # For pre-determined app IDs, such as those created by # Lutris, use them as-is entry["appid"] = appid_ if icon_path: entry["icon"] = icon_path data["shortcuts"][str(shortcut_id)] = entry shortcut_path.write_bytes(vdf.binary_dumps(data)) if not appid: appid = get_appid_from_shortcut( target=str(Path(install_dir) / name), name=name ) # Create the fake prefix (steam_dir / "steamapps" / "compatdata" / str(appid) / "pfx").mkdir( parents=True) (steam_dir / "steamapps" / "compatdata" / str(appid) / "pfx.lock").touch() return shortcut_id return func @pytest.fixture(scope="function", autouse=True) def steam_config_path(steam_dir): """ Fake Steam config file at ~/.steam/steam/config/config.vdf """ CONFIG_DEFAULT = { "InstallConfigStore": { "Software": { "Valve": { "Steam": { "ToolMapping": {}, "CompatToolMapping": {} } } } } } (steam_dir / "config" / "config.vdf").write_text( vdf.dumps(CONFIG_DEFAULT) ) yield steam_dir / "config" / "config.vdf" @pytest.fixture(scope="function", autouse=True) def appinfo_compat_tool_factory(appinfo_factory, steam_dir): """ Factory function to add compat tool entries to the appinfo.vdf binary file """ def func(proton_app, compat_tool_name, aliases=None): manifest_appinfo = next( section["appinfo"] for section in iter_appinfo_sections(steam_dir / "appcache" / "appinfo.vdf") if section["appinfo"]["appid"] == 891390 ) if not aliases: aliases = [] aliases.append(compat_tool_name) manifest_appinfo["extended"]["compat_tools"][compat_tool_name] = { "appid": proton_app.appid, "compat_tool_name": compat_tool_name, "aliases": ",".join(aliases) } # Update the appinfo.vdf with the compat tools that have been # added so far. appinfo_factory( appid=891390, # Steam Play 2.0 Manifests app ID, appinfo=manifest_appinfo ) return func @pytest.fixture(scope="function") def appinfo_app_mapping_factory(appinfo_factory, steam_dir): """ Factory function to add Steam app specific compat tool app mappings to the appinfo.vdf binary file """ def func(steam_app, compat_tool_name): manifest_appinfo = next( section["appinfo"] for section in iter_appinfo_sections(steam_dir / "appcache" / "appinfo.vdf") if section["appinfo"]["appid"] == 891390 ) manifest_appinfo["extended"]["app_mappings"][str(steam_app.appid)] = { "appid": steam_app.appid, "tool": compat_tool_name } # Update the appinfo.vdf with the compat tools that have been # added so far. appinfo_factory( appid=891390, # Steam Play 2.0 Manifests app ID, appinfo=manifest_appinfo ) return func @pytest.fixture(scope="function", autouse=True) def appinfo_factory(steam_dir): """ Factory function to add app entries to the appinfo.vdf binary file """ app_entries = { # Populate the file with empty Steam Play 2.0 Manifests. # Protontricks assumes this always exists. 891390: { "appinfo": { "appid": 891390, "extended": { "compat_tools": {}, "app_mappings": {} } } } } def save_vdf(): # Compile all app entries to the appinfo.vdf file. # Start with the header. content = struct.pack( APPINFO_STRUCT_HEADER, b"(DV\x07", # v28 magic number 1 # Universe, protontricks ignores this ) # The fields in the header preceding every VDF section. # Use hardcoded values for everything except the app ID and section # size. infostate = 2 last_updated = 2 access_token = 2 change_number = 2 sha_hash = b"0"*20 vdf_sha_hash = b"0"*20 struct_size = struct.calcsize(APPINFO_V28_STRUCT_SECTION) for appid_, entry in app_entries.items(): binary_vdf = vdf.binary_dumps(entry) entry_size = len(binary_vdf) + (struct_size - 8) content += struct.pack( APPINFO_V28_STRUCT_SECTION, appid_, entry_size, infostate, last_updated, access_token, sha_hash, change_number, vdf_sha_hash ) content += binary_vdf # Add the EOF section content += b"ffff" (steam_dir / "appcache" / "appinfo.vdf").write_bytes(content) def func(appid, appinfo): entry = { "appinfo": { "appid": appid } } entry["appinfo"].update(appinfo) app_entries[appid] = entry save_vdf() # Create the empty VDF file save_vdf() return func @pytest.fixture(scope="function", autouse=True) def steam_libraryfolders_path(steam_dir): """ Fake libraryfolders.vdf file at ~/.steam/steam/steamapps/libraryfolders.vdf """ LIBRARYFOLDERS_DEFAULT = { "LibraryFolders": { # These fields are completely meaningless as far as Protontricks # is concerned "TimeNextStatsReport": "281946123974", "ContentStatsID": "23157498213759321679" } } (steam_dir / "steamapps" / "libraryfolders.vdf").write_text( vdf.dumps(LIBRARYFOLDERS_DEFAULT) ) return steam_dir / "steamapps" / "libraryfolders.vdf" @pytest.fixture(scope="function") def steam_app_factory(steam_dir, steam_config_path): """ Factory function to add fake Steam apps """ def func( name, appid, compat_tool_name=None, library_dir=None, add_prefix=True, required_tool_app=None): if not library_dir: steamapps_dir = steam_dir / "steamapps" else: steamapps_dir = library_dir / "steamapps" install_path = steamapps_dir / "common" / name install_path.mkdir(parents=True) if required_tool_app: (install_path / "toolmanifest.vdf").write_text( vdf.dumps({ "manifest": { "require_tool_appid": required_tool_app.appid } }) ) (steamapps_dir / f"appmanifest_{appid}.acf").write_text( vdf.dumps({ "AppState": { "appid": str(appid), "name": name, "installdir": name } }) ) # Add Wine prefix if add_prefix: (steamapps_dir / "compatdata" / str(appid) / "pfx").mkdir( parents=True ) (steamapps_dir / "compatdata" / str(appid) / "pfx.lock").touch() # Set the preferred Proton installation for the app if provided if compat_tool_name: steam_config = vdf.loads(steam_config_path.read_text()) steam_config["InstallConfigStore"]["Software"]["Valve"]["Steam"][ "CompatToolMapping"][str(appid)] = { "name": compat_tool_name, "config": "", "Priority": "250" } steam_config_path.write_text(vdf.dumps(steam_config)) steam_app = SteamApp( name=name, appid=appid, install_path=str(steamapps_dir / "common" / name), prefix_path=str( steamapps_dir / "compatdata" / str(appid) / "pfx" ), icon_path=( steam_dir / "appcache" / "librarycache" / f"{appid}_icon.jpg" ) ) if required_tool_app: # In actual code, `required_tool_app` attribute is populated later # when we have retrieved all Steam apps and can find the # corresponding app using its app ID steam_app.required_tool_app = required_tool_app steam_app.required_tool_appid = required_tool_app.appid return steam_app return func @pytest.fixture(scope="function") def proton_factory( steam_app_factory, appinfo_compat_tool_factory, steam_config_path): """ Factory function to add fake Proton installations """ def func( name, appid, compat_tool_name, is_default_proton=True, library_dir=None, required_tool_app=None, aliases=None): if not aliases: aliases = [] steam_app = steam_app_factory( name=name, appid=appid, library_dir=library_dir, required_tool_app=required_tool_app ) shutil.rmtree(str(Path(steam_app.prefix_path).parent)) steam_app.prefix_path = None install_path = Path(steam_app.install_path) (install_path / "proton").touch() (install_path / "dist" / "bin").mkdir(parents=True) (install_path / "dist" / "bin" / "wine").touch() (install_path / "dist" / "bin" / "wineserver").touch() # Update config if is_default_proton: steam_config = vdf.loads(steam_config_path.read_text()) steam_config["InstallConfigStore"]["Software"]["Valve"]["Steam"][ "CompatToolMapping"]["0"] = { "name": compat_tool_name, "config": "", "Priority": "250" } steam_config_path.write_text(vdf.dumps(steam_config)) # Add the Proton installation to the appinfo.vdf, which contains # a manifest of all official Proton installations appinfo_compat_tool_factory( proton_app=steam_app, compat_tool_name=compat_tool_name, aliases=aliases ) return steam_app return func @pytest.fixture(scope="function") def runtime_app_factory( steam_app_factory, appinfo_compat_tool_factory, steam_config_path): """ Factory function to add fake Steam Runtimes that are installed as Steam apps """ def func(name, appid, runtime_dir_name, library_dir=None): runtime_app = steam_app_factory( name=name, appid=appid, library_dir=library_dir, add_prefix=False ) install_path = Path(runtime_app.install_path) runtime_root_path = install_path / runtime_dir_name / "files" (runtime_root_path / "lib" / "i386-linux-gnu").mkdir(parents=True) (runtime_root_path / "lib" / "x86_64-linux-gnu").mkdir(parents=True) (install_path / "run.sh").touch() (install_path / "toolmanifest.vdf").write_text( vdf.dumps({ "manifest": {"commandline": "/run.sh --"} }) ) return runtime_app return func @pytest.fixture(scope="function") def steam_runtime_soldier(runtime_app_factory): """ Fake Steam Runtime Soldier installation """ return runtime_app_factory( name="Steam Linux Runtime - Soldier", appid=1391110, runtime_dir_name="soldier" ) @pytest.fixture(scope="function") def custom_proton_factory(steam_dir): """ Factory function to add fake custom Proton installations """ def func(name, compat_tool_dir=None, required_tool_app=None): if not compat_tool_dir: compat_tool_dir = \ steam_dir.parent / "root" / "compatibilitytools.d" / name else: compat_tool_dir = compat_tool_dir / name compat_tool_dir.mkdir(parents=True, exist_ok=True) (compat_tool_dir / "proton").touch() (compat_tool_dir / "proton").chmod(0o744) (compat_tool_dir / "dist" / "bin").mkdir(parents=True) (compat_tool_dir / "dist" / "bin" / "wine").touch() (compat_tool_dir / "dist" / "bin" / "wineserver").touch() (compat_tool_dir / "compatibilitytool.vdf").write_text( vdf.dumps({ "compatibilitytools": { "compat_tools": { name: { "install_path": ".", "display_name": name, "from_oslist": "windows", "to_oslist": "linux" } } } }) ) if required_tool_app: (compat_tool_dir / "toolmanifest.vdf").write_text( vdf.dumps({ "manifest": { "require_tool_appid": required_tool_app.appid } }) ) else: (compat_tool_dir / "toolmanifest.vdf").write_text( vdf.dumps({"manifest": {}}) ) return SteamApp( name=name, install_path=str(compat_tool_dir) ) return func @pytest.fixture(scope="function") def default_proton(proton_factory): """ Mocked default Proton installation """ return proton_factory( name="Proton 4.20", appid=123450, compat_tool_name="proton_420", is_default_proton=True, aliases=["proton-stable"] ) @pytest.fixture(scope="function") def default_new_proton(proton_factory, steam_runtime_soldier): """ Mocked newer default Proton installation that uses separate Steam Runtime """ return proton_factory( name="Proton 7.0", appid=543210, compat_tool_name="proton_70", is_default_proton=True, required_tool_app=steam_runtime_soldier ) @pytest.fixture(scope="function") def steam_library_factory(steam_dir, steam_libraryfolders_path, tmp_path): """ Factory function to add fake Steam library folders """ def func(name, new_struct=False): library_dir = Path(str(tmp_path)) / "mnt" / name library_dir.mkdir(parents=True) # Update libraryfolders.vdf with the new library folder libraryfolders_config = vdf.loads( steam_libraryfolders_path.read_text() ) # Each new library adds a new entry into the config file with the # field name that starts from 1 and increases with each new library # folder. # Newer Steam releases stores the library entry in a dict, while # older releases just store the full path as the field value library_id = len(libraryfolders_config["LibraryFolders"].keys()) - 1 if new_struct: libraryfolders_config["LibraryFolders"][str(library_id)] = { "path": str(library_dir), "label": "", "mounted": "1" } else: libraryfolders_config["LibraryFolders"][str(library_id)] = \ str(library_dir) steam_libraryfolders_path.write_text(vdf.dumps(libraryfolders_config)) return library_dir return func @pytest.fixture(scope="function") def xdg_user_dir_bin(home_dir): """ Mock the 'xdg-user-dir' executable used to determine XDG directory locations """ # Only mock PICTURES and DOWNLOAD; mocking everything isn't necessary # for the tests. (home_dir / ".local" / "bin" / "xdg-user-dir").write_text( '#!/bin/bash\n' 'if [[ "$1" == "PICTURES" ]]; then\n' ' echo "$HOME/Pictures"\n' 'elif [[ "$1" == "DOWNLOAD" ]]; then\n' 'echo "$HOME/Downloads"\n' 'fi' ) (home_dir / ".local" / "bin" / "xdg-user-dir").chmod(0o744) class MockSubprocess: def __init__( self, args=None, kwargs=None, mock_stdout=None, launcher_alive=True, check=False, cwd=None, input=None, shell=False, env=None, **_): self.args = args self.kwargs = kwargs self.check = check self.shell = shell self.cwd = cwd self.input = input self.pid = 5 self.returncode = 0 if not mock_stdout: self.mock_stdout = "" else: self.mock_stdout = mock_stdout # The state of the mocked 'bwrap-launcher'. This will be set to False # once 'terminate()' is called on the corresponding Popen object to # mock the launcher stopping. self.launcher_alive = launcher_alive self.env = env def wait(self, timeout=None): name = self.args[0] if name.endswith("bwrap-launcher"): if self.launcher_alive: raise TimeoutExpired(name, timeout=timeout) else: # Fake launcher crashing self.returncode = 1 def terminate(self): name = self.args[0] if name.endswith("bwrap-launcher"): self.launcher_alive = False class MockResult: def __init__(self, stdout, returncode=0): self.stdout = stdout self.returncode = returncode @pytest.fixture(scope="function") def gui_provider(monkeypatch): """ Monkeypatch the subprocess.run to store the args passed to the yad/zenity command and to manipulate the output of the command """ mock_gui_provider = MockSubprocess() def mock_subprocess_run(args, **kwargs): mock_gui_provider.args = args mock_gui_provider.kwargs = kwargs return MockResult( stdout=mock_gui_provider.mock_stdout.encode("utf-8"), returncode=mock_gui_provider.returncode ) monkeypatch.setattr( "protontricks.gui.run", mock_subprocess_run ) yield mock_gui_provider class CommandMock: def __init__(self): self.commands = [] self.launcher_working = True @pytest.fixture(scope="function") def command_mock(monkeypatch): """ Fixture to mock all subprocess calls. Returns instance containing command history. """ command_mock = CommandMock() def mock_subprocess_run(*args, **kwargs): try: # Command provided as a list executable = args[0][0] except ValueError: # Command provided as a string executable = args[0].split(" ")[0] # Don't mock "/sbin/ldconfig" and "locale" if executable in ["/sbin/ldconfig", "locale"]: return run(*args, **kwargs) mock_command = MockSubprocess( *args, **{**kwargs, **{"launcher_alive": command_mock.launcher_working}} ) command_mock.commands.append(mock_command) return MockResult(stdout=b"") def mock_Popen(*args, **kwargs): mock_command = MockSubprocess( *args, **{**kwargs, **{"launcher_alive": command_mock.launcher_working}} ) command_mock.commands.append(mock_command) return mock_command monkeypatch.setattr( "protontricks.util.run", mock_subprocess_run ) monkeypatch.setattr( "protontricks.util.Popen", mock_Popen ) monkeypatch.setattr( "protontricks.cli.desktop_install.run", mock_subprocess_run ) return command_mock @pytest.fixture(scope="function") def steam_deck(monkeypatch, tmp_path): """ Mock a Steam Deck environment """ os_release_path = tmp_path / "etc" / "os-release" os_release_path.parent.mkdir(parents=True) os_release_path.write_text("\n".join([ 'NAME="SteamOS"', "ID=steamos", "VARIANT_ID=steamdeck" ])) monkeypatch.setattr( "protontricks.util.OS_RELEASE_PATHS", [str(tmp_path / "etc" / "os-release")] ) def _run_cli(monkeypatch, capsys, cli_func): """ Run protontricks with the given arguments and environment variables and return the output """ def func(args, env=None, include_stderr=False, expect_returncode=0): if not env: env = {} with monkeypatch.context() as monkeypatch_ctx: # Monkeypatch environments values for the duration # of the CLI call for name, val in env.items(): monkeypatch_ctx.setenv(name, val) try: cli_func(args) except SystemExit as exc: assert exc.code == expect_returncode stdout, stderr = capsys.readouterr() if include_stderr: return stdout, stderr else: return stdout return func @pytest.fixture(scope="function") def cli(monkeypatch, capsys): """ Run `protontricks` with the given arguments and environment variables, and return the output """ return _run_cli(monkeypatch, capsys, main_cli_entrypoint) @pytest.fixture(scope="function") def launch_cli(monkeypatch, capsys): """ Run `protontricks-launch` with the given arguments and environment variables, and return the output """ return _run_cli(monkeypatch, capsys, launch_cli_entrypoint) @pytest.fixture(scope="function") def desktop_install_cli(monkeypatch, capsys): """ Run `protontricks-desktop-install` with the given arguments and environment variables, and return the output """ return _run_cli(monkeypatch, capsys, desktop_install_cli_entrypoint) protontricks-1.12.0/tests/data/000077500000000000000000000000001467175317500164275ustar00rootroot00000000000000protontricks-1.12.0/tests/data/appinfo_v29.vdf000066400000000000000000000003751467175317500212710ustar00rootroot00000000000000)DVUrffCg Im}Q5W~WO5~k>|BȐUrffCg Im}Q5W~WO5~k>|BȐ appinfoappidpublic_onlycommonnametypeprotontricks-1.12.0/tests/test_config.py000066400000000000000000000016051467175317500203760ustar00rootroot00000000000000from protontricks.config import get_config def test_config(home_dir): """ Test creating a configuration file, inserting a value into it and reading it back """ config = get_config() config.set("General", "test_field", "test_value") # Ensure the configuration file now exists config_path = home_dir / ".config/protontricks/config.ini" assert config_path.exists() assert "test_value" in config_path.read_text() # Open the configuration file again, we should be able to read the value # back config = get_config() assert config.get("General", "test_field") == "test_value" def test_config_default(): """ Test that a default value can be used if the field doesn't exist in the configuration file """ config = get_config() assert config.get( "General", "fake_field", "default_value" ) == "default_value" protontricks-1.12.0/tests/test_flatpak.py000066400000000000000000000156401467175317500205570ustar00rootroot00000000000000import pytest from pathlib import Path from protontricks.flatpak import (get_inaccessible_paths, get_running_flatpak_version) class TestGetRunningFlatpakVersion: def test_flatpak_not_active(self): """ Test Flatpak version detection when Flatpak is not active """ assert get_running_flatpak_version() is None def test_flatpak_active(self, monkeypatch, tmp_path): """ Test Flatpak version detection when Flatpak is active """ flatpak_info_path = tmp_path / "flatpak-info" flatpak_info_path.write_text( "[Application]\n" "name=fake.flatpak.Protontricks\n" "\n" "[Instance]\n" "flatpak-version=1.12.1" ) monkeypatch.setattr( "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) ) assert get_running_flatpak_version() == (1, 12, 1) class TestGetInaccessiblePaths: def test_flatpak_disabled(self): """ Test that an empty list is returned if Flatpak is not active """ assert get_inaccessible_paths(["/fake", "/fake_2"]) == [] def test_flatpak_active(self, monkeypatch, home_dir, tmp_path): """ Test that inaccessible paths are correctly detected when Flatpak is active """ flatpak_info_path = tmp_path / "flatpak-info" flatpak_info_path.write_text( "[Application]\n" "name=fake.flatpak.Protontricks\n" "\n" "[Instance]\n" "flatpak-version=1.12.1\n" "\n" "[Context]\n" "filesystems=/mnt/SSD_A;/mnt/SSD_B;xdg-data/Steam;" ) monkeypatch.setattr( "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) ) inaccessible_paths = get_inaccessible_paths([ "/mnt/SSD_A", "/mnt/SSD_C", str(home_dir / ".local/share/SteamOld"), str(home_dir / ".local/share/Steam") ]) assert len(inaccessible_paths) == 2 assert str(inaccessible_paths[0]) == "/mnt/SSD_C" assert str(inaccessible_paths[1]) == \ str(Path("~/.local/share/SteamOld").expanduser()) def test_flatpak_home(self, monkeypatch, tmp_path, home_dir): """ Test that 'home' filesystem permission grants permission to the home directory """ flatpak_info_path = tmp_path / "flatpak-info" flatpak_info_path.write_text( "[Application]\n" "name=fake.flatpak.Protontricks\n" "\n" "[Instance]\n" "flatpak-version=1.12.1\n" "\n" "[Context]\n" "filesystems=home;" ) monkeypatch.setattr( "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) ) inaccessible_paths = get_inaccessible_paths([ "/mnt/SSD_A", "/var/fake_path", str(home_dir / "fake_path"), str(home_dir / ".local/share/FakePath") ]) assert len(inaccessible_paths) == 2 assert str(inaccessible_paths[0]) == "/mnt/SSD_A" assert str(inaccessible_paths[1]) == "/var/fake_path" def test_flatpak_home_tilde(self, monkeypatch, tmp_path, home_dir): """ Test that tilde slash is expanded if included in the list of file systems """ flatpak_info_path = tmp_path / "flatpak-info" flatpak_info_path.write_text( "[Application]\n" "name=fake.flatpak.Protontricks\n" "\n" "[Instance]\n" "flatpak-version=1.12.1\n" "\n" "[Context]\n" "filesystems=~/fake_path" ) monkeypatch.setattr( "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) ) inaccessible_paths = get_inaccessible_paths([ str(home_dir / "fake_path"), str(home_dir / "fake_path_2") ]) assert len(inaccessible_paths) == 1 assert str(inaccessible_paths[0]) == str(home_dir / "fake_path_2") def test_flatpak_host(self, monkeypatch, tmp_path, home_dir): """ Test that 'host' filesystem permission grants permission to the whole file system """ flatpak_info_path = tmp_path / "flatpak-info" flatpak_info_path.write_text( "[Application]\n" "name=fake.flatpak.Protontricks\n" "\n" "[Instance]\n" "flatpak-version=1.12.1\n" "\n" "[Context]\n" "filesystems=host;" ) monkeypatch.setattr( "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) ) inaccessible_paths = get_inaccessible_paths([ "/mnt/SSD_A", "/var/fake_path", str(home_dir / "fake_path"), ]) assert len(inaccessible_paths) == 0 @pytest.mark.usefixtures("xdg_user_dir_bin") def test_flatpak_xdg_user_dir(self, monkeypatch, tmp_path, home_dir): """ Test that XDG filesystem permissions such as 'xdg-pictures' and 'xdg-download' are detected correctly """ flatpak_info_path = tmp_path / "flatpak-info" flatpak_info_path.write_text( "[Application]\n" "name=fake.flatpak.Protontricks\n" "\n" "[Instance]\n" "flatpak-version=1.12.1\n" "\n" "[Context]\n" "filesystems=xdg-pictures;" ) monkeypatch.setattr( "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) ) inaccessible_paths = get_inaccessible_paths([ str(home_dir / "Pictures"), str(home_dir / "Download") ]) assert len(inaccessible_paths) == 1 assert str(inaccessible_paths[0]) == str(home_dir / "Download") def test_flatpak_unknown_permission(self, monkeypatch, tmp_path, caplog): """ Test that unknown filesystem permissions are ignored """ flatpak_info_path = tmp_path / "flatpak-info" flatpak_info_path.write_text( "[Application]\n" "name=fake.flatpak.Protontricks\n" "\n" "[Instance]\n" "flatpak-version=1.12.1\n" "\n" "[Context]\n" "filesystems=home;unknown-fs;" ) monkeypatch.setattr( "protontricks.flatpak.FLATPAK_INFO_PATH", str(flatpak_info_path) ) inaccessible_paths = get_inaccessible_paths([ "/mnt/SSD", ]) assert len(inaccessible_paths) == 1 # Unknown filesystem permission is logged records = caplog.records assert len(records) == 1 assert records[0].levelname == "WARNING" assert "Unknown Flatpak file system permission 'unknown-fs'" \ in records[0].message protontricks-1.12.0/tests/test_gui.py000066400000000000000000000366351467175317500177300ustar00rootroot00000000000000import contextlib import shutil from subprocess import CalledProcessError import pytest from conftest import MockResult from PIL import Image from protontricks.gui import (prompt_filesystem_access, select_steam_app_with_gui, select_steam_installation) @pytest.fixture(scope="function") def broken_zenity(gui_provider, monkeypatch): """ Mock a broken Zenity executable that prints an error as described in the following GitHub issue: https://github.com/Matoking/protontricks/issues/20 """ def mock_subprocess_run(args, **kwargs): gui_provider.args = args raise CalledProcessError( returncode=-6, cmd=args, output=gui_provider.mock_stdout, stderr=b"free(): double free detected in tcache 2\n" ) monkeypatch.setattr( "protontricks.gui.run", mock_subprocess_run ) yield gui_provider @pytest.fixture(scope="function") def locale_error_zenity(gui_provider, monkeypatch): """ Mock a Zenity executable returning a 255 error due to a locale issue on first run and working normally on second run """ def mock_subprocess_run(args, **kwargs): if not gui_provider.args: gui_provider.args = args raise CalledProcessError( returncode=255, cmd=args, output="", stderr=( b"This option is not available. " b"Please see --help for all possible usages." ) ) return MockResult(stdout=gui_provider.mock_stdout.encode("utf-8")) monkeypatch.setattr( "protontricks.gui.run", mock_subprocess_run ) monkeypatch.setenv("PROTONTRICKS_GUI", "zenity") yield gui_provider class TestSelectApp: def test_select_game(self, gui_provider, steam_app_factory, steam_dir): """ Select a game using the GUI """ steam_apps = [ steam_app_factory(name="Fake game 1", appid=10), steam_app_factory(name="Fake game 2", appid=20) ] # Fake user selecting 'Fake game 2' gui_provider.mock_stdout = "Fake game 2: 20" steam_app = select_steam_app_with_gui( steam_apps=steam_apps, steam_path=steam_dir ) assert steam_app == steam_apps[1] input_ = gui_provider.kwargs["input"] # Check that choices were displayed assert b"Fake game 1: 10\n" in input_ assert b"Fake game 2: 20" in input_ def test_select_game_icons( self, gui_provider, steam_app_factory, steam_dir): """ Select a game using the GUI. Ensure that icons are used in the dialog whenever available. """ steam_apps = [ steam_app_factory(name="Fake game 1", appid=10), steam_app_factory(name="Fake game 2", appid=20), steam_app_factory(name="Fake game 3", appid=30), ] # Create icons for game 1 and 3 for appid in (10, 30): Image.new("RGB", (32, 32)).save( steam_dir / "appcache" / "librarycache" / f"{appid}_icon.jpg") gui_provider.mock_stdout = "Fake game 2: 20" select_steam_app_with_gui(steam_apps=steam_apps, steam_path=steam_dir) input_ = gui_provider.kwargs["input"] assert b"librarycache/10_icon.jpg\nFake game 1" in input_ assert b"icon_placeholder.png\nFake game 2" in input_ assert b"librarycache/30_icon.jpg\nFake game 3" in input_ def test_select_game_icons_ensure_resize( self, gui_provider, steam_app_factory, steam_dir, home_dir): """ Select a game using the GUI. Ensure custom icons with sizes other than 32x32 are resized. """ steam_apps = [ steam_app_factory(name="Fake game 1", appid=10) ] Image.new("RGB", (64, 64)).save( steam_dir / "appcache" / "librarycache" / "10_icon.jpg" ) gui_provider.mock_stdout = "Fake game 1: 10" select_steam_app_with_gui(steam_apps=steam_apps, steam_path=steam_dir) # Resized icon should have been created with the correct size and used resized_icon_path = \ home_dir / ".cache" / "protontricks" / "app_icons" / "10.jpg" assert resized_icon_path.is_file() with Image.open(resized_icon_path) as img: assert img.size == (32, 32) input_ = gui_provider.kwargs["input"] assert f"{resized_icon_path}\nFake game 1".encode("utf-8") in input_ # Any existing icon should be overwritten if it already exists resized_icon_path.write_bytes(b"not valid") select_steam_app_with_gui(steam_apps=steam_apps, steam_path=steam_dir) with Image.open(resized_icon_path) as img: assert img.size == (32, 32) def test_select_game_unidentifiable_icon_skipped( self, gui_provider, steam_app_factory, steam_dir, home_dir, caplog): """ Select a game using the GUI. Ensure a custom icon that's not identifiable by Pillow is skipped. """ steam_apps = [ steam_app_factory(name="Fake game 1", appid=10) ] icon_path = steam_dir / "appcache" / "librarycache" / "10_icon.jpg" icon_path.write_bytes(b"") gui_provider.mock_stdout = "Fake game 1: 10" selected_app = select_steam_app_with_gui( steam_apps=steam_apps, steam_path=steam_dir ) # Warning about icon was logged, but the app was selected successfully record = caplog.records[-1] assert record.message.startswith(f"Could not resize {icon_path}") assert selected_app.appid == 10 def test_select_game_no_choice( self, gui_provider, steam_app_factory, steam_dir): """ Try choosing a game but make no choice """ steam_apps = [steam_app_factory(name="Fake game 1", appid=10)] # Fake user doesn't select any game gui_provider.mock_stdout = "" with pytest.raises(SystemExit) as exc: select_steam_app_with_gui( steam_apps=steam_apps, steam_path=steam_dir ) assert exc.value.code == 1 def test_select_game_broken_zenity( self, broken_zenity, monkeypatch, steam_app_factory, steam_dir): """ Try choosing a game with a broken Zenity executable that prints a specific error message that Protontricks knows how to ignore """ monkeypatch.setenv("PROTONTRICKS_GUI", "zenity") steam_apps = [ steam_app_factory(name="Fake game 1", appid=10), steam_app_factory(name="Fake game 2", appid=20) ] # Fake user selecting 'Fake game 2' broken_zenity.mock_stdout = "Fake game 2: 20" steam_app = select_steam_app_with_gui( steam_apps=steam_apps, steam_path=steam_dir) assert steam_app == steam_apps[1] def test_select_game_locale_error( self, locale_error_zenity, steam_app_factory, steam_dir, caplog): """ Try choosing a game with an environment that can't handle non-ASCII characters """ steam_apps = [ steam_app_factory(name="Fäke game 1", appid=10), steam_app_factory(name="Fäke game 2", appid=20) ] # Fake user selecting 'Fäke game 2'. The non-ASCII character 'ä' # is stripped since Zenity wouldn't be able to display the character. locale_error_zenity.mock_stdout = "Fke game 2: 20" steam_app = select_steam_app_with_gui( steam_apps=steam_apps, steam_path=steam_dir ) assert steam_app == steam_apps[1] assert ( "Your system locale is incapable of displaying all characters" in caplog.records[-1].message ) @pytest.mark.parametrize("gui_cmd", ["yad", "zenity"]) def test_select_game_gui_provider_env( self, gui_provider, steam_app_factory, monkeypatch, gui_cmd, steam_dir): """ Test that the correct GUI provider is selected based on the `PROTONTRICKS_GUI` environment variable """ monkeypatch.setenv("PROTONTRICKS_GUI", gui_cmd) steam_apps = [ steam_app_factory(name="Fake game 1", appid=10), steam_app_factory(name="Fake game 2", appid=20) ] gui_provider.mock_stdout = "Fake game 2: 20" select_steam_app_with_gui( steam_apps=steam_apps, steam_path=steam_dir ) # The flags should differ slightly depending on which provider is in # use if gui_cmd == "yad": assert gui_provider.args[0] == "yad" assert gui_provider.args[2] == "--no-headers" elif gui_cmd == "zenity": assert gui_provider.args[0] == "zenity" assert gui_provider.args[2] == "--hide-header" class TestSelectSteamInstallation: @pytest.mark.usefixtures("flatpak_sandbox") @pytest.mark.parametrize("gui_cmd", ["yad", "zenity"]) def test_select_steam_gui_provider_env( self, gui_provider, monkeypatch, gui_cmd, steam_dir, flatpak_steam_dir): """ Test that the correct GUI provider is selected based on the `PROTONTRICKS_GUI` environment variable """ monkeypatch.setenv("PROTONTRICKS_GUI", gui_cmd) gui_provider.mock_stdout = "1: Flatpak - /foo/bar" select_steam_installation([ (steam_dir, steam_dir), (flatpak_steam_dir, flatpak_steam_dir) ]) # The flags should differ slightly depending on which provider is in # use if gui_cmd == "yad": assert gui_provider.args[0] == "yad" assert gui_provider.args[2] == "--no-headers" elif gui_cmd == "zenity": assert gui_provider.args[0] == "zenity" assert gui_provider.args[2] == "--hide-header" @pytest.mark.parametrize( "path,label", [ (".steam", "Native"), (".local/share/Steam", "Native"), (".var/app/com.valvesoftware.Steam/.local/share/Steam", "Flatpak"), ("snap/steam/common/.local/share/Steam", "Snap") ] ) def test_correct_labels_detected( self, gui_provider, steam_dir, home_dir, path, label): """ Test that the Steam installation selection dialog uses the correct label for each Steam installation depending on its type """ steam_new_dir = home_dir / path with contextlib.suppress(FileExistsError): # First test cases try copying against existing dirs, this can be # ignored shutil.copytree(steam_dir, steam_new_dir) select_steam_installation([ (steam_new_dir, steam_new_dir), # Use an additional nonsense path; there need to be at least # two paths or user won't be prompted as there is no need ("/mock-steam", "/mock-steam") ]) prompt_input = gui_provider.kwargs["input"].decode("utf-8") assert f"{label} - {steam_new_dir}" in prompt_input @pytest.mark.usefixtures("flatpak_sandbox") class TestPromptFilesystemAccess: def test_prompt_without_desktop(self, home_dir, caplog): """ Test that calling 'prompt_filesystem_access' without showing the dialog only generates a warning """ prompt_filesystem_access( [home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"], show_dialog=False ) assert len(caplog.records) == 1 record = caplog.records[0] assert record.levelname == "WARNING" assert "Protontricks does not appear to have access" in record.message assert "--filesystem=/mnt/fake_SSD" in record.message assert "--filesystem=/mnt/fake_SSD_2" in record.message assert str(home_dir / "fake_path") not in record.message def test_prompt_home_dir(self, home_dir, tmp_path, caplog): """ Test that calling 'prompt_filesystem_access' with a path in the home directory will result in the command using a tilde slash as the shorthand instead """ flatpak_info_path = tmp_path / "flatpak-info" flatpak_info_path.write_text( "[Application]\n" "name=fake.flatpak.Protontricks\n" "\n" "[Instance]\n" "flatpak-version=1.12.1\n" "\n" "[Context]\n" "filesystems=/mnt/SSD_A" ) prompt_filesystem_access( [home_dir / "fake_path", "/mnt/SSD_A"], show_dialog=False ) assert len(caplog.records) == 1 record = caplog.records[0] assert record.levelname == "WARNING" assert "Protontricks does not appear to have access" in record.message assert "--filesystem='~/fake_path'" in record.message assert "/mnt/SSD_A" not in record.message def test_prompt_with_desktop_no_dialog(self, home_dir, gui_provider): """ Test that calling 'prompt_filesystem_access' with 'show_dialog' displays a dialog """ prompt_filesystem_access( [home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"], show_dialog=True ) input_ = gui_provider.kwargs["input"].decode("utf-8") assert str(home_dir / "fake_path") not in input_ assert "--filesystem=/mnt/fake_SSD" in input_ assert "--filesystem=/mnt/fake_SSD_2" in input_ def test_prompt_with_desktop_dialog(self, home_dir, gui_provider): """ Test that calling 'prompt_filesystem_access' with 'show_dialog' displays a dialog """ # Mock the user closing the dialog without ignoring the messages gui_provider.returncode = 1 prompt_filesystem_access( [home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"], show_dialog=True ) input_ = gui_provider.kwargs["input"].decode("utf-8") # Dialog was displayed assert "/mnt/fake_SSD" in input_ assert "/mnt/fake_SSD_2" in input_ # Mock the user selecting "Ignore, don't ask again" gui_provider.returncode = 0 gui_provider.kwargs["input"] = None prompt_filesystem_access( [home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"], show_dialog=True ) # Dialog is still displayed, but it won't be the next time input_ = gui_provider.kwargs["input"].decode("utf-8") assert "/mnt/fake_SSD" in input_ assert "/mnt/fake_SSD_2" in input_ gui_provider.kwargs["input"] = None prompt_filesystem_access( [home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2"], show_dialog=True ) # Dialog is not shown, since the user has opted to ignore the warning # for the current paths assert not gui_provider.kwargs["input"] # A new path makes the warning reappear prompt_filesystem_access( [ home_dir / "fake_path", "/mnt/fake_SSD", "/mnt/fake_SSD_2", "/mnt/fake_SSD_3" ], show_dialog=True ) input_ = gui_provider.kwargs["input"].decode("utf-8") assert "/mnt/fake_SSD " not in input_ assert "/mnt/fake_SSD_2" not in input_ assert "/mnt/fake_SSD_3" in input_ protontricks-1.12.0/tests/test_steam.py000066400000000000000000001145431467175317500202500ustar00rootroot00000000000000import os import shutil import time from pathlib import Path import pytest import vdf from protontricks.steam import (SteamApp, _get_steamapps_subdirs, find_appid_proton_prefix, find_steam_compat_tool_app, find_steam_installations, find_steam_path, get_custom_compat_tool_installations, get_custom_windows_shortcuts, get_steam_apps, get_steam_lib_paths, iter_appinfo_sections) class TestSteamApp: def test_steam_app_from_appmanifest(self, steam_app_factory, steam_dir): """ Create a SteamApp from an appmanifest file """ steam_app = steam_app_factory(name="Fake game", appid=10) appmanifest_path = \ Path(steam_app.install_path).parent.parent / "appmanifest_10.acf" steam_app = SteamApp.from_appmanifest( path=appmanifest_path, steam_lib_paths=[steam_dir / "steam" / "steamapps"] ) assert steam_app.name == "Fake game" assert steam_app.appid == 10 assert not steam_app.icon_path def test_steam_app_from_appmanifest_and_steam_path( self, steam_app_factory, steam_dir): """ Create a SteamApp from an appmanifest file and Steam path """ steam_app = steam_app_factory(name="Fake game", appid=10) appmanifest_path = \ Path(steam_app.install_path).parent.parent / "appmanifest_10.acf" steam_app = SteamApp.from_appmanifest( path=appmanifest_path, steam_lib_paths=[steam_dir / "steam" / "steamapps"], steam_path=steam_dir ) assert steam_app.name == "Fake game" assert steam_app.appid == 10 assert steam_app.icon_path \ == steam_dir / "appcache" / "librarycache" / "10_icon.jpg" @pytest.mark.parametrize( "content", [ b"", # Empty VDF is ignored b"corrupted", # Can't be parsed as VDF bytes([255]), # Can't be decoded as Unicode ] ) def test_steam_app_from_appmanifest_invalid( self, steam_app_factory, content): steam_app = steam_app_factory(name="Fake game", appid=10) appmanifest_path = \ Path(steam_app.install_path).parent.parent / "appmanifest_10.acf" appmanifest_path.write_bytes(content) # Invalid appmanifest file is ignored assert not SteamApp.from_appmanifest( path=appmanifest_path, steam_lib_paths=[] ) def test_steam_app_from_appmanifest_empty(self, steam_app_factory): """ Try to deserialize an empty appmanifest and check that no SteamApp is returned """ steam_app = steam_app_factory(name="Fake game", appid=10) appmanifest_path = \ Path(steam_app.install_path).parent.parent / "appmanifest_10.acf" appmanifest_path.write_text("") # Empty appmanifest file is ignored assert not SteamApp.from_appmanifest( path=appmanifest_path, steam_lib_paths=[] ) @pytest.mark.parametrize( "content", [ b"", # Empty VDF is ignored b"corrupted", # Can't be parsed as VDF ] ) def test_steam_app_from_appmanifest_corrupted_toolmanifest( self, steam_runtime_soldier, proton_factory, caplog, content): """ Test trying to a SteamApp manifest from an incomplete Proton installation with an empty or corrupted toolmanifest.vdf file """ proton_app = proton_factory( name="Proton 5.13", appid=10, compat_tool_name="proton_513", required_tool_app=steam_runtime_soldier ) # Empty the "toolmanifest.vdf" file (proton_app.install_path / "toolmanifest.vdf").write_bytes(content) assert not SteamApp.from_appmanifest( path=proton_app.install_path.parent.parent / "appmanifest_10.acf", steam_lib_paths=[] ) assert len(caplog.records) == 1 record = caplog.records[0] assert "Tool manifest for Proton 5.13 is empty" in record.message def test_steam_app_from_appmanifest_permission_denied( self, steam_app_factory, caplog, monkeypatch): """ Test trying to read a SteamApp manifest that the user doesn't have read permission for """ def _mock_read_text(self, encoding=None): """ Mock `pathlib.Path.read_text` that mimics a failure due to insufficient permissions """ raise PermissionError("Permission denied") steam_app = steam_app_factory(name="Fake game", appid=10) appmanifest_path = \ Path(steam_app.install_path).parent.parent / "appmanifest_10.acf" monkeypatch.setattr( "pathlib.Path.read_text", _mock_read_text ) assert not SteamApp.from_appmanifest( path=appmanifest_path, steam_lib_paths=[] ) record = caplog.records[-1] assert record.getMessage() == ( f"Skipping appmanifest {appmanifest_path} due " "to insufficient permissions" ) def test_steam_app_proton_dist_path(self, default_proton): """ Check that correct path to Proton binarires and libraries is found using the `SteamApp.proton_dist_path` property """ # 'dist' exists and is found correctly assert str(default_proton.proton_dist_path).endswith( "Proton 4.20/dist" ) # Create a copy named 'files'. This will be favored over 'dist'. shutil.copytree( str(default_proton.install_path / "dist"), str(default_proton.install_path / "files") ) assert str(default_proton.proton_dist_path).endswith( "Proton 4.20/files" ) # If neither exists, None is returned shutil.rmtree(str(default_proton.install_path / "dist")) shutil.rmtree(str(default_proton.install_path / "files")) assert default_proton.proton_dist_path is None def test_steam_app_userconfig_name(self, steam_app_factory): """ Try creating a SteamApp from an older version of the app manifest which contains the application name in a different field See GitHub issue #103 for details """ steam_app = steam_app_factory(name="Fake game", appid=10) appmanifest_path = \ Path(steam_app.install_path).parent.parent / "appmanifest_10.acf" data = vdf.loads(appmanifest_path.read_text()) # Older installations store the name in `userconfig/name` instead del data["AppState"]["name"] data["AppState"]["userconfig"] = { "name": "Fake game" } appmanifest_path.write_text(vdf.dumps(data)) app = SteamApp.from_appmanifest( path=appmanifest_path, steam_lib_paths=[] ) assert app.name == "Fake game" class TestFindSteamCompatToolApp: def test_find_steam_specific_app_proton( self, steam_app_factory, steam_dir, default_proton, proton_factory): """ Set a specific Proton version for a game and check that it is detected correctly """ custom_proton = proton_factory( name="Proton 6.66", appid=54440, compat_tool_name="proton_6_66" ) steam_app_factory( name="Fake game", appid=10, compat_tool_name="proton_6_66") proton_app = find_steam_compat_tool_app( steam_path=steam_dir, steam_apps=[default_proton, custom_proton], appid=10 ) # Proton 4.20 is the global default, but Proton 6.66 is the selected # version for this game assert proton_app.name == "Proton 6.66" @pytest.mark.usefixtures("info_logging") def test_find_legacy_tool_mapping_global( self, steam_dir, steam_config_path, proton_factory, steam_app_factory): """ Check that legacy tool mappings are detected """ custom_proton_a = proton_factory( name="Proton A", compat_tool_name="proton_a", appid=123 ) custom_proton_b = proton_factory( name="Proton B", compat_tool_name="proton_b", appid=321 ) # Set Proton A as the game specific Proton, and B as the global default steam_config_path.write_text( vdf.dumps({ "InstallConfigStore": { "Software": { "Valve": { "Steam": { "ToolMapping": { "0": { "name": "proton_b" }, "10": { "name": "proton_a" } } } } } } }) ) # Game specific Proton detected correctly proton_app = find_steam_compat_tool_app( steam_path=steam_dir, steam_apps=[custom_proton_a, custom_proton_b], appid=10 ) assert proton_app.name == "Proton A" # Global Proton detected correctly proton_app = find_steam_compat_tool_app( steam_path=steam_dir, steam_apps=[custom_proton_a, custom_proton_b] ) assert proton_app.name == "Proton B" @pytest.mark.usefixtures("steam_deck", "info_logging") def test_find_steam_deck_profile( self, steam_app_factory, proton_factory, appinfo_factory, default_proton, steam_config_path, steam_dir): """ Create a Steam Deck compatibility profile for a game and ensure that it is used if `config.vdf` doesn't contain any configuration """ custom_proton = proton_factory( name="Proton 7.77", compat_tool_name="proton_7_77", appid=777 ) steam_app_factory(name="Fake game", appid=10) # Add Steam Deck compatibility profile appinfo_factory( appid=10, appinfo={ "common": { "steam_deck_compatibility": { "configuration": { "recommended_runtime": "proton_7_77" } } } } ) # Remove the tool mappings from 'config.vdf' to ensure it won't be used steam_config_path.write_text( vdf.dumps({ "InstallConfigStore": { "Software": { "Valve": { "Steam": {} } } } }) ) proton_app = find_steam_compat_tool_app( steam_path=steam_dir, steam_apps=[default_proton, custom_proton], appid=10 ) assert proton_app.name == "Proton 7.77" @pytest.mark.usefixtures("info_logging") def test_find_steam_default_proton( self, steam_app_factory, steam_dir, default_proton, proton_factory, steam_config_path, caplog): """ Ensure the function returns the stable version of Proton if no other configuration is available """ steam_app_factory(name="Fake game", appid=10) # Clear the 'config.vdf' and remove any tool mappings, emulating # a situation in which the user has only installed games but hasn't # touched any Steam Play settings steam_config_path.write_text( vdf.dumps({ "InstallConfigStore": { "Software": { "Valve": { "Steam": {} } } } }) ) proton_app = find_steam_compat_tool_app( steam_path=steam_dir, steam_apps=[default_proton], appid=10 ) assert proton_app.name == "Proton 4.20" assert any( record for record in caplog.records if "Using stable version of Proton" in record.message ) @pytest.mark.usefixtures("info_logging") def test_find_steam_steamplay_manifest_app_mapping( self, steam_app_factory, steam_dir, proton_factory, appinfo_app_mapping_factory, steam_config_path, caplog): """ Ensure the function returns the Proton version defined in the Steam Play manifest if one is defined """ steam_app = steam_app_factory(name="Fake game", appid=10) proton_app = proton_factory( name="Test Proton", appid=123450, compat_tool_name="test-proton" ) appinfo_app_mapping_factory( steam_app=steam_app, compat_tool_name="test-proton" ) steam_config_path.write_text( vdf.dumps({ "InstallConfigStore": { "Software": { "Valve": { "Steam": {} } } } }) ) proton_app = find_steam_compat_tool_app( steam_path=steam_dir, steam_apps=[steam_app, proton_app], appid=10 ) assert proton_app.appid == 123450 assert any( record for record in caplog.records if "App has default compatibility tool mapping in the Steam Play" in record.message ) class TestFindLibraryPaths: @pytest.mark.parametrize( "new_struct", [False, True], ids=["old struct", "new struct"] ) def test_get_steam_lib_paths( self, steam_dir, steam_library_factory, new_struct): """ Find the Steam library folders generated with either the old or new structure. Older Steam releases only use a field value containing the path to the library, while newer releases store a dict with additional information besides the library path. """ library_a = steam_library_factory( "TestLibrary_A", new_struct=new_struct ) library_b = steam_library_factory( "TestLibrary_B", new_struct=new_struct ) lib_paths = get_steam_lib_paths(steam_dir) lib_paths.sort(key=lambda path: str(path)) assert len(lib_paths) == 3 assert str(lib_paths[0]) == str(steam_dir) assert str(lib_paths[1]) == str(library_a) assert str(lib_paths[2]) == str(library_b) def test_get_steam_lib_paths_corrupted_libraryfolders( self, steam_dir, steam_library_factory): """ Try to find the Steam library folders and ensure a corrupted libraryfolders.vdf causes an exception to be raised """ steam_library_factory("TestLibrary") (steam_dir / "steamapps" / "libraryfolders.vdf").write_text( "Corrupted" ) with pytest.raises(ValueError) as exc: get_steam_lib_paths(steam_dir) assert "Library folder configuration file" in str(exc.value) def test_get_steam_lib_paths_duplicate_paths( self, steam_dir, steam_library_factory): """ Retrive Steam library folders and ensure duplicate paths (eg. an existing path OR a symlink that resolves to an existing path) are removed from the returned list. Regression test for #118 """ library_dir = steam_library_factory("TestLibrary_A") # Create a symlink from TestLibrary_B to TestLibrary_A (library_dir.parent / "TestLibrary_B").symlink_to(library_dir) # Add the duplicate library folder vdf_data = vdf.loads( (steam_dir / "steamapps" / "libraryfolders.vdf").read_text() ) vdf_data["LibraryFolders"]["2"] = str( library_dir.parent / "TestLibrary_B" ) (steam_dir / "steamapps" / "libraryfolders.vdf").write_text( vdf.dumps(vdf_data) ) library_paths = get_steam_lib_paths(steam_dir) # Only two paths should be returned assert len(library_paths) == 2 assert steam_dir in library_paths assert library_dir in library_paths def test_get_steam_lib_paths_adjust_flatpak_steam_path( self, steam_dir, steam_library_factory, home_dir): """ Retrieve Steam library folders and ensure that the "~/.local/share/Steam" path is adjusted in the library folder configuration file if Steam is installed using Flatpak. Regression test for flathub/com.github.Matoking.protontricks#10 """ flatpak_steam_dir = \ home_dir / ".var/app/com.valvesoftware.Steam/data/Steam" flatpak_steam_dir.mkdir(parents=True) steam_dir.rename(str(flatpak_steam_dir)) shutil.rmtree(str(home_dir / ".local")) libraryfolders_data = { "libraryfolders": { "0": { "path": str(home_dir / ".local/share/Steam") } } } (flatpak_steam_dir / "steamapps/libraryfolders.vdf").write_text( vdf.dumps(libraryfolders_data), "utf-8" ) library_paths = get_steam_lib_paths( home_dir / ".var/app/com.valvesoftware.Steam/data/Steam" ) # The only library folder should point under "~/.var/app", even if the # path in the configuration file is "~/.local/share/Steam". # This needs to be done to adjust for the different path in the # Steam Flatpak sandbox. assert len(library_paths) == 1 assert str(library_paths[0]) == str(flatpak_steam_dir) class TestFindAppidProtonPrefix: def test_find_appid_proton_prefix_steamapps_case( self, steam_app_factory, steam_dir, default_proton, steam_library_factory): """ Find the proton prefix directory for a game located inside a "SteamApps" directory instead of the default "steamapps". Regression test for #33. """ library_dir = steam_library_factory("TestLibrary") steam_app_factory(name="Test game", appid=10, library_dir=library_dir) os.rename( str(library_dir / "steamapps"), str(library_dir / "SteamApps") ) path = find_appid_proton_prefix( appid=10, steam_lib_paths=[steam_dir, library_dir] ) assert path == \ library_dir / "SteamApps" / "compatdata" / "10" / "pfx" def test_find_appid_proton_prefix_latest_compatdata( self, steam_app_factory, steam_library_factory): """ Find the correct Proton prefix directory for a game that has three compatdata directories, two of which are old. """ library_dir_a = steam_library_factory("TestLibraryA") library_dir_b = steam_library_factory("TestLibraryB") library_dir_c = steam_library_factory("TestLibraryC") steam_app_factory( name="Test game", appid=10, library_dir=library_dir_a ) shutil.copytree( str(library_dir_a / "steamapps" / "compatdata"), str(library_dir_b / "steamapps" / "compatdata"), ) shutil.copytree( str(library_dir_a / "steamapps" / "compatdata"), str(library_dir_c / "steamapps" / "compatdata") ) # Give the copy in library B the most recent modification timestamp os.utime( str(library_dir_a / "steamapps" / "compatdata" / "10" / "pfx.lock"), (time.time() - 100, time.time() - 100) ) os.utime( str(library_dir_b / "steamapps" / "compatdata" / "10" / "pfx.lock"), (time.time() - 25, time.time() - 25) ) os.utime( str(library_dir_c / "steamapps" / "compatdata" / "10" / "pfx.lock"), (time.time() - 50, time.time() - 50) ) path = find_appid_proton_prefix( appid=10, steam_lib_paths=[library_dir_a, library_dir_b, library_dir_c] ) assert \ path == library_dir_b / "steamapps" / "compatdata" / "10" / "pfx" class TestFindSteamPath: def test_find_steam_path_env( self, steam_dir, steam_root, tmp_path, monkeypatch): """ Ensure the Steam directory is found when using STEAM_DIR env var and when both runtime and steamapps directories exist inside the path """ custom_path = tmp_path / "custom_steam" custom_path.mkdir() monkeypatch.setenv("STEAM_DIR", str(custom_path)) os.rename( str(steam_dir / "steamapps"), str(custom_path / "steamapps") ) # The path isn't valid yet assert find_steam_path() == (None, None) os.rename( str(steam_root / "ubuntu12_32"), str(custom_path / "ubuntu12_32") ) steam_paths = find_steam_path() assert str(steam_paths[0]) == str(custom_path) assert str(steam_paths[1]) == str(custom_path) @pytest.mark.parametrize( "new_path", [ ".var/app/com.valvesoftware.Steam/data/Steam", "snap/steam/common/.local/share/Steam" ] ) def test_find_steam_path_non_native( self, steam_dir, steam_root, home_dir, new_path): """ Ensure that `steam_path` and `steam_root` both point to the Flatpak/Snap installation of Steam if either installation is found. Regression test for flathub/com.github.Matoking.protontricks#10 """ # Copy the existing Steam directory steam_non_native_dir = home_dir / new_path steam_non_native_dir.parent.mkdir(parents=True) shutil.copytree(steam_dir, steam_non_native_dir) steam_installations = find_steam_installations() steam_path, steam_root = next( (steam_path, steam_root) for (steam_path, steam_root) in steam_installations if str(steam_path) == str(steam_non_native_dir) ) # Since Steam Flatpak installation was found, both of its paths # should point to the same installation directory assert str(steam_path) == str(steam_non_native_dir) assert str(steam_root) == str(steam_non_native_dir) def test_find_steam_path_multiple_install_warning( self, steam_dir, steam_root, home_dir, caplog): """ Ensure that warning is printed if multiple Steam directories are found, instructing the user to select the correct one as necessary. """ steam_flatpak_dir = ( home_dir / ".var" / "app" / "com.valvesoftware.Steam" / "data" / "Steam" ) steam_flatpak_dir.parent.mkdir(parents=True) shutil.copytree(steam_dir, steam_flatpak_dir) find_steam_path() assert "Found multiple Steam directories" in caplog.records[2].message assert "STEAM_DIR=" in caplog.records[3].message assert "The following Steam directories were found" \ in caplog.records[4].message assert str(steam_dir) in caplog.records[5].message assert str(steam_flatpak_dir) in caplog.records[6].message class TestGetSteamApps: def test_get_steam_apps_custom_proton( self, default_proton, custom_proton_factory, steam_dir, steam_root): """ Create a custom Proton installation and ensure 'get_steam_apps' can find it """ custom_proton = custom_proton_factory(name="Custom Proton") steam_apps = get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[steam_dir] ) assert len(steam_apps) == 2 found_custom_proton = next( app for app in steam_apps if app.name == "Custom Proton" ) assert str(found_custom_proton.install_path) == \ str(custom_proton.install_path) def test_get_steam_apps_custom_proton_empty_toolmanifest( self, custom_proton_factory, steam_runtime_soldier, steam_dir, steam_root, caplog): """ Create a custom Proton installation with an empty toolmanifest and ensure a warning is printed and the app is ignored """ custom_proton = custom_proton_factory(name="Custom Proton") (custom_proton.install_path / "toolmanifest.vdf").write_text("") steam_apps = get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[steam_dir] ) # Custom Proton is skipped due to empty tool manifest assert not any( app for app in steam_apps if app.name == "Custom Proton" ) assert len([ record for record in caplog.records if record.levelname == "WARNING" ]) == 1 record = next( record for record in caplog.records if record.levelname == "WARNING" ) assert record.getMessage().startswith( "Tool manifest for Custom Proton is empty" ) @pytest.mark.parametrize( "content,error", [ (b"corrupted", " is corrupted. "), (b"", " is empty. "), # Regression test for #241 (b"\"incompatibilitytools\"\n{\n}", " is incomplete. ") ] ) def test_get_steam_apps_custom_proton_corrupted_compatibilitytool( self, custom_proton_factory, steam_dir, steam_root, caplog, content, error): """ Create a custom Proton installation with a corrupted compatibilitytool.vdf and ensure a warning is printed and the app is ignored """ custom_proton = custom_proton_factory(name="Custom Proton") (custom_proton.install_path / "compatibilitytool.vdf").write_bytes( content ) steam_apps = get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[steam_dir] ) # Custom Proton is skipped due to empty tool manifest assert not any( app for app in steam_apps if app.name == "Custom Proton" ) assert len([ record for record in caplog.records if record.levelname == "WARNING" ]) == 1 record = next( record for record in caplog.records if record.levelname == "WARNING" ) assert error in record.getMessage() def test_get_steam_apps_in_library_folder( self, default_proton, steam_library_factory, steam_app_factory, steam_dir, steam_root): """ Create two games, one installed in the Steam installation directory and another in a Steam library folder """ library_dir = steam_library_factory(name="GameDrive") steam_app_factory(name="Fake game 1", appid=10) steam_app_factory( name="Fake game 2", appid=20, library_dir=library_dir) steam_apps = get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[steam_dir, library_dir] ) # Two games and the default Proton installation should be found assert len(steam_apps) == 3 steam_app_a = next(app for app in steam_apps if app.appid == 10) steam_app_b = next(app for app in steam_apps if app.appid == 20) assert str(steam_app_a.install_path) == \ str(steam_dir / "steamapps" / "common" / "Fake game 1") assert str(steam_app_b.install_path) == \ str(library_dir / "steamapps" / "common" / "Fake game 2") def test_get_steam_apps_proton_precedence( self, custom_proton_factory, home_dir, steam_root, steam_dir, monkeypatch): """ Create two Proton apps with the same name but located in different paths. Only one will be returned due to precedence in the directory paths """ custom_compat_dir = home_dir / "CompatTools" monkeypatch.setenv( "STEAM_EXTRA_COMPAT_TOOLS_PATHS", str(custom_compat_dir) ) proton_app_a = custom_proton_factory( name="Fake Proton", compat_tool_dir=custom_compat_dir ) steam_apps = get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[steam_dir] ) assert len(steam_apps) == 1 assert str(steam_apps[0].install_path) == \ str(proton_app_a.install_path) # Create a Proton app with the same name in the default directory; # this will override the former Proton app we created proton_app_b = custom_proton_factory(name="Fake Proton") steam_apps = get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[steam_dir] ) assert len(steam_apps) == 1 assert str(steam_apps[0].install_path) == \ str(proton_app_b.install_path) def test_get_steam_apps_escape_chars( self, steam_app_factory, steam_library_factory, steam_root, steam_dir): """ Create a Steam library directory with a name containing the character '[' and ensure it is found correctly. Regression test for https://github.com/Matoking/protontricks/issues/47 """ library_dir = steam_library_factory(name="[HDD-1] SteamLibrary") steam_app_factory(name="Test game", appid=10, library_dir=library_dir) steam_apps = get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[steam_dir, library_dir] ) assert len(steam_apps) == 1 assert steam_apps[0].name == "Test game" assert str(steam_apps[0].install_path).startswith(str(library_dir)) def test_get_steam_apps_steamapps_case_warning( self, steam_root, steam_dir, caplog): """ Ensure a warning is logged if both 'steamapps' and 'SteamApps' directories exist at one of the Steam library directories """ get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[steam_dir] ) # No log was created yet assert len([ record for record in caplog.records if record.levelname == "WARNING" ]) == 0 (steam_dir / "SteamApps").mkdir() get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[steam_dir] ) # Warning was logged due to two Steam app directories log = next( record for record in caplog.records if record.levelname == "WARNING" ) assert f"directories were found at {steam_dir}" in log.getMessage() def test_get_steam_apps_steamapps_case_insensitive_fs( self, monkeypatch, steam_root, steam_dir, caplog): """ Ensure that the "'steamapps' and 'SteamApps' both exist" warning is not printed if a case-insensitive file system is in use Regression test for https://github.com/Matoking/protontricks/issues/112 """ def _mock_is_dir(self): return self.name in ("steamapps", "SteamApps", "steam") # Mock the "existence" of both 'steamapps' and 'SteamApps' by # monkeypatching pathlib monkeypatch.setattr("pathlib.Path.is_dir", _mock_is_dir) get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[steam_dir] ) # No warning is printed assert len([ record for record in caplog.records if record.levelname == "WARNING" ]) == 0 def test_get_steam_apps_missing_library_folder( self, steam_library_factory, steam_dir, steam_root, caplog): """ Create multiple Steam library folders, delete one of them and ensure a warning is printed. This can happen if Protontricks is executed inside a Flatpak sandbox without the necessary filesystem permissions. """ library_dir_a = steam_library_factory(name="LibraryA") library_dir_b = steam_library_factory(name="LibraryB") # Delete library B shutil.rmtree(str(library_dir_b)) get_steam_apps( steam_root=steam_root, steam_path=steam_dir, steam_lib_paths=[library_dir_a, library_dir_b] ) warnings = [ record for record in caplog.records if record.levelname == "WARNING" ] assert len(warnings) == 2 warning = warnings[-1] assert f"{library_dir_b} not found." in warning.message class TestGetWindowsShortcuts: def test_get_custom_windows_shortcuts_derive_appid( self, steam_dir, shortcut_factory): """ Retrieve custom Windows shortcut. The app ID is derived from the executable name since it's not found in shortcuts.vdf. """ shortcut_factory(install_dir="fake/path/", name="fakegame.exe") shortcut_apps = get_custom_windows_shortcuts( steam_dir, steam_lib_paths=[steam_dir] ) assert len(shortcut_apps) == 1 assert shortcut_apps[0].name == "Non-Steam shortcut: fakegame.exe" assert shortcut_apps[0].appid == 4149337689 def test_get_custom_windows_shortcuts_read_vdf( self, steam_dir, shortcut_factory): """ Retrieve custom Windows shortcut. The app ID is read and derived directly from the shortcuts.vdf, which is used on newer Steam versions. """ shortcut_factory( install_dir="fake/path/", name="fakegame.exe", appid_in_vdf=True ) shortcut_apps = get_custom_windows_shortcuts( steam_dir, steam_lib_paths=[steam_dir] ) assert len(shortcut_apps) == 1 assert shortcut_apps[0].name == "Non-Steam shortcut: fakegame.exe" assert shortcut_apps[0].appid == 4149337689 def test_get_custom_windows_shortcuts_populate_icon_path( self, steam_dir, shortcut_factory): """ Retrieve custom Windows shortcut. The 'icon_path' property is populated if the icon path is found in `shortcuts.vdf`. """ shortcut_factory( install_dir="fake/path/", name="fakegame.exe", icon_path="/fake/icon/path.png" ) shortcut_apps = get_custom_windows_shortcuts( steam_dir, steam_lib_paths=[steam_dir] ) assert len(shortcut_apps) == 1 assert shortcut_apps[0].name == "Non-Steam shortcut: fakegame.exe" assert shortcut_apps[0].appid == 4149337689 assert shortcut_apps[0].icon_path == Path("/fake/icon/path.png") @pytest.mark.usefixtures("info_logging") def test_get_custom_windows_shortcuts_non_numeric_appid( self, steam_dir, shortcut_factory, caplog): """ Retrieve custom Windows shortcuts when one of the contained shortcuts has a non-numeric app ID. This is usually the case for app IDs created with 3rd party applications such as Lutris. """ # This won't be reported by Protontricks shortcut_factory( install_dir="/usr/bin", name="lutris", appid_in_vdf=True, appid="lutris-fake-game" ) shortcut_factory( install_dir="fake/path/", name="fakegame.exe", appid_in_vdf=True ) shortcut_apps = get_custom_windows_shortcuts( steam_dir, steam_lib_paths=[steam_dir] ) assert len(shortcut_apps) == 1 assert shortcut_apps[0].name == "Non-Steam shortcut: fakegame.exe" # The non-numeric app ID was logged record = next( record for record in caplog.records if "Skipping unrecognized" in record.message ) assert record.levelname == "INFO" assert ( "Skipping unrecognized non-Steam shortcut with " "app ID 'lutris-fake-game'" in record.message ) @pytest.mark.usefixtures("info_logging") def test_get_custom_windows_shortcuts_no_prefix( self, steam_dir, shortcut_factory, caplog): """ Retrieve list of Windows shortcuts and ensure shortcuts without prefixes are omitted and cause a log message """ shortcut_factory( install_dir="fake/path/", name="fakegame.exe", appid_in_vdf=True ) # Remove the prefix shutil.rmtree(steam_dir / "steamapps" / "compatdata" / "4149337689") shortcut_apps = get_custom_windows_shortcuts( steam_dir, steam_lib_paths=[steam_dir] ) assert not shortcut_apps # The non-existent prefix was logged record = next( record for record in caplog.records if "does not have a prefix" in record.message ) assert record.levelname == "INFO" assert ( "Shortcut fakegame.exe (4149337689) does not have a prefix" in record.message ) def test_parse_appinfo_v29(): """ Test parsing an appinfo.vdf V29 file and retrieving the app sections correctly. Creating proper appinfo.vdf V29 files on demand for test fixtures involves plenty of work and is probably a rare use case for the library to support, so just use a hardcoded test file for the time being. """ app_sections = list( iter_appinfo_sections(Path("./tests/data/appinfo_v29.vdf").resolve()) ) assert len(app_sections) == 2 assert app_sections[0]["appinfo"]["appid"] == 5 assert app_sections[1]["appinfo"]["appid"] == 10 def test_get_steamapps_subdirs(steam_dir, steam_library_factory): """ Retrieve Steam library directory containing multiple 'steamapps' directories. Ensure the one with the exact 'steamapps' case is picked first. """ library = steam_library_factory("TestLibrary") (library / "SteamApps").mkdir() (library / "STEAMAPPS").mkdir() (library / "steamapps").mkdir() (library / "SteaMAppS").mkdir() steamapps_dirs = _get_steamapps_subdirs(library) assert len(steamapps_dirs) == 4 assert steamapps_dirs[0].name == "steamapps" protontricks-1.12.0/tests/test_util.py000066400000000000000000000222571467175317500201140ustar00rootroot00000000000000import stat import textwrap from pathlib import Path import pytest from protontricks.util import (create_wine_bin_dir, is_steam_deck, lower_dict, run_command) def get_files_in_dir(d): return {binary.name for binary in d.iterdir()} class TestCreateWineBinDir: def test_wine_bin_dir_updated(self, home_dir, default_proton): """ Test that the directory containing the helper scripts is kept up-to-date with the Proton installation's binaries """ create_wine_bin_dir(default_proton) # Check that the Wine binaries exist files = get_files_in_dir( home_dir / ".cache" / "protontricks" / "proton" / "Proton 4.20" / "bin" ) assert set([ "wine", "wineserver", "wineserver-keepalive", "bwrap-launcher", "wineserver-keepalive.bat" ]) == files # Create a new binary for the Proton installation and delete another # one proton_bin_path = Path(default_proton.install_path) / "dist" / "bin" (proton_bin_path / "winedine").touch() (proton_bin_path / "wineserver").unlink() # The old scripts will be deleted and regenerated now that the Proton # installation's contents changed create_wine_bin_dir(default_proton) files = get_files_in_dir( home_dir / ".cache" / "protontricks" / "proton" / "Proton 4.20" / "bin" ) # Scripts are regenerated assert set([ "wine", "winedine", "wineserver-keepalive", "bwrap-launcher", "wineserver-keepalive.bat" ]) == files class TestRunCommand: def test_user_environment_variables_used( self, default_proton, steam_runtime_dir, steam_app_factory, home_dir, command_mock, monkeypatch): """ Test that user-provided environment variables are used even when Steam Runtime is enabled """ steam_app = steam_app_factory(name="Fake game", appid=10) run_command( winetricks_path=Path("/usr/bin/winetricks"), proton_app=default_proton, steam_app=steam_app, command=["echo", "nothing"], use_steam_runtime=True, legacy_steam_runtime_path=steam_runtime_dir / "steam-runtime" ) # Proxy scripts are used if no environment variables are set by the # user wine_bin_dir = ( home_dir / ".cache" / "protontricks" / "proton" / "Proton 4.20" / "bin" ) command = command_mock.commands[-1] assert command.args == ["echo", "nothing"] assert command.env["WINE"] == str(wine_bin_dir / "wine") assert command.env["WINELOADER"] == str(wine_bin_dir / "wine") assert command.env["WINESERVER"] == str(wine_bin_dir / "wineserver") monkeypatch.setenv("WINE", "/fake/wine") monkeypatch.setenv("WINESERVER", "/fake/wineserver") run_command( winetricks_path=Path("/usr/bin/winetricks"), proton_app=default_proton, steam_app=steam_app, command=["echo", "nothing"], use_steam_runtime=True, legacy_steam_runtime_path=steam_runtime_dir / "steam-runtime" ) # User provided Wine paths are used even when Steam Runtime is enabled command = command_mock.commands[-1] assert command.args == ["echo", "nothing"] assert command.env["WINE"] == "/fake/wine" assert command.env["WINELOADER"] == "/fake/wine" assert command.env["WINESERVER"] == "/fake/wineserver" @pytest.mark.usefixtures("command_mock") def test_unknown_steam_runtime_detected( self, home_dir, proton_factory, runtime_app_factory, steam_app_factory, caplog): """ Test that Protontricks will log a warning if it encounters a Steam Runtime it does not recognize """ steam_runtime_medic = runtime_app_factory( name="Steam Linux Runtime - Medic", appid=14242420, runtime_dir_name="medic" ) proton_app = proton_factory( name="Proton 5.20", appid=100, compat_tool_name="proton_520", is_default_proton=True, required_tool_app=steam_runtime_medic ) steam_app = steam_app_factory(name="Fake game", appid=10) run_command( winetricks_path=Path("/usr/bin/winetricks"), proton_app=proton_app, steam_app=steam_app, command=["echo", "nothing"], shell=True, use_steam_runtime=True ) # Warning will be logged since Protontricks does not recognize # Steam Runtime Medic and can't ensure it's being configured correctly warning = next( record for record in caplog.records if record.levelname == "WARNING" and "not recognized" in record.getMessage() ) assert warning.getMessage() == \ "Current Steam Runtime not recognized by Protontricks." @pytest.mark.usefixtures("steam_deck") def test_locale_fixed_on_steam_deck( self, proton_factory, default_proton, steam_app_factory, home_dir, command_mock, caplog): """ Test that Protontricks will fix locale settings if nonexistent locale settings are detected and Steam Deck is used to run Protontricks """ # Create binary to fake the 'locale' executable locale_script_path = home_dir / ".local" / "bin" / "locale" locale_script_path.write_text("""#!/bin/sh if [ "$1" = "-a" ]; then echo 'C' echo 'C.UTF-8' echo 'en_US' echo 'en_US.utf8' else echo 'LANG=fi_FI.UTF-8' echo 'LC_CTYPE=en_US.utf8' echo 'LC_TIME=en_US.UTF-8' echo 'LC_NUMERIC=D' fi """) locale_script_path.chmod( locale_script_path.stat().st_mode | stat.S_IEXEC ) steam_app = steam_app_factory(name="Fake game", appid=10) run_command( winetricks_path=Path("/usr/bin/winetricks"), proton_app=default_proton, steam_app=steam_app, command=["/bin/env"], env={ # Use same environment variables as in the mocked 'locale' # script "LANG": "fi_FI.UTF-8", "LC_CTYPE": "en_US.utf8", "LC_TIME": "en_US.UTF-8", "LC_NUMERIC": "D" } ) # Warning will be logged to indicate 'LANG' was changed warning = next( record for record in caplog.records if record.levelname == "WARNING" and "locale has been reset" in record.getMessage() ) assert warning.getMessage().endswith( "for the following categories: LANG, LC_NUMERIC" ) # Ensure the incorrect locale settings were changed for the command command = command_mock.commands[-1] assert command.env["LANG"] == "en_US.UTF-8" # LC_CTYPE was not changed as 'en_US.UTF-8' and 'en_US.utf8' # are identical after normalization. assert command.env["LC_CTYPE"] == "en_US.utf8" assert command.env["LC_TIME"] == "en_US.UTF-8" assert command.env["LC_NUMERIC"] == "en_US.UTF-8" def test_bwrap_launcher_crash_detected( self, default_new_proton, steam_app_factory, command_mock): """ Test that Protontricks will raise an exception if `bwrap-launcher` crashes unexpectedly """ steam_app = steam_app_factory(name="Fake game", appid=10) # Mock a crashing 'bwrap-launcher' command_mock.launcher_working = False with pytest.raises(RuntimeError) as exc: run_command( winetricks_path=Path("/usr/bin/winetricks"), proton_app=default_new_proton, steam_app=steam_app, command=["echo", "nothing"], shell=True, use_steam_runtime=True ) assert str(exc.value) == "bwrap launcher crashed, returncode: 1" class TestLowerDict: def test_lower_nested_dict(self): """ Turn all keys in a nested dictionary to lowercase using `lower_dict` """ before = { "AppState": { "Name": "Blah", "appid": 123450, "userconfig": { "Language": "English" } } } after = { "appstate": { "name": "Blah", "appid": 123450, "userconfig": { "language": "English" } } } assert lower_dict(before) == after class TestIsSteamDeck: def test_not_steam_deck(self): """ Test that non-Steam Deck environment is detected correctly """ assert not is_steam_deck() @pytest.mark.usefixtures("steam_deck") def test_is_steam_deck(self): """ Test that Steam Deck environment is detected correctly """ assert is_steam_deck() protontricks-1.12.0/tests/test_winetricks.py000066400000000000000000000013001467175317500213030ustar00rootroot00000000000000from protontricks.winetricks import get_winetricks_path class TestGetWinetricksPath: def test_get_winetricks_env(self, monkeypatch, tmp_path): """ Use a custom Winetricks executable using an env var """ (tmp_path / "winetricks").touch() monkeypatch.setenv( "WINETRICKS", str(tmp_path / "winetricks") ) assert str(get_winetricks_path()) == str(tmp_path / "winetricks") def test_get_winetricks_env_not_found(self, monkeypatch): """ Try using a custom Winetricks with a non-existent path """ monkeypatch.setenv("WINETRICKS", "/invalid/path") assert not get_winetricks_path()