pax_global_header00006660000000000000000000000064141662703630014522gustar00rootroot0000000000000052 comment=12df681aae1e34785d93539ec6589fe6a9a89c6a protontricks-1.7.0/000077500000000000000000000000001416627036300142705ustar00rootroot00000000000000protontricks-1.7.0/.github/000077500000000000000000000000001416627036300156305ustar00rootroot00000000000000protontricks-1.7.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001416627036300200135ustar00rootroot00000000000000protontricks-1.7.0/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000017441416627036300225130ustar00rootroot00000000000000--- 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 version: run `protontricks --version` to print the version - Steam version: check if you're running Steam beta; this can be checked in _Steam_ -> _Settings_ -> _Account_ -> _Beta participation_ **Additional context** **If the error happens when trying to run a Protontricks command, run the command again using the `-v` flag and copy the output!** For example, if the command that causes the error is `protontricks 42 faudio`, run `protontricks -v 42 faudio` instead and copy the output here. protontricks-1.7.0/.github/workflows/000077500000000000000000000000001416627036300176655ustar00rootroot00000000000000protontricks-1.7.0/.github/workflows/tests.yml000066400000000000000000000027751416627036300215650ustar00rootroot00000000000000# 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] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10'] 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.7.0/.gitignore000066400000000000000000000030111416627036300162530ustar00rootroot00000000000000# 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 [Ss]cripts [Tt]cl pyvenv.cfg pip-selfcheck.json # End of https://www.gitignore.io/api/python,virtualenv protontricks-1.7.0/CHANGELOG.md000066400000000000000000000224731416627036300161110ustar00rootroot00000000000000# 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.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.7.0...HEAD [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.7.0/CONTRIBUTING.md000066400000000000000000000021061416627036300165200ustar00rootroot00000000000000# 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.7.0/LICENSE000066400000000000000000001045131416627036300153010ustar00rootroot00000000000000 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.7.0/MANIFEST.in000066400000000000000000000002101416627036300160170ustar00rootroot00000000000000include MANIFEST.in LICENSE *.md graft src/protontricks graft data exclude *.yml global-exclude *.py[cod] global-exclude __pycache__ protontricks-1.7.0/Makefile000066400000000000000000000004771416627036300157400ustar00rootroot00000000000000SHELL = /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.7.0/README.md000066400000000000000000000167041416627036300155570ustar00rootroot00000000000000Protontricks ============ [![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) A wrapper that runs Winetricks commands for Proton enabled games among other useful features, requires Winetricks. 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.5 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 # 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 within game's installation directory protontricks -c # Run the Protontricks GUI protontricks --gui # 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 or **pipx**. **pip** can also be used, but it is not recommended due to possible problems. **If you're using the Flatpak version of Steam**, follow the [Flatpak-specific installation instructions](https://github.com/flathub/com.github.Matoking.protontricks) instead. Unless you're using community packages, **you may need to install Winetricks separately**. See the [installation instructions](https://github.com/Winetricks/winetricks#installing) for further details. ## 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. * Arch Linux ([release](https://aur.archlinux.org/packages/protontricks/), [git](https://aur.archlinux.org/packages/protontricks-git/)) * Fedora ([release](https://src.fedoraproject.org/rpms/protontricks)) * NixOS ([nixpkgs](https://github.com/NixOS/nixpkgs/blob/master/pkgs/tools/package-management/protontricks/default.nix)) * Void Linux ([void-packages](https://github.com/void-linux/void-packages/blob/master/srcpkgs/protontricks/template)) [![Packaging status](https://repology.org/badge/vertical-allrepos/protontricks.svg)](https://repology.org/project/protontricks/versions) If you maintain a community package for Protontricks, feel free to create a pull request adding an entry to this section! ## pipx (recommended) 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.7.0/TROUBLESHOOTING.md000066400000000000000000000034371416627036300171100ustar00rootroot00000000000000Troubleshooting =============== 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`. protontricks-1.7.0/data/000077500000000000000000000000001416627036300152015ustar00rootroot00000000000000protontricks-1.7.0/data/com.github.Matoking.protontricks.metainfo.xml000066400000000000000000000041541416627036300260770ustar00rootroot00000000000000 com.github.Matoking.protontricks Protontricks A simple wrapper that does winetricks things for Proton enabled games Um invólucro simples que executa truques de winetricks para jogos habilitados para usar o Proton Un contenedor simple que realiza trucos de winetricks para juegos habilitados para usar Proton 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

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.

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.7.0/data/screenshot.png000066400000000000000000002200531416627036300200660ustar00rootroot00000000000000PNG  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.7.0/pyproject.toml000066400000000000000000000003071416627036300172040ustar00rootroot00000000000000[build-system] requires = [ "setuptools", "wheel", "setuptools-scm < 6 ; python_version <= '3.5'", "setuptools-scm ; python_version > '3.5'" ] build-backend = "setuptools.build_meta" protontricks-1.7.0/requirements.txt000066400000000000000000000000111416627036300175440ustar00rootroot00000000000000vdf==2.4 protontricks-1.7.0/requirements_dev.txt000066400000000000000000000000541416627036300204110ustar00rootroot00000000000000pytest>=6.0 pytest-cov>=2.10 setuptools-scm protontricks-1.7.0/setup.py000066400000000000000000000042241416627036300160040ustar00rootroot00000000000000from setuptools import setup DESCRIPTION = ( "A simple wrapper for running Winetricks commands for Proton-enabled " "games." ) LONG_DESCRIPTION = ( "A simple wrapper for running Winetricks commands for Proton-enabled " "games. Protontricks requires Winetricks." ) AUTHOR = "Janne Pulkkinen" AUTHOR_EMAIL = "janne.pulkkinen@protonmail.com" URL = "https://github.com/Matoking/protontricks" setup( name="protontricks", use_scm_version={ "write_to": "src/protontricks/_version.py" }, description=DESCRIPTION, long_description=LONG_DESCRIPTION, data_files=[ ( "share/applications", [ "src/protontricks/data/protontricks.desktop", "src/protontricks/data/protontricks-launch.desktop" ] ) ], author=AUTHOR, author_email=AUTHOR_EMAIL, python_requires=">=3.5", url=URL, packages=["protontricks"], package_data={"": ["LICENSE"]}, package_dir={"protontricks": "src/protontricks"}, install_requires=[ "setuptools", # Required for pkg_resources "vdf>=3.2" ], entry_points={ "console_scripts": [ "protontricks = protontricks.cli.main:cli", "protontricks-launch = protontricks.cli.launch:cli", # `protontricks-desktop-install` is only responsible for installing # .desktop files and should be omitted if the distro package # already ships .desktop files properly ("protontricks-desktop-install " "= protontricks.cli.desktop_install:cli") ] }, include_package_data=True, license="GPL3", classifiers=[ 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'Topic :: Utilities', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', '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' ], ) protontricks-1.7.0/src/000077500000000000000000000000001416627036300150575ustar00rootroot00000000000000protontricks-1.7.0/src/protontricks/000077500000000000000000000000001416627036300176205ustar00rootroot00000000000000protontricks-1.7.0/src/protontricks/__init__.py000066400000000000000000000003311416627036300217260ustar00rootroot00000000000000from .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.7.0/src/protontricks/cli/000077500000000000000000000000001416627036300203675ustar00rootroot00000000000000protontricks-1.7.0/src/protontricks/cli/__init__.py000066400000000000000000000000001416627036300224660ustar00rootroot00000000000000protontricks-1.7.0/src/protontricks/cli/desktop_install.py000066400000000000000000000031311416627036300241360ustar00rootroot00000000000000import 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/protontricks.desktop" ), pkg_resources.resource_filename( "protontricks", "data/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("\nDone. Files have been installed under {}".format(str(install_dir))) print("The Protontricks shortcut and desktop integration should now work.") if __name__ == "__main__": main() protontricks-1.7.0/src/protontricks/cli/launch.py000066400000000000000000000112541416627036300222160ustar00rootroot00000000000000import argparse import logging import shlex import sys from pathlib import Path from subprocess import run from ..gui import select_steam_app_with_gui from ..steam import find_steam_path, 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="store_true", help="Print debug information") 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( "--appid", type=int, nargs="?", default=None ) parser.add_argument("executable", type=str) parser.add_argument("exec_args", nargs=argparse.REMAINDER) 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) try: executable_path = Path(args.executable).resolve(strict=True) except TypeError: # Python 3.5 executable_path = Path(args.executable).resolve() # 1. Find Steam path steam_path, steam_root = find_steam_path() if not steam_path: exit_("Steam installation directory could not be found.") # 2. Find any Steam library folders steam_lib_paths = get_steam_lib_paths(steam_path) # 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="Choose Wine prefix to run {}".format(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 += ["--verbose"] if args.no_runtime: cli_args += ["--no-runtime"] if args.no_bwrap: cli_args += ["--no-bwrap"] inner_args = " ".join( ["wine", "'{}'".format(str(executable_path))] + exec_args ) 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) if __name__ == "__main__": main() protontricks-1.7.0/src/protontricks/cli/main.py000077500000000000000000000253531416627036300217000ustar00rootroot00000000000000# _____ _ _ _ _ # | _ |___ ___| |_ ___ ___| |_ ___|_|___| |_ ___ # | __| _| . | _| . | | _| _| | _| '_|_ -| # |__| |_| |___|_| |___|_|_|_| |_| |_|___|_,_|___| # 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 ..gui import select_steam_app_with_gui from ..steam import (find_legacy_steam_runtime_path, find_proton_app, find_steam_path, get_steam_apps, get_steam_lib_paths) from ..util import get_running_flatpak_version, FLATPAK_BWRAP_COMPATIBLE_VERSION, 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): """ '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" "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'" ), formatter_class=argparse.RawTextHelpFormatter ) parser.add_argument( "--verbose", "-v", action="store_true", help="Print debug information" ) 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( "-c", "--command", type=str, dest="command", required=False, help="Run a command in the game's installation directory 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=False, help="Disable bwrap containerization when using Steam Runtime" ) 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="%(prog)s ({})".format(__version__) ) 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) do_command = bool(args.command) do_search = bool(args.search) do_gui = bool(args.gui) do_winetricks = bool(args.appid and args.winetricks_command) use_bwrap = not bool(args.no_bwrap) if not do_command and not do_search and not do_gui and not do_winetricks: parser.print_help() return # Don't allow more than one action if sum([do_search, 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 steam_path, steam_root = find_steam_path() if not steam_path: exit_("Steam installation directory could not be found.") # 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) # 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." ) # 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!") 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 ) return # Perform a search elif args.search: # 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([ "{} ({})".format(app.name, app.appid) for app in matching_apps ]) print( "Found the following games:" "\n{}\n".format(matching_games) ) 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 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 " ) 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, command=[str(winetricks_path)] + args.winetricks_command ) 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, # Pass the command directly into the shell *without* # escaping it cwd=str(steam_app.install_path), shell=True ) sys.exit(returncode) if __name__ == "__main__": main() protontricks-1.7.0/src/protontricks/cli/util.py000066400000000000000000000112531416627036300217200ustar00rootroot00000000000000import argparse import atexit import functools import logging import os import sys import tempfile import traceback from pathlib import Path from subprocess import run from ..gui import get_gui_provider def _get_log_file_path(): """ Get the log file path to use for this Protontricks process. """ temp_dir = tempfile.gettempdir() return Path(temp_dir) / "protontricks{}.log".format(os.getpid()) 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(info=False, record_to_file=True): """ Enables logging. If info is True, print INFO messages in addition to WARNING and ERROR messages :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. """ level = logging.INFO if info else logging.WARNING # Logs printed to stderr will follow the log level stream_handler = logging.StreamHandler() stream_handler.setLevel(level) stream_handler.setFormatter( logging.Formatter("%(name)s (%(levelname)s): %(message)s") ) logger = logging.getLogger("protontricks") logger.setLevel(logging.INFO) logger.addHandler(stream_handler) if not record_to_file: return # Record log files to temporary file. This means log messages can be # printed at the end of the session in an error dialog. # *All* 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.setLevel(logging.INFO) logger.addHandler(file_handler) # Ensure the log file is removed before the process exits atexit.register(_delete_log_file) 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. """ def _get_yad_args(): return [ "yad", "--text-info", "--window-icon", "error", "--title", "Protontricks", "--width", "600", "--height", "600", "--button=OK:1", "--wrap", "--margins", "2", "--center" ] def _get_zenity_args(): return [ "zenity", "--text-info", "--window-icon", "error", "--title", "Protontricks", "--width", "600", "--height", "600" ] 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", "{}\n\n".format(error), "=============\n\n", "Please include this entire error message when making a bug report.\n", "Log messages:\n\n", "{}".format(log_messages) ]) if get_gui_provider() == "yad": args = _get_yad_args() else: args = _get_zenity_args() run(args, input=message.encode("utf-8"), check=False) 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.7.0/src/protontricks/data/000077500000000000000000000000001416627036300205315ustar00rootroot00000000000000protontricks-1.7.0/src/protontricks/data/icon_placeholder.png000066400000000000000000000112261416627036300245330ustar00rootroot00000000000000PNG  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.7.0/src/protontricks/data/protontricks-launch.desktop000066400000000000000000000003631416627036300261370ustar00rootroot00000000000000[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.7.0/src/protontricks/data/protontricks.desktop000066400000000000000000000003671416627036300246730ustar00rootroot00000000000000[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.7.0/src/protontricks/gui.py000066400000000000000000000151441416627036300207630ustar00rootroot00000000000000import functools import itertools import logging import os import shutil import sys from pathlib import Path from subprocess import PIPE, CalledProcessError, run import pkg_resources __all__ = ("LocaleError", "select_steam_app_with_gui") logger = logging.getLogger("protontricks") class LocaleError(Exception): pass @functools.lru_cache(maxsize=1) def get_gui_provider(): """ Get the GUI provider used to display dialogs. Returns either 'yad' or 'zenity', preferring 'yad' if both exist. """ try: candidates = ["yad", "zenity"] # Allow overriding the GUI provider using an envvar if os.environ.get("PROTONTRICKS_GUI", "").lower() in candidates: candidates.insert(0, os.environ["PROTONTRICKS_GUI"].lower()) cmd = next(cmd for cmd in candidates if shutil.which(cmd)) logger.info("Using '%s' as GUI provider", cmd) return cmd except StopIteration as exc: raise FileNotFoundError( "'yad' or 'zenity' was not found. Either executable is required " "for Protontricks GUI." ) from exc def _get_appid2icon(steam_apps, steam_path): """ Get icons for Steam apps to show in the app selection dialog. Return a {appid: icon_path} dict. """ placeholder_path = Path( pkg_resources.resource_filename( "protontricks", "data/icon_placeholder.png" ) ) icon_dir = steam_path / "appcache" / "librarycache" existing_names = [path.name for path in icon_dir.glob("*")] appid2icon = {} for app in steam_apps: # Use library icon for Steam apps, fallback to placeholder icon # for non-Steam shortcuts and missing icons appid2icon[app.appid] = ( icon_dir / "{}_icon.jpg".format(app.appid) if "{}_icon.jpg".format(app.appid) in existing_names else placeholder_path ) return appid2icon def select_steam_app_with_gui(steam_apps, steam_path, title=None): """ Prompt the user to select a Proton-enabled Steam app from a dropdown list. Return the selected SteamApp """ def _get_yad_args(): return [ "yad", "--list", "--no-headers", "--center", "--window-icon", "wine", # Disabling markup means we won't have to escape special characters "--no-markup", "--search-column", "2", "--print-column", "2", "--width", "600", "--height", "400", "--text", title, "--title", "Protontricks", "--column", "Icon:IMG", "--column", "Steam app" ] def _get_zenity_args(): return [ "zenity", "--list", "--hide-header", "--width", "600", "--height", "400", "--text", title, "--title", "Protontricks", "--column", "Steam app" ] def run_gui(args, input_=None, strip_nonascii=False): """ Run YAD/Zenity with the given args. If 'strip_nonascii' is True, strip non-ASCII characters to workaround environments that can't handle all characters """ if strip_nonascii: # Convert to bytes and back to strings while stripping # non-ASCII characters args = [ arg.encode("ascii", "ignore").decode("ascii") for arg in args ] if input_: input_ = input_.encode("ascii", "ignore").decode("ascii") if input_: input_ = input_.encode("utf-8") try: return run( args, input=input_, check=True, stdout=PIPE, stderr=PIPE, ) except CalledProcessError as exc: if exc.returncode == 255: # User locale incapable of handling all characters in the # command raise LocaleError() raise if not title: title = "Select Steam app" gui_provider = get_gui_provider() if gui_provider == "yad": args = _get_yad_args() # YAD implementation has icons for app selection appid2icon = _get_appid2icon(steam_apps, steam_path=steam_path) cmd_input = [ [ str(appid2icon[app.appid]), "{}: {}".format(app.name, app.appid) ] for app in steam_apps if app.is_windows_app ] # Flatten the list cmd_input = list(itertools.chain.from_iterable(cmd_input)) else: args = _get_zenity_args() cmd_input = [ '{}: {}'.format(app.name, app.appid) for app in steam_apps if app.is_windows_app ] cmd_input = "\n".join(cmd_input) try: try: result = run_gui(args, input_=cmd_input) except LocaleError: # User has weird locale settings. Log a warning and # run the command while stripping non-ASCII characters. logger.warning( "Your system locale is incapable of displaying all " "characters. Some app names may not show up correctly. " "Please use an UTF-8 locale to avoid this warning." ) result = run_gui(args, strip_nonascii=True) choice = result.stdout except CalledProcessError as exc: # TODO: Remove this hack once the bug has been fixed upstream # Newer versions of zenity have a bug that causes long dropdown choice # lists to crash the command with a specific message. # Since stdout still prints the correct value, we can safely ignore # this error. # # The error is usually the message # 'free(): double free detected in tcache 2', but it can vary # depending on the environment. Instead, check if the returncode # is -6 # # Related issues: # https://github.com/Matoking/protontricks/issues/20 # https://gitlab.gnome.org/GNOME/zenity/issues/7 if exc.returncode == -6: logger.info("Ignoring zenity crash bug") choice = exc.stdout elif exc.returncode in (1, 252): # YAD returns 252 when dialog is closed by pressing Esc # No game was selected choice = b"" else: raise RuntimeError("{} returned an error".format(gui_provider)) if choice in (b"", b" \n"): print("No game was selected. Quitting...") sys.exit(1) appid = str(choice).rsplit(':')[-1] appid = ''.join(x for x in appid if x.isdigit()) appid = int(appid) steam_app = next( app for app in steam_apps if app.appid == appid) return steam_app protontricks-1.7.0/src/protontricks/steam.py000066400000000000000000001041371416627036300213110ustar00rootroot00000000000000import functools import logging import os import string import struct import zlib from pathlib import Path import vdf from .util import lower_dict, is_flatpak_sandbox __all__ = ( "COMMON_STEAM_DIRS", "SteamApp", "find_steam_path", "find_legacy_steam_runtime_path", "get_appinfo_sections", "get_tool_appid", "find_steam_compat_tool_app", "find_appid_proton_prefix", "find_proton_app", "get_steam_lib_paths", "get_compat_tool_dirs", "get_custom_compat_tool_installations_in_dir", "get_custom_compat_tool_installations", "find_current_steamid3", "get_appid_from_shortcut", "get_custom_windows_shortcuts", "get_steam_apps" ) COMMON_STEAM_DIRS = [ ".steam/steam", ".local/share/Steam" ] 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", "required_tool_appid", "required_tool_app" ) def __init__( self, name, install_path, 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 :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 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 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): """ Parse appmanifest_X.acf file containing Steam app installation metadata and return a SteamApp object """ 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"] # 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, 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 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") """ def has_steamapps_dir(path): """ Return True if the path either has a 'steamapps' or a 'SteamApps' subdirectory, False otherwise """ # 'steamapps' is the usual name under Linux Steam installations # 'SteamApps' name appears in installations imported from Windows return (path / "steamapps").is_dir() or (path / "SteamApps").is_dir() 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" 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 steam_path, steam_path logger.error( "$STEAM_DIR was provided but didn't point to a valid Steam " "installation." ) return None, None # If we're inside a Flatpak sandbox, # prioritize Flatpak installation of Steam steam_dirs_to_search = ( [".var/app/com.valvesoftware.Steam/data/Steam"] + COMMON_STEAM_DIRS if is_flatpak_sandbox() else COMMON_STEAM_DIRS ) for steam_path in steam_dirs_to_search: # The common Steam directories are found inside the home directory steam_path = Path.home() / steam_path if has_steamapps_dir(steam_path): logger.info( "Found Steam directory at %s. You can also define Steam " "directory manually using $STEAM_DIR", steam_path ) if not steam_root: steam_root = steam_path return steam_path, steam_root 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_STRUCT_SECTION = "/appcache/appinfo.vdf appinfo_path = steam_path / "appcache" / "appinfo.vdf" tool_appid = get_tool_appid(compat_tool_name, appinfo_path) if not tool_appid: logger.error( "Could not find compatibility tool's App ID from appinfo.vdf" ) return None # We've now got the appid. Return the corresponding SteamApp try: app = next(app for app in steam_apps if app.appid == tool_appid) logger.info( "Found active compatibility tool: %s", app.name ) return app except StopIteration: return None 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' portion of the path can also be 'SteamApps' for steamapps_part in ("steamapps", "SteamApps"): prefix_path = \ path / steamapps_part / "compatdata" / str(appid) / "pfx" if prefix_path.is_dir(): candidates.append(prefix_path) 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 library_folders.append(Path(value["path"])) else: # Older releases just store the library path as a string # and nothing else library_folders.append(Path(value)) logger.info( "Found %d Steam library folders", len(library_folders) ) return library_folders # Try finding Steam library folders using libraryfolders.vdf in Steam root if (steam_path / "steamapps").is_dir(): folders_vdf_path = steam_path / "steamapps" / "libraryfolders.vdf" elif (steam_path / "SteamApps").is_dir(): folders_vdf_path = steam_path / "SteamApps" / "libraryfolders.vdf" 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( "Library folder configuration file {} is corrupted".format( folders_vdf_path ) ) 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: 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() 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 # 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. 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"] 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 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 ] # 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): """ 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() vdf_data = lower_dict(vdf.binary_loads(content)) 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: appid = shortcut_data["appid"] & 0xffffffff else: appid = get_appid_from_shortcut( target=shortcut_data["exe"], name=shortcut_data["appname"] ) prefix_path = \ steam_path / "steamapps" / "compatdata" / str(appid) / "pfx" install_path = Path(shortcut_data["startdir"].strip('"')) if not prefix_path.is_dir(): continue steam_apps.append( SteamApp( appid=appid, name="Non-Steam shortcut: {}".format(shortcut_data["appname"]), prefix_path=prefix_path, install_path=install_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: if not path.is_dir(): logger.warning( "Steam library folder %s not found. Protontricks " "might not have access to the directory.", str(path) ) continue appmanifest_paths = [] is_lowercase = (path / "steamapps").is_dir() is_mixedcase = (path / "SteamApps").is_dir() if is_lowercase: appmanifest_paths = path.glob("steamapps/appmanifest_*.acf") elif is_mixedcase: appmanifest_paths = path.glob("SteamApps/appmanifest_*.acf") if is_lowercase and is_mixedcase: # 'steamapps' and 'SteamApps' may both map to the same # directory if the file system is case-insensitive. # Check that we're actually dealing with more than one directory # before printing a warning. is_case_sensitive_fs = sum( 1 for path in path.glob("*") if path.name.lower() == "steamapps" ) >= 2 if is_case_sensitive_fs: # Log a warning if both 'steamapps' and 'SteamApps' directories # exist, as both Protontricks and Steam client have problems # dealing with it (see issue #51) logger.warning( "Both 'steamapps' and 'SteamApps' directories were found " "at %s. 'SteamApps' directory should be removed to " "prevent issues with app and Proton discovery.", str(path) ) for manifest_path in appmanifest_paths: steam_app = SteamApp.from_appmanifest( manifest_path, steam_lib_paths=steam_lib_paths ) 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) # 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.7.0/src/protontricks/util.py000066400000000000000000000351271416627036300211570ustar00rootroot00000000000000import configparser import logging import os import shlex import shutil import stat from pathlib import Path from subprocess import PIPE, check_output, run __all__ = ( "SUPPORTED_STEAM_RUNTIMES", "is_flatpak_sandbox", "get_running_flatpak_version", "lower_dict", "get_legacy_runtime_library_paths", "get_host_library_paths", "RUNTIME_ROOT_GLOB_PATTERNS", "get_runtime_library_paths", "WINE_SCRIPT_RUNTIME_V1_TEMPLATE", "WINE_SCRIPT_RUNTIME_V2_TEMPLATE", "create_wine_bin_dir", "run_command" ) logger = logging.getLogger("protontricks") SUPPORTED_STEAM_RUNTIMES = [ "Steam Linux Runtime - Soldier" ] # 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_running_flatpak_version(): """ Get the running Flatpak version if running inside a Flatpak sandbox, or None if Flatpak sandbox isn't active """ config = configparser.ConfigParser() try: config.read_string(Path(FLATPAK_INFO_PATH).read_text(encoding="utf-8")) except FileNotFoundError: 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 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 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( "Could not find Steam Runtime runtime root for {}".format( 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_RUNTIME_V1_TEMPLATE = ( "#!/bin/bash\n" "# Helper script created by Protontricks to run Wine binaries using Steam Runtime\n" "export LD_LIBRARY_PATH=\"$PROTON_LD_LIBRARY_PATH\"\n" "exec \"$PROTON_DIST_PATH\"/bin/{name} \"$@\"" ) WINE_SCRIPT_RUNTIME_V2_TEMPLATE = """#!/bin/bash # Helper script created by Protontricks to run Wine binaries using Steam Runtime set -o errexit 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" ) 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 if [[ -n "$PROTONTRICKS_INSIDE_STEAM_RUNTIME" ]]; then # Command is being executed inside Steam Runtime # LD_LIBRARY_PATH can now be set. export LD_LIBRARY_PATH="$LD_LIBRARY_PATH":"$PROTON_LD_LIBRARY_PATH" "$PROTON_DIST_PATH"/bin/{name} "$@" else exec "$STEAM_RUNTIME_PATH"/run --share-pid --batch \ "${{mount_params[@]}}" -- \ env PROTONTRICKS_INSIDE_STEAM_RUNTIME=1 \ "$PROTONTRICKS_PROXY_SCRIPT_PATH" "$@" fi """ 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 """ # If the Proton installation uses a newer version of Steam Runtime, # use a different template for the scripts bin_template = ( WINE_SCRIPT_RUNTIME_V2_TEMPLATE if proton_app.required_tool_app and use_bwrap else WINE_SCRIPT_RUNTIME_V1_TEMPLATE ) binaries = list((proton_app.proton_dist_path / "bin").iterdir()) # Create the base directory containing files for every Proton installation xdg_cache_dir = os.environ.get( "XDG_CACHE_HOME", os.path.expanduser("~/.cache") ) base_path = Path(xdg_cache_dir) / "protontricks" / "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 = bin_template.format( name=shlex.quote(binary.name), script_path=str(proxy_script_path) ).encode("utf-8") proxy_script_path.write_bytes(content) script_stat = proxy_script_path.stat() # Make the helper script executable proxy_script_path.chmod(script_stat.st_mode | stat.S_IEXEC) return bin_path def run_command( winetricks_path, proton_app, steam_app, command, use_steam_runtime=False, legacy_steam_runtime_path=None, use_bwrap=True, **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. :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( "{} is missing the required Steam Runtime. You may need to launch " "a Steam app using this Proton version to finish the " "installation.".format(proton_app.name) ) # Make a copy of the environment variables to restore later environ_copy = os.environ.copy() user_provided_wine = os.environ.get("WINE", False) user_provided_wineserver = os.environ.get("WINESERVER", False) if not user_provided_wine: logger.info( "WINE environment variable is not available. " "Setting WINE environment variable to Proton bundled version" ) os.environ["WINE"] = \ str(proton_app.proton_dist_path / "bin" / "wine") if not user_provided_wineserver: logger.info( "WINESERVER environment variable is not available. " "Setting WINESERVER environment variable to Proton bundled version" ) os.environ["WINESERVER"] = \ str(proton_app.proton_dist_path / "bin" / "wineserver") os.environ["WINETRICKS"] = str(winetricks_path) os.environ["WINEPREFIX"] = str(steam_app.prefix_path) os.environ["WINELOADER"] = os.environ["WINE"] os.environ["WINEDLLPATH"] = "".join([ str(proton_app.proton_dist_path / "lib64" / "wine"), os.pathsep, str(proton_app.proton_dist_path / "lib" / "wine") ]) os.environ["PATH"] = "".join([ str(proton_app.proton_dist_path / "bin"), os.pathsep, os.environ["PATH"] ]) # Expose the path to Proton installation. This is mainly used for # Wine helper scripts, but other scripts could use it as well. os.environ["PROTON_PATH"] = str(proton_app.install_path) os.environ["PROTON_DIST_PATH"] = str(proton_app.proton_dist_path) # Unset WINEARCH, which might be set for another Wine installation os.environ.pop("WINEARCH", "") wine_bin_dir = None if use_steam_runtime: if proton_app.required_tool_app: os.environ["STEAM_RUNTIME_PATH"] = \ str(proton_app.required_tool_app.install_path) os.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: 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." ) 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 os.environ["PROTON_LD_LIBRARY_PATH"] = \ get_legacy_runtime_library_paths( legacy_steam_runtime_path, proton_app ) # When Steam Runtime is enabled, create a set of helper scripts # that load the underlying Proton Wine executables with Steam Runtime # and Proton libraries instead of system libraries wine_bin_dir = create_wine_bin_dir( proton_app=proton_app, use_bwrap=use_bwrap ) os.environ["LEGACY_STEAM_RUNTIME_PATH"] = \ str(legacy_steam_runtime_path) os.environ["PATH"] = "".join([ str(wine_bin_dir), os.pathsep, os.environ["PATH"] ]) if not user_provided_wine: os.environ["WINE"] = str(wine_bin_dir / "wine") os.environ["WINELOADER"] = os.environ["WINE"] if not user_provided_wineserver: os.environ["WINESERVER"] = str(wine_bin_dir / "wineserver") logger.info("Attempting to run command %s", command) try: result = run(command, check=False, **kwargs) return result.returncode finally: # Restore original env vars os.environ.clear() os.environ.update(environ_copy) protontricks-1.7.0/src/protontricks/winetricks.py000066400000000000000000000017571416627036300223660ustar00rootroot00000000000000import 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.7.0/tests/000077500000000000000000000000001416627036300154325ustar00rootroot00000000000000protontricks-1.7.0/tests/cli/000077500000000000000000000000001416627036300162015ustar00rootroot00000000000000protontricks-1.7.0/tests/cli/__init__.py000066400000000000000000000000001416627036300203000ustar00rootroot00000000000000protontricks-1.7.0/tests/cli/test_desktop_install.py000066400000000000000000000007731416627036300230200ustar00rootroot00000000000000def test_run_desktop_install(home_dir, command, desktop_install_cli): """ Ensure that `desktop-file-install` is called properly """ # `protontricks-desktop-install` takes no arguments desktop_install_cli([]) 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.7.0/tests/cli/test_launch.py000066400000000000000000000111741416627036300210700ustar00rootroot00000000000000import 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, 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 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, 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 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, launch_cli, monkeypatch): """ Try running an EXE file and apply all arguments; those should also be passed to the main entrypoint """ cli_args = [] monkeypatch.setattr( "protontricks.cli.launch.cli_main", cli_args.extend ) steam_app_factory(name="Fake game", appid=10) launch_cli([ "--verbose", "--no-bwrap", "--no-runtime", "--appid", "10", "test.exe" ]) # CLI flags are passed through to the main CLI entrypoint assert cli_args[0:4] == [ "--verbose", "--no-runtime", "--no-bwrap", "-c" ] assert cli_args[4].startswith("wine ") assert cli_args[4].endswith("test.exe'") assert cli_args[5] == "10" 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 protontricks-1.7.0/tests/cli/test_main.py000066400000000000000000000634361416627036300205520ustar00rootroot00000000000000import os import shutil from pathlib import Path import pytest class TestCLIRun: def test_run_winetricks( self, cli, steam_app_factory, default_proton, command, 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 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, 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 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, 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.env["PROTON_PATH"] == str(custom_proton.install_path) def test_run_winetricks_select_steam( self, cli, steam_app_factory, default_proton, command, 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")} ) 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, 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 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 "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() # The script template for legacy Steam Runtime is used assert "\"$PROTON_DIST_PATH\"/bin/{}".format(name) in content assert "PROTONTRICKS_INSIDE_STEAM_RUNTIME" not in content def test_run_winetricks_steam_runtime_v2( self, cli, home_dir, steam_app_factory, steam_runtime_dir, steam_runtime_soldier, command, 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" ) # 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"] == "".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) # 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() # The script template for bwrap-based Steam Runtime is used assert "\"$PROTON_DIST_PATH\"/bin/{}".format(name) in content assert "PROTONTRICKS_INSIDE_STEAM_RUNTIME" 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, 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" ) # 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) # 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() # The script template for normal Steam Runtime is used assert "\"$PROTON_DIST_PATH\"/bin/{}".format(name) in content assert "PROTONTRICKS_INSIDE_STEAM_RUNTIME" not in content def test_run_winetricks_game_not_found( self, cli, steam_app_factory, default_proton, command): """ 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 def test_run_no_command(self, cli): """ Run only the 'protontricks' command. """ result = cli([]) # Help will be printed if no specific command is given assert result.startswith("usage: ") @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_commands(self, cli): """ Try performing multiple commands 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_runtime_incomplete( self, cli, steam_app_factory, steam_runtime_soldier, command, 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 class TestCLIGUI: def test_run_gui( self, cli, default_proton, steam_app_factory, gui_provider, command, 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"]) # '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 class TestCLICommand: def test_run_command( self, cli, default_proton, steam_app_factory, gui_provider, command, 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"]) # The command is just 'bash' assert command.args == "bash" assert command.kwargs["cwd"] == str(steam_app.install_path) assert command.kwargs["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") ) 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_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 protontricks-1.7.0/tests/cli/test_util.py000066400000000000000000000062041416627036300205710ustar00rootroot00000000000000import pytest from protontricks.cli.util import (_delete_log_file, _get_log_file_path, 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_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:1" in gui_provider.args elif gui_cmd == "zenity": assert gui_provider.args[0] == "zenity" # Zenity doesn't have custom button declarations assert "--button=OK:1" 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.7.0/tests/conftest.py000066400000000000000000000527511416627036300176430ustar00rootroot00000000000000import os import random import shutil import struct import zlib from collections import defaultdict from pathlib import Path from subprocess import run 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.gui import get_gui_provider from protontricks.steam import (APPINFO_STRUCT_HEADER, APPINFO_STRUCT_SECTION, SteamApp, get_appid_from_shortcut) @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", "") @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() @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(home_dir): """ Fake Steam directory """ steam_path = home_dir / ".steam" steam_path.mkdir() (steam_path / "root" / "compatibilitytools.d").mkdir(parents=True) (steam_path / "steam" / "appcache" / "librarycache").mkdir(parents=True) (steam_path / "steam" / "config").mkdir(parents=True) (steam_path / "steam" / "steamapps").mkdir(parents=True) yield steam_path / "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", 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): 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 }) 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"] entry = { "AppName": name_, "StartDir": install_dir_, "exe": str(Path(install_dir_) / name_) } # Derive the shortcut ID 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. entry["appid"] = ~(result ^ 0xffffffff) data["shortcuts"][str(shortcut_id)] = entry shortcut_path.write_bytes(vdf.binary_dumps(data)) 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_factory(steam_dir): """ Factory function to add entries to the appinfo.vdf binary file """ compat_tools = [] (steam_dir / "appcache" / "appinfo.vdf").touch() def func(proton_app, compat_tool_name): compat_tools.append({ "appid": proton_app.appid, "compat_tool_name": compat_tool_name }) # Add the header section content = struct.pack( APPINFO_STRUCT_HEADER, b"'DV\x07", # Magic number 1 # Universe, protontricks ignores this ) # Serialize the Proton manifest VDF section, which contains # information about different Proton installations appid = 123500 infostate = 2 last_updated = 2 access_token = 2 change_number = 2 sha_hash = b"0"*20 compat_tool_entries = {} for compat_tool in compat_tools: compat_tool_entries[compat_tool["compat_tool_name"]] = { # The name implies it could be a list, but in practice # it has been a string. Do the same thing here. "aliases": compat_tool["compat_tool_name"], "appid": compat_tool["appid"] } binary_vdf = vdf.binary_dumps({ "appinfo": { "extended": { "compat_tools": compat_tool_entries } } }) entry_size = len(binary_vdf) + 40 # Add the only VDF binary section content += struct.pack( APPINFO_STRUCT_SECTION, appid, entry_size, infostate, last_updated, access_token, sha_hash, change_number ) content += binary_vdf # Add the EOF section content += b"ffff" (steam_dir / "appcache" / "appinfo.vdf").write_bytes(content) 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 / "appmanifest_{}.acf".format(appid)).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" ) ) 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_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): 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_factory( proton_app=steam_app, compat_tool_name=compat_tool_name ) return steam_app return func @pytest.fixture(scope="function") def runtime_app_factory(steam_app_factory, appinfo_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 ) @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 class MockSubprocess: def __init__( self, args=None, kwargs=None, mock_stdout=None, env=None): self.args = args self.kwargs = kwargs if not mock_stdout: self.mock_stdout = "" else: self.mock_stdout = mock_stdout self.env = env 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")) monkeypatch.setattr( "protontricks.gui.run", mock_subprocess_run ) monkeypatch.setattr( "protontricks.cli.util.run", mock_subprocess_run ) yield mock_gui_provider @pytest.fixture(scope="function") def command(monkeypatch): """ Monkeypatch the subprocess.run to store the args and environment variables passed to the last executed command """ mock_command = MockSubprocess() def mock_subprocess_run(args, **kwargs): # Don't mock "/sbin/ldconfig" if args[0] == "/sbin/ldconfig": return run(args, **kwargs) mock_command.args = args mock_command.kwargs = kwargs mock_command.env = os.environ.copy() return MockResult(stdout=b"") monkeypatch.setattr( "protontricks.util.run", mock_subprocess_run ) monkeypatch.setattr( "protontricks.cli.desktop_install.run", mock_subprocess_run ) yield mock_command 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.7.0/tests/test_gui.py000066400000000000000000000150471416627036300176360ustar00rootroot00000000000000from subprocess import CalledProcessError from protontricks.gui import select_steam_app_with_gui import pytest from conftest import MockResult @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 (steam_dir / "appcache" / "librarycache" / "10_icon.jpg").touch() (steam_dir / "appcache" / "librarycache" / "30_icon.jpg").touch() 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_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[0].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" protontricks-1.7.0/tests/test_steam.py000066400000000000000000000571721416627036300201700ustar00rootroot00000000000000import os import shutil import time from pathlib import Path import pytest import vdf from protontricks.steam import (SteamApp, find_appid_proton_prefix, find_steam_compat_tool_app, find_steam_path, get_custom_compat_tool_installations, get_custom_windows_shortcuts, get_steam_apps, get_steam_lib_paths) 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 @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() == ( "Skipping appmanifest {} due to insufficient permissions".format( str(appmanifest_path) ) ) 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" 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 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) 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" ) def test_get_steam_apps_custom_proton_corrupted_compatibilitytool( self, custom_proton_factory, steam_dir, steam_root, caplog): """ 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_text( "corrupted" ) 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( "Compatibility tool declaration at" ) 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 ( "directories were found at {}".format(str(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) == 1 warning = warnings[0] assert "{} not found.".format(str(library_dir_b)) 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) 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) assert len(shortcut_apps) == 1 assert shortcut_apps[0].name == "Non-Steam shortcut: fakegame.exe" assert shortcut_apps[0].appid == 4149337689 protontricks-1.7.0/tests/test_util.py000066400000000000000000000140021416627036300200150ustar00rootroot00000000000000from pathlib import Path from protontricks.util import (create_wine_bin_dir, get_running_flatpak_version, 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"]) == 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"]) == files class TestRunCommand: def test_user_environment_variables_used( self, default_proton, steam_runtime_dir, steam_app_factory, home_dir, command, 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" ) 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 assert command.env["WINE"] == "/fake/wine" assert command.env["WINELOADER"] == "/fake/wine" assert command.env["WINESERVER"] == "/fake/wineserver" 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." 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 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.util.FLATPAK_INFO_PATH", str(flatpak_info_path) ) assert get_running_flatpak_version() == (1, 12, 1) protontricks-1.7.0/tests/test_winetricks.py000066400000000000000000000013001416627036300212170ustar00rootroot00000000000000from 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()