lutris-0.5.19/0000775000175000017500000000000014756670027012124 5ustar hibbyhibbylutris-0.5.19/optional_settings.py.in0000664000175000017500000000016214756670027016647 0ustar hibbyhibby"""Optional configuration automatically generated by the build system""" # Paths LOCALE_DIR = "@localedir_path@" lutris-0.5.19/MANIFEST.in0000664000175000017500000000022114756670027013655 0ustar hibbyhibbyrecursive-include lutris *.py include bin/lutris include LICENSE include AUTHORS include MANIFEST.in include README.rst graft debian prune tests lutris-0.5.19/ruff.toml0000664000175000017500000000474314756670027013773 0ustar hibbyhibbyline-length = 120 [lint] select = ["A", "ARG", "B", "E", "F", "I", "W", "PERF", "RUF"] ignore = [ # Ignores that is not worth/too hard to fix "RUF001", # String contains ambiguous `!` (FULLWIDTH EXCLAMATION MARK). Did you mean `!` (EXCLAMATION MARK)? - used ,mostly on tests "B028", # No explicit `stacklevel` keyword argument found - not sure why this is needed "E402", # Module level import not at top of file - gtk stuff # Ignores that should be fixed and removed "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` "PERF401", # Use a list comprehension to create a transformed list "RUF100", # [*] Unused `noqa` directive (non-enabled: `C901`) - unused pylint/noqa directives that should be removed "RUF013", # PEP 484 prohibits implicit `Optional` "PERF203", # `try`-`except` within a loop incurs performance overhead "RUF015", # Prefer `next(...)` over single element slice - opinionated, but some cases could be changed "PERF102",# When using only the values of a dict use the `values()` method "B011", # Do not `assert False` (`python -O` removes these calls), raise `AssertionError()` "RUF005", # Consider iterable unpacking instead of concatenation "A003", # Class attribute `id` is shadowing a Python builtin "B024", # `DiscordRichPresenceBase` is an abstract base class, but it has no abstract methods "B905", # `zip()` without an explicit `strict=` parameter "ARG002", # Unused method argument: `mock_path_exists` "E722", # Do not use bare `except` "B904", # Within an `except` clause, raise exceptions with `raise ... from err` or `raise ... from None` to distinguish them from errors in exception handling "B008", # Do not perform function call `_try_import_moddb_library` in argument defaults; instead, perform the call within the function, or read the default from a module-level singleton variable "ARG005", # Unused lambda argument: `e` "ARG001", # Unused function argument: `args` "PERF402", # Use `list` or `list.copy` to create a copy of a list "B009", # Do not call `getattr` with a constant attribute value. It is not any safer than normal property access. "A002", # Argument `type` is shadowing a Python builtin "ARG004", # Unused static method argument: `x` "B020", # Loop control variable `upstream_runners` overrides iterable it iterates "B018", # Found useless expression. Either assign it to a variable or remove it ] fixable = ["ALL"] unfixable = [] lutris-0.5.19/debian/0000775000175000017500000000000014756670027013346 5ustar hibbyhibbylutris-0.5.19/meson.build0000664000175000017500000000433614756670027014274 0ustar hibbyhibbyproject( 'lutris', license: 'GPL-3.0-or-later', meson_version: '>=0.46.0', ) # Find Python installation python = import('python').find_installation() # Set folders prefix = get_option('prefix') bindir = get_option('bindir') pylibdir = python.get_install_dir() datadir = get_option('datadir') localedir = get_option('localedir') mandir = get_option('mandir') lutrisdir = join_paths(pylibdir, 'lutris') podir = join_paths(meson.source_root(), 'po') # Generate configuration files config = configuration_data() config.set('localedir_path', join_paths(prefix, localedir)) configure_file( input: 'optional_settings.py.in', output: 'optional_settings.py', configuration: config, install_dir: lutrisdir, ) # Translations subdir('po') # Do installation install_data( files('bin/lutris'), install_dir: bindir, install_mode: 'rwxr-xr-x', ) install_subdir( 'lutris', install_dir: pylibdir, ) install_subdir( 'share/icons', install_dir: join_paths(datadir, 'icons'), strip_directory: true, ) install_subdir( 'share/lutris', install_dir: join_paths(datadir, 'lutris'), strip_directory: true, ) install_man( files('share/man/man1/lutris.1'), install_dir: join_paths(mandir, 'man1'), ) desktop_file = i18n.merge_file( input: files('share/applications/net.lutris.Lutris.desktop'), output: 'net.lutris.Lutris.desktop', type: 'desktop', po_dir: podir, install: true, install_dir: join_paths(datadir, 'applications'), ) # Validate the desktop file desktop_file_validate = find_program('desktop-file-validate', required:false) if desktop_file_validate.found() test ( 'Validate desktop file', desktop_file_validate, args: [ desktop_file.full_path() ], ) endif metainfo_file = i18n.merge_file( input: files('share/metainfo/net.lutris.Lutris.metainfo.xml'), output: 'net.lutris.Lutris.metainfo.xml', type: 'xml', po_dir: podir, install: true, install_dir: join_paths(datadir, 'metainfo'), ) # Validate the metainfo file appstreamcli = find_program('appstream-util', required: false) if appstreamcli.found() test ( 'Validate metainfo file', appstreamcli, args: ['validate-relax', '--nonet', metainfo_file.full_path() ] ) endif meson.add_install_script('utils/meson_post_install.py') lutris-0.5.19/CONTRIBUTING.md0000664000175000017500000001547514756670027014371 0ustar hibbyhibbyContributing to Lutris ====================== Finding features to work on --------------------------- If you are looking for issues to work on, have a look at the [milestones](https://github.com/lutris/lutris/milestones) and see which one is the closest to release then look at the tickets targeted at this release. Don't forget that Lutris is not only a desktop client, there are also a lot of issues to work on [on the website](https://github.com/lutris/website/issues) and also in the [build scripts repository](https://github.com/lutris/buildbot) where you can submit bash scripts for various open source games and engines we do not already have. Another area where users can help is [confirming some issues](https://github.com/lutris/lutris/issues?q=is%3Aissue+is%3Aopen+label%3A%22can%27t+reproduce%22+) that can't be reproduced on the developers setup. Please make sure that you're able to reproduce an issue before attempting to fix it. Note that Lutris is not a playground or a toy project. One cannot submit new features that aren't on the roadmap and submit a pull request for them without agreeing on a design first with the development team. Please get in touch with the developers before writing any code, so that you don't waste your efforts on something that isn't going to be merged. Make sure to post all the relevant information in a ticket or on the pull request. New features must at all times have a valid use case based on an actual game, be very specific about why you are implementing a feature otherwise it will get rejected. Avoid adding options in the GUI or introducing new installer directives for things that can be automated. Lutris focuses heavily on automation and on doing the right thing by default. Only introduce new options when absolutely necessary. Contributors are welcome to suggest architectural changes or better code design if they feel like the current implementation should be improved but please take note that we're trying to stay as lean as possible. Requests introducing complex architectural changes for the sake of "modularity", "Unix pureness" or subjective aspects might not be received warmly. There are no current plans for any rewrite in another language. Once again, make sure to discuss any change with a core developer before writing a large amount of code. Keeping your pull requests as small as a possible is the best way to have them reviewed and merged quickly. Running Lutris from Git ----------------------- Running Lutris from a local git repository is easy, it only requires cloning the repository and executing Lutris from there. git clone https://github.com/lutris/lutris cd lutris ./bin/lutris -d Make sure you have all necessary dependencies installed. It is recommended that you keep a copy of the stable version installed with your package manager to ensure that all dependencies are available. If you are working on newly written code that might introduce new dependencies, check in the package configuration files for new packages to install. Debian based distros will have their dependencies listed in `debian/control` and RPM based ones in `lutris.spec`. The PyGOject introspection libraries are not regular python packages, for that reason, using a virtualenv for development is heavily discouraged. Make sure to always use PyGOject from your distribution's package manager. Also install the necessary GObject bindings as described in the INSTALL file. Set up your development environment ----------------------------------- To ensure you have the proper tools installed run `make dev` This will install all necessary python to allow testing and validating your code. This project includes .editorconfig so you're good to go if you're using any editor/IDE that supports this. Otherwise make sure to configure your max line length to 120, indent style to space and always end files with an empty new line. Formatting your code -------------------- To ensure getting your contributions getting merged faster and to avoid other developers from going back and fixing your code, please make sure your code passes style checks by running `make sc` and fixing any reported issues before submitting your code. This runs a series of tools to apply PEP 8 coding style conventions, sorting and grouping imports and checking for formatting issues and other code smells. You can help fix formatting issues or other code smells by having a look at the CodeFactor page: https://www.codefactor.io/repository/github/lutris/lutris Writing tests ------------- If your patch does not require interactions with a GUI or external processes, please consider adding unit tests for your code. Have a look at the existing test suite in the `tests` folder to see what kind of features are tested. Running tests ------------- Be sure to test your changes thoroughly, never submit changes without running the code. Also run the test suite and check that nothing broke. You can run the test suite by typing `make test` in the source directory. QAing your changes ------------------ It is very important that any of your changes be tested manually, especially if you didn't add unit tests. Even trivial changes should be tested as they could potentially introduce breaking changes from a simple oversight. Submitting your changes ----------------------- Make a new git branch based of `master` in most cases. Send a pull request through GitHub describing what issue the patch solves. If the PR is related to and existing bug report, you can add `(Closes #nnnn)` or `(Fixes #nnnn)` to your PR title or message, where `nnnn` is the ticket number you're fixing. If you contribute to Lutris on a somewhat regular basis, be sure to add yourself to the AUTHORS file! Developer resources ------------------- Lutris uses Python 3 and GObject / Gtk+ 3 as its core stack, here are some links to some resources that can help you familiarize yourself with the project's code base. * [Python 3 documentation](https://docs.python.org/3/) * [PyGObject documentation](https://pygobject.readthedocs.io/en/latest/) * [Python Gtk 3 tutorial](https://python-gtk-3-tutorial.readthedocs.io/en/latest/objects.html) Project structure ----------------- [root]-+ Config files and READMEs | +-[bin] Main lutris executable script +-[debian] Debian / Ubuntu packaging configuration +-[docs] User documentation +-[lutris]-+ Source folder | | | +-[gui] Gtk UI code | +-[installer] Install script interpreter | +-[migrations] Migration scripts for user side changes | +-[runners] Runner code, detailing launch options and settings | +-[services] External services (Steam, GOG, ...) | +-[util] Generic utilities | +-[po] Translation files +-[share] Lutris resources like icons, ui files, scripts +-[tests] Unit tests lutris-0.5.19/.mypy_baseline0000664000175000017500000000000114756670027014754 0ustar hibbyhibby lutris-0.5.19/.gitignore0000664000175000017500000000057614756670027014124 0ustar hibbyhibbynbproject build .project .pydevproject .settings .ropeproject .idea tags *.pyc *.pyo *.ui~ PYSMELLTAGS lutris.e4p .coverage pga.db tests/coverage/* /dist /lutris.egg-info /flake.nix /flake.lock # meson builddirs builddir # i18n files po/lutris.pot po/*.mo transl-builddir # virtual environment folders venv .venv env .env # VS Codium files .vscode # glade recovery files *.ui~ lutris-0.5.19/.github/0000775000175000017500000000000014756670027013464 5ustar hibbyhibbylutris-0.5.19/.github/scripts/0000775000175000017500000000000014756670027015153 5ustar hibbyhibbylutris-0.5.19/.github/scripts/build-ubuntu-generic.sh0000775000175000017500000000473314756670027021552 0ustar hibbyhibby#!/bin/bash # Default build script for producing a generic Lutris build. # It requires several environment variables which are typically # passed in from the ./build-ubuntu.sh script. # # CODEBASE_ROOT # The absolute real path to the git repository root directory. # # LUTRIS_VERSION # The version of Lutris being built, in semver format. # Ex. "0.5.12-1" or "0.5.12" # # OS_CODENAME # The Ubuntu codename the package is being built for. # Ex. "jammy" or "kinetic" # # LUTRIS_DEBIAN_VERSION # The Debian-specific version which follows the guidelines # specified here: https://help.launchpad.net/Packaging/PPA/BuildingASourcePackage#Versioning # Ex. "0.5.12-0ubuntu1" # # PPA_VERSION # The PPA release version which follows the same guide as mentioned # in the LUTRIS_DEBIAN_VERSION variable. # Ex. "ppa1~ubuntu22.04" or "ppa3~ubuntu22.04" # # PPA_GPG_KEY_ID # The Full or Partial ID of a GPG Key that is imported into the GPG # key store. This can be listed with `gpg --list-secret-keys`. # Ex. 7596C2FB25663E2B6DD9F97CF380C7EDED8F0491 (Full Key ID) # Ex. F380C7EDED8F0491 (Partial Key ID) # # PPA_GPG_PASSPHRASE # The passphrase to unlock the above PPA_GPG_KEY_ID. # # Make sure the changelog has a proper entry for the version being built. if ! grep -q "${LUTRIS_VERSION}" "${CODEBASE_ROOT}/debian/changelog"; then echo "Error: ${CODEBASE_ROOT}/debian/changelog does not contain an entry for our current version." exit 255 fi # Does an initial make process for creating a debian source package. debmake -n -p lutris -u "${LUTRIS_VERSION}" -b":python3" # Updates debian/control file based on current environment. sudo mk-build-deps --install \ --tool='apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' \ "${CODEBASE_ROOT}/debian/control" # Update the changelog entry. Specifically we change the top most # changelog entry codename to match our current OS and the version # number to match the Debian+PPA version scheme described above. sed -i"" \ -re"1s/\s\w+;/ ${OS_CODENAME};/" \ -re"1s/${LUTRIS_VERSION}/${LUTRIS_DEBIAN_VERSION}${PPA_VERSION}/" \ "${CODEBASE_ROOT}/debian/changelog" # Builds and signs the debian package files. # PPA_GPG_KEY_ID and PPA_GPG_PASSPHRASE environment variables must be defined # by this point. make github-ppa # Clean up. sudo rm -f "${CODEBASE_ROOT}/lutris-build-deps"* git clean -df "${CODEBASE_ROOT}" git reset --hard lutris-0.5.19/.github/scripts/build-ubuntu-22.04.sh0000775000175000017500000000261214756670027020575 0ustar hibbyhibby#!/bin/bash # This script is intended to be run as part of a GitHub workflow. This specific # script overrides the build generic build process for Ubuntu 22.04 (Jammy) and # helps us work around the lack of GitHub Workflow Runners for non-LTS versions # of Ubuntu. # # Required Environment Variables: # # CODEBASE_ROOT # The absolute real path to the git repository root directory. # # Optional Environment Variables: # # JAMMY_BUILDS # A space or new-line separated list of Ubuntu codenames. # # First run the standard build process for Jammy. # shellcheck source=./build-ubuntu-generic.sh source "${CODEBASE_ROOT}/.github/scripts/build-ubuntu-generic.sh" echo "::endgroup::" # Rerun the build process for all codenames in the JAMMY_BUILDS env # variable. We override the OS_CODENAME env variable, and then recurse # into the ./build-ubuntu.sh build script to build those versions within # our Jammy runner. if [[ -n ${JAMMY_BUILDS} ]]; then for OS_CODENAME in ${JAMMY_BUILDS}; do if ! distro-info --series "${OS_CODENAME}"; then echo "Bad JAMMY_BUILDS codename '${OS_CODENAME}' provided. Skipping this build." else # Clean up the codebase between runs. git reset --hard git clean -df # shellcheck source=./build-ubuntu.sh source "${CODEBASE_ROOT}/.github/scripts/build-ubuntu.sh" fi done fi lutris-0.5.19/.github/scripts/build-ubuntu.sh0000775000175000017500000001373014756670027020135 0ustar hibbyhibby#!/bin/bash -e # This script is intended to be run as part of a GitHub workflow where we # build multiple times under different OS versions, which _may_ produce # differences in the built packages. # # It expects the following environment variables: # # PPA_GPG_PRIVATE_KEY # Private key with access to the Ubuntu PPA. Note that if the # optional env variable PPA_GPG_KEY_ID is passed, then this # variable is not required. If both are passed, then PPA_GPG_KEY_ID # is used. # # PPA_GPG_PASSPHRASE # Decrypts the above private key or PPA_GPG_KEY_ID. # # PPA_URI # The URI of the PPA to push updates to. # Ex. ppa:lutris-team/lutris # # The following environment variables are optional and will override # default values. # # CODEBASE_ROOT # The absolute real path to the git repository root directory. # # LUTRIS_VERSION # The version of Lutris being built, in semver format. # Ex. "0.5.12-1" or "0.5.12" # # OS_CODENAME # The Ubuntu codename the package is being built for. # Ex. "jammy" or "kinetic" # # LUTRIS_DEBIAN_VERSION # The Debian-specific version which follows the guidelines # specified here: https://help.launchpad.net/Packaging/PPA/BuildingASourcePackage#Versioning # Ex. "0.5.12-0ubuntu1" # # PPA_VERSION # The PPA release version which follows the same guide as mentioned # in the LUTRIS_DEBIAN_VERSION variable. # Ex. "ppa1~ubuntu22.04" or "ppa3~ubuntu22.04" # # PPA_GPG_KEY_ID # The Full or Partial ID of a GPG Key that is imported into the GPG # key store. This can be listed with `gpg --list-secret-keys`. # Ex. 7596C2FB25663E2B6DD9F97CF380C7EDED8F0491 (Full Key ID) # Ex. F380C7EDED8F0491 (Partial Key ID) # # Go three directories up to get the codebase root path. if [[ -z $CODEBASE_ROOT ]]; then CODEBASE_ROOT="$(dirname "$(dirname "$(dirname "$(readlink -f "$0")")")")" fi # This gets the Ubuntu codename & version from the local OS if they are # not passed in to us already. if [[ -z $OS_CODENAME ]]; then OS_CODENAME="$(grep 'VERSION_CODENAME=' /etc/os-release | cut -f2 -d'=' | tr -d '"')" fi # Get the OS version associated with OS_CODENAME. OS_VERSION="$(distro-info --series "${OS_CODENAME}" -r | cut -f1 -d' ')" # Get the base Lutris version in the same way that the Makefile does. if [[ -z $LUTRIS_VERSION ]]; then LUTRIS_VERSION="$(grep "__version__" "${CODEBASE_ROOT}/lutris/__init__.py" | cut -d" " -f 3 | sed 's|"\(.*\)"|\1|')" fi # Creates a GPG keyring using the key passed from the GitHub workflow. if [[ -z $PPA_GPG_KEY_ID ]]; then echo "::group::Importing GPG private key..." PPA_GPG_KEY_ID=$(echo "${PPA_GPG_PRIVATE_KEY}" | gpg --import-options show-only --import | sed -n '2s/^\s*//p') export PPA_GPG_KEY_ID echo "${PPA_GPG_KEY_ID}" echo "${PPA_GPG_PRIVATE_KEY}" | gpg --batch --passphrase "${PPA_GPG_PASSPHRASE}" --import echo "::endgroup::" # May as well since we don't need after at this point. unset PPA_GPG_PRIVATE_KEY fi # Add the ppa if it isn't already. if ! grep -qi "${PPA_URI//ppa:/}" /etc/apt/sources.list.d/*.list; then sudo apt-add-repository -y "${PPA_URI}" fi # Get the current version of Lutris on the PPA. if APT_VERSION="$(apt show lutris | grep 'Version: ' | cut -f2 -d' ')"; then echo "Latest version on '${PPA_URI}' is '${APT_VERSION}'" else echo "Pushing first package to '${PPA_URI}'" fi # Version numbers are recommended to follow the guide at: # https://help.launchpad.net/Packaging/PPA/BuildingASourcePackage#Versioning # # The basic format is: # -ubuntuppa~ubuntu # # ex. 0.5.12-0ubuntu1 (for just the package version) # ex. 0.5.12-0ubuntu1ppa1~ubuntu22.04 (for a package version meant for jammy) # ex. 0.5.12-0ubuntu1ppa1~ubuntu20.04 (for a package version meant for focal) # etc... # PPA_VERSION="ppa1~ubuntu${OS_VERSION}" # If the Lutris version doesn't have a revision, we add revision 0. LUTRIS_DEBIAN_VERSION="${LUTRIS_VERSION}" if [[ "${LUTRIS_VERSION}" = "${LUTRIS_VERSION/-*/}" ]]; then LUTRIS_DEBIAN_VERSION="${LUTRIS_DEBIAN_VERSION}-0" fi # Finally, add an ubuntu revision, so that other packages can override ours # without bumping the actual version number. LUTRIS_DEBIAN_VERSION="${LUTRIS_DEBIAN_VERSION}ubuntu1" # If the version we're currently building exists on the PPA, increment # the PPA version number so that we supersede it. Assuming a version # scheme like 0.5.12-0ubuntu1ppa1~ubuntu22.04, we trim everything up to # and including 'ppa' and everything after and including '~' to get the # PPA version number. if [[ "${APT_VERSION//ppa*/}" = "${LUTRIS_DEBIAN_VERSION}" ]]; then echo "PPA (${PPA_URI}) has matching package version: ${LUTRIS_DEBIAN_VERSION}" APT_PPA_VERSION="${APT_VERSION//*ppa/}" APT_PPA_VERSION="${APT_PPA_VERSION//~*/}" # Only autoincrement if the derived APT_PPA_VERSION is an integer. if [[ ${APT_PPA_VERSION} =~ ^[0-9]*$ ]]; then (( APT_PPA_VERSION++ )) PPA_VERSION="ppa${APT_PPA_VERSION}~ubuntu${OS_VERSION}" else echo "Could not derive APT_PPA_VERSION from '${APT_VERSION}'; using default." fi fi # Runs a specific build script for an OS Version if it exists or runs # the generic build script if not. GitHub seems to only support LTS # versions of Ubuntu runners for build jobs, and so we use this to # build the non-LTS versions on the closest matching LTS version. if [[ -f "${CODEBASE_ROOT}/.github/scripts/build-ubuntu-${OS_VERSION}.sh" ]]; then echo "::group::Building version-specific deb for ${OS_CODENAME}: ${LUTRIS_DEBIAN_VERSION}${PPA_VERSION}" # shellcheck disable=SC1090 source "${CODEBASE_ROOT}/.github/scripts/build-ubuntu-${OS_VERSION}.sh" else echo "::group::Building generic deb for ${OS_CODENAME}: ${LUTRIS_DEBIAN_VERSION}${PPA_VERSION}" # shellcheck source=./build-ubuntu-generic.sh source "${CODEBASE_ROOT}/.github/scripts/build-ubuntu-generic.sh" fi echo "::endgroup::" lutris-0.5.19/.github/scripts/install-ubuntu-generic.sh0000775000175000017500000000040014756670027022104 0ustar hibbyhibby#!/bin/bash # Default dependencies needed on a fresh install of ubuntu in order to # build the lutris package. sudo apt update sudo apt install \ debhelper \ debmake \ devscripts \ dh-python \ meson \ equivs \ git-buildpackage lutris-0.5.19/.github/scripts/install-ubuntu.sh0000775000175000017500000000325414756670027020504 0ustar hibbyhibby#!/bin/bash -e # Handles installing dependencies for the build process. If an # install-ubuntu-$OS_VERSION.sh script exists, install-ubuntu-22.04.sh # for example, then that script is executed to install dependencies # for that particular build instead of the install-ubuntu-generic.sh # script. # # The following environment variables are optional and will override # default values. # # CODEBASE_ROOT # The absolute real path to the git repository root directory. # # OS_CODENAME # The Ubuntu codename the package is being built for. # Ex. "jammy" or "kinetic" # # Go three directories up to get the codebase root path. if [[ -z $CODEBASE_ROOT ]]; then CODEBASE_ROOT="$(dirname "$(dirname "$(dirname "$(readlink -f "$0")")")")" fi # This gets the Ubuntu codename & version from the local OS, or allows # it to be passed in as an environment variable. if [[ -z $OS_CODENAME ]]; then OS_CODENAME="$(grep 'VERSION_CODENAME=' /etc/os-release | cut -f2 -d'=' | tr -d '"')" fi if [[ -z $OS_VERSION ]]; then OS_VERSION="$(grep 'VERSION_ID=' /etc/os-release | cut -f2 -d'=' | tr -d '"')" fi # Runs a specific install script for an OS version if it exists or runs # the generic install script. if [[ -e "${CODEBASE_ROOT}/.github/scripts/install-ubuntu-${OS_VERSION}.sh" ]]; then echo "::group::Installing $OS_CODENAME ($OS_VERSION) build dependencies" # shellcheck disable=SC1090 source "${CODEBASE_ROOT}/.github/scripts/install-ubuntu-${OS_VERSION}.sh" else echo "::group::Installing generic build dependencies" # shellcheck source=./install-ubuntu-generic.sh source "${CODEBASE_ROOT}/.github/scripts/install-ubuntu-generic.sh" fi echo "::endgroup::" lutris-0.5.19/.github/FUNDING.yml0000664000175000017500000000010414756670027015274 0ustar hibbyhibbypatreon: lutris liberapay: Lutris custom: https://lutris.net/donate lutris-0.5.19/.github/ISSUE_TEMPLATE/0000775000175000017500000000000014756670027015647 5ustar hibbyhibbylutris-0.5.19/.github/ISSUE_TEMPLATE/bug_report_form.yml0000664000175000017500000000733414756670027021574 0ustar hibbyhibbyname: Bug Report description: File a bug report labels: ["needs triage"] body: - type: markdown id: importantnotice attributes: value: | ### Important notice **This repository is not a place for requesting support with a game, runner or an installer.** *DO NOT REPORT BUGS FOR OLD LUTRIS VERSIONS. MAKE SURE YOU UP TO DATE AND ARE USING THE LATEST VERSION AVAILABLE. *BUGS REPORTS SUBMITTED ON OLD VERSIONS WILL BE CLOSED WITHOUT FURTHER CONSIDERATION. *If you have issues installing a game or launching it*, make sure you follow our essential guides on [graphics driver installation](https://github.com/lutris/docs/blob/master/InstallingDrivers.md) and [dependencies for the wine runner](https://github.com/lutris/docs/blob/master/WineDependencies.md). *If you followed the guides and the issues persist*, try asking for help in our official [Lutris Discord Server](https://discord.com/invite/Pnt5CuY). *If you think there is a legitimate issue with our Lutris Wine builds*, open an issue in our [Lutris Wine repository](https://github.com/lutris/wine) instead. *If you think Lutris is missing an important feature*, try opening a *Feature Request* instead. - type: textarea id: description attributes: label: "Bug description" description: "A clear and detailed description of what the bug is." placeholder: "Tell us about your problem with Lutris in a clear and detailed way" validations: required: true - type: textarea id: howtoreproduce attributes: label: How to Reproduce description: "Steps to reproduce the behavior and what should be observed in the end." placeholder: "Tell us step by step how we can replicate your problem and what we should see in the end" value: | Steps to reproduce the behavior: 1. Go to '....' 2. Click on '....' 3. Do '....' 4. See '....' validations: required: true - type: textarea id: expected-behavior attributes: label: "Expected behavior" description: "A clear and detailed description of what you think should happen." placeholder: "Tell us what you expected Lutris to do" validations: required: true - type: textarea id: logs attributes: label: Log output description: "Close Lutris, launch it in the terminal using the command `lutris -d`, reproduce your issue, then paste all of the output from the terminal here. Do not shortnen/prune the output." placeholder: "Full output from the `lutris -d` command after reproducing the issue" render: shell validations: required: true - type: textarea id: sysinfo attributes: label: "System Information" description: "An auto-generated summary about system configuration from the Lutris settings." placeholder: "Paste it in this text form" render: shell validations: required: true - type: markdown id: systemsummarygif attributes: value: | ***You can obtain a quick system summary from within Lutris like this:*** ![Peek 2021-11-12 05-09-2](https://user-images.githubusercontent.com/10602045/142093883-fb1169f2-28ab-4382-8e54-d7de9c96243e.gif) - type: textarea id: media attributes: label: Media (optional) description: "Screenshots or a Peek recorded GIF that showcases the problem." placeholder: If applicable, click on this form to activate it, then attach a GIF or a screenshot of the issue here by selecting or drag-and-dropping it - type: markdown id: peekinfo attributes: value: | ***Learn more about Peek and how it can record a GIF of your desktop here: https://github.com/phw/peek*** lutris-0.5.19/.github/workflows/0000775000175000017500000000000014756670027015521 5ustar hibbyhibbylutris-0.5.19/.github/workflows/publish-lutris-ppa.yml0000664000175000017500000000331614756670027022013 0ustar hibbyhibby# Handles publishing Lutris builds to the Lutris Ubuntu PPAs. This # workflow is triggered when a release or prerelease is published, # checks out the tag associated with that release, builds a debian # source package, and then pushes that to the Ubuntu Launchpad.net PPA # where it is then built & published on their servers. name: Publish Lutris PPA on: # See below for details about the 'release' event. # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#release release: types: [released,prereleased] # Requires that PPA_GPG_PRIVATE_KEY & PPA_GPG_PASSPHRASE be defined # as repository secrets and that PPA_RELEASE_URI & PPA_STAGING_URI # be defined as repository variables. # Ex. PPA_GPG_PRIVATE_KEY: (( output of `gpg -a --export-secret-key ` )) # Ex. PPA_GPG_PASSPHRASE: (( password used to export the above key )) # Ex. PPA_STAGING_URI: ppa:lutris-team/lutris-staging # Ex. PPA_RELEASE_URI: ppa:lutris-team/lutris # # The only thing that really differs between the release and staging # builds is the target PPA_URI. jobs: # Only build the release PPA when the event is not a prerelease. release-ppa: if: github.event.release.prerelease == false uses: ./.github/workflows/publish-ppa.yml with: PPA_URI: ${{ vars.PPA_RELEASE_URI }} secrets: PPA_GPG_PRIVATE_KEY: ${{ secrets.PPA_GPG_PRIVATE_KEY }} PPA_GPG_PASSPHRASE: ${{ secrets.PPA_GPG_PASSPHRASE }} # Push all builds out to the staging PPA. staging-ppa: uses: ./.github/workflows/publish-ppa.yml with: PPA_URI: ${{ vars.PPA_STAGING_URI }} secrets: PPA_GPG_PRIVATE_KEY: ${{ secrets.PPA_GPG_PRIVATE_KEY }} PPA_GPG_PASSPHRASE: ${{ secrets.PPA_GPG_PASSPHRASE }} lutris-0.5.19/.github/workflows/publish-ppa.yml0000664000175000017500000000452514756670027020476 0ustar hibbyhibby# This is a reusable workflow that should not be called directly, but # instead, from another workflow as an action to be executed. name: Publish PPA on: workflow_call: inputs: # This should be in the format: "ppa:username/repo" # Ex. "ppa:lutris-team/lutris" PPA_URI: required: true type: string # Signing packages uses the secrets referenced below and passes # them through the build-ubuntu.sh script and into the "github-ppa" # Makefile directive. # # It would probably be a good idea to have a unique GPG key just # for this process, but whatever GPG key is passed here _must_ be # registered on the PPA_URI as an authorized key. secrets: PPA_GPG_PRIVATE_KEY: required: true PPA_GPG_PASSPHRASE: required: true jobs: publish-ppa: # GitHub only has runners for LTS versions of Ubuntu. # https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources # Also note that, although a runner exists for Ubuntu 18.04, the # lutris build-dependencies are not supported on that version. strategy: matrix: os: [ ubuntu-22.04, ubuntu-20.04 ] runs-on: ${{ matrix.os }} steps: - name: Checkout the repository uses: actions/checkout@v3 - name: Install build dependencies run: | ./.github/scripts/install-ubuntu.sh # This builds and signs the debian package for each OS in matrix.os # This script also recalculates dependencies based on the current # OS and can potentially produce different package control files, # but these files are not kept. - name: Build the Debian Package env: PPA_URI: ${{ inputs.PPA_URI }} PPA_GPG_PRIVATE_KEY: ${{ secrets.PPA_GPG_PRIVATE_KEY }} PPA_GPG_PASSPHRASE: ${{ secrets.PPA_GPG_PASSPHRASE }} # This will be passed through to the build-specific script for # Jammy and causes it to produce additional builds for the Ubuntu # codenames listed below. # Ex. "lunar mantic" JAMMY_BUILDS: "lunar mantic" run: | ./.github/scripts/build-ubuntu.sh # Pushes the build to the PPA_URI. - name: Publish to PPA run: | dput ${{ inputs.PPA_URI }} ../*.changes lutris-0.5.19/.github/workflows/static.yml0000664000175000017500000000232014756670027017530 0ustar hibbyhibbyon: [push, pull_request] name: Static Analysis jobs: mypy-checker: name: Mypy runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Print current dir run: pwd - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install Ubuntu dependencies run: | sudo apt update sudo apt-get install libdbus-1-dev pkg-config libgirepository1.0-dev python3-gi-cairo libcairo2-dev - name: Install Python dependencies run: | python -m pip install --upgrade pip make req-python make dev - name: Run mypy analysis run: | mypy --version mypy . ruff-checker: name: Ruff runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install Python dependencies run: | python -m pip install --upgrade pip make dev - name: Check code style run: ruff --version ruff check . - name: Check format run: ruff format . --check lutris-0.5.19/lutris/0000775000175000017500000000000014756670027013446 5ustar hibbyhibbylutris-0.5.19/lutris/runners/0000775000175000017500000000000014756670027015142 5ustar hibbyhibbylutris-0.5.19/lutris/runners/duckstation.py0000664000175000017500000000765214756670027020056 0ustar hibbyhibby"""DuckStation Runner""" import os.path from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger class duckstation(Runner): human_name = _("DuckStation") description = _("PlayStation 1 Emulator") platforms = [_("Sony PlayStation")] runnable_alone = True runner_executable = "duckstation/DuckStation-x64.AppImage" flatpak_id = "org.duckstation.DuckStation" config_dir = os.path.expanduser("~/.local/share/duckstation/") config_file = os.path.join(config_dir, "settings.ini") download_url = "https://github.com/stenzek/duckstation/releases/download/latest/DuckStation-x64.AppImage" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "default_path": "game_path", } ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "section": _("Graphics"), "help": _("Enters fullscreen mode immediately after starting."), "default": True, }, { "option": "nofullscreen", "type": "bool", "label": _("No Fullscreen"), "section": _("Graphics"), "help": _("Prevents fullscreen mode from triggering if enabled."), "default": False, }, { "option": "nogui", "type": "bool", "label": _("Batch Mode"), "section": _("Boot"), "help": _("Enables batch mode (exits after powering off)."), "default": True, "advanced": True, }, { "option": "fastboot", "type": "bool", "label": _("Force Fastboot"), "section": _("Boot"), "help": _("Force fast boot."), "default": False, }, { "option": "slowboot", "type": "bool", "label": _("Force Slowboot"), "section": _("Boot"), "help": _("Force slow boot."), "default": False, }, { "option": "nocontroller", "type": "bool", "label": _("No Controllers"), "section": _("Controllers"), "help": _( "Prevents the emulator from polling for controllers. Try this option if you're " "having difficulties starting the emulator." ), "default": False, }, { "option": "settings", "type": "file", "label": _("Custom configuration file"), "help": _( "Loads a custom settings configuration from the specified filename. " "Default settings applied if file not found." ), "default": config_file, "advanced": True, }, ] # Duckstation uses an AppImage, no need for the runtime. system_options_override = [{"option": "disable_runtime", "default": True}] def play(self): arguments = self.get_command() runner_flags = { "nogui": "-batch", "fastboot": "-fastboot", "slowboot": "-slowboot", "fullscreen": "-fullscreen", "nofullscreen": "-nofullscreen", "nocontroller": "-nocontroller", } for option, flag in runner_flags.items(): if self.runner_config.get(option): arguments.append(flag) arguments += ["-settings", self.config_file, "--"] rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments.append(rom) logger.debug("DuckStation starting with args: %s", arguments) return {"command": arguments} lutris-0.5.19/lutris/runners/o2em.py0000664000175000017500000000777314756670027016374 0ustar hibbyhibby# Standard Library import os from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class o2em(Runner): human_name = _("O2EM") description = _("Magnavox Odyssey² Emulator") platforms = ( _("Magnavox Odyssey²"), _("Phillips C52"), _("Phillips Videopac+"), _("Brandt Jopac"), ) bios_path = os.path.expanduser("~/.o2em/bios") runner_executable = "o2em/o2em" checksums = { "o2rom": "562d5ebf9e030a40d6fabfc2f33139fd", "c52": "f1071cdb0b6b10dde94d3bc8a6146387", "jopac": "279008e4a0db2dc5f1c048853b033828", "g7400": "79008e4a0db2dc5f1c048853b033828", } bios_choices = [ (_("Magnavox Odyssey²"), "o2rom"), (_("Phillips C52"), "c52"), (_("Phillips Videopac+"), "g7400"), (_("Brandt Jopac"), "jopac"), ] controller_choices = [ (_("Disable"), "0"), (_("Arrow Keys and Right Shift"), "1"), (_("W,S,A,D,SPACE"), "2"), (_("Joystick"), "3"), ] game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "default_path": "game_path", "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ { "option": "bios", "type": "choice", "choices": bios_choices, "label": _("BIOS"), "default": "o2rom", }, { "option": "controller1", "type": "choice", "choices": controller_choices, "section": _("Controllers"), "label": _("First controller"), "default": "2", }, { "option": "controller2", "type": "choice", "choices": controller_choices, "section": _("Controllers"), "label": _("Second controller"), "default": "1", }, { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": False, }, { "option": "scanlines", "type": "bool", "section": _("Graphics"), "label": _("Scanlines display style"), "default": False, "help": _("Activates a display filter adding scanlines to imitate " "the displays of yesteryear."), }, ] def get_platform(self): bios = self.runner_config.get("bios") if bios: for i, b in enumerate(self.bios_choices): if b[1] == bios: return self.platforms[i] return "" def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*args): if not system.path_exists(self.bios_path): os.makedirs(self.bios_path) if callback: callback() super().install(install_ui_delegate, version, on_runner_installed) def play(self): arguments = ["-biosdir=%s" % self.bios_path] if self.runner_config.get("fullscreen"): arguments.append("-fullscreen") if self.runner_config.get("scanlines"): arguments.append("-scanlines") if "controller1" in self.runner_config: arguments.append("-s1=%s" % self.runner_config["controller1"]) if "controller2" in self.runner_config: arguments.append("-s2=%s" % self.runner_config["controller2"]) rom_path = self.game_config.get("main_file") or "" if not system.path_exists(rom_path): raise MissingGameExecutableError(filename=rom_path) romdir = os.path.dirname(rom_path) romfile = os.path.basename(rom_path) arguments.append("-romdir=%s/" % romdir) arguments.append(romfile) return {"command": self.get_command() + arguments} lutris-0.5.19/lutris/runners/__init__.py0000664000175000017500000000640614756670027017261 0ustar hibbyhibby"""Runner loaders""" __all__ = [ # Native "linux", "steam", "web", "flatpak", "zdoom", # Microsoft based "wine", "dosbox", "xemu", # Multi-system "easyrpg", "mame", "mednafen", "scummvm", "libretro", # Commodore "fsuae", "vice", # Atari "atari800", "hatari", # Nintendo "snes9x", "mupen64plus", "dolphin", "ryujinx", "yuzu", "cemu", # Sony "duckstation", "pcsx2", "rpcs3", "vita3k", # Sega "osmose", "reicast", "redream", # Fantasy consoles "pico8", # Misc legacy systems "jzintv", "o2em", ] from lutris.exceptions import LutrisError, MisconfigurationError ADDON_RUNNERS = {} _cached_runner_human_names = {} class InvalidRunnerError(MisconfigurationError): """Raise if a runner name is used that is not known to Lutris.""" class RunnerInstallationError(LutrisError): """Raised if the attempt to install a runner fails, perhaps because of invalid data from a server.""" class NonInstallableRunnerError(LutrisError): """Raised if installed a runner that Lutris cannot install, like Flatpak. These must be installed separately.""" def get_runner_module(runner_name): if not is_valid_runner_name(runner_name): raise InvalidRunnerError("Invalid runner name '%s'" % runner_name) module = __import__("lutris.runners.%s" % runner_name, globals(), locals(), [runner_name], 0) if not module: raise InvalidRunnerError("Runner module for '%s' could not be imported." % runner_name) return module def import_runner(runner_name): """Dynamically import a runner class.""" if runner_name in ADDON_RUNNERS: return ADDON_RUNNERS[runner_name] runner_module = get_runner_module(runner_name) return getattr(runner_module, runner_name) def import_task(runner, task): """Return a runner task.""" runner_module = get_runner_module(runner) return getattr(runner_module, task) def get_installed(sort=True): """Return a list of installed runners (class instances).""" installed = [] for runner_name in __all__: runner = import_runner(runner_name)() if runner.is_installed(): installed.append(runner) return sorted(installed) if sort else installed def inject_runners(runners): for runner_name in runners: if runner_name not in __all__: ADDON_RUNNERS[runner_name] = runners[runner_name] __all__.append(runner_name) _cached_runner_human_names.clear() def get_runner_names(): return __all__ def is_valid_runner_name(runner_name: str) -> bool: return runner_name in __all__ def get_runner_human_name(runner_name): """Returns a human-readable name for a runner; as a convenience, if the name is falsy (None or blank) this returns an empty string. Provides caching for the names.""" if runner_name: if runner_name not in _cached_runner_human_names: try: _cached_runner_human_names[runner_name] = import_runner(runner_name)().human_name except InvalidRunnerError: _cached_runner_human_names[runner_name] = runner_name # an obsolete runner return _cached_runner_human_names[runner_name] return "" lutris-0.5.19/lutris/runners/redream.py0000664000175000017500000001114414756670027017134 0ustar hibbyhibbyimport os import shutil from gettext import gettext as _ from lutris import settings from lutris.runners.runner import Runner class redream(Runner): human_name = _("Redream") description = _("Sega Dreamcast emulator") platforms = [_("Sega Dreamcast")] runner_executable = "redream/redream" download_url = "https://redream.io/download/redream.x86_64-linux-v1.5.0.tar.gz" game_options = [ { "option": "main_file", "type": "file", "label": _("Disc image file"), "help": _("Game data file\nSupported formats: GDI, CDI, CHD"), } ] runner_options = [ {"option": "fs", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": False}, { "option": "ar", "type": "choice", "section": _("Graphics"), "label": _("Aspect Ratio"), "choices": [(_("4:3"), "4:3"), (_("Stretch"), "stretch")], "default": "4:3", }, { "option": "region", "type": "choice", "label": _("Region"), "choices": [(_("USA"), "usa"), (_("Europe"), "europe"), (_("Japan"), "japan")], "default": "usa", }, { "option": "language", "type": "choice", "label": _("System Language"), "choices": [ (_("English"), "english"), (_("German"), "german"), (_("French"), "french"), (_("Spanish"), "spanish"), (_("Italian"), "italian"), (_("Japanese"), "japanese"), ], "default": "english", }, { "option": "broadcast", "type": "choice", "label": "Television System", "choices": [ (_("NTSC"), "ntsc"), (_("PAL"), "pal"), (_("PAL-M (Brazil)"), "pal_m"), (_("PAL-N (Argentina, Paraguay, Uruguay)"), "pal_n"), ], "default": "ntsc", }, { "option": "time_sync", "type": "choice", "label": _("Time Sync"), "choices": [ (_("Audio and video"), "audio and video"), (_("Audio"), "audio"), (_("Video"), "video"), (_("None"), "none"), ], "default": "audio and video", "advanced": True, }, { "option": "int_res", "type": "choice", "label": _("Internal Video Resolution Scale"), "choices": [ ("×1", "1"), ("×2", "2"), ("×3", "3"), ("×4", "4"), ("×5", "5"), ("×6", "6"), ("×7", "7"), ("×8", "8"), ], "default": "2", "advanced": True, "help": _("Only available in premium version."), }, ] def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*args): license_filename = install_ui_delegate.show_install_file_inquiry( question=_("Do you want to select a premium license file?"), title=_("Use premium version?"), message=_("Use premium version?"), ) if license_filename: shutil.copy(license_filename, os.path.join(settings.RUNNER_DIR, "redream")) super().install(install_ui_delegate, version=version, callback=on_runner_installed) def play(self): command = self.get_command() if self.runner_config.get("fs") is True: command.append("--fullscreen=1") else: command.append("--fullscreen=0") if self.runner_config.get("ar"): command.append("--aspect=" + self.runner_config.get("ar")) if self.runner_config.get("region"): command.append("--region=" + self.runner_config.get("region")) if self.runner_config.get("language"): command.append("--language=" + self.runner_config.get("language")) if self.runner_config.get("broadcast"): command.append("--broadcast=" + self.runner_config.get("broadcast")) if self.runner_config.get("time_sync"): command.append("--time_sync=" + self.runner_config.get("time_sync")) if self.runner_config.get("int_res"): command.append("--res=" + self.runner_config.get("int_res")) command.append(self.game_config.get("main_file")) return {"command": command} lutris-0.5.19/lutris/runners/libretro.py0000664000175000017500000003117714756670027017347 0ustar hibbyhibby"""libretro runner""" import os from gettext import gettext as _ from operator import itemgetter from zipfile import ZipFile import requests from lutris import settings from lutris.config import LutrisConfig from lutris.exceptions import GameConfigError, MissingBiosError, MissingGameExecutableError, UnspecifiedVersionError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.libretro import RetroConfig from lutris.util.log import logger from lutris.util.retroarch.firmware import get_firmware, scan_firmware_directory RETROARCH_DIR = os.path.join(settings.RUNNER_DIR, "retroarch") def get_default_config_path(path): return os.path.join(RETROARCH_DIR, path) def get_libretro_cores(): cores = [] if not os.path.exists(RETROARCH_DIR): return [] # Get core identifiers from info dir info_path = os.path.join(RETROARCH_DIR, "info") if not os.path.exists(info_path): req = requests.get("http://buildbot.libretro.com/assets/frontend/info.zip", allow_redirects=True, timeout=5) if req.status_code == requests.codes.ok: # pylint: disable=no-member with open(os.path.join(RETROARCH_DIR, "info.zip"), "wb") as info_zip: info_zip.write(req.content) with ZipFile(os.path.join(RETROARCH_DIR, "info.zip"), "r") as info_zip: info_zip.extractall(info_path) else: logger.error("Error retrieving libretro info archive from server: %s - %s", req.status_code, req.reason) return [] # Parse info files to fetch display name and platform/system for info_file in os.listdir(info_path): if "_libretro.info" not in info_file: continue core_identifier = info_file.replace("_libretro.info", "") core_config = RetroConfig(os.path.join(info_path, info_file)) if "categories" in core_config.keys() and "Emulator" in core_config["categories"]: core_label = core_config["display_name"] or "" core_system = core_config["systemname"] or "" cores.append((core_label, core_identifier, core_system)) cores.sort(key=itemgetter(0)) return cores # List of supported libretro cores # First element is the human readable name for the core with the platform's short name # Second element is the core identifier # Third element is the platform's long name LIBRETRO_CORES = get_libretro_cores() def get_core_choices(): return [(core[0], core[1]) for core in LIBRETRO_CORES] class libretro(Runner): human_name = _("Libretro") description = _("Multi-system emulator") runnable_alone = True runner_executable = "retroarch/retroarch" flatpak_id = "org.libretro.RetroArch" game_options = [ {"option": "main_file", "type": "file", "label": _("ROM file")}, { "option": "core", "type": "choice", "label": _("Core"), "choices": get_core_choices(), }, ] runner_options = [ { "option": "config_file", "type": "file", "label": _("Config file"), "default": os.path.join(RETROARCH_DIR, "retroarch.cfg"), }, { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": True, }, { "option": "verbose", "type": "bool", "label": _("Verbose logging"), "default": False, }, ] @property def directory(self): return os.path.join(settings.RUNNER_DIR, "retroarch") @property def platforms(self): return [core[2] for core in LIBRETRO_CORES] def get_platform(self): game_core = self.game_config.get("core") if not game_core: logger.warning("Game don't have a core set") return for core in LIBRETRO_CORES: if core[1] == game_core: return core[2] logger.warning("'%s' not found in Libretro cores", game_core) return "" def get_core_path(self, core): """Return the path of a core, prioritizing Retroarch cores""" lutris_cores_folder = os.path.join(self.directory, "cores") retroarch_core_folder = os.path.join(os.path.expanduser("~/.config/retroarch/cores")) core_filename = "{}_libretro.so".format(core) retroarch_core = os.path.join(retroarch_core_folder, core_filename) if system.path_exists(retroarch_core): return retroarch_core return os.path.join(lutris_cores_folder, core_filename) def get_version(self, use_default=True): return self.game_config["core"] def is_installed(self, flatpak_allowed: bool = True, core=None) -> bool: if not core and self.has_explicit_config and self.game_config.get("core"): core = self.game_config["core"] if not core or self.runner_config.get("runner_executable"): return super().is_installed(flatpak_allowed=flatpak_allowed) is_core_installed = system.path_exists(self.get_core_path(core)) return super().is_installed(flatpak_allowed=flatpak_allowed) and is_core_installed def is_installed_for(self, interpreter): core = interpreter.installer.script["game"].get("core") return self.is_installed(core=core) def get_installer_runner_version(self, installer, use_runner_config: bool = True) -> str: version = installer.script["game"].get("core") if not version: raise UnspecifiedVersionError(_("The installer does not specify the libretro 'core' version.")) return version def install(self, install_ui_delegate, version=None, callback=None): captured_super = super() # super() does not work inside install_core() def install_core(): if not version: if callback: callback() else: captured_super.install(install_ui_delegate, version, callback) if not super().is_installed(): captured_super.install(install_ui_delegate, version=None, callback=install_core) else: captured_super.install(install_ui_delegate, version, callback) def get_run_data(self): return { "command": self.get_command() + self.get_runner_parameters(), "env": self.get_env(), } def get_config_file(self): return self.runner_config.get("config_file") or os.path.join(RETROARCH_DIR, "retroarch.cfg") @staticmethod def get_system_directory(retro_config): """Return the system directory used for storing BIOS and firmwares.""" system_directory = retro_config["system_directory"] if not system_directory or system_directory == "default": system_directory = "~/.config/retroarch/system" return os.path.expanduser(system_directory) def prelaunch(self): # pylint: disable=too-many-locals,too-many-branches,too-many-statements config_file = self.get_config_file() # TODO: review later # Create retroarch.cfg if it doesn't exist. if not system.path_exists(config_file): with open(config_file, "w", encoding="utf-8") as f: f.write("# Lutris RetroArch Configuration") f.close() # Build the default config settings. retro_config = RetroConfig(config_file) retro_config["libretro_directory"] = get_default_config_path("cores") retro_config["libretro_info_path"] = get_default_config_path("info") retro_config["content_database_path"] = get_default_config_path("database/rdb") retro_config["cheat_database_path"] = get_default_config_path("database/cht") retro_config["cursor_directory"] = get_default_config_path("database/cursors") retro_config["screenshot_directory"] = get_default_config_path("screenshots") retro_config["input_remapping_directory"] = get_default_config_path("remaps") retro_config["video_shader_dir"] = get_default_config_path("shaders") retro_config["core_assets_directory"] = get_default_config_path("downloads") retro_config["thumbnails_directory"] = get_default_config_path("thumbnails") retro_config["playlist_directory"] = get_default_config_path("playlists") retro_config["joypad_autoconfig_dir"] = get_default_config_path("autoconfig") retro_config["rgui_config_directory"] = get_default_config_path("config") retro_config["overlay_directory"] = get_default_config_path("overlay") retro_config["assets_directory"] = get_default_config_path("assets") retro_config["system_directory"] = "~/.config/retroarch/system" retro_config.save() else: retro_config = RetroConfig(config_file) core = self.game_config.get("core") info_file = os.path.join(RETROARCH_DIR, f"info/{core}_libretro.info") if system.path_exists(info_file): retro_config = RetroConfig(info_file) try: firmware_count = int(retro_config["firmware_count"]) except (ValueError, TypeError): firmware_count = 0 system_path = self.get_system_directory(retro_config) notes = str(retro_config["notes"] or "") checksums = {} if notes.startswith("(!)"): parts = notes.split("|") for part in parts: try: filename, checksum = part.split(" (md5): ") except ValueError: logger.warning("Unable to parse firmware info: %s", notes) continue filename = filename.replace("(!) ", "") checksums[filename] = checksum # If this requires firmware, confirm we have the firmware folder configured in the first place # then rescan it in case the user added anything since the last time they changed it if firmware_count > 0: lutris_config = LutrisConfig() firmware_directory = lutris_config.raw_system_config.get("bios_path") if not firmware_directory: raise MissingBiosError( _("The emulator files BIOS location must be configured in the Preferences dialog.") ) scan_firmware_directory(firmware_directory) for index in range(firmware_count): required_firmware_filename = retro_config["firmware%d_path" % index] required_firmware_path = os.path.join(system_path, required_firmware_filename) required_firmware_name = required_firmware_filename.split("/")[-1] required_firmware_checksum = checksums[required_firmware_filename] if system.path_exists(required_firmware_path): if required_firmware_filename in checksums: checksum = system.get_md5_hash(required_firmware_path) if checksum == required_firmware_checksum: checksum_status = "Checksum good" else: checksum_status = "Checksum failed" else: checksum_status = "No checksum info" logger.info("Firmware '%s' found (%s)", required_firmware_filename, checksum_status) else: get_firmware(required_firmware_name, required_firmware_checksum, system_path) logger.warning("Firmware '%s' not found!", required_firmware_filename) def get_runner_parameters(self): parameters = [] # Fullscreen fullscreen = self.runner_config.get("fullscreen") if fullscreen: parameters.append("--fullscreen") # Verbose verbose = self.runner_config.get("verbose") if verbose: parameters.append("--verbose") parameters.append("--config={}".format(self.get_config_file())) return parameters def play(self): command = self.get_command() + self.get_runner_parameters() # Core core = self.game_config.get("core") if not core: raise GameConfigError(_("No core has been selected for this game")) command.append("--libretro={}".format(self.get_core_path(core))) # Main file file = self.game_config.get("main_file") if not file: raise GameConfigError(_("No game file specified")) if not system.path_exists(file): raise MissingGameExecutableError(filename=file) command.append(file) return {"command": command} lutris-0.5.19/lutris/runners/rpcs3.py0000664000175000017500000000234614756670027016553 0ustar hibbyhibby# Standard Library from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class rpcs3(Runner): human_name = _("RPCS3") description = _("PlayStation 3 emulator") platforms = [_("Sony PlayStation 3")] runnable_alone = True runner_executable = "rpcs3/rpcs3" flatpak_id = "net.rpcs3.RPCS3" game_options = [ { "option": "main_file", "type": "file", "default_path": "game_path", "label": _("Path to EBOOT.BIN"), } ] runner_options = [{"option": "nogui", "type": "bool", "label": _("No GUI"), "default": False}] # RPCS3 currently uses an AppImage, no need for the runtime. system_options_override = [{"option": "disable_runtime", "default": True}] def play(self): arguments = self.get_command() if self.runner_config.get("nogui"): arguments.append("--no-gui") eboot = self.game_config.get("main_file") or "" if not system.path_exists(eboot): raise MissingGameExecutableError(filename=eboot) arguments.append(eboot) return {"command": arguments} lutris-0.5.19/lutris/runners/vice.py0000664000175000017500000001563014756670027016447 0ustar hibbyhibby# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.exceptions import GameConfigError, MisconfigurationError, MissingExecutableError, MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger class vice(Runner): description = _("Commodore Emulator") human_name = _("Vice") # flatpak_id = "net.sf.VICE" # needs adjustments platforms = [ _("Commodore 64"), _("Commodore 128"), _("Commodore VIC20"), _("Commodore PET"), _("Commodore Plus/4"), _("Commodore CBM II"), ] machine_choices = [ ("C64", "c64"), ("C128", "c128"), ("vic20", "vic20"), ("PET", "pet"), ("Plus/4", "plus4"), ("CBM-II", "cbmii"), ] game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _( "The game data, commonly called a ROM image.\n" "Supported formats: X64, D64, G64, P64, D67, D71, D81, " "D80, D82, D1M, D2M, D4M, T46, P00 and CRT." ), } ] runner_options = [ {"option": "joy", "type": "bool", "label": _("Use joysticks"), "default": False}, { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": False, }, { "option": "double", "type": "bool", "section": _("Graphics"), "label": _("Scale up display by 2"), "default": True, }, { "option": "aspect_ratio", "type": "bool", "section": _("Graphics"), "label": _("Preserve aspect ratio"), "default": True, }, { "option": "renderer", "type": "choice", "section": _("Graphics"), "label": _("Graphics renderer"), "choices": [("OpenGL", "opengl"), (_("Software"), "software")], "default": "opengl", }, { "option": "drivesound", "type": "bool", "label": _("Enable sound emulation of disk drives"), "default": False, }, { "option": "machine", "type": "choice", "label": _("Machine"), "choices": machine_choices, "default": "c64", }, ] def get_platform(self): machine = self.game_config.get("machine") if machine: for index, choice in enumerate(self.machine_choices): if choice[1] == machine: return self.platforms[index] return self.platforms[0] # Default to C64 def get_executable(self, machine: str = None) -> str: if not machine: machine = "c64" executables = { "c64": "x64", "c128": "x128", "vic20": "xvic", "pet": "xpet", "plus4": "xplus4", "cbmii": "xcbm2", } try: executable = executables[machine] exe = os.path.join(settings.RUNNER_DIR, "vice/bin/%s" % executable) if not os.path.isfile(exe): raise MissingExecutableError(_("The executable '%s' could not be found.") % exe) return exe except KeyError as ex: raise MisconfigurationError("Invalid machine '%s'" % machine) from ex def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*args): config_path = system.create_folder("~/.vice") lib_dir = os.path.join(settings.RUNNER_DIR, "vice/lib/vice") if not system.path_exists(lib_dir): lib_dir = os.path.join(settings.RUNNER_DIR, "vice/lib64/vice") if not system.path_exists(lib_dir): logger.error("Missing lib folder in the Vice runner") else: system.merge_folders(lib_dir, config_path) if callback: callback() super().install(install_ui_delegate, version, on_runner_installed) def get_roms_path(self, machine=None): if not machine: machine = "c64" paths = { "c64": "C64", "c128": "C128", "vic20": "VIC20", "pet": "PET", "plus4": "PLUS4", "cmbii": "CBM-II", } root_dir = os.path.dirname(os.path.dirname(self.get_executable())) return os.path.join(root_dir, "lib64/vice", paths[machine]) @staticmethod def get_option_prefix(machine): prefixes = { "c64": "VICII", "c128": "VICII", "vic20": "VIC", "pet": "CRTC", "plus4": "TED", "cmbii": "CRTC", } return prefixes[machine] @staticmethod def get_joydevs(machine): joydevs = {"c64": 2, "c128": 2, "vic20": 1, "pet": 0, "plus4": 2, "cmbii": 0} return joydevs[machine] @staticmethod def get_rom_args(machine, rom): args = [] if rom.endswith(".crt"): crt_option = { "c64": "-cartcrt", "c128": "-cartcrt", "vic20": "-cartgeneric", "pet": None, "plus4": "-cart", "cmbii": None, } if crt_option[machine]: args.append(crt_option[machine]) args.append(rom) return args def play(self): machine = self.runner_config.get("machine") rom = self.game_config.get("main_file") if not rom: raise GameConfigError(_("No rom provided")) if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) params = [self.get_executable(machine)] rom_dir = os.path.dirname(rom) params.append("-chdir") params.append(rom_dir) option_prefix = self.get_option_prefix(machine) if self.runner_config.get("fullscreen"): params.append("-{}full".format(option_prefix)) if self.runner_config.get("double"): params.append("-{}dsize".format(option_prefix)) if self.runner_config.get("renderer"): params.append("-sdl2renderer") params.append(self.runner_config["renderer"]) if not self.runner_config.get("aspect_ratio", True): params.append("-sdlaspectmode") params.append("0") if self.runner_config.get("drivesound"): params.append("-drivesound") if self.runner_config.get("joy"): for dev in range(self.get_joydevs(machine)): params += ["-joydev{}".format(dev + 1), "4"] params.extend(self.get_rom_args(machine, rom)) return {"command": params} lutris-0.5.19/lutris/runners/snes9x.py0000664000175000017500000000570114756670027016750 0ustar hibbyhibby# Standard Library import os import subprocess import xml.etree.ElementTree as etree from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger SNES9X_DIR = os.path.join(settings.DATA_DIR, "runners/snes9x") class snes9x(Runner): description = _("Super Nintendo emulator") human_name = _("Snes9x") platforms = [_("Nintendo SNES")] runnable_alone = True runner_executable = "snes9x/bin/snes9x-gtk" flatpak_id = "com.snes9x.Snes9x" game_options = [ { "option": "main_file", "type": "file", "default_path": "game_path", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ {"option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": "1"}, { "option": "maintain_aspect_ratio", "type": "bool", "section": _("Graphics"), "label": _("Maintain aspect ratio (4:3)"), "default": "1", "help": _( "Super Nintendo games were made for 4:3 " "screens with rectangular pixels, but modern screens " "have square pixels, which results in a vertically " "squeezed image. This option corrects this by displaying " "rectangular pixels." ), }, { "option": "sound_driver", "type": "choice", "label": _("Sound driver"), "advanced": True, "choices": (("SDL", "1"), ("ALSA", "2"), ("OSS", "0")), "default": "1", }, ] def set_option(self, option, value): config_file = os.path.expanduser("~/.snes9x/snes9x.xml") if not system.path_exists(config_file): with subprocess.Popen(self.get_command() + ["-help"]) as snes9x_process: snes9x_process.communicate() if not system.path_exists(config_file): logger.error("Snes9x config file creation failed") return tree = etree.parse(config_file) node = tree.find("./preferences/option[@name='%s']" % option) if node is not None: # some settings are just for Lutris, they won't be in the file if isinstance(value, bool): value = "1" if value else "0" node.attrib["value"] = value tree.write(config_file) def play(self): for option_name in self.config.runner_config: self.set_option(option_name, self.runner_config.get(option_name)) rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) return {"command": self.get_command() + [rom]} lutris-0.5.19/lutris/runners/pcsx2.py0000664000175000017500000000321614756670027016555 0ustar hibbyhibby# Standard Library from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class pcsx2(Runner): human_name = _("PCSX2") description = _("PlayStation 2 emulator") platforms = [_("Sony PlayStation 2")] runnable_alone = True runner_executable = "pcsx2/PCSX2" flatpak_id = "net.pcsx2.PCSX2" game_options = [ { "option": "main_file", "type": "file", "label": _("ISO file"), "default_path": "game_path", } ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": False, }, {"option": "full_boot", "type": "bool", "label": _("Fullboot"), "default": False}, {"option": "nogui", "type": "bool", "label": _("No GUI"), "default": False}, ] # PCSX2 currently uses an AppImage, no need for the runtime. system_options_override = [{"option": "disable_runtime", "default": True}] def play(self): arguments = self.get_command() if self.runner_config.get("fullscreen"): arguments.append("-fullscreen") if self.runner_config.get("full_boot"): arguments.append("-slowboot") if self.runner_config.get("nogui"): arguments.append("-nogui") iso = self.game_config.get("main_file") or "" if not system.path_exists(iso): raise MissingGameExecutableError(filename=iso) arguments.append(iso) return {"command": arguments} lutris-0.5.19/lutris/runners/pico8.py0000664000175000017500000002111114756670027016532 0ustar hibbyhibby"""Runner for the PICO-8 fantasy console""" import json import math import os import shutil from gettext import gettext as _ from time import sleep from lutris import settings from lutris.database.games import get_game_by_field from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.downloader import Downloader from lutris.util.log import logger from lutris.util.strings import split_arguments class pico8(Runner): description = _("Runs PICO-8 fantasy console cartridges") multiple_versions = False human_name = _("PICO-8") platforms = [_("PICO-8")] game_options = [ { "option": "main_file", "type": "file", "label": _("Cartridge file/URL/ID"), "help": _("You can put a .p8.png file path, URL, or BBS cartridge ID here."), } ] runner_options = [ { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": True, "help": _("Launch in fullscreen."), }, { "option": "window_size", "section": _("Graphics"), "label": _("Window size"), "type": "string", "default": "640x512", "help": _("The initial size of the game window."), }, { "option": "splore", "type": "bool", "label": _("Start in splore mode"), "default": False, }, { "option": "args", "type": "string", "label": _("Extra arguments"), "default": "", "help": _("Extra arguments to the executable"), "advanced": True, }, { "option": "engine", "type": "string", "label": _("Engine (web only)"), "default": "pico8_0111g_4", "help": _("Name of engine (will be downloaded) or local file path"), }, ] system_options_override = [{"option": "disable_runtime", "default": True}] runner_executable = "pico8/web.py" def __init__(self, config=None): super().__init__(config) self.runnable_alone = self.is_native @property def is_native(self): return self.runner_config.get("runner_executable", "") != "" @property def engine_path(self): engine = self.runner_config.get("engine") if not engine.lower().endswith(".js") and not os.path.exists(engine): engine = os.path.join( settings.RUNNER_DIR, "pico8/web/engines", self.runner_config.get("engine") + ".js", ) return engine @property def cart_path(self): main_file = self.game_config.get("main_file") if self.is_native and main_file.startswith("http"): return os.path.join(settings.RUNNER_DIR, "pico8/cartridges", "tmp.p8.png") if not os.path.exists(main_file) and main_file.isdigit(): return os.path.join(settings.RUNNER_DIR, "pico8/cartridges", main_file + ".p8.png") return main_file @property def launch_args(self): if self.is_native: args = self.get_command() args.append("-windowed") args.append("0" if self.runner_config.get("fullscreen") else "1") if self.runner_config.get("splore"): args.append("-splore") size = self.runner_config.get("window_size").split("x") if len(size) == 2: args.append("-width") args.append(size[0]) args.append("-height") args.append(size[1]) extra_args = self.runner_config.get("args", "") for arg in split_arguments(extra_args): args.append(arg) else: args = self.get_command() + [ os.path.join(settings.RUNNER_DIR, "pico8/web/player.html"), "--window-size", self.runner_config.get("window_size"), ] return args def get_run_data(self): return {"command": self.launch_args, "env": self.get_env(os_env=False)} def is_installed(self, flatpak_allowed: bool = True) -> bool: """Checks if pico8 runner is installed and if the pico8 executable available.""" if self.is_native and system.path_exists(self.runner_config.get("runner_executable")): return True return system.path_exists(os.path.join(settings.RUNNER_DIR, "pico8/web/player.html")) def prelaunch(self): if not self.game_config.get("main_file") and self.is_installed(): return True if os.path.exists(os.path.join(settings.RUNNER_DIR, "pico8/cartridges", "tmp.p8.png")): os.remove(os.path.join(settings.RUNNER_DIR, "pico8/cartridges", "tmp.p8.png")) # Don't download cartridge if using web backend and cart is url if self.is_native or not self.game_config.get("main_file").startswith("http"): if not os.path.exists(self.game_config.get("main_file")) and ( self.game_config.get("main_file").isdigit() or self.game_config.get("main_file").startswith("http") ): if not self.game_config.get("main_file").startswith("http"): pid = int(self.game_config.get("main_file")) num = math.floor(pid / 10000) downloadUrl = "https://www.lexaloffle.com/bbs/cposts/" + str(num) + "/" + str(pid) + ".p8.png" else: downloadUrl = self.game_config.get("main_file") cartPath = self.cart_path system.create_folder(os.path.dirname(cartPath)) downloadCompleted = False def on_downloaded_cart(): nonlocal downloadCompleted # If we are offline we don't want an empty file to overwrite the cartridge if dl.downloaded_size: shutil.move(cartPath + ".download", cartPath) else: os.remove(cartPath + ".download") downloadCompleted = True dl = Downloader( downloadUrl, cartPath + ".download", True, callback=on_downloaded_cart, ) dl.start() # Wait for download to complete or continue if it exists (to work in offline mode) while not os.path.exists(cartPath): if downloadCompleted or dl.state == Downloader.ERROR: logger.error("Could not download cartridge from %s", downloadUrl) return False sleep(0.1) # Download js engine if not self.is_native and not os.path.exists(self.runner_config.get("engine")): enginePath = os.path.join( settings.RUNNER_DIR, "pico8/web/engines", self.runner_config.get("engine") + ".js", ) if not os.path.exists(enginePath): downloadUrl = "https://www.lexaloffle.com/bbs/" + self.runner_config.get("engine") + ".js" system.create_folder(os.path.dirname(enginePath)) dl = Downloader(downloadUrl, enginePath, True) dl.start() dl.join() def play(self): launch_info = {} launch_info["env"] = self.get_env(os_env=False) game_data = get_game_by_field(self.config.game_config_id, "configpath") command = self.launch_args if self.is_native: if not self.runner_config.get("splore"): command.append("-run") cartPath = self.cart_path if not os.path.exists(cartPath): raise MissingGameExecutableError(filename=cartPath) command.append(cartPath) else: command.append("--name") command.append(game_data.get("name") + " - PICO-8") # icon = datapath.get_icon_path(game_data.get("slug")) # if icon: # command.append("--icon") # command.append(icon) webargs = { "cartridge": self.cart_path, "engine": self.engine_path, "fullscreen": self.runner_config.get("fullscreen") is True, } command.append("--execjs") command.append("load_config(" + json.dumps(webargs) + ")") launch_info["command"] = command return launch_info lutris-0.5.19/lutris/runners/scummvm.py0000664000175000017500000004676714756670027017227 0ustar hibbyhibbyimport os import subprocess from gettext import gettext as _ from typing import Any, Dict, List, Optional from lutris import settings from lutris.config import LutrisConfig from lutris.exceptions import MissingExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.strings import split_arguments _supported_scale_factors = { "hq": ["2", "3"], "edge": ["2", "3"], "advmame": ["2", "3"], "sai": ["2"], "supersai": ["2"], "supereagle": ["2"], "dotmatrix": ["2"], "tv2x": ["2"], } def _get_opengl_warning(_option_key: str, config: LutrisConfig) -> Optional[str]: runner_config = config.runner_config if "scaler" in runner_config and "renderer" in runner_config: renderer = runner_config["renderer"] if renderer and renderer != "software": scaler = runner_config["scaler"] if scaler and scaler != "normal": return _("Warning Scalers may not work with OpenGL rendering.") return None def _get_scale_factor_warning(_option_key: str, config: LutrisConfig) -> Optional[str]: """Generate a warning message for when the scaler and scale-factor can't be used together.""" runner_config = config.runner_config if "scaler" in runner_config and "scale-factor" in runner_config: scaler = runner_config["scaler"] if scaler in _supported_scale_factors: scale_factor = runner_config["scale-factor"] if scale_factor not in _supported_scale_factors[scaler]: return _("Warning The '%s' scaler does not work with a scale factor of %s.") % ( scaler, scale_factor, ) return None class scummvm(Runner): description = _("Engine for point-and-click games.") human_name = _("ScummVM") platforms = [_("Linux")] runnable_alone = True runner_executable = "scummvm/bin/scummvm" flatpak_id = "org.scummvm.ScummVM" game_options = [ {"option": "game_id", "type": "string", "label": _("Game identifier")}, {"option": "path", "type": "directory", "label": _("Game files location")}, { "option": "args", "type": "string", "label": _("Arguments"), "help": _("Command line arguments used when launching the game"), }, ] option_map = { "aspect": "--aspect-ratio", "subtitles": "--subtitles", "fullscreen": "--fullscreen", "scaler": "--scaler=%s", "scale-factor": "--scale-factor=%s", "renderer": "--renderer=%s", "render-mode": "--render-mode=%s", "stretch-mode": "--stretch-mode=%s", "filtering": "--filtering", "platform": "--platform=%s", "engine-speed": "--engine-speed=%s", "talk-speed": "--talkspeed=%s", "dimuse-tempo": "--dimuse-tempo=%s", "music-tempo": "--tempo=%s", "opl-driver": "--opl-driver=%s", "output-rate": "--output-rate=%s", "music-driver": "--music-driver=%s", "multi-midi": "--multi-midi", "midi-gain": "--midi-gain=%s", "soundfont": "--soundfont=%s", "music-volume": "--music-volume=%s", "sfx-volume": "--sfx-volume=%s", "speech-volume": "--speech-volume=%s", "native-mt32": "--native-mt32", "enable-gs": "--enable-gs", "joystick": "--joystick=%s", "language": "--language=%s", "alt-intro": "--alt-intro", "copy-protection": "--copy-protection", "demo-mode": "--demo-mode", "debug-level": "--debug-level=%s", "debug-flags": "--debug-flags=%s", } option_empty_map = {"fullscreen": "--no-fullscreen"} runner_options = [ { "option": "fullscreen", "section": _("Graphics"), "label": _("Fullscreen"), "type": "bool", "default": True, }, { "option": "subtitles", "section": _("Graphics"), "label": _("Enable subtitles"), "type": "bool", "default": False, "help": ("Enable subtitles for games with voice"), }, { "option": "aspect", "section": _("Graphics"), "label": _("Aspect ratio correction"), "type": "bool", "default": True, "help": _( "Most games supported by ScummVM were made for VGA " "display modes using rectangular pixels. Activating " "this option for these games will preserve the 4:3 " "aspect ratio they were made for." ), }, { "option": "scaler", "section": _("Graphics"), "label": _("Graphic scaler"), "type": "choice", "default": "normal", "choices": [ ("normal", "normal"), ("hq", "hq"), ("edge", "edge"), ("advmame", "advmame"), ("sai", "sai"), ("supersai", "supersai"), ("supereagle", "supereagle"), ("pm", "pm"), ("dotmatrix", "dotmatrix"), ("tv2x", "tv2x"), ], "warning": _get_opengl_warning, "help": _( "The algorithm used to scale up the game's base " "resolution, resulting in different visual styles. " ), }, { "option": "scale-factor", "section": _("Graphics"), "label": _("Scale factor"), "type": "choice", "default": "3", "choices": [ ("1", "1"), ("2", "2"), ("3", "3"), ("4", "4"), ("5", "5"), ], "help": _( "Changes the resolution of the game. " "For example, a 2x scale will take a 320x200 " "resolution game and scale it up to 640x400. " ), "warning": _get_scale_factor_warning, }, { "option": "renderer", "section": _("Graphics"), "label": _("Renderer"), "type": "choice", "choices": [ (_("Auto"), ""), (_("Software"), "software"), (_("OpenGL"), "opengl"), (_("OpenGL (with shaders)"), "opengl_shaders"), ], "default": "", "advanced": True, "help": _("Changes the rendering method used for 3D games."), }, { "option": "render-mode", "section": _("Graphics"), "label": _("Render mode"), "type": "choice", "choices": [ (_("Auto"), ""), (_("Hercules (Green)"), "hercGreen"), (_("Hercules (Amber)"), "hercAmber"), (_("CGA"), "cga"), (_("EGA"), "ega"), (_("VGA"), "vga"), (_("Amiga"), "amiga"), (_("FM Towns"), "fmtowns"), (_("PC-9821"), "pc9821"), (_("PC-9801"), "pc9801"), (_("Apple IIgs"), "2gs"), (_("Atari ST"), "atari"), (_("Macintosh"), "macintosh"), ], "default": "", "advanced": True, "help": _("Changes the graphics hardware the game will target, if the game supports this."), }, { "option": "stretch-mode", "section": _("Graphics"), "label": _("Stretch mode"), "type": "choice", "choices": [ (_("Auto"), ""), (_("Center"), "center"), (_("Pixel Perfect"), "pixel-perfect"), (_("Even Pixels"), "even-pixels"), (_("Stretch"), "stretch"), (_("Fit"), "fit"), (_("Fit (force aspect ratio)"), "fit_force_aspect"), ], "default": "", "advanced": True, "help": _("Changes how the game is placed when the window is resized."), }, { "option": "filtering", "section": _("Graphics"), "label": _("Filtering"), "type": "bool", "help": _( "Uses bilinear interpolation instead of nearest neighbor " "resampling for the aspect ratio correction and stretch mode." ), "default": False, "advanced": True, }, { "option": "datadir", "label": _("Data directory"), "type": "directory", "help": _("Defaults to share/scummvm if unspecified."), "advanced": True, }, { "option": "platform", "type": "string", "label": _("Platform"), "help": _( "Specifes platform of game. Allowed values: 2gs, 3do, acorn, amiga, atari, c64, " "fmtowns, nes, mac, pc pc98, pce, segacd, wii, windows" ), "advanced": True, }, { "option": "joystick", "type": "string", "label": _("Joystick"), "help": _("Enables joystick input (default: 0 = first joystick)"), "advanced": True, }, { "option": "language", "type": "string", "label": _("Language"), "help": _("Selects language (en, de, fr, it, pt, es, jp, zh, kr, se, gb, hb, ru, cz)"), "advanced": True, }, { "option": "engine-speed", "type": "string", "label": _("Engine speed"), "help": _( "Sets frames per second limit (0 - 100) for Grim Fandango " "or Escape from Monkey Island (default: 60)." ), "advanced": True, }, { "option": "talk-speed", "type": "string", "label": _("Talk speed"), "help": _("Sets talk speed for games (default: 60)"), "advanced": True, }, { "option": "music-tempo", "type": "string", "section": _("Audio"), "label": _("Music tempo"), "help": _("Sets music tempo (in percent, 50-200) for SCUMM games (default: 100)"), "advanced": True, }, { "option": "dimuse-tempo", "type": "string", "section": _("Audio"), "label": _("Digital iMuse tempo"), "help": _("Sets internal Digital iMuse tempo (10 - 100) per second (default: 10)"), "advanced": True, }, { "option": "music-driver", "section": _("Audio"), "label": _("Music driver"), "type": "choice", "choices": [ ("null", "null"), ("auto", "auto"), ("seq", "seq"), ("sndio", "sndio"), ("alsa", "alsa"), ("fluidsynth", "fluidsynth"), ("mt32", "mt32"), ("adlib", "adlib"), ("pcspk", "pcspk"), ("pcjr", "pcjr"), ("cms", "cms"), ("timidity", "timidity"), ], "help": _("Specifies the device ScummVM uses to output audio."), "advanced": True, }, { "option": "output-rate", "section": _("Audio"), "label": _("Output rate"), "type": "choice", "choices": [ ("11025", "11025"), ("22050", "22050"), ("44100", "44100"), ], "help": _("Selects output sample rate in Hz."), "advanced": True, }, { "option": "opl-driver", "section": _("Audio"), "label": _("OPL driver"), "type": "choice", "choices": [ ("auto", "auto"), ("mame", "mame"), ("db", "db"), ("nuked", "nuked"), ("alsa", "alsa"), ("op2lpt", "op2lpt"), ("op3lpt", "op3lpt"), ("rwopl3", "rwopl3"), ], "help": _( "Chooses which emulator is used by ScummVM when the AdLib emulator " "is chosen as the Preferred device." ), "advanced": True, }, { "option": "music-volume", "type": "string", "section": _("Audio"), "label": _("Music volume"), "help": _("Sets the music volume, 0-255 (default: 192)"), "advanced": True, }, { "option": "sfx-volume", "type": "string", "section": _("Audio"), "label": _("SFX volume"), "help": _("Sets the sfx volume, 0-255 (default: 192)"), "advanced": True, }, { "option": "speech-volume", "type": "string", "section": _("Audio"), "label": _("Speech volume"), "help": _("Sets the speech volume, 0-255 (default: 192)"), "advanced": True, }, { "option": "midi-gain", "type": "string", "section": _("Audio"), "label": _("MIDI gain"), "help": _("Sets the gain for MIDI playback. 0-1000 (default: 100)"), "advanced": True, }, { "option": "soundfont", "section": _("Audio"), "type": "string", "label": _("Soundfont"), "help": _("Specifies the path to a soundfont file."), "advanced": True, }, { "option": "multi-midi", "section": _("Audio"), "label": _("Mixed AdLib/MIDI mode"), "type": "bool", "default": False, "help": _("Combines MIDI music with AdLib sound effects."), "advanced": True, }, { "option": "native-mt32", "section": _("Audio"), "label": _("True Roland MT-32"), "type": "bool", "default": False, "help": _( "Tells ScummVM that the MIDI device is an actual Roland MT-32, " "LAPC-I, CM-64, CM-32L, CM-500 or other MT-32 device." ), "advanced": True, }, { "option": "enable-gs", "section": _("Audio"), "label": _("Enable Roland GS"), "type": "bool", "default": False, "help": _( "Tells ScummVM that the MIDI device is a GS device that has " "an MT-32 map, such as an SC-55, SC-88 or SC-8820." ), "advanced": True, }, { "option": "alt-intro", "type": "bool", "label": _("Use alternate intro"), "help": _("Uses alternative intro for CD versions"), "advanced": True, }, { "option": "copy-protection", "type": "bool", "label": _("Copy protection"), "help": _("Enables copy protection"), "advanced": True, }, { "option": "demo-mode", "type": "bool", "label": _("Demo mode"), "help": _("Starts demo mode of Maniac Mansion or The 7th Guest"), "advanced": True, }, { "option": "debug-level", "type": "string", "section": _("Debugging"), "label": _("Debug level"), "help": _("Sets debug verbosity level"), "advanced": True, }, { "option": "debug-flags", "type": "string", "section": _("Debugging"), "label": _("Debug flags"), "help": _("Enables engine specific debug flags"), "advanced": True, }, ] @property def game_path(self): return self.game_config.get("path") def get_extra_libs(self) -> List[str]: """Scummvm runner ships additional libraries, they may be removed in a future version.""" try: base_runner_path = os.path.join(settings.RUNNER_DIR, "scummvm") if self.get_executable().startswith(base_runner_path): path = os.path.join(settings.RUNNER_DIR, "scummvm/lib") if system.path_exists(path): return [path] except MissingExecutableError: pass return [] def get_command(self) -> List[str]: command = super().get_command() if not command: return [] if "flatpak" in command[0]: return command data_dir = self.get_scummvm_data_dir() return command + [ "--extrapath=%s" % data_dir, "--themepath=%s" % data_dir, ] def get_scummvm_data_dir(self) -> str: data_dir = self.runner_config.get("datadir") if data_dir is None: root_dir = os.path.dirname(os.path.dirname(self.get_executable())) data_dir = os.path.join(root_dir, "share/scummvm") return data_dir def get_run_data(self) -> Dict[str, Any]: env = self.get_env() lib_paths = filter(None, self.get_extra_libs() + [env.get("LD_LIBRARY_PATH")]) if lib_paths: env["LD_LIBRARY_PATH"] = os.pathsep.join(lib_paths) return {"env": env, "command": self.get_command()} def inject_runner_option(self, command, key, cmdline, cmdline_empty=None): value = self.runner_config.get(key) if value: if "%s" in cmdline: command.append(cmdline % value) else: command.append(cmdline) elif cmdline_empty: command.append(cmdline_empty) def play(self): command = self.get_command() for option, cmdline in self.option_map.items(): self.inject_runner_option(command, option, cmdline, self.option_empty_map.get(option)) command.append("--path=%s" % self.game_path) args = self.game_config.get("args") or "" for arg in split_arguments(args): command.append(arg) command.append(self.game_config.get("game_id")) output = {"command": command} extra_libs = self.get_extra_libs() if extra_libs: output["ld_library_path"] = os.pathsep.join(extra_libs) return output def get_game_list(self) -> List[List[str]]: """Return the entire list of games supported by ScummVM.""" with subprocess.Popen( self.get_command() + ["--list-games"], stdout=subprocess.PIPE, encoding="utf-8", universal_newlines=True ) as scummvm_process: scumm_output = scummvm_process.communicate()[0] game_list = str.split(scumm_output, "\n") game_array = [] game_list_start = False for game in game_list: if game_list_start: if len(game) > 1: dir_limit = game.index(" ") else: dir_limit = None if dir_limit is not None: game_dir = game[0:dir_limit] game_name = game[dir_limit + 1 : len(game)].strip() game_array.append([game_dir, game_name]) # The actual list is below a separator if game.startswith("-----"): game_list_start = True return game_array lutris-0.5.19/lutris/runners/openmsx.py0000664000175000017500000000150414756670027017205 0ustar hibbyhibby# Standard Library from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class openmsx(Runner): human_name = _("openMSX") description = _("MSX computer emulator") platforms = [_("MSX, MSX2, MSX2+, MSX turboR")] flatpak_id = "org.openmsx.openMSX" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image."), } ] def play(self): rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) return {"command": self.get_command() + [rom]} lutris-0.5.19/lutris/runners/dolphin.py0000664000175000017500000000434014756670027017152 0ustar hibbyhibby"""Dolphin runner""" from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system PLATFORMS = [_("Nintendo GameCube"), _("Nintendo Wii")] class dolphin(Runner): description = _("GameCube and Wii emulator") human_name = _("Dolphin") platforms = PLATFORMS require_libs = [ "libOpenGL.so.0", ] runnable_alone = True runner_executable = "dolphin/dolphin-emu" flatpak_id = "org.DolphinEmu.dolphin-emu" game_options = [ { "option": "main_file", "type": "file", "default_path": "game_path", "label": _("ISO file"), }, { "option": "platform", "type": "choice", "label": _("Platform"), "choices": ((_("Nintendo GameCube"), "0"), (_("Nintendo Wii"), "1")), }, ] runner_options = [ { "option": "batch", "type": "bool", "label": _("Batch"), "default": True, "advanced": True, "help": _("Exit Dolphin with emulator."), }, { "option": "user_directory", "type": "directory", "warn_if_non_writable_parent": True, "advanced": True, "label": _("Custom Global User Directory"), }, ] def get_platform(self): selected_platform = self.game_config.get("platform") if selected_platform: return self.platforms[int(selected_platform)] return "" def play(self): command = self.get_command() # Batch isn't available in nogui if self.runner_config.get("batch"): command.append("--batch") # Custom Global User Directory if self.runner_config.get("user_directory"): command.append("-u") command.append(self.runner_config["user_directory"]) # Retrieve the path to the file iso = self.game_config.get("main_file") or "" if not system.path_exists(iso): raise MissingGameExecutableError(filename=iso) command.extend(["-e", iso]) return {"command": command} lutris-0.5.19/lutris/runners/mednafen.py0000664000175000017500000004121114756670027017270 0ustar hibbyhibby# Standard Library import subprocess from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system from lutris.util.display import DISPLAY_MANAGER from lutris.util.joypad import get_controller_mappings from lutris.util.log import logger DEFAULT_MEDNAFEN_SCALER = "nn4x" class mednafen(Runner): human_name = _("Mednafen") description = _("Multi-system emulator: NES, PC Engine, PSX…") platforms = [ _("Nintendo Game Boy (Color)"), _("Nintendo Game Boy Advance"), _("Sega Game Gear"), _("Sega Genesis/Mega Drive"), _("Atari Lynx"), _("Sega Master System"), _("SNK Neo Geo Pocket (Color)"), _("Nintendo NES"), _("NEC PC Engine TurboGrafx-16"), _("NEC PC-FX"), _("Sony PlayStation"), _("Sega Saturn"), _("Nintendo SNES"), _("Bandai WonderSwan"), _("Nintendo Virtual Boy"), ] machine_choices = ( (_("Game Boy (Color)"), "gb"), (_("Game Boy Advance"), "gba"), (_("Game Gear"), "gg"), (_("Genesis/Mega Drive"), "md"), (_("Lynx"), "lynx"), (_("Master System"), "sms"), (_("Neo Geo Pocket (Color)"), "gnp"), (_("NES"), "nes"), (_("PC Engine"), "pce_fast"), (_("PC-FX"), "pcfx"), (_("PlayStation"), "psx"), (_("Saturn"), "ss"), (_("SNES"), "snes"), (_("WonderSwan"), "wswan"), (_("Virtual Boy"), "vb"), ) runner_executable = "mednafen/bin/mednafen" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _( "The game data, commonly called a ROM image. \n" "Mednafen supports GZIP and ZIP compressed ROMs." ), }, { "option": "machine", "type": "choice", "label": _("Machine type"), "choices": machine_choices, "help": _("The emulated machine."), }, ] runner_options = [ {"option": "fs", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": False}, { "option": "stretch", "type": "choice", "section": _("Graphics"), "label": _("Aspect ratio"), "choices": ( (_("Disabled"), "0"), (_("Stretched"), "full"), (_("Preserve aspect ratio"), "aspect"), (_("Integer scale"), "aspect_int"), (_("Multiple of 2 scale"), "aspect_mult2"), ), "default": "aspect_int", }, { "option": "scaler", "type": "choice", "section": _("Graphics"), "label": _("Video scaler"), "choices": ( ("none", "none"), ("hq2x", "hq2x"), ("hq3x", "hq3x"), ("hq4x", "hq4x"), ("scale2x", "scale2x"), ("scale3x", "scale3x"), ("scale4x", "scale4x"), ("2xsai", "2xsai"), ("super2xsai", "super2xsai"), ("supereagle", "supereagle"), ("nn2x", "nn2x"), ("nn3x", "nn3x"), ("nn4x", "nn4x"), ("nny2x", "nny2x"), ("nny3x", "nny3x"), ("nny4x", "nny4x"), ), "default": DEFAULT_MEDNAFEN_SCALER, }, { "option": "sound_device", "type": "choice", "label": _("Sound device"), "choices": ( (_("Mednafen default"), "default"), (_("ALSA default"), "sexyal-literal-default"), ("hw:0", "hw:0,0"), ("hw:1", "hw:1,0"), ("hw:2", "hw:2,0"), ), "default": "sexyal-literal-default", }, { "option": "dont_map_controllers", "type": "bool", "label": _("Use default Mednafen controller configuration"), "default": False, }, ] def get_platform(self): machine = self.game_config.get("machine") if machine: for index, choice in enumerate(self.machine_choices): if choice[1] == machine: return self.platforms[index] return "" def find_joysticks(self): """Detect connected joysticks and return their ids""" joy_ids = [] if not self.is_installed: return [] with subprocess.Popen( [self.get_executable(), "dummy"], stdout=subprocess.PIPE, universal_newlines=True, ) as mednafen_process: output = mednafen_process.communicate()[0].split("\n") found = False joy_list = [] for line in output: if found and "Joystick" in line: joy_list.append(line) else: found = False if "Initializing joysticks" in line: found = True for joy in joy_list: index = joy.find("Unique ID:") joy_id = joy[index + 11 :] logger.debug("Joystick found id %s ", joy_id) joy_ids.append(joy_id) return joy_ids @staticmethod def set_joystick_controls(joy_ids, machine): """Setup joystick mappings per machine""" # Get the controller mappings controller_mappings = get_controller_mappings() if not controller_mappings: logger.warning("No controller detected for joysticks %s.", joy_ids) return [] # TODO currently only supports the first controller. Add support for other controllers. mapping = controller_mappings[0][1] # Construct a dictionnary of button codes to parse to mendafen map_code = { "a": "", "b": "", "c": "", "x": "", "y": "", "z": "", "back": "", "start": "", "leftshoulder": "", "rightshoulder": "", "lefttrigger": "", "righttrigger": "", "leftstick": "", "rightstick": "", "select": "", "shoulder_l": "", "shoulder_r": "", "i": "", "ii": "", "iii": "", "iv": "", "v": "", "vi": "", "run": "", "ls": "", "rs": "", "fire1": "", "fire2": "", "option_1": "", "option_2": "", "cross": "", "circle": "", "square": "", "triangle": "", "r1": "", "r2": "", "l1": "", "l2": "", "option": "", "l": "", "r": "", "right-x": "", "right-y": "", "left-x": "", "left-y": "", "up-x": "", "up-y": "", "down-x": "", "down-y": "", "up-l": "", "up-r": "", "down-l": "", "down-r": "", "left-l": "", "left-r": "", "right-l": "", "right-r": "", "lstick_up": "0000c001", "lstick_down": "00008001", "lstick_right": "00008000", "lstick_left": "0000c000", "rstick_up": "0000c003", "rstick_down": "00008003", "rstick_left": "0000c002", "rstick_right": "00008002", "dpup": "0000c005", "dpdown": "00008005", "dpleft": "0000c004", "dpright": "00008004", } # Insert the button mapping number into the map_codes for button in mapping.keys: bttn_id = mapping.keys[button] if bttn_id[0] == "b": # it's a button map_code[button] = "000000" + bttn_id[1:].zfill(2) # Duplicate button names that are emulated in mednanfen map_code["up"] = map_code["dpup"] # map_code["down"] = map_code["dpdown"] # map_code["left"] = map_code["dpleft"] # Multiple systems map_code["right"] = map_code["dpright"] map_code["select"] = map_code["back"] # map_code["shoulder_r"] = map_code["rightshoulder"] # GBA map_code["shoulder_l"] = map_code["leftshoulder"] # map_code["i"] = map_code["b"] # map_code["ii"] = map_code["a"] # map_code["iii"] = map_code["leftshoulder"] map_code["iv"] = map_code["y"] # PCEngine and PCFX map_code["v"] = map_code["x"] # map_code["vi"] = map_code["rightshoulder"] map_code["run"] = map_code["start"] # map_code["ls"] = map_code["leftshoulder"] # map_code["rs"] = map_code["rightshoulder"] # Saturn map_code["c"] = map_code["righttrigger"] # map_code["z"] = map_code["lefttrigger"] # map_code["fire1"] = map_code["a"] # Master System map_code["fire2"] = map_code["b"] # map_code["option_1"] = map_code["x"] # Lynx map_code["option_2"] = map_code["y"] # map_code["r1"] = map_code["rightshoulder"] # map_code["r2"] = map_code["righttrigger"] # map_code["l1"] = map_code["leftshoulder"] # map_code["l2"] = map_code["lefttrigger"] # PlayStation map_code["cross"] = map_code["a"] # map_code["circle"] = map_code["b"] # map_code["square"] = map_code["x"] # map_code["triangle"] = map_code["y"] # map_code["option"] = map_code["select"] # NeoGeo pocket map_code["l"] = map_code["leftshoulder"] # SNES map_code["r"] = map_code["rightshoulder"] # map_code["right-x"] = map_code["dpright"] # map_code["left-x"] = map_code["dpleft"] # map_code["up-x"] = map_code["dpup"] # map_code["down-x"] = map_code["dpdown"] # Wonder Swan map_code["right-y"] = map_code["lstick_right"] map_code["left-y"] = map_code["lstick_left"] # map_code["up-y"] = map_code["lstick_up"] # map_code["down-y"] = map_code["lstick_down"] # map_code["up-l"] = map_code["dpup"] # map_code["down-l"] = map_code["dpdown"] # map_code["left-l"] = map_code["dpleft"] # map_code["right-l"] = map_code["dpright"] # map_code["up-r"] = map_code["rstick_up"] # map_code["down-r"] = map_code["rstick_down"] # Virtual boy map_code["left-r"] = map_code["rstick_left"] # map_code["right-r"] = map_code["rstick_right"] # map_code["lt"] = map_code["leftshoulder"] # map_code["rt"] = map_code["rightshoulder"] # # Define which buttons to use for each machine layout = { "nes": ["a", "b", "start", "select", "up", "down", "left", "right"], "gb": ["a", "b", "start", "select", "up", "down", "left", "right"], "gba": [ "a", "b", "shoulder_r", "shoulder_l", "start", "select", "up", "down", "left", "right", ], "pce": [ "i", "ii", "iii", "iv", "v", "vi", "run", "select", "up", "down", "left", "right", ], "ss": [ "a", "b", "c", "x", "y", "z", "ls", "rs", "start", "up", "down", "left", "right", ], "gg": ["button1", "button2", "start", "up", "down", "left", "right"], "md": [ "a", "b", "c", "x", "y", "z", "start", "up", "down", "left", "right", ], "sms": ["fire1", "fire2", "up", "down", "left", "right"], "lynx": ["a", "b", "option_1", "option_2", "up", "down", "left", "right"], "psx": [ "cross", "circle", "square", "triangle", "l1", "l2", "r1", "r2", "start", "select", "lstick_up", "lstick_down", "lstick_right", "lstick_left", "rstick_up", "rstick_down", "rstick_left", "rstick_right", "up", "down", "left", "right", ], "pcfx": [ "i", "ii", "iii", "iv", "v", "vi", "run", "select", "up", "down", "left", "right", ], "ngp": ["a", "b", "option", "up", "down", "left", "right"], "snes": [ "a", "b", "x", "y", "l", "r", "start", "select", "up", "down", "left", "right", ], "wswan": [ "a", "b", "right-x", "right-y", "left-x", "left-y", "up-x", "up-y", "down-x", "down-y", "start", ], "vb": [ "up-l", "down-l", "left-l", "right-l", "up-r", "down-r", "left-r", "right-r", "a", "b", "lt", "rt", ], } # Select a the gamepad type controls = [] if machine in ["gg", "lynx", "wswan", "gb", "gba", "vb"]: gamepad = "builtin.gamepad" elif machine in ["md"]: gamepad = "port1.gamepad6" controls.append("-md.input.port1") controls.append("gamepad6") elif machine in ["psx"]: gamepad = "port1.dualshock" controls.append("-psx.input.port1") controls.append("dualshock") else: gamepad = "port1.gamepad" # Construct the controlls options for button in layout[machine]: controls.append("-{}.input.{}.{}".format(machine, gamepad, button)) controls.append("joystick {} {}".format(joy_ids[0], map_code[button])) return controls def play(self): """Runs the game""" rom = self.game_config.get("main_file") or "" machine = self.game_config.get("machine") or "" fullscreen = self.runner_config.get("fs") or "0" if fullscreen is True: fullscreen = "1" elif fullscreen is False: fullscreen = "0" stretch = self.runner_config.get("stretch") or "0" scaler = self.runner_config.get("scaler") or DEFAULT_MEDNAFEN_SCALER sound_device = self.runner_config.get("sound_device") xres, yres = DISPLAY_MANAGER.get_current_resolution() options = [ "-fs", fullscreen, "-force_module", machine, "-sound.device", sound_device, "-" + machine + ".xres", xres, "-" + machine + ".yres", yres, "-" + machine + ".stretch", stretch, "-" + machine + ".special", scaler, "-" + machine + ".videoip", "1", ] joy_ids = self.find_joysticks() dont_map_controllers = self.runner_config.get("dont_map_controllers") if joy_ids and not dont_map_controllers: controls = self.set_joystick_controls(joy_ids, machine) for control in controls: options.append(control) if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) command = self.get_command() for option in options: command.append(option) command.append(rom) return {"command": command} lutris-0.5.19/lutris/runners/zdoom.py0000664000175000017500000001314714756670027016652 0ustar hibbyhibbyimport os from gettext import gettext as _ from lutris.runners.runner import Runner from lutris.util import display from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.strings import split_arguments class zdoom(Runner): # http://zdoom.org/wiki/Command_line_parameters description = _("ZDoom DOOM Game Engine") human_name = _("ZDoom") platforms = [_("Linux")] runner_executable = "zdoom/gzdoom" flatpak_id = "org.zdoom.GZDoom" game_options = [ { "option": "main_file", "type": "file", "label": _("WAD file"), "help": _("The game data, commonly called a WAD file."), }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _("Command line arguments used when launching the game."), }, { "option": "files", "type": "multiple_file", "label": _("PWAD files"), "help": _("Used to load one or more PWAD files which generally contain " "user-created levels."), }, { "option": "warp", "type": "string", "label": _("Warp to map"), "help": _("Starts the game on the given map."), }, { "option": "savedir", "type": "directory", "label": _("Save path"), "warn_if_non_writable_parent": True, "help": _("User-specified path where save files should be located."), }, ] runner_options = [ {"option": "2", "label": _("Pixel Doubling"), "type": "bool", "default": False}, {"option": "4", "label": _("Pixel Quadrupling"), "type": "bool", "default": False}, { "option": "nostartup", "label": _("Disable Startup Screens"), "type": "bool", "default": False, }, { "option": "skill", "label": _("Skill"), "type": "choice", "default": "", "choices": { (_("None"), ""), (_("I'm Too Young To Die (1)"), "1"), (_("Hey, Not Too Rough (2)"), "2"), (_("Hurt Me Plenty (3)"), "3"), (_("Ultra-Violence (4)"), "4"), (_("Nightmare! (5)"), "5"), }, }, { "option": "config", "label": _("Config file"), "type": "file", "help": _( "Used to load a user-created configuration file. If specified, " "the file must contain the wad directory list or launch will fail." ), }, ] def prelaunch(self): if not LINUX_SYSTEM.get_soundfonts(): logger.warning("FluidSynth is not installed, you might not have any music") @property def working_dir(self): wad = self.game_config.get("main_file") if wad: return os.path.dirname(os.path.expanduser(wad)) wad_files = self.game_config.get("files") if wad_files: return os.path.dirname(os.path.expanduser(wad_files[0])) def play(self): # noqa: C901 command = self.get_command() resolution = self.runner_config.get("resolution") if resolution: if resolution == "desktop": width, height = display.DISPLAY_MANAGER.get_current_resolution() else: width, height = resolution.split("x") command.append("-width") command.append(width) command.append("-height") command.append(height) # Append any boolean options. bool_options = ["2", "4", "nostartup"] for option in bool_options: if self.runner_config.get(option): command.append("-%s" % option) # Append the skill level. skill = self.runner_config.get("skill") if skill: command.append("-skill") command.append(skill) # Append directory for configuration file, if provided. config = self.runner_config.get("config") if config: command.append("-config") command.append(config) # Append the warp arguments. warp = self.game_config.get("warp") if warp: command.append("-warp") for warparg in warp.split(" "): command.append(warparg) # Append directory for save games, if provided. savedir = self.game_config.get("savedir") if savedir: command.append("-savedir") command.append(savedir) # Append the wad file to load, if provided. wad = self.game_config.get("main_file") if wad: command.append("-iwad") command.append(wad) # Append the pwad files to load, if provided. files = self.game_config.get("files") or [] pwads = [f for f in files if f.lower().endswith(".wad") or f.lower().endswith(".pk3")] deh = [f for f in files if f.lower().endswith(".deh")] bex = [f for f in files if f.lower().endswith(".bex")] if deh: command.append("-deh") command.append(deh[0]) if bex: command.append("-bex") command.append(bex[0]) if pwads: command.append("-file") for pwad in pwads: command.append(pwad) # Append additional arguments, if provided. args = self.game_config.get("args") or "" for arg in split_arguments(args): command.append(arg) return {"command": command} lutris-0.5.19/lutris/runners/wine.py0000664000175000017500000014650614756670027016472 0ustar hibbyhibby"""Wine runner""" # pylint: disable=too-many-lines import os import shlex from gettext import gettext as _ from typing import Any, Dict, Iterable, List, Optional, Set, Tuple from lutris import runtime, settings from lutris.api import format_runner_version, normalize_version_architecture from lutris.config import LutrisConfig from lutris.database.games import get_game_by_field from lutris.exceptions import ( EsyncLimitError, FsyncUnsupportedError, MisconfigurationError, MissingExecutableError, MissingGameExecutableError, UnspecifiedVersionError, ) from lutris.game import Game from lutris.gui.dialogs import FileDialog from lutris.runners.commands.wine import ( # noqa: F401 pylint: disable=unused-import create_prefix, delete_registry_key, eject_disc, install_cab_component, open_wine_terminal, set_regedit, set_regedit_file, winecfg, wineexec, winekill, winetricks, ) from lutris.runners.runner import Runner from lutris.util import system from lutris.util.display import DISPLAY_MANAGER, get_default_dpi from lutris.util.graphics import drivers, vkquery from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.process import Process from lutris.util.strings import split_arguments from lutris.util.wine import proton from lutris.util.wine.d3d_extras import D3DExtrasManager from lutris.util.wine.dgvoodoo2 import dgvoodoo2Manager from lutris.util.wine.dxvk import REQUIRED_VULKAN_API_VERSION, DXVKManager from lutris.util.wine.dxvk_nvapi import DXVKNVAPIManager from lutris.util.wine.extract_icon import PEFILE_AVAILABLE, ExtractIcon from lutris.util.wine.prefix import DEFAULT_DLL_OVERRIDES, WinePrefixManager, find_prefix from lutris.util.wine.vkd3d import VKD3DManager from lutris.util.wine.wine import ( WINE_DEFAULT_ARCH, WINE_PATHS, detect_arch, get_default_wine_runner_version_info, get_default_wine_version, get_installed_wine_versions, get_overrides_env, get_real_executable, get_runner_files_dir_for_version, get_system_wine_version, get_wine_path_for_version, is_esync_limit_set, is_fsync_supported, is_gstreamer_build, ) def _is_pre_proton(_option_key: str, config: LutrisConfig) -> bool: version = config.runner_config.get("version") return not proton.is_proton_version(version) def _get_version_warning(_option_key: str, config: LutrisConfig) -> Optional[str]: arch = config.game_config.get("arch") version = config.runner_config.get("version") if arch == "win32" and proton.is_proton_version(version): return _("Proton is not compatible with 32-bit prefixes.") return None def _get_prefix_warning(_option_key: str, config: LutrisConfig) -> Optional[str]: game_config = config.game_config if game_config.get("prefix"): return None exe = game_config.get("exe") if exe and find_prefix(exe): return None return _("Warning Some Wine configuration options cannot be applied, if no prefix can be found.") def _get_dxvk_warning() -> Optional[str]: if drivers.is_outdated(): driver_info = drivers.get_nvidia_driver_info() return _( "Warning Your NVIDIA driver is outdated.\n" "You are currently running driver %s which does not " "fully support all features for Vulkan and DXVK games." ) % (driver_info["version"],) return None def _get_simple_vulkan_support_error(option_key: str, config: LutrisConfig, feature: str) -> Optional[str]: if os.environ.get("LUTRIS_NO_VKQUERY"): return None if config.runner_config.get(option_key) and not LINUX_SYSTEM.is_vulkan_supported(): return ( _("Error Vulkan is not installed or is not supported by your system, " "%s is not available.") % feature ) return None def _get_dxvk_version_warning(_option_key: str, config: LutrisConfig) -> Optional[str]: if os.environ.get("LUTRIS_NO_VKQUERY"): return None runner_config = config.runner_config if runner_config.get("dxvk") and LINUX_SYSTEM.is_vulkan_supported(): version = runner_config.get("dxvk_version") if version and not version.startswith("v1."): library_api_version = vkquery.get_vulkan_api_version() if library_api_version and library_api_version < REQUIRED_VULKAN_API_VERSION: return _( "Warning Lutris has detected that Vulkan API version %s is installed, " "but to use the latest DXVK version, %s is required." ) % (vkquery.format_version(library_api_version), vkquery.format_version(REQUIRED_VULKAN_API_VERSION)) devices = vkquery.get_device_info() if devices and devices[0].api_version < REQUIRED_VULKAN_API_VERSION: return _( "Warning Lutris has detected that the best device available ('%s') supports Vulkan API %s, " "but to use the latest DXVK version, %s is required." ) % ( devices[0].name, vkquery.format_version(devices[0].api_version), vkquery.format_version(REQUIRED_VULKAN_API_VERSION), ) return None def _get_esync_warning(_option_key: str, config: LutrisConfig) -> Optional[str]: if config.runner_config.get("esync"): limits_set = is_esync_limit_set() if not limits_set: return _( "Warning Your limits are not set correctly. Please increase them as described here:\n" "" "How-to-Esync (https://github.com/lutris/docs/blob/master/HowToEsync.md)" ) return "" def _get_fsync_warning(_option_key: str, config: LutrisConfig) -> Optional[str]: if config.runner_config.get("fsync"): fsync_supported = is_fsync_supported() if not fsync_supported: return _("Warning Your kernel is not patched for fsync.") return None def _get_virtual_desktop_warning(_option_key: str, config: LutrisConfig) -> Optional[str]: message = _("Wine virtual desktop is no longer supported") runner_config = config.runner_config if runner_config.get("Desktop"): version = str(runner_config.get("version")).casefold() if "-ge-" in version or "proton" in version: message += "\n" message += _("Virtual desktops cannot be enabled in Proton or GE Wine versions.") return message def _get_wine_version_choices(): version_choices = [(_("Custom (select executable below)"), "custom")] labels = { "winehq-devel": _("WineHQ Devel ({})"), "winehq-staging": _("WineHQ Staging ({})"), "wine-development": _("Wine Development ({})"), "system": _("System ({})"), } versions = get_installed_wine_versions() for version in versions: if version in labels: version_number = get_system_wine_version(WINE_PATHS[version]) label = labels[version].format(version_number) elif version == "ge-proton": label = _("GE-Proton (Latest)") else: label = version version_choices.append((label, version)) return version_choices class wine(Runner): description = _("Runs Windows games") human_name = _("Wine") platforms = [_("Windows")] multiple_versions = True entry_point_option = "exe" game_options = [ { "option": "exe", "type": "file", "label": _("Executable"), "help": _("The game's main EXE file"), }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _("Windows command line arguments used when launching the game"), "validator": shlex.split, }, { "option": "working_dir", "type": "directory", "label": _("Working directory"), "help": _( "The location where the game is run from.\n" "By default, Lutris uses the directory of the " "executable." ), }, { "option": "prefix", "type": "directory", "label": _("Wine prefix"), "warning": _get_prefix_warning, "help": _( "The prefix used by Wine.\n" "It's a directory containing a set of files and " "folders making up a confined Windows environment." ), }, { "option": "arch", "type": "choice", "label": _("Prefix architecture"), "choices": [(_("Auto"), "auto"), (_("32-bit"), "win32"), (_("64-bit"), "win64")], "default": "auto", "help": _("The architecture of the Windows environment"), }, { "option": "desktop_integration", "type": "bool", "label": _("Integrate system files in the prefix"), "default": False, "advanced": True, "help": _( "Place 'Documents', 'Pictures', and similar files in your home folder, instead of " "keeping them in the game's prefix. This includes some saved games." ), }, ] runner_options = [ { "option": "version", "label": _("Wine version"), "type": "choice", "choices": _get_wine_version_choices, "default": get_default_wine_version, "warning": _get_version_warning, "help": _( "The version of Wine used to launch the game.\n" "Using the last version is generally recommended, " "but some games work better on older versions." ), }, { "option": "custom_wine_path", "label": _("Custom Wine executable"), "type": "file", "advanced": True, "help": _("The Wine executable to be used if you have " 'selected "Custom" as the Wine version.'), }, { "option": "system_winetricks", "label": _("Use system winetricks"), "type": "bool", "default": False, "advanced": True, "help": _("Switch on to use /usr/bin/winetricks for winetricks."), }, { "option": "dxvk", "section": _("Graphics"), "label": _("Enable DXVK"), "type": "bool", "default": True, "warning": _get_dxvk_warning, "error": lambda k, c: _get_simple_vulkan_support_error(k, c, _("DXVK")), "active": True, "help": _( "Use DXVK to " "increase compatibility and performance in Direct3D 11, 10 " "and 9 applications by translating their calls to Vulkan." ), }, { "option": "dxvk_version", "section": _("Graphics"), "label": _("DXVK version"), "advanced": True, "type": "choice_with_entry", "visible": _is_pre_proton, "condition": LINUX_SYSTEM.is_vulkan_supported(), "conditional_on": "dxvk", "choices": lambda: DXVKManager().version_choices, "default": lambda: DXVKManager().version, "warning": _get_dxvk_version_warning, }, { "option": "vkd3d", "section": _("Graphics"), "label": _("Enable VKD3D"), "type": "bool", "visible": _is_pre_proton, "error": lambda k, c: _get_simple_vulkan_support_error(k, c, _("VKD3D")), "default": True, "active": True, "help": _( "Use VKD3D to enable support for Direct3D 12 " "applications by translating their calls to Vulkan." ), }, { "option": "vkd3d_version", "section": _("Graphics"), "label": _("VKD3D version"), "advanced": True, "type": "choice_with_entry", "visible": _is_pre_proton, "condition": LINUX_SYSTEM.is_vulkan_supported(), "conditional_on": "vkd3d", "choices": lambda: VKD3DManager().version_choices, "default": lambda: VKD3DManager().version, }, { "option": "d3d_extras", "section": _("Graphics"), "label": _("Enable D3D Extras"), "type": "bool", "default": True, "advanced": True, "visible": _is_pre_proton, "help": _( "Replace Wine's D3DX and D3DCOMPILER libraries with alternative ones. " "Needed for proper functionality of DXVK with some games." ), }, { "option": "d3d_extras_version", "section": _("Graphics"), "label": _("D3D Extras version"), "advanced": True, "visible": _is_pre_proton, "conditional_on": "d3d_extras", "type": "choice_with_entry", "choices": lambda: D3DExtrasManager().version_choices, "default": lambda: D3DExtrasManager().version, }, { "option": "dxvk_nvapi", "section": _("Graphics"), "label": _("Enable DXVK-NVAPI / DLSS"), "type": "bool", "error": lambda k, c: _get_simple_vulkan_support_error(k, c, _("DXVK-NVAPI / DLSS")), "default": True, "advanced": True, "visible": _is_pre_proton, "help": _("Enable emulation of Nvidia's NVAPI and add DLSS support, if available."), }, { "option": "dxvk_nvapi_version", "section": _("Graphics"), "label": _("DXVK NVAPI version"), "advanced": True, "conditional_on": "dxvk_nvapi", "visible": _is_pre_proton, "type": "choice_with_entry", "choices": lambda: DXVKNVAPIManager().version_choices, "default": lambda: DXVKNVAPIManager().version, }, { "option": "dgvoodoo2", "section": _("Graphics"), "label": _("Enable dgvoodoo2"), "type": "bool", "default": False, "advanced": False, "help": _( "dgvoodoo2 is an alternative translation layer for rendering old games " "that utilize D3D1-7 and Glide APIs. As it translates to D3D11, it's " "recommended to use it in combination with DXVK. Only 32-bit apps are supported." ), }, { "option": "dgvoodoo2_version", "section": _("Graphics"), "label": _("dgvoodoo2 version"), "advanced": True, "type": "choice_with_entry", "choices": lambda: dgvoodoo2Manager().version_choices, "default": lambda: dgvoodoo2Manager().version, "conditional_on": "dgvoodoo2", }, { "option": "esync", "label": _("Enable Esync"), "type": "bool", "warning": _get_esync_warning, "active": True, "default": True, "help": _( "Enable eventfd-based synchronization (esync). " "This will increase performance in applications " "that take advantage of multi-core processors." ), }, { "option": "fsync", "label": _("Enable Fsync"), "type": "bool", "default": is_fsync_supported(), "warning": _get_fsync_warning, "active": True, "help": _( "Enable futex-based synchronization (fsync). " "This will increase performance in applications " "that take advantage of multi-core processors. " "Requires kernel 5.16 or above." ), }, { "option": "fsr", "label": _("Enable AMD FidelityFX Super Resolution (FSR)"), "type": "bool", "default": True, "help": _( "Use FSR to upscale the game window to native resolution.\n" "Requires Lutris Wine FShack >= 6.13 and setting the game to a lower resolution.\n" "Does not work with games running in borderless window mode or that perform their own upscaling." ), }, { "option": "battleye", "label": _("Enable BattlEye Anti-Cheat"), "type": "bool", "default": True, "help": _( "Enable support for BattlEye Anti-Cheat in supported games\n" "Requires Lutris Wine 6.21-2 and newer or any other compatible Wine build.\n" ), }, { "option": "eac", "label": _("Enable Easy Anti-Cheat"), "type": "bool", "default": True, "help": _( "Enable support for Easy Anti-Cheat in supported games\n" "Requires Lutris Wine 7.2 and newer or any other compatible Wine build.\n" ), }, { "option": "Desktop", "section": _("Virtual Desktop"), "label": _("Windowed (virtual desktop)"), "type": "bool", "advanced": True, "visible": _is_pre_proton, "warning": _get_virtual_desktop_warning, "default": False, "help": _( "Run the whole Windows desktop in a window.\n" "Otherwise, run it fullscreen.\n" "This corresponds to Wine's Virtual Desktop option." ), }, { "option": "WineDesktop", "section": _("Virtual Desktop"), "label": _("Virtual desktop resolution"), "type": "choice_with_entry", "visible": _is_pre_proton, "conditional_on": "Desktop", "advanced": True, "choices": DISPLAY_MANAGER.get_resolutions, "help": _("The size of the virtual desktop in pixels."), }, { "option": "Dpi", "section": _("DPI"), "label": _("Enable DPI Scaling"), "type": "bool", "advanced": True, "default": False, "help": _( "Enables the Windows application's DPI scaling.\n" "Otherwise, the Screen Resolution option in 'Wine configuration' controls this." ), }, { "option": "ExplicitDpi", "section": _("DPI"), "label": _("DPI"), "type": "string", "conditional_on": "Dpi", "advanced": True, "help": _( "The DPI to be used if 'Enable DPI Scaling' is turned on.\n" "If blank or 'auto', Lutris will auto-detect this." ), }, { "option": "MouseWarpOverride", "label": _("Mouse Warp Override"), "type": "choice", "choices": [ (_("Enable"), "enable"), (_("Disable"), "disable"), (_("Force"), "force"), ], "default": "enable", "advanced": True, "help": _( "Override the default mouse pointer warping behavior\n" "Enable: (Wine default) warp the pointer when the " "mouse is exclusively acquired \n" "Disable: never warp the mouse pointer \n" "Force: always warp the pointer" ), }, { "option": "Audio", "label": _("Audio driver"), "type": "choice", "advanced": True, "choices": [ (_("Auto"), "auto"), ("ALSA", "alsa"), ("PulseAudio", "pulse"), ("OSS", "oss"), ], "default": "auto", "help": _( "Which audio backend to use.\n" "By default, Wine automatically picks the right one " "for your system." ), }, { "option": "overrides", "type": "mapping", "label": _("DLL overrides"), "help": _("Sets WINEDLLOVERRIDES when launching the game."), }, { "option": "show_debug", "label": _("Output debugging info"), "type": "choice", "choices": [ (_("Disabled"), "-all"), (_("Enabled"), ""), (_("Inherit from environment"), "inherit"), (_("Show FPS"), "+fps"), (_("Full (CAUTION: Will cause MASSIVE slowdown)"), "+all"), ], "default": "-all", "help": _("Output debugging information in the game log " "(might affect performance)"), }, { "option": "ShowCrashDialog", "label": _("Show crash dialogs"), "type": "bool", "default": False, "advanced": True, }, { "option": "autoconf_joypad", "type": "bool", "label": _("Autoconfigure joypads"), "advanced": True, "default": False, "help": _("Automatically disables one of Wine's detected joypad " "to avoid having 2 controllers detected"), }, ] reg_prefix = "HKEY_CURRENT_USER/Software/Wine" reg_keys = { "Audio": r"%s/Drivers" % reg_prefix, "MouseWarpOverride": r"%s/DirectInput" % reg_prefix, "Desktop": "MANAGED", "WineDesktop": "MANAGED", "ShowCrashDialog": "MANAGED", } core_processes = ( "services.exe", "winedevice.exe", "plugplay.exe", "explorer.exe", "rpcss.exe", "rundll32.exe", "wineboot.exe", ) def __init__(self, config=None, prefix=None, working_dir=None, wine_arch=None): # noqa: C901 super().__init__(config) self._prefix = prefix self._working_dir = working_dir self._wine_arch = wine_arch self.dll_overrides = DEFAULT_DLL_OVERRIDES.copy() # we'll modify this, so we better copy it @property def runner_warning(self): if not get_system_wine_version(): return _( "Warning Wine is not installed on your system\n\n" "Having Wine installed on your system guarantees that " "Wine builds from Lutris will have all required dependencies.\nPlease " "follow the instructions given in the Lutris Wiki to " "install Wine." ) @property def context_menu_entries(self): """Return the contexual menu entries for wine""" return [ ("wineexec", _("Run EXE inside Wine prefix"), self.run_wineexec), ("wineshell", _("Open Bash terminal"), self.run_wine_terminal), ("wineconsole", _("Open Wine console"), self.run_wineconsole), (None, "-", None), ("winecfg", _("Wine configuration"), self.run_winecfg), ("wine-regedit", _("Wine registry"), self.run_regedit), ("winecpl", _("Wine Control Panel"), self.run_winecpl), ("winetaskmgr", _("Wine Task Manager"), self.run_taskmgr), (None, "-", None), ("winetricks", _("Winetricks"), self.run_winetricks), ] @property def prefix_path(self): """Return the absolute path of the Wine prefix. Falls back to default WINE prefix.""" _prefix_path = self._prefix or self.game_config.get("prefix") or os.environ.get("WINEPREFIX") if not _prefix_path and self.game_config.get("exe"): # Find prefix from game if we have one _prefix_path = find_prefix(self.game_exe) if _prefix_path: _prefix_path = os.path.expanduser(_prefix_path) # just in case! return _prefix_path @property def game_exe(self): """Return the game's executable's path, which may not exist. None if there is no exe path defined.""" exe = self.game_config.get("exe") if not exe: logger.error("The game doesn't have an executable") return None exe = os.path.expanduser(exe) # just in case! if os.path.isabs(exe): return system.fix_path_case(exe) if not self.game_path: logger.warning("The game has an executable, but not a game path") return None return system.fix_path_case(os.path.join(self.game_path, exe)) @property def working_dir(self): """Return the working directory to use when running the game.""" _working_dir = self._working_dir or self.game_config.get("working_dir") if _working_dir: return os.path.expanduser(_working_dir) if self.game_exe: game_dir = os.path.dirname(self.game_exe) if os.path.isdir(game_dir): return game_dir return super().working_dir @property def nvidia_shader_cache_path(self): """WINE should give each game its own shader cache if possible.""" return self.game_path or self.shader_cache_dir @property def wine_arch(self): """Return the wine architecture. Get it from the config or detect it from the prefix""" arch = self._wine_arch or self.game_config.get("arch") or "auto" if arch not in ("win32", "win64"): prefix_path = self.prefix_path if prefix_path: arch = detect_arch(prefix_path, self.get_executable()) else: arch = WINE_DEFAULT_ARCH return arch def get_runner_version(self, version: str = None) -> Optional[Dict[str, str]]: if not version: default_version_info = get_default_wine_runner_version_info() default_version = format_runner_version(default_version_info) if default_version_info else None version = self.read_version_from_config(default=default_version) if version in WINE_PATHS: return {"version": version} return super().get_runner_version(version) def read_version_from_config(self, default: str = None) -> str: """Return the Wine version to use. use_default can be set to false to force the installation of a specific wine version. If no version is configured, we return the default supplied, or the4 global Wine default if none is.""" # We must use the config levels to avoid getting a default if the setting # is not set; we'll fall back to get_default_version() for level in [self.config.game_level, self.config.runner_level]: if "wine" in level: runner_version = level["wine"].get("version") if runner_version: return runner_version if default: return default return get_default_wine_version() def get_path_for_version(self, version: str) -> str: """Return the absolute path of a wine executable for a given version""" return get_wine_path_for_version(version, config=self.runner_config) def resolve_config_path(self, path, relative_to=None): # Resolve paths with tolerance for Windows-isms; # first try to fix mismatched casing, and then if that # finds no file or directory, try again after swapping in # slashes for backslashes. resolved = super().resolve_config_path(path, relative_to) resolved = system.fix_path_case(resolved) if not os.path.exists(resolved) and "\\" in path: fixed = path.replace("\\", "/") fixed_resolved = super().resolve_config_path(fixed, relative_to) fixed_resolved = system.fix_path_case(fixed_resolved) return fixed_resolved return resolved def get_executable(self, version: str = None, fallback: bool = True) -> str: """Return the path to the Wine executable. A specific version can be specified if needed. """ if version is None: version = self.read_version_from_config() if proton.is_proton_version(version): return proton.get_proton_wine_path(version) try: wine_path = self.get_path_for_version(version) if system.path_exists(wine_path): return wine_path except MissingExecutableError: if not fallback: raise if not fallback: raise MissingExecutableError(_("The Wine executable at '%s' is missing.") % wine_path) # Fallback to default version default_version = get_default_wine_version() wine_path = self.get_path_for_version(default_version) if not system.path_exists(wine_path): raise MissingExecutableError(_("The Wine executable at '%s' is missing.") % wine_path) # Update the version in the config if version == self.runner_config.get("version"): self.runner_config["version"] = default_version # TODO: runner_config is a dict so we have to instanciate a # LutrisConfig object to save it. # XXX: The version key could be either in the game specific # config or the runner specific config. We need to know # which one to get the correct LutrisConfig object. return wine_path def get_command(self) -> List[str]: command = super().get_command() if command: if proton.is_proton_path(command[0]) and not proton.is_umu_path(command[0]): command[0] = proton.get_umu_path() if proton.is_umu_path(command[0]) and self.wine_arch == "win32": raise RuntimeError(_("Proton is not compatible with 32-bit prefixes.")) return command def is_installed(self, flatpak_allowed: bool = True, version: str = None, fallback: bool = True) -> bool: """Check if Wine is installed. If no version is passed, checks if any version of wine is available """ try: if version: # We don't care where Wine is, but only if it was found at all. self.get_executable(version, fallback) return True return bool(get_installed_wine_versions()) except MisconfigurationError: return False def is_installed_for(self, interpreter): try: version = self.get_installer_runner_version(interpreter.installer, use_api=True) return self.is_installed(version=version, fallback=False) except MisconfigurationError: return False def get_installer_runner_version( self, installer, use_runner_config: bool = True, use_api: bool = False ) -> Optional[str]: # If a version is specified in the script choose this one version = None if installer.script.get(installer.runner): version = installer.script[installer.runner].get("version") version = normalize_version_architecture(version) # If the installer is an extension, use the wine version from the base game elif installer.requires: db_game = get_game_by_field(installer.requires, field="installer_slug") if not db_game: db_game = get_game_by_field(installer.requires, field="slug") if not db_game: raise MisconfigurationError(_("The required game '%s' could not be found.") % installer.requires) game = Game(db_game["id"]) version = game.config.runner_config["version"] if not version and use_runner_config: # Try to read the version from the saved runner config for Wine. try: return wine.get_runner_version_and_config()[0] except UnspecifiedVersionError: pass # fall back to the API in this case if not version and use_api: # Try to obtain the default wine version from the Lutris API. default_version_info = self.get_runner_version() if default_version_info and "version" in default_version_info: logger.debug("Default wine version is %s", default_version_info["version"]) version = format_runner_version(default_version_info) return version def adjust_installer_runner_config(self, installer_runner_config: Dict[str, Any]) -> None: version = installer_runner_config.get("version") if version: installer_runner_config["version"] = normalize_version_architecture(version) @classmethod def get_runner_version_and_config(cls) -> Tuple[str, LutrisConfig]: runner_config = LutrisConfig(runner_slug="wine") if "wine" in runner_config.runner_level: config_version = runner_config.runner_level["wine"].get("version") if config_version: return config_version, runner_config raise UnspecifiedVersionError(_("The runner configuration does not specify a Wine version.")) @classmethod def msi_exec( cls, msi_file, quiet=False, prefix=None, wine_path=None, working_dir=None, blocking=False, ): msi_args = "/i %s" % msi_file if quiet: msi_args += " /q" return wineexec( "msiexec", args=msi_args, prefix=prefix, wine_path=wine_path, working_dir=working_dir, blocking=blocking, ) def _run_executable(self, executable): """Runs a Windows executable using this game's configuration""" wineexec( executable, wine_path=self.get_executable(), prefix=self.prefix_path, working_dir=self.prefix_path, config=self, env=self.get_env(os_env=True), runner=self, ) def run_wineexec(self, *args): """Ask the user for an arbitrary exe file to run in the game's prefix""" dlg = FileDialog(_("Select an EXE or MSI file"), default_path=self.game_path) filename = dlg.filename if not filename: return self.prelaunch() self._run_executable(filename) def run_wineconsole(self, *args): """Runs wineconsole inside wine prefix.""" self.prelaunch() self._run_executable("wineconsole") def run_winecfg(self, *args): """Run winecfg in the current context""" self.prelaunch() winecfg( wine_path=self.get_executable(), prefix=self.prefix_path, arch=self.wine_arch, config=self, env=self.get_env(os_env=True), runner=self, ) def run_regedit(self, *args): """Run regedit in the current context""" self.prelaunch() self._run_executable("regedit") def run_wine_terminal(self, *args): terminal = self.system_config.get("terminal_app") system_winetricks = self.runner_config.get("system_winetricks") open_wine_terminal( terminal=terminal, wine_path=self.get_executable(), prefix=self.prefix_path, env=self.get_env(os_env=True), system_winetricks=system_winetricks, ) def run_winetricks(self, *args): """Run winetricks in the current context""" self.prelaunch() disable_runtime = not self.use_runtime() system_winetricks = self.runner_config.get("system_winetricks") if system_winetricks: # Don't run the system winetricks with the runtime; let the # system be the system disable_runtime = True winetricks( "", prefix=self.prefix_path, wine_path=self.get_executable(), config=self, disable_runtime=disable_runtime, system_winetricks=system_winetricks, env=self.get_env(os_env=True, disable_runtime=disable_runtime), runner=self, ) def run_winecpl(self, *args): """Execute Wine control panel.""" self.prelaunch() self._run_executable("control") def run_taskmgr(self, *args): """Execute Wine task manager""" self.prelaunch() self._run_executable("taskmgr") def run_winekill(self, *args): """Runs wineserver -k.""" winekill( self.prefix_path, arch=self.wine_arch, wine_path=self.get_executable(), env=self.get_env(), initial_pids=self.get_wine_executable_pids(), ) return True def set_regedit_keys(self): """Reset regedit keys according to config.""" prefix_manager = WinePrefixManager(self.prefix_path) # Those options are directly changed with the prefix manager and skip # any calls to regedit. managed_keys = { "ShowCrashDialog": prefix_manager.set_crash_dialogs, "Desktop": prefix_manager.set_virtual_desktop, "WineDesktop": prefix_manager.set_desktop_size, } for key, path in self.reg_keys.items(): value = self.runner_config.get(key) or "auto" if not value or value == "auto" and key not in managed_keys: prefix_manager.clear_registry_subkeys(path, key) elif key in self.runner_config: if key in managed_keys: # Do not pass fallback 'auto' value to managed keys if value == "auto": value = None if ( value and key in ("Desktop", "WineDesktop") and ( "wine-ge" in self.get_executable().casefold() or proton.is_proton_path(self.get_executable()) ) ): logger.warning("Wine Virtual Desktop can't be used with Wine-GE and Proton") value = None managed_keys[key](value) continue # Convert numeric strings to integers so they are saved as dword if value.isdigit(): value = int(value) prefix_manager.set_registry_key(path, key, value) # We always configure the DPI, because if the user turns off DPI scaling, but it # had been on the only way to implement that is to save 96 DPI into the registry. prefix_manager.set_dpi(self.get_dpi()) def get_dpi(self): """Return the DPI to be used by Wine; returns None to allow Wine's own setting to govern.""" if bool(self.runner_config.get("Dpi")): explicit_dpi = self.runner_config.get("ExplicitDpi") if explicit_dpi == "auto": explicit_dpi = None else: try: explicit_dpi = int(explicit_dpi) except: explicit_dpi = None return explicit_dpi or get_default_dpi() return None def prelaunch(self): if not get_system_wine_version(): logger.warning("Wine is not installed on your system; required dependencies may be missing.") prefix_path = self.prefix_path if prefix_path: if not system.path_exists(os.path.join(prefix_path, "user.reg")): logger.warning("No valid prefix detected in %s, creating one...", prefix_path) create_prefix(prefix_path, wine_path=self.get_executable(), arch=self.wine_arch, runner=self) prefix_manager = WinePrefixManager(prefix_path) if self.runner_config.get("autoconf_joypad", False): prefix_manager.configure_joypads() prefix_manager.create_user_symlinks() self.configure_desktop_integration(prefix_manager) self.set_regedit_keys() for manager, enabled in self.get_dll_managers().items(): manager.setup(enabled) def get_dll_managers(self, enabled_only=False): """Returns the DLL managers in a dict; the keys are the managers themselves, and the values are the enabled flags for them. If 'enabled_only' is true, only enabled managers are returned, so disabled managers are not created.""" manager_classes = [ (DXVKManager, "dxvk", "dxvk_version"), (VKD3DManager, "vkd3d", "vkd3d_version"), (DXVKNVAPIManager, "dxvk_nvapi", "dxvk_nvapi_version"), (D3DExtrasManager, "d3d_extras", "d3d_extras_version"), (dgvoodoo2Manager, "dgvoodoo2", "dgvoodoo2_version"), ] managers = {} is_proton = proton.is_proton_path(self.get_executable()) for manager_class, enabled_option, version_option in manager_classes: enabled = bool(self.runner_config.get(enabled_option)) version = self.runner_config.get(version_option) if enabled or not enabled_only: manager = manager_class(self.prefix_path, arch=self.wine_arch, version=version) if not manager.can_enable(): enabled = False if not manager.proton_compatible and is_proton: enabled = False if enabled or not enabled_only: managers[manager] = enabled return managers def get_dll_overrides(self): """Return the DLLs overriden at runtime""" try: overrides = self.runner_config["overrides"] except KeyError: overrides = {} if not isinstance(overrides, dict): logger.warning("DLL overrides is not a mapping: %s", overrides) overrides = {} return overrides def get_env(self, os_env=False, disable_runtime=False): """Return environment variables used by the game""" # Always false to runner.get_env, the default value # of os_env is inverted in the wine class, # the OS env is read later. env = super().get_env(os_env, disable_runtime=disable_runtime) show_debug = self.runner_config.get("show_debug", "-all") if show_debug != "inherit": env["WINEDEBUG"] = show_debug env["DXVK_LOG_LEVEL"] = "error" env["UMU_LOG"] = "1" if show_debug == "": env["DXVK_LOG_LEVEL"] = "info" env["UMU_LOG"] = "warning" elif show_debug == "+all": env["DXVK_LOG_LEVEL"] = "debug" env["UMU_LOG"] = "debug" env["WINEARCH"] = self.wine_arch wine_exe = self.get_executable() is_proton = proton.is_proton_path(wine_exe) wine_config_version = self.read_version_from_config() env["WINE"] = wine_exe files_dir = get_runner_files_dir_for_version(wine_config_version) if files_dir: env["WINE_MONO_CACHE_DIR"] = os.path.join(files_dir, "mono") env["WINE_GECKO_CACHE_DIR"] = os.path.join(files_dir, "gecko") # We don't want to override gstreamer for proton, it has it's own version if files_dir and not is_proton and is_gstreamer_build(wine_exe): path_64 = os.path.join(files_dir, "lib64/gstreamer-1.0/") path_32 = os.path.join(files_dir, "lib/gstreamer-1.0/") if os.path.exists(path_64) or os.path.exists(path_32): env["GST_PLUGIN_SYSTEM_PATH_1_0"] = path_64 + ":" + path_32 if self.prefix_path: env["WINEPREFIX"] = self.prefix_path if "WINEESYNC" not in env: env["WINEESYNC"] = "1" if self.runner_config.get("esync") else "0" # Proton uses an env-var with the opposite sense! if "PROTON_NO_ESYNC" not in env and not self.runner_config.get("esync"): env["PROTON_NO_ESYNC"] = "1" if "WINEFSYNC" not in env: env["WINEFSYNC"] = "1" if self.runner_config.get("fsync") else "0" # Proton uses an env-var with the opposite sense! if "PROTON_NO_FSYNC" not in env and not self.runner_config.get("fsync"): env["PROTON_NO_FSYNC"] = "1" if self.runner_config.get("fsr"): env["WINE_FULLSCREEN_FSR"] = "1" if self.runner_config.get("dxvk_nvapi"): env["DXVK_NVAPIHACK"] = "0" env["DXVK_ENABLE_NVAPI"] = "1" if self.runner_config.get("battleye"): env["PROTON_BATTLEYE_RUNTIME"] = os.path.join(settings.RUNTIME_DIR, "battleye_runtime") if self.runner_config.get("eac"): env["PROTON_EAC_RUNTIME"] = os.path.join(settings.RUNTIME_DIR, "eac_runtime") if not self.runner_config.get("dxvk") or not LINUX_SYSTEM.is_vulkan_supported(): env["PROTON_USE_WINED3D"] = "1" # We always use DXVK D3D8; so should Proton. if "PROTON_DXVK_D3D8" not in env: env["PROTON_DXVK_D3D8"] = "1" for dll_manager in self.get_dll_managers(enabled_only=True): self.dll_overrides.update(dll_manager.get_enabling_dll_overrides()) overrides = self.get_dll_overrides() if overrides: self.dll_overrides.update(overrides) env["WINEDLLOVERRIDES"] = get_overrides_env(self.dll_overrides) return env def finish_env(self, env: Dict[str, str], game) -> None: super().finish_env(env, game) wine_exe = self.get_executable() if proton.is_proton_path(wine_exe): game_id = proton.get_game_id(game, env) proton.update_proton_env(wine_exe, env, game_id=game_id) def get_runtime_env(self): """Return runtime environment variables with path to wine for Lutris builds""" try: wine_path = os.path.dirname(os.path.dirname(self.get_executable())) except MisconfigurationError: wine_path = None return runtime.get_env( version="Ubuntu-18.04", prefer_system_libs=self.system_config.get("prefer_system_libs", True), wine_path=wine_path, ) def get_wine_executable_pids(self): """Return a list of pids of processes using the current wine exe.""" try: exe = self.get_executable() if proton.is_proton_path(exe): logger.debug("Tracking PIDs of Proton games is not possible at the moment") return set() if not exe.startswith("/"): exe = system.find_required_executable(exe) pids = system.get_pids_using_file(exe) if self.wine_arch == "win64" and os.path.basename(exe) == "wine": pids = pids | system.get_pids_using_file(exe + "64") except MisconfigurationError: return set() # Add wineserver PIDs to the mix (at least one occurence of fuser not # picking the games's PID from wine/wine64 but from wineserver for some # unknown reason. pids = pids | system.get_pids_using_file(os.path.join(os.path.dirname(exe), "wineserver")) return pids def configure_desktop_integration(self, wine_prefix): try: if self.game_config.get("desktop_integration", False): wine_prefix.install_desktop_integration() else: wine_prefix.remove_desktop_integration() except Exception as ex: logger.exception("Failed to setup desktop integration, the prefix may not be valid: %s", ex) def play(self): # pylint: disable=too-many-return-statements # noqa: C901 game_exe = self.game_exe arguments = self.game_config.get("args", "") launch_info = {"env": self.get_env(os_env=False)} using_dxvk = self.runner_config.get("dxvk") and LINUX_SYSTEM.is_vulkan_supported if using_dxvk: # Set this to 1 to enable access to more RAM for 32-bit applications launch_info["env"]["WINE_LARGE_ADDRESS_AWARE"] = "1" if not game_exe or not system.path_exists(game_exe): raise MissingGameExecutableError(filename=game_exe) if launch_info["env"].get("WINEESYNC") == "1": limit_set = is_esync_limit_set() if not limit_set: raise EsyncLimitError() if launch_info["env"].get("WINEFSYNC") == "1": fsync_supported = is_fsync_supported() if not fsync_supported: raise FsyncUnsupportedError() command = self.get_command() game_exe, args, _working_dir = get_real_executable(game_exe, self.working_dir) command.append(game_exe) if args: command = command + args if arguments: for arg in split_arguments(arguments): command.append(arg) launch_info["command"] = command return launch_info def filter_game_pids(self, candidate_pids: Iterable[int], game_uuid: str, game_folder: str) -> Set[int]: """Checks the pids given and returns a set containing only those that are part of the running game, identified by its UUID and directory.""" if proton.is_proton_path(self.get_executable()): folder_pids = set() for pid in candidate_pids: cmdline = Process(pid).cmdline or "" # pressure-vessel: This could potentially pick up PIDs not started by lutris? if game_folder in cmdline or "pressure-vessel" in cmdline: folder_pids.add(pid) uuid_pids = set(pid for pid in candidate_pids if Process(pid).environ.get("LUTRIS_GAME_UUID") == game_uuid) return folder_pids & uuid_pids else: return super().filter_game_pids(candidate_pids, game_uuid, game_folder) def force_stop_game(self, game_pids: Iterable[int]) -> None: """Kill WINE with kindness, or at least with -k. This seems to leave a process alive for some reason, but the caller will detect this and SIGKILL it.""" winekill( self.prefix_path, arch=self.wine_arch, wine_path=self.get_executable(), env=self.get_env(), initial_pids=game_pids, ) def extract_icon(self, game_slug): """Extracts the 128*128 icon from EXE and saves it, if not resizes the biggest icon found. returns true if an icon is saved, false if not""" try: wantedsize = (128, 128) pathtoicon = settings.ICON_PATH + "/lutris_" + game_slug + ".png" exe = self.game_exe if not exe or os.path.exists(pathtoicon) or not PEFILE_AVAILABLE: return False extractor = ExtractIcon(self.game_exe) groups = extractor.get_group_icons() if not groups: return False icons = [] biggestsize = (0, 0) biggesticon = -1 for i in range(len(groups[0])): icons.append(extractor.export(groups[0], i)) if icons[i].size > biggestsize: biggesticon = i biggestsize = icons[i].size elif icons[i].size == wantedsize: icons[i].save(pathtoicon) return True if biggesticon >= 0: resized = icons[biggesticon].resize(wantedsize) resized.save(pathtoicon) return True return False except Exception as ex: logger.exception("Unable to extract icon from %s: %s", exe, ex) return False lutris-0.5.19/lutris/runners/mupen64plus.py0000664000175000017500000000306614756670027017723 0ustar hibbyhibby# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system class mupen64plus(Runner): human_name = _("Mupen64Plus") description = _("Nintendo 64 emulator") platforms = [_("Nintendo 64")] runner_executable = "mupen64plus/mupen64plus" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": True, }, {"option": "hideosd", "type": "bool", "label": _("Hide OSD"), "default": True}, ] @property def working_dir(self): return os.path.join(settings.RUNNER_DIR, "mupen64plus") def play(self): arguments = self.get_command() if self.runner_config.get("hideosd"): arguments.append("--noosd") else: arguments.append("--osd") if self.runner_config.get("fullscreen"): arguments.append("--fullscreen") else: arguments.append("--windowed") rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments.append(rom) return {"command": arguments} lutris-0.5.19/lutris/runners/ryujinx.py0000664000175000017500000000607714756670027017236 0ustar hibbyhibbyimport filecmp import os from gettext import gettext as _ from shutil import copyfile from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger class ryujinx(Runner): human_name = _("Ryujinx") platforms = [_("Nintendo Switch")] description = _("Nintendo Switch emulator") runnable_alone = True runner_executable = "ryujinx/publish/Ryujinx" flatpak_id = "org.ryujinx.Ryujinx" download_url = "https://lutris.nyc3.digitaloceanspaces.com/runners/ryujinx/ryujinx-1.0.7074-linux_x64.tar.gz" game_options = [ { "option": "main_file", "type": "file", "label": _("NSP file"), "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ { "option": "prod_keys", "label": _("Encryption keys"), "type": "file", "help": _("File containing the encryption keys."), }, { "option": "title_keys", "label": _("Title keys"), "type": "file", "help": _("File containing the title keys."), }, ] @property def ryujinx_data_dir(self): """Return dir where Ryujinx files lie.""" candidates = ("~/.local/share/ryujinx",) for candidate in candidates: path = system.fix_path_case(os.path.join(os.path.expanduser(candidate), "nand")) if system.path_exists(path): return path[: -len("nand")] def play(self): """Run the game.""" arguments = self.get_command() rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments.append(rom) return {"command": arguments} def _update_key(self, key_type): """Update a keys file if set""" ryujinx_data_dir = self.ryujinx_data_dir if not ryujinx_data_dir: logger.error("Ryujinx data dir not set") return if key_type == "prod_keys": key_loc = os.path.join(ryujinx_data_dir, "keys/prod.keys") elif key_type == "title_keys": key_loc = os.path.join(ryujinx_data_dir, "keys/title.keys") else: logger.error("Invalid keys type %s!", key_type) return key = self.runner_config.get(key_type) if not key: logger.debug("No %s file was set.", key_type) return if not system.path_exists(key): logger.warning("Keys file %s does not exist!", key) return keys_dir = os.path.dirname(key_loc) if not os.path.exists(keys_dir): os.makedirs(keys_dir) elif os.path.isfile(key_loc) and filecmp.cmp(key, key_loc): # If the files are identical, don't do anything return copyfile(key, key_loc) def prelaunch(self): for key in ["prod_keys", "title_keys"]: self._update_key(key_type=key) lutris-0.5.19/lutris/runners/xemu.py0000664000175000017500000000247414756670027016501 0ustar hibbyhibbyfrom gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system class xemu(Runner): human_name = _("xemu") platforms = [_("Xbox")] description = _("Xbox emulator") runnable_alone = True runner_executable = "xemu/xemu" flatpak_id = "app.xemu.xemu" game_options = [ { "option": "main_file", "type": "file", "label": _("ISO file"), "help": _("DVD image in iso format"), } ] runner_options = [ { "option": "fullscreen", "label": _("Fullscreen"), "type": "bool", "default": True, }, ] # xemu currently uses an AppImage, no need for the runtime. system_options_override = [{"option": "disable_runtime", "default": True}] def play(self): """Run the game.""" arguments = self.get_command() fullscreen = self.runner_config.get("fullscreen") if fullscreen: arguments.append("-full-screen") iso = self.game_config.get("main_file") or "" if not system.path_exists(iso): raise MissingGameExecutableError(filename=iso) arguments += ["-dvd_path", iso] return {"command": arguments} lutris-0.5.19/lutris/runners/easyrpg.py0000664000175000017500000005025614756670027017176 0ustar hibbyhibby# Standard Library from gettext import gettext as _ from os import path from lutris.exceptions import DirectoryNotFoundError, GameConfigError, MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner class easyrpg(Runner): human_name = _("EasyRPG Player") description = _("Runs RPG Maker 2000/2003 games") platforms = [_("Linux")] runnable_alone = True entry_point_option = "project_path" runner_executable = "easyrpg/easyrpg-player" download_url = "https://easyrpg.org/downloads/player/0.8/easyrpg-player-0.8-linux.tar.gz" game_options = [ { "option": "project_path", "type": "directory", "label": _("Game directory"), "help": _("Select the directory of the game. (required)"), }, { "option": "encoding", "type": "choice", "advanced": True, "label": _("Encoding"), "help": _( "Instead of auto detecting the encoding or using the " "one in RPG_RT.ini, the specified encoding is used." ), "choices": [ (_("Auto"), ""), (_("Auto (ignore RPG_RT.ini)"), "auto"), (_("Western European"), "1252"), (_("Central/Eastern European"), "1250"), (_("Japanese"), "932"), (_("Cyrillic"), "1251"), (_("Korean"), "949"), (_("Chinese (Simplified)"), "936"), (_("Chinese (Traditional)"), "950"), (_("Greek"), "1253"), (_("Turkish"), "1254"), (_("Hebrew"), "1255"), (_("Arabic"), "1256"), (_("Baltic"), "1257"), (_("Thai"), "874"), ], "default": "", }, { "option": "engine", "type": "choice", "advanced": True, "label": _("Engine"), "help": _("Disable auto detection of the simulated engine."), "choices": [ (_("Auto"), ""), (_("RPG Maker 2000 engine (v1.00 - v1.10)"), "rpg2k"), (_("RPG Maker 2000 engine (v1.50 - v1.51)"), "rpg2kv150"), (_("RPG Maker 2000 (English release) engine"), "rpg2ke"), (_("RPG Maker 2003 engine (v1.00 - v1.04)"), "rpg2k3"), (_("RPG Maker 2003 engine (v1.05 - v1.09a)"), "rpg2k3v105"), (_("RPG Maker 2003 (English release) engine"), "rpg2k3e"), ], "default": "", }, { "option": "patch", "type": "string", "advanced": True, "label": _("Patches"), "help": _( "Instead of autodetecting patches used by this game, force emulation of certain patches.\n" "\nAvailable patches:\n" 'common-this: "This Event" in common events' "dynrpg: DynRPG patch by Cherry" "key-patch: Key Patch by Ineluki" "maniac: Maniac Patch by BingShan" "pic-unlock: Pictures are not blocked by messages" "rpg2k3-cmds: Support all RPG Maker 2003 event commands in any version of the engine" "\n\nYou can provide multiple patches or use 'none' to disable all engine patches." ), }, { "option": "language", "type": "string", "advanced": True, "label": _("Language"), "help": _("Load the game translation in the language/LANG directory."), }, { "option": "save_path", "type": "directory", "label": _("Save path"), "warn_if_non_writable_parent": True, "help": _( "Instead of storing save files in the game directory they are stored in the specified path. " "The directory must exist." ), }, { "option": "new_game", "type": "bool", "label": _("New game"), "help": _("Skip the title scene and start a new game directly."), "default": False, }, { "option": "load_game_id", "type": "range", "label": _("Load game ID"), "help": _("Skip the title scene and load SaveXX.lsd.\n" "Set to 0 to disable."), "min": 0, "max": 99, "default": 0, }, { "option": "record_input", "type": "file", "advanced": True, "label": _("Record input"), "help": _("Records all button input to the specified log file."), }, { "option": "replay_input", "type": "file", "advanced": True, "label": _("Replay input"), "help": _( "Replays button input from the specified log file, as generated by 'Record input'.\n" "If the RNG seed and the state of the save file directory is also the same as it was " "when the log was recorded, this should reproduce an identical run to the one recorded." ), }, { "option": "test_play", "type": "bool", "advanced": True, "section": _("Debug"), "label": _("Test play"), "help": _("Enable TestPlay (debug) mode."), "default": False, }, { "option": "hide_title", "type": "bool", "advanced": True, "section": _("Debug"), "label": _("Hide title"), "help": _("Hide the title background image and center the command menu."), "default": False, }, { "option": "start_map_id", "type": "range", "advanced": True, "section": _("Debug"), "label": _("Start map ID"), "help": _( "Overwrite the map used for new games and use MapXXXX.lmu instead.\n" "Set to 0 to disable.\n\n" "Incompatible with 'Load game ID'." ), "min": 0, "max": 9999, "default": 0, }, { "option": "start_position", "type": "string", "advanced": True, "section": _("Debug"), "label": _("Start position"), "help": _( "Overwrite the party start position and move the party to the specified position.\n" "Provide two numbers separated by a space.\n\n" "Incompatible with 'Load game ID'." ), }, { "option": "start_party", "type": "string", "advanced": True, "section": _("Debug"), "label": _("Start party"), "help": _( "Overwrite the starting party members with the actors with the specified IDs.\n" "Provide one to four numbers separated by spaces.\n\n" "Incompatible with 'Load game ID'." ), }, { "option": "battle_test", "type": "string", "advanced": True, "section": _("Debug"), "label": _("Battle test"), "help": _("Start a battle test with the specified monster party."), }, ] runner_options = [ { "option": "autobattle_algo", "type": "choice", "advanced": True, "section": _("Engine"), "label": _("AutoBattle algorithm"), "help": _( "Which AutoBattle algorithm to use.\n\n" "RPG_RT: The default RPG_RT compatible algorithm, including RPG_RT bugs.\n" "RPG_RT+: The default RPG_RT compatible algorithm, with bug-fixes.\n" "ATTACK: Like RPG_RT+ but only physical attacks, no skills." ), "choices": [ (_("Auto"), ""), (_("RPG_RT"), "RPG_RT"), (_("RPG_RT+"), "RPG_RT+"), (_("ATTACK"), "ATTACK"), ], "default": "", }, { "option": "enemyai_algo", "type": "choice", "advanced": True, "section": _("Engine"), "label": _("EnemyAI algorithm"), "help": _( "Which EnemyAI algorithm to use.\n\n" "RPG_RT: The default RPG_RT compatible algorithm, including RPG_RT bugs.\n" "RPG_RT+: The default RPG_RT compatible algorithm, with bug-fixes.\n" ), "choices": [ (_("Auto"), ""), (_("RPG_RT"), "RPG_RT"), (_("RPG_RT+"), "RPG_RT+"), ], "default": "", }, { "option": "seed", "type": "range", "advanced": True, "section": _("Engine"), "label": _("RNG seed"), "help": _("Seeds the random number generator.\n" "Use -1 to disable."), "min": -1, "max": 2147483647, "default": -1, }, { "option": "audio", "type": "bool", "section": _("Audio"), "label": _("Enable audio"), "help": _("Switch off to disable audio."), "default": True, }, { "option": "music_volume", "type": "range", "section": _("Audio"), "label": _("BGM volume"), "help": _("Volume of the background music."), "min": 0, "max": 100, "default": 100, }, { "option": "sound_volume", "type": "range", "section": _("Audio"), "label": _("SFX volume"), "help": _("Volume of the sound effects."), "min": 0, "max": 100, "default": 100, }, { "option": "soundfont", "type": "file", "advanced": True, "section": _("Audio"), "label": _("Soundfont"), "help": _("Soundfont in sf2 format to use when playing MIDI files."), }, { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "help": _("Start in fullscreen mode."), "default": False, }, { "option": "game_resolution", "type": "choice", "section": _("Graphics"), "advanced": True, "label": _("Game resolution"), "help": _( "Force a different game resolution.\n\n" "This is experimental and can cause glitches or break games!" ), "choices": [ (_("320×240 (4:3, Original)"), "original"), (_("416×240 (16:9, Widescreen)"), "widescreen"), (_("560×240 (21:9, Ultrawide)"), "ultrawide"), ], "default": "original", }, { "option": "scaling", "type": "choice", "section": _("Graphics"), "label": _("Scaling"), "help": _( "How the video output is scaled.\n\n" "Nearest: Scale to screen size (causes scaling artifacts)\n" "Integer: Scale to multiple of the game resolution\n" "Bilinear: Like Nearest, but output is blurred to avoid artifacts\n" ), "choices": [ (_("Nearest"), "nearest"), (_("Integer"), "integer"), (_("Bilinear"), "bilinear"), ], "default": "bilinear", }, { "option": "stretch", "type": "bool", "section": _("Graphics"), "label": _("Stretch"), "help": _("Ignore the aspect ratio and stretch video output to the entire width of the screen."), "default": False, }, { "option": "vsync", "type": "bool", "section": _("Graphics"), "label": _("Enable VSync"), "help": _("Switch off to disable VSync and use the FPS limit."), "default": True, }, { "option": "fps_limit", "type": "range", "section": _("Graphics"), "label": _("FPS limit"), "help": _( "Set a custom frames per second limit.\n" "If unspecified, the default is 60 FPS.\n" "Set to 0 to disable the frame limiter." ), "min": 0, "max": 9999, "default": 60, }, { "option": "show_fps", "type": "choice", "section": _("Graphics"), "label": _("Show FPS"), "help": _("Enable frames per second counter."), "choices": [ (_("Disabled"), "off"), (_("Fullscreen & title bar"), "on"), (_("Fullscreen, title bar & window"), "full"), ], "default": "off", }, { "option": "rtp", "type": "bool", "section": _("Runtime Package"), "label": _("Enable RTP"), "help": _("Switch off to disable support for the Runtime Package (RTP)."), "default": True, }, { "option": "rpg2k_rtp_path", "type": "directory", "section": _("Runtime Package"), "label": _("RPG2000 RTP location"), "help": _("Full path to a directory containing an extracted " "RPG Maker 2000 Run-Time-Package (RTP)."), }, { "option": "rpg2k3_rtp_path", "type": "directory", "section": _("Runtime Package"), "label": _("RPG2003 RTP location"), "help": _("Full path to a directory containing an extracted " "RPG Maker 2003 Run-Time-Package (RTP)."), }, { "option": "rpg_rtp_path", "type": "directory", "section": _("Runtime Package"), "label": _("Fallback RTP location"), "help": _("Full path to a directory containing a combined RTP."), }, ] @property def game_path(self): game_path = self.game_data.get("directory") if game_path: return path.expanduser(game_path) # just in case # Default to the directory of the entry point entry_point = self.game_config.get(self.entry_point_option) if entry_point: return path.expanduser(entry_point) return "" def get_env(self, os_env=False, disable_runtime=False): env = super().get_env(os_env, disable_runtime=disable_runtime) rpg2k_rtp_path = self.runner_config.get("rpg2k_rtp_path") if rpg2k_rtp_path: env["RPG2K_RTP_PATH"] = rpg2k_rtp_path rpg2k3_rtp_path = self.runner_config.get("rpg2k3_rtp_path") if rpg2k3_rtp_path: env["RPG2K3_RTP_PATH"] = rpg2k3_rtp_path rpg_rtp_path = self.runner_config.get("rpg_rtp_path") if rpg_rtp_path: env["RPG_RTP_PATH"] = rpg_rtp_path return env def get_command(self): cmd = super().get_command() # Engine autobattle_algo = self.runner_config.get("autobattle_algo") if autobattle_algo: cmd.extend(("--autobattle-algo", autobattle_algo)) enemyai_algo = self.runner_config.get("enemyai_algo") if enemyai_algo: cmd.extend(("--enemyai-algo", enemyai_algo)) seed = self.runner_config.get("seed") if seed: cmd.extend(("--seed", str(seed))) # Audio if not self.runner_config["audio"]: cmd.append("--no-audio") music_volume = self.runner_config.get("music_volume") if music_volume: cmd.extend(("--music-volume", str(music_volume))) sound_volume = self.runner_config.get("sound_volume") if sound_volume: cmd.extend(("--sound-volume", str(sound_volume))) soundfont = self.runner_config.get("soundfont") if soundfont: cmd.extend(("--soundfont", soundfont)) # Graphics if self.runner_config["fullscreen"]: cmd.append("--fullscreen") else: cmd.append("--window") game_resolution = self.runner_config.get("game_resolution") if game_resolution: cmd.extend(("--game-resolution", game_resolution)) scaling = self.runner_config.get("scaling") if scaling: cmd.extend(("--scaling", scaling)) if self.runner_config["stretch"]: cmd.append("--stretch") if not self.runner_config["vsync"]: cmd.append("--no-vsync") fps_limit = self.runner_config.get("fps_limit") if fps_limit: cmd.extend(("--fps-limit", str(fps_limit))) show_fps = self.runner_config.get("show_fps") if show_fps != "off": cmd.append("--show-fps") if show_fps == "full": cmd.append("--fps-render-window") # Runtime Package if not self.runner_config["rtp"]: cmd.append("--no-rtp") return cmd def get_run_data(self): cmd = self.get_command() if self.default_path: game_path = path.expanduser(self.default_path) cmd.extend(("--project-path", game_path)) return {"command": cmd, "env": self.get_env()} def play(self): if not self.game_path: raise GameConfigError(_("No game directory provided")) if not path.isdir(self.game_path): raise DirectoryNotFoundError(directory=self.game_path) cmd = self.get_command() cmd.extend(("--project-path", self.game_path)) encoding = self.game_config.get("encoding") if encoding: cmd.extend(("--encoding", encoding)) engine = self.game_config.get("engine") if engine: cmd.extend(("--engine", engine)) patches = self.game_config.get("patches") if patches == "none": cmd.append("--no-patch") elif patches: cmd.extend(("--patches", *patches.split())) language = self.game_config.get("language") if language: cmd.extend(("--language", language)) save_path = self.game_config.get("save_path") if save_path: save_path = path.expanduser(save_path) if not path.isdir(save_path): raise DirectoryNotFoundError(directory=self.game_path) cmd.extend(("--save-path", save_path)) record_input = self.game_config.get("record_input") if record_input: record_input = path.expanduser(record_input) cmd.extend(("--record-input", record_input)) replay_input = self.game_config.get("replay_input") if replay_input: replay_input = path.expanduser(replay_input) if not path.isfile(replay_input): raise MissingGameExecutableError(filename=replay_input) cmd.extend(("--replay-input", replay_input)) load_game_id = self.game_config.get("load_game_id") if load_game_id: cmd.extend(("--load-game-id", str(load_game_id))) # Debug if self.game_config["test_play"]: cmd.append("--test-play") if self.game_config["hide_title"]: cmd.append("--hide-title") start_map_id = self.game_config.get("start_map_id") if start_map_id: cmd.extend(("--start-map-id", str(start_map_id))) start_position = self.game_config.get("start_position") if start_position: cmd.extend(("--start-position", *start_position.split())) start_party = self.game_config.get("start_party") if start_party: cmd.extend(("--start-party", *start_party.split())) battle_test = self.game_config.get("battle_test") if battle_test: cmd.extend(("--battle-test", battle_test)) return {"command": cmd} lutris-0.5.19/lutris/runners/osmose.py0000664000175000017500000000260714756670027017026 0ustar hibbyhibby# Standard Library from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class osmose(Runner): human_name = _("Osmose") description = _("Sega Master System Emulator") platforms = [_("Sega Master System")] runner_executable = "osmose/osmose" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "default_path": "game_path", "help": _( "The game data, commonly called a ROM image.\n" "Supported formats: SMS and GG files. ZIP compressed " "ROMs are supported." ), } ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": False, } ] def play(self): """Run Sega Master System game""" arguments = self.get_command() rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments.append(rom) if self.runner_config.get("fullscreen"): arguments.append("-fs") arguments.append("-bilinear") return {"command": arguments} lutris-0.5.19/lutris/runners/mame.py0000664000175000017500000003143614756670027016442 0ustar hibbyhibby"""Runner for MAME""" import os from gettext import gettext as _ from lutris import runtime, settings from lutris.exceptions import GameConfigError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.mame.database import get_supported_systems from lutris.util.strings import split_arguments MAME_CACHE_DIR = os.path.join(settings.CACHE_DIR, "mame") MAME_XML_PATH = os.path.join(MAME_CACHE_DIR, "mame.xml") def write_mame_xml(force=False): if not system.path_exists(MAME_CACHE_DIR): system.create_folder(MAME_CACHE_DIR) if system.path_exists(MAME_XML_PATH, exclude_empty=True) and not force: return False logger.info("Writing full game list from MAME to %s", MAME_XML_PATH) mame_inst = mame() mame_inst.write_xml_list() if system.get_disk_size(MAME_XML_PATH) == 0: logger.warning("MAME did not write anything to %s", MAME_XML_PATH) return False return True def notify_mame_xml(result, error): if error: logger.error("Failed writing MAME XML") elif result: logger.info("Finished writing MAME XML") def get_system_choices(include_year=True): """Return list of systems for inclusion in dropdown""" if not system.path_exists(MAME_XML_PATH, exclude_empty=True): mame_inst = mame() if mame_inst.is_installed(): AsyncCall(write_mame_xml, notify_mame_xml) return [] for system_id, info in sorted( get_supported_systems(MAME_XML_PATH).items(), key=lambda sys: (sys[1]["manufacturer"], sys[1]["description"]), ): if info["description"].startswith(info["manufacturer"]): template = "" else: template = "%(manufacturer)s " template += "%(description)s" if include_year: template += " %(year)s" system_name = template % info system_name = system_name.replace("", "").strip() yield (system_name, system_id) class mame(Runner): # pylint: disable=invalid-name """MAME runner""" human_name = _("MAME") description = _("Arcade game emulator") runner_executable = "mame/mame" flatpak_id = "org.mamedev.MAME" runnable_alone = True config_dir = os.path.expanduser("~/.mame") cache_dir = os.path.join(settings.CACHE_DIR, "mame") xml_path = os.path.join(cache_dir, "mame.xml") _platforms = [] game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), }, { "option": "machine", "type": "choice_with_search", "label": _("Machine"), "choices": get_system_choices, "help": _("The emulated machine."), }, { "option": "device", "type": "choice_with_entry", "label": _("Storage type"), "choices": [ (_("Floppy disk"), "flop"), (_("Floppy drive 1"), "flop1"), (_("Floppy drive 2"), "flop2"), (_("Floppy drive 3"), "flop3"), (_("Floppy drive 4"), "flop4"), (_("Cassette (tape)"), "cass"), (_("Cassette 1 (tape)"), "cass1"), (_("Cassette 2 (tape)"), "cass2"), (_("Cartridge"), "cart"), (_("Cartridge 1"), "cart1"), (_("Cartridge 2"), "cart2"), (_("Cartridge 3"), "cart3"), (_("Cartridge 4"), "cart4"), (_("Snapshot"), "snapshot"), (_("Hard Disk"), "hard"), (_("Hard Disk 1"), "hard1"), (_("Hard Disk 2"), "hard2"), (_("CD-ROM"), "cdrm"), (_("CD-ROM 1"), "cdrm1"), (_("CD-ROM 2"), "cdrm2"), (_("Snapshot (dump)"), "dump"), (_("Quickload"), "quickload"), (_("Memory Card"), "memc"), (_("Cylinder"), "cyln"), (_("Punch Tape 1"), "ptap1"), (_("Punch Tape 2"), "ptap2"), (_("Print Out"), "prin"), ], }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _("Command line arguments used when launching the game"), }, { "option": "slots", "type": "string", "label": ("Slot System"), "help": ("For slot devices that is needed for romsloads"), }, { "option": "autoboot_command", "type": "string", "section": _("Autoboot"), "label": _("Autoboot command"), "help": _( "Autotype this command when the system has started, " "an enter keypress is automatically added." ), }, { "option": "autoboot_delay", "type": "range", "section": _("Autoboot"), "label": _("Delay before entering autoboot command"), "min": 0, "max": 120, }, ] runner_options = [ { "option": "rompath", "type": "directory", "label": _("ROM/BIOS path"), "help": _( "Choose the folder containing ROMs and BIOS files.\n" "These files contain code from the original hardware " "necessary to the emulation." ), }, { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": True, }, { "option": "crt", "type": "bool", "section": _("Graphics"), "label": _("CRT effect ()"), "help": _("Applies a CRT effect to the screen." "Requires OpenGL renderer."), "default": False, }, { "option": "video", "type": "choice", "section": _("Graphics"), "label": _("Video backend"), "choices": ( (_("Auto"), ""), ("OpenGL", "opengl"), ("BGFX", "bgfx"), ("SDL2", "accel"), (_("Software"), "soft"), ), "default": "opengl", }, { "option": "waitvsync", "type": "bool", "section": _("Graphics"), "label": _("Wait for VSync"), "help": _( "Enable waiting for the start of vblank before " "flipping screens; reduces tearing effects." ), "advanced": True, "default": False, }, { "option": "uimodekey", "type": "choice_with_entry", "label": _("Menu mode key"), "choices": [ (_("Scroll Lock"), "SCRLOCK"), (_("Num Lock"), "NUMLOCK"), (_("Caps Lock"), "CAPSLOCK"), (_("Menu"), "MENU"), (_("Right Control"), "RCONTROL"), (_("Left Control"), "LCONTROL"), (_("Right Alt"), "RALT"), (_("Left Alt"), "LALT"), (_("Right Super"), "RWIN"), (_("Left Super"), "LWIN"), ], "default": "SCRLOCK", "advanced": True, "help": _("Key to switch between Full Keyboard Mode and " "Partial Keyboard Mode (default: Scroll Lock)"), }, ] @property def working_dir(self): return os.path.join(settings.RUNNER_DIR, "mame") @property def platforms(self): if self._platforms: return self.platforms self._platforms = [choice[0] for choice in get_system_choices(include_year=False)] self._platforms += [_("Arcade"), _("Nintendo Game & Watch")] return self._platforms def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*args): def on_mame_ready(result, error): notify_mame_xml(result, error) if callback: callback(*args) AsyncCall(write_mame_xml, on_mame_ready) super().install(install_ui_delegate, version=version, callback=on_runner_installed) @property def default_path(self): """Return the default path, use the runner's rompath""" main_file = self.game_config.get("main_file") if main_file: return os.path.dirname(main_file) return self.runner_config.get("rompath") def write_xml_list(self): """Write the full game list in XML to disk""" env = runtime.get_env() listxml_command = self.get_command() + ["-listxml"] os.makedirs(self.cache_dir, exist_ok=True) output, error_output = system.execute_with_error(listxml_command, env=env) if output: with open(self.xml_path, "w", encoding="utf-8") as xml_file: xml_file.write(output) logger.info("MAME XML list written to %s", self.xml_path) else: logger.warning("Couldn't get any output for mame -listxml: %s", error_output) def get_platform(self): selected_platform = self.game_config.get("platform") if selected_platform: return self.platforms[int(selected_platform)] if self.game_config.get("machine"): machine_mapping = {choice[1]: choice[0] for choice in get_system_choices(include_year=False)} # get_system_choices() can return [] if not yet ready, so we'll return # None in that case. return machine_mapping.get(self.game_config["machine"]) rom_file = os.path.basename(self.game_config.get("main_file", "")) if rom_file.startswith("gnw_"): return _("Nintendo Game & Watch") return _("Arcade") def prelaunch(self): if not system.path_exists(os.path.join(self.config_dir, "mame.ini")): try: os.makedirs(self.config_dir) except OSError: pass system.execute( self.get_command() + ["-createconfig", "-inipath", self.config_dir], env=runtime.get_env(), cwd=self.working_dir, ) def get_shader_params(self, shader_dir, shaders): """Returns a list of CLI parameters to apply a list of shaders""" params = [] shader_path = os.path.join(self.working_dir, "shaders", shader_dir) for index, shader in enumerate(shaders): params += ["-gl_glsl", "-glsl_shader_mame%s" % index, os.path.join(shader_path, shader)] return params def play(self): command = self.get_command() + ["-skip_gameinfo", "-inipath", self.config_dir] if self.runner_config.get("video"): command += ["-video", self.runner_config["video"]] if not self.runner_config.get("fullscreen"): command.append("-window") if self.runner_config.get("waitvsync"): command.append("-waitvsync") if self.runner_config.get("uimodekey"): command += ["-uimodekey", self.runner_config["uimodekey"]] if self.runner_config.get("crt"): command += self.get_shader_params("CRT-geom", ["Gaussx", "Gaussy", "CRT-geom-halation"]) command += ["-nounevenstretch"] if self.game_config.get("machine"): rompath = self.runner_config.get("rompath") if rompath: command += ["-rompath", rompath] command.append(self.game_config["machine"]) for slot_arg in split_arguments(self.game_config.get("slots")): command.append(slot_arg) device = self.game_config.get("device") if not device: raise GameConfigError(_("No device is set for machine %s") % self.game_config["machine"]) rom = self.game_config.get("main_file") if rom: command += ["-" + device, rom] else: rompath = os.path.dirname(self.game_config.get("main_file")) if not rompath: rompath = self.runner_config.get("rompath") rom = os.path.basename(self.game_config.get("main_file")) if not rompath: raise GameConfigError(_("The path '%s' is not set. please set it in the options.") % "rompath") command += ["-rompath", rompath, rom] if self.game_config.get("autoboot_command"): command += ["-autoboot_command", self.game_config["autoboot_command"] + "\\n"] if self.game_config.get("autoboot_delay"): command += ["-autoboot_delay", str(self.game_config["autoboot_delay"])] for arg in split_arguments(self.game_config.get("args")): command.append(arg) return {"command": command} lutris-0.5.19/lutris/runners/linux.py0000664000175000017500000001423214756670027016655 0ustar hibbyhibby"""Runner for Linux games""" # Standard Library import os import stat from gettext import gettext as _ from typing import Callable # Lutris Modules from lutris.exceptions import GameConfigError, MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.strings import split_arguments class linux(Runner): human_name = _("Linux") description = _("Runs native games") platforms = [_("Linux")] entry_point_option = "exe" game_options = [ { "option": "exe", "type": "file", "default_path": "game_path", "label": _("Executable"), "help": _("The game's main executable file"), }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _("Command line arguments used when launching the game"), }, { "option": "working_dir", "type": "directory", "label": _("Working directory"), "help": _( "The location where the game is run from.\n" "By default, Lutris uses the directory of the " "executable." ), }, { "option": "ld_preload", "type": "file", "label": _("Preload library"), "advanced": True, "help": _("A library to load before running the game's executable."), }, { "option": "ld_library_path", "type": "directory", "label": _("Add directory to LD_LIBRARY_PATH"), "advanced": True, "help": _( "A directory where libraries should be searched for " "first, before the standard set of directories; this is " "useful when debugging a new library or using a " "nonstandard library for special purposes." ), }, ] def __init__(self, config=None): super().__init__(config) self.ld_preload = None @property def game_exe(self): """Return the game's executable's path. The file may not exist, but this returns None if the exe path is not defined.""" exe = self.game_config.get("exe") if not exe: return None exe = os.path.expanduser(exe) # just in case! if os.path.isabs(exe): return exe if self.game_path: return os.path.join(self.game_path, exe) return system.find_executable(exe) def resolve_game_path(self): return super().resolve_game_path() or os.path.dirname(self.game_exe or "") def get_relative_exe(self, exe_path, working_dir): """Return a relative path if a working dir is provided Some games such as Unreal Gold fail to run if given the absolute path """ if exe_path and working_dir: relative = os.path.relpath(exe_path, start=working_dir) if not relative.startswith("../"): # We can't use the working dir implicitly to start a command # so we make it explicit with "./" if not os.path.isabs(relative): relative = "./" + relative return relative return exe_path @property def working_dir(self): """Return the working directory to use when running the game.""" option = self.game_config.get("working_dir") if option: return os.path.expanduser(option) if self.game_exe: return os.path.dirname(self.game_exe) return super().working_dir @property def nvidia_shader_cache_path(self): """Linux programs should get individual shader caches if possible.""" return self.game_path or self.shader_cache_dir def is_installed(self, flatpak_allowed: bool = True) -> bool: """Well of course Linux is installed, you're using Linux right ?""" return True def can_uninstall(self): return False def uninstall(self, uninstall_callback: Callable[[], None]) -> None: raise RuntimeError("Linux shouldn't be installed.") def get_launch_config_command(self, gameplay_info, launch_config): # The linux runner has no command (by default) beyond the 'exe' itself; # so the command in gameplay_info is discarded. if "command" in launch_config: command = split_arguments(launch_config["command"]) else: command = [] working_dir = os.path.expanduser(launch_config.get("working_dir") or self.working_dir) if "exe" in launch_config: config_exe = os.path.expanduser(launch_config["exe"] or "") command.append(self.get_relative_exe(config_exe, working_dir)) elif len(command) == 0: raise GameConfigError(_("The runner could not find a command or exe to use for this configuration.")) if launch_config.get("args"): command += split_arguments(launch_config["args"]) return command def get_command(self): # There's no command for a Linux game; the game executable # is the first thing in the game's command line, not any runner thing. return [] def play(self): """Run native game.""" launch_info = {} exe = self.game_exe if not exe or not system.path_exists(exe): raise MissingGameExecutableError(filename=exe) # Quit if the file is not executable mode = os.stat(exe).st_mode if not mode & stat.S_IXUSR: raise GameConfigError(_("The file %s is not executable") % exe) ld_preload = self.game_config.get("ld_preload") if ld_preload: launch_info["ld_preload"] = ld_preload ld_library_path = self.game_config.get("ld_library_path") if ld_library_path: launch_info["ld_library_path"] = os.path.expanduser(ld_library_path) command = [self.get_relative_exe(exe, self.working_dir)] args = self.game_config.get("args") or "" for arg in split_arguments(args): command.append(arg) launch_info["command"] = command return launch_info lutris-0.5.19/lutris/runners/reicast.py0000664000175000017500000001231414756670027017147 0ustar hibbyhibby# Standard Library import os import re import shutil from collections import Counter from configparser import RawConfigParser from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.runners.runner import Runner from lutris.util import joypad, system class reicast(Runner): human_name = _("Reicast") description = _("Sega Dreamcast emulator") platforms = [_("Sega Dreamcast")] runner_executable = "reicast/reicast.elf" entry_point_option = "iso" flatpak_id = "org.flycast.Flycast" joypads = None game_options = [ { "option": "iso", "type": "file", "label": _("Disc image file"), "help": _("The game data.\n" "Supported formats: ISO, CDI"), } ] def __init__(self, config=None): super().__init__(config) self.runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": False, }, { "option": "device_id_1", "type": "choice", "section": _("Gamepads"), "label": _("Gamepad 1"), "choices": self.get_joypads, "default": "-1", }, { "option": "device_id_2", "type": "choice", "section": _("Gamepads"), "label": _("Gamepad 2"), "choices": self.get_joypads, "default": "-1", }, { "option": "device_id_3", "type": "choice", "section": _("Gamepads"), "label": _("Gamepad 3"), "choices": self.get_joypads, "default": "-1", }, { "option": "device_id_4", "type": "choice", "section": _("Gamepads"), "label": _("Gamepad 4"), "choices": self.get_joypads, "default": "-1", }, ] def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*args): mapping_path = system.create_folder("~/.reicast/mappings") mapping_source = os.path.join(settings.RUNNER_DIR, "reicast/mappings") for mapping_file in os.listdir(mapping_source): shutil.copy(os.path.join(mapping_source, mapping_file), mapping_path) system.create_folder("~/.reicast/data") super().install(install_ui_delegate, version, on_runner_installed) def get_joypads(self): """Return list of joypad in a format usable in the options""" if self.joypads: return self.joypads joypad_list = [("No joystick", "-1")] joypad_devices = joypad.get_joypads() name_counter = Counter([j[1] for j in joypad_devices]) name_indexes = {} for dev, joy_name in joypad_devices: dev_id = re.findall(r"(\d+)", dev)[0] if name_counter[joy_name] > 1: if joy_name not in name_indexes: index = 1 else: index = name_indexes[joy_name] + 1 name_indexes[joy_name] = index else: index = 0 if index: joy_name += " (%d)" % index joypad_list.append((joy_name, dev_id)) self.joypads = joypad_list return joypad_list @staticmethod def write_config(config): # use RawConfigParser to preserve case-sensitive configs written by Reicast # otherwise, Reicast will write with mixed-case and Lutris will overwrite with all lowercase # which will confuse Reicast parser = RawConfigParser() parser.optionxform = lambda option: option config_path = os.path.expanduser("~/.reicast/emu.cfg") if system.path_exists(config_path): with open(config_path, "r", encoding="utf-8") as config_file: parser.read_file(config_file) for section in config: if not parser.has_section(section): parser.add_section(section) for key, value in config[section].items(): parser.set(section, key, str(value)) with open(config_path, "w", encoding="utf-8") as config_file: parser.write(config_file) def play(self): fullscreen = "1" if self.runner_config.get("fullscreen") else "0" reicast_config = { "x11": {"fullscreen": fullscreen}, "input": {}, "players": {"nb": "1"}, } players = 1 reicast_config["input"] = {} for index in range(1, 5): config_string = "device_id_%d" % index joy_id = self.runner_config.get(config_string) or "-1" reicast_config["input"]["evdev_{}".format(config_string)] = joy_id if index > 1 and joy_id != "-1": players += 1 reicast_config["players"]["nb"] = players self.write_config(reicast_config) iso = self.game_config.get("iso") return {"command": self.get_command() + ["-config", f"config:image={iso}"]} lutris-0.5.19/lutris/runners/commands/0000775000175000017500000000000014756670027016743 5ustar hibbyhibbylutris-0.5.19/lutris/runners/commands/__init__.py0000664000175000017500000000000014756670027021042 0ustar hibbyhibbylutris-0.5.19/lutris/runners/commands/wine.py0000664000175000017500000004367414756670027020275 0ustar hibbyhibby"""Wine commands for installers""" # pylint: disable=too-many-arguments import os import shlex import time from lutris import runtime, settings from lutris.monitored_command import MonitoredCommand from lutris.runners import import_runner from lutris.util import linux, system from lutris.util.log import logger from lutris.util.shell import get_shell_command from lutris.util.strings import split_arguments from lutris.util.wine import proton from lutris.util.wine.cabinstall import CabInstaller from lutris.util.wine.prefix import WinePrefixManager from lutris.util.wine.wine import ( WINE_DEFAULT_ARCH, WINE_DIR, detect_arch, get_overrides_env, get_real_executable, is_installed_systemwide, is_prefix_directory, ) def set_regedit( path, key, value="", type="REG_SZ", # pylint: disable=redefined-builtin wine_path=None, prefix=None, arch=WINE_DEFAULT_ARCH, ): """Add keys to the windows registry. Path is something like HKEY_CURRENT_USER/Software/Wine/Direct3D """ formatted_value = { "REG_SZ": '"%s"' % value, "REG_DWORD": "dword:" + value, "REG_BINARY": "hex:" + value.replace(" ", ","), "REG_MULTI_SZ": "hex(2):" + value, "REG_EXPAND_SZ": "hex(7):" + value, } # Make temporary reg file reg_path = os.path.join(settings.CACHE_DIR, "winekeys.reg") with open(reg_path, "w", encoding="utf-8") as reg_file: reg_file.write('REGEDIT4\n\n[%s]\n"%s"=%s\n' % (path, key, formatted_value[type])) logger.debug("Setting [%s]:%s=%s", path, key, formatted_value[type]) set_regedit_file(reg_path, wine_path=wine_path, prefix=prefix, arch=arch) os.remove(reg_path) def set_regedit_file(filename, wine_path=None, prefix=None, arch=WINE_DEFAULT_ARCH, proton_verb=None): """Apply a regedit file to the Windows registry.""" if arch == "win64" and wine_path and system.path_exists(wine_path + "64"): # Use wine64 by default if set to a 64bit prefix. Using regular wine # will prevent some registry keys from being created. Most likely to be # a bug in Wine. see: https://github.com/lutris/lutris/issues/804 wine_path = wine_path + "64" if proton.is_proton_path(wine_path): proton_verb = "run" wineexec( "regedit", args="/S '%s'" % filename, wine_path=wine_path, prefix=prefix, arch=arch, blocking=True, proton_verb=proton_verb, ) def delete_registry_key(key, wine_path=None, prefix=None, arch=WINE_DEFAULT_ARCH, proton_verb=None): """Deletes a registry key from a Wine prefix""" if proton.is_proton_path(wine_path): proton_verb = "run" wineexec( "regedit", args='/S /D "%s"' % key, wine_path=wine_path, prefix=prefix, arch=arch, blocking=True, proton_verb=proton_verb, ) def create_prefix( prefix, wine_path=None, arch=WINE_DEFAULT_ARCH, overrides=None, install_gecko=None, install_mono=None, runner=None ): """Create a new Wine prefix.""" # pylint: disable=too-many-locals if overrides is None: overrides = {} if not prefix: raise ValueError("No Wine prefix path given") prefix = os.path.expanduser(prefix) logger.info("Creating a %s prefix in %s", arch, prefix) # Follow symlinks, don't delete existing ones as it would break some setups if os.path.islink(prefix): prefix = os.readlink(prefix) # Avoid issue of 64bit Wine refusing to create win32 prefix # over an existing empty folder. if os.path.isdir(prefix) and not os.listdir(prefix): try: os.rmdir(prefix) except OSError: logger.error("Failed to delete %s, you may lack permissions on this folder.", prefix) if not wine_path: if not runner: runner = import_runner("wine")() wine_path = runner.get_executable() logger.info("Winepath: %s", wine_path) wineenv = { "WINEARCH": arch, "WINEPREFIX": prefix, "WINEDLLOVERRIDES": get_overrides_env(overrides), "WINE_MONO_CACHE_DIR": os.path.join(os.path.dirname(os.path.dirname(wine_path)), "mono"), "WINE_GECKO_CACHE_DIR": os.path.join(os.path.dirname(os.path.dirname(wine_path)), "gecko"), } if install_gecko == "False": wineenv["WINE_SKIP_GECKO_INSTALLATION"] = "1" overrides["mshtml"] = "disabled" if install_mono == "False": wineenv["WINE_SKIP_MONO_INSTALLATION"] = "1" overrides["mscoree"] = "disabled" if proton.is_proton_path(wine_path): # All proton path prefixes are created via Umu; if you aren't using # the default Umu, we'll use PROTONPATH to indicate what Proton is # to be used. wineenv["PROTON_VERB"] = "run" proton.update_proton_env(wine_path, wineenv) command = MonitoredCommand([proton.get_umu_path(), "createprefix"], env=wineenv) command.start() else: wineboot_path = os.path.join(os.path.dirname(wine_path), "wineboot") if not system.path_exists(wineboot_path): logger.error( "No wineboot executable found in %s, " "your wine installation is most likely broken", wine_path, ) return system.execute([wineboot_path], env=wineenv) for loop_index in range(1000): time.sleep(0.5) if ( system.path_exists(os.path.join(prefix, "user.reg")) and system.path_exists(os.path.join(prefix, "userdef.reg")) and system.path_exists(os.path.join(prefix, "system.reg")) ): break if loop_index == 60: logger.warning("Wine prefix creation is taking longer than expected...") if not os.path.exists(os.path.join(prefix, "user.reg")): logger.error("No user.reg found after prefix creation. Prefix might not be valid") return logger.info("%s Prefix created in %s", arch, prefix) prefix_manager = WinePrefixManager(prefix) prefix_manager.setup_defaults() def winekill(prefix, arch=WINE_DEFAULT_ARCH, wine_path=None, env=None, initial_pids=None, runner=None): """Kill processes in Wine prefix.""" initial_pids = initial_pids or [] if not env: env = {"WINEARCH": arch, "WINEPREFIX": prefix} if proton.is_proton_path(wine_path): command = [proton.get_umu_path(), "wineboot", "-k"] env["GAMEID"] = proton.DEFAULT_GAMEID env["WINEPREFIX"] = prefix env["PROTON_VERB"] = "runinprefix" env["PROTONPATH"] = proton.get_proton_path_by_path(wine_path) else: if not wine_path: if not runner: runner = import_runner("wine")() wine_path = runner.get_executable() wine_root = os.path.dirname(wine_path) command = [os.path.join(wine_root, "wineserver"), "-k"] logger.debug("Killing all wine processes (%s) in prefix %s: %s", initial_pids, prefix, command) logger.debug(command) logger.debug(" ".join(command)) system.execute(command, env=env, quiet=True) logger.debug("Waiting for wine processes to terminate") # Wineserver needs time to terminate processes num_cycles = 0 while True: num_cycles += 1 running_processes = [pid for pid in initial_pids if system.path_exists("/proc/%s" % pid)] if not running_processes: break if num_cycles > 20: logger.warning( "Some wine processes are still running: %s", ", ".join(running_processes), ) break time.sleep(0.1) logger.debug("Done waiting.") def use_lutris_runtime(wine_path, force_disable=False): """Returns whether to use the Lutris runtime. The runtime can be forced to be disabled, otherwise it's disabled automatically if Wine is installed system wide. """ if proton.is_proton_path(wine_path): return False if force_disable or runtime.RUNTIME_DISABLED: return False if WINE_DIR in wine_path: return True if is_installed_systemwide(): return False return True # pragma pylint: disable=too-many-locals def wineexec( executable, args="", wine_path=None, prefix=None, arch=None, working_dir=None, winetricks_wine="", blocking=False, config=None, include_processes=None, exclude_processes=None, disable_runtime=False, env=None, overrides=None, runner=None, proton_verb=None, ): """ Execute a Wine command. Args: executable (str): wine program to run, pass None to run wine itself args (str): program arguments wine_path (str): path to the wine version to use prefix (str): path to the wine prefix to use arch (str): wine architecture of the prefix working_dir (str): path to the working dir for the process winetricks_wine (str): path to the wine version used by winetricks blocking (bool): if true, do not run the process in a thread config (LutrisConfig): LutrisConfig object for the process context watch (list): list of process names to monitor (even when in a ignore list) runner (runner): the wine runner that carries the configuration to use Returns: Process results if the process is running in blocking mode or MonitoredCommand instance otherwise. """ if env is None: env = {} if exclude_processes is None: exclude_processes = [] if include_processes is None: include_processes = [] executable = str(executable) if executable else "" if isinstance(include_processes, str): include_processes = shlex.split(include_processes) if isinstance(exclude_processes, str): exclude_processes = shlex.split(exclude_processes) if not runner: runner = import_runner("wine")(prefix=prefix, working_dir=working_dir, wine_arch=arch) if not wine_path: wine_path = runner.get_executable() if not working_dir: if os.path.isfile(executable): working_dir = os.path.dirname(executable) executable, _args, working_dir = get_real_executable(executable, working_dir) if _args: args = '{} "{}"'.format(_args[0], _args[1]) wineenv = {"WINEARCH": arch} if winetricks_wine and winetricks_wine is not wine_path and not proton.is_proton_path(wine_path): wineenv["WINE"] = winetricks_wine else: wineenv["WINE"] = wine_path if prefix: wineenv["WINEPREFIX"] = prefix # Create prefix if necessary if arch not in ("win32", "win64"): arch = detect_arch(prefix, wine_path) if not is_prefix_directory(prefix): wine_bin = winetricks_wine if winetricks_wine and not proton.is_proton_path(wine_path) else wine_path create_prefix(prefix, wine_path=wine_bin, arch=arch, runner=runner) wine_system_config = config.system_config if config else runner.system_config disable_runtime = disable_runtime or wine_system_config["disable_runtime"] if use_lutris_runtime(wine_path=wineenv["WINE"], force_disable=disable_runtime) and not proton.is_proton_path( wine_path ): if WINE_DIR in wine_path: wine_root_path = os.path.dirname(os.path.dirname(wine_path)) elif winetricks_wine and WINE_DIR in winetricks_wine: wine_root_path = os.path.dirname(os.path.dirname(winetricks_wine)) else: wine_root_path = None wineenv["LD_LIBRARY_PATH"] = ":".join( runtime.get_paths( prefer_system_libs=wine_system_config["prefer_system_libs"], wine_path=wine_root_path, ) ) if overrides: wineenv["WINEDLLOVERRIDES"] = get_overrides_env(overrides) if proton_verb: wineenv["PROTON_VERB"] = proton_verb baseenv = runner.get_env(disable_runtime=disable_runtime) baseenv.update(wineenv) baseenv.update(env) if proton.is_proton_path(wine_path): proton.update_proton_env(wine_path, baseenv) command_parameters = [] if proton.is_proton_path(wine_path): command_parameters.append(proton.get_umu_path()) if winetricks_wine and wine_path not in winetricks_wine: command_parameters.append("winetricks") else: command_parameters.append(wine_path) if executable: command_parameters.append(executable) command_parameters += split_arguments(args) runner.prelaunch() if blocking: return system.execute(command_parameters, env=baseenv, cwd=working_dir) command = MonitoredCommand( command_parameters, runner=runner, env=baseenv, cwd=working_dir, include_processes=include_processes, exclude_processes=exclude_processes, ) command.start() return command # pragma pylint: enable=too-many-locals def find_winetricks(env=None, system_winetricks=False): """Find winetricks path.""" winetricks_path = os.path.join(settings.RUNTIME_DIR, "winetricks/winetricks") if system_winetricks or not system.path_exists(winetricks_path): winetricks_path = system.find_required_executable("winetricks") working_dir = None else: # We will use our own zenity if available, which is here, and it # also needs a data file in this directory. We have to set the # working_dir, so it will find the data file. working_dir = os.path.join(settings.RUNTIME_DIR, "winetricks") if not env: env = {} path = env.get("PATH", os.environ["PATH"]) env["PATH"] = "%s:%s" % (working_dir, path) return (winetricks_path, working_dir, env) def winetricks( app, prefix=None, arch=None, silent=True, wine_path=None, config=None, env=None, disable_runtime=False, system_winetricks=False, runner=None, proton_verb=None, ): """Execute winetricks.""" winetricks_path, working_dir, env = find_winetricks(env, system_winetricks) if wine_path: winetricks_wine = wine_path if proton.is_proton_path(wine_path): protonfixes_path = os.path.join(proton.get_proton_path_by_path(wine_path), "protonfixes") if os.path.exists(protonfixes_path): winetricks_wine = os.path.join(protonfixes_path, "winetricks") winetricks_path = wine_path if not app: silent = False app = "--gui" else: logger.info("winetricks: Valve official Proton builds do not support winetricks.") return else: if not runner: runner = import_runner("wine")() winetricks_wine = runner.get_executable() if arch not in ("win32", "win64"): arch = detect_arch(prefix, winetricks_wine) args = app if str(silent).lower() in ("yes", "on", "true") and not proton.is_proton_path(wine_path): args = "-q " + args else: if proton.is_proton_path(wine_path): proton_verb = "waitforexitandrun" # Execute wineexec return wineexec( None, prefix=prefix, winetricks_wine=winetricks_wine, wine_path=winetricks_path, working_dir=working_dir, arch=arch, args=args, config=config, env=env, disable_runtime=disable_runtime, runner=runner, proton_verb=proton_verb, ) def winecfg(wine_path=None, prefix=None, arch=WINE_DEFAULT_ARCH, config=None, env=None, runner=None, proton_verb=None): """Execute winecfg.""" if not wine_path: logger.debug("winecfg: Reverting to default wine") wine = import_runner("wine") wine_path = wine().get_executable() if proton.is_proton_path(wine_path): proton_verb = "waitforexitandrun" return wineexec( "winecfg.exe", prefix=prefix, winetricks_wine=wine_path, wine_path=wine_path, arch=arch, config=config, env=env, include_processes=["winecfg.exe"], runner=runner, proton_verb=proton_verb, ) def eject_disc(wine_path, prefix, proton_verb=None): """Use Wine to eject a drive""" if proton.is_proton_path(wine_path): proton_verb = "run" wineexec("eject", prefix=prefix, wine_path=wine_path, args="-a", proton_verb=proton_verb) def install_cab_component(cabfile, component, wine_path=None, prefix=None, arch=None, proton_verb=None): """Install a component from a cabfile in a prefix""" if proton.is_proton_path(wine_path): proton_verb = "run" cab_installer = CabInstaller(prefix, wine_path=wine_path, arch=arch) files = cab_installer.extract_from_cab(cabfile, component) registry_files = cab_installer.get_registry_files(files) for registry_file, _arch in registry_files: set_regedit_file(registry_file, wine_path=wine_path, prefix=prefix, arch=_arch, proton_verb=proton_verb) cab_installer.cleanup() def open_wine_terminal(terminal, wine_path, prefix, env, system_winetricks): winetricks_path, _working_dir, env = find_winetricks(env, system_winetricks) path_paths = [os.path.dirname(wine_path)] if proton.is_proton_path(wine_path): proton.update_proton_env(wine_path, env) umu_path = proton.get_umu_path() path_paths.insert(0, os.path.dirname(umu_path)) wine_command = umu_path + " wine" else: wine_command = wine_path aliases = { "wine": wine_command, "winecfg": wine_command + "cfg", "wineserver": wine_command + "server", "wineboot": wine_command + "boot", "winetricks": winetricks_path, } env["WINEPREFIX"] = prefix # Ensure scripts you run see the desired version of WINE too # by putting it on the PATH. path_paths.append(env.get("PATH", os.environ["PATH"])) path_paths = [p for p in path_paths if p] if path_paths: env["PATH"] = ":".join(path_paths) shell_command = get_shell_command(prefix, env, aliases) terminal = terminal or linux.get_default_terminal() system.spawn([terminal, "-e", shell_command]) lutris-0.5.19/lutris/runners/commands/dosbox.py0000664000175000017500000000334114756670027020614 0ustar hibbyhibby"""DOSBox installer commands""" # Standard Library import os # Lutris Modules from lutris import runtime from lutris.runners import import_runner from lutris.util import system from lutris.util.log import logger def dosexec(config_file=None, executable=None, args=None, close_on_exit=True, working_dir=None): """Execute Dosbox with given config_file.""" if config_file: run_with = "config {}".format(config_file) if not working_dir: working_dir = os.path.dirname(config_file) elif executable: run_with = "executable {}".format(executable) if not working_dir: working_dir = os.path.dirname(executable) else: raise ValueError("Neither a config file or an executable were provided") logger.debug("Running dosbox with %s", run_with) working_dir = system.create_folder(working_dir) dosbox = import_runner("dosbox") dosbox_runner = dosbox() command = [dosbox_runner.get_executable()] if config_file: command += ["-conf", config_file] if executable: if not system.path_exists(executable): raise OSError("Can't find file {}".format(executable)) command += [executable] if args: command += args.split() if close_on_exit: command.append("-exit") system.execute(command, cwd=working_dir, env=runtime.get_env()) def makeconfig(path, drives, commands): system.create_folder(os.path.dirname(path)) with open(path, "w", encoding="utf-8") as config_file: config_file.write("[autoexec]\n") for drive in drives: config_file.write('mount {} "{}"\n'.format(drive, drives[drive])) for command in commands: config_file.write("{}\n".format(command)) lutris-0.5.19/lutris/runners/yuzu.py0000664000175000017500000000651614756670027016540 0ustar hibbyhibbyimport filecmp import os from gettext import gettext as _ from shutil import copyfile from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger class yuzu(Runner): human_name = _("Yuzu") platforms = [_("Nintendo Switch")] description = _("Nintendo Switch emulator") runnable_alone = True runner_executable = "yuzu/yuzu-mainline.AppImage" flatpak_id = "org.yuzu_emu.yuzu" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ { "option": "prod_keys", "label": _("Encryption keys"), "type": "file", "help": _("File containing the encryption keys."), }, { "option": "title_keys", "label": _("Title keys"), "type": "file", "help": _("File containing the title keys."), }, { "option": "fullscreen", "label": _("Fullscreen"), "type": "bool", "default": True, }, ] # yuzu currently uses an AppImage, no need for the runtime. system_options_override = [{"option": "disable_runtime", "default": True}] @property def yuzu_data_dir(self): """Return dir where Yuzu files lie.""" candidates = ("~/.local/share/yuzu",) for candidate in candidates: path = system.fix_path_case(os.path.join(os.path.expanduser(candidate), "nand")) if system.path_exists(path): return path[: -len("nand")] def play(self): """Run the game.""" arguments = self.get_command() fullscreen = self.runner_config.get("fullscreen") if fullscreen: arguments.append("-f") rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments += ["-g", rom] return {"command": arguments} def _update_key(self, key_type): """Update a keys file if set""" yuzu_data_dir = self.yuzu_data_dir if not yuzu_data_dir: logger.error("Yuzu data dir not set") return if key_type == "prod_keys": key_loc = os.path.join(yuzu_data_dir, "keys/prod.keys") elif key_type == "title_keys": key_loc = os.path.join(yuzu_data_dir, "keys/title.keys") else: logger.error("Invalid keys type %s!", key_type) return key = self.runner_config.get(key_type) if not key: logger.debug("No %s file was set.", key_type) return if not system.path_exists(key): logger.warning("Keys file %s does not exist!", key) return keys_dir = os.path.dirname(key_loc) if not os.path.exists(keys_dir): os.makedirs(keys_dir) elif os.path.isfile(key_loc) and filecmp.cmp(key, key_loc): # If the files are identical, don't do anything return copyfile(key, key_loc) def prelaunch(self): for key in ["prod_keys", "title_keys"]: self._update_key(key_type=key) lutris-0.5.19/lutris/runners/fsuae.py0000664000175000017500000004116714756670027016630 0ustar hibbyhibbyimport os from collections import defaultdict from gettext import gettext as _ from lutris import settings from lutris.runners.runner import Runner from lutris.util import system from lutris.util.display import DISPLAY_MANAGER AMIGAS = { "A500": { "name": _("Amiga 500"), "bios_sha1": [ "891e9a547772fe0c6c19b610baf8bc4ea7fcb785", "c39bd9094d4e5f4e28c1411f3086950406062e87", "90933936cce43ca9bc6bf375662c076b27e3c458", ], }, "A500+": {"name": _("Amiga 500+"), "bios_sha1": ["c5839f5cb98a7a8947065c3ed2f14f5f42e334a1"]}, "A600": {"name": _("Amiga 600"), "bios_sha1": ["02843c4253bbd29aba535b0aa3bd9a85034ecde4"]}, "A1200": {"name": _("Amiga 1200"), "bios_sha1": ["e21545723fe8374e91342617604f1b3d703094f1"]}, "A3000": {"name": _("Amiga 3000"), "bios_sha1": ["f8e210d72b4c4853e0c9b85d223ba20e3d1b36ee"]}, "A4000": { "name": _("Amiga 4000"), "bios_sha1": ["5fe04842d04a489720f0f4bb0e46948199406f49", "c3c481160866e60d085e436a24db3617ff60b5f9"], }, "A1000": {"name": _("Amiga 1000"), "bios_sha1": ["11f9e62cf299f72184835b7b2a70a16333fc0d88"]}, "CD32": { "name": _("Amiga CD32"), "bios_sha1": ["3525be8887f79b5929e017b42380a79edfee542d"], "bios_ext_sha1": ["5bef3d628ce59cc02a66e6e4ae0da48f60e78f7f"], }, "CDTV": { "name": _("Commodore CDTV"), "bios_sha1": [ "891e9a547772fe0c6c19b610baf8bc4ea7fcb785", "c39bd9094d4e5f4e28c1411f3086950406062e87", "90933936cce43ca9bc6bf375662c076b27e3c458", ], "bios_ext_sha1": ["7ba40ffa17e500ed9fed041f3424bd81d9c907be"], }, } def get_bios_hashes(): """Return mappings of sha1 hashes to Amiga models The first mapping contains the kickstarts and the second one, the extensions (for CD32/CDTV) """ hashes = defaultdict(list) ext_hashes = defaultdict(list) for model, model_def in AMIGAS.items(): for sha1_hash in model_def["bios_sha1"]: hashes[sha1_hash].append(model) if "bios_ext_sha1" in model_def: for sha1_hash in model_def["bios_ext_sha1"]: ext_hashes[sha1_hash].append(model) return hashes, ext_hashes def scan_dir_for_bios(path): """Return a tuple of mappings of Amiga models and their corresponding kickstart file. Kickstart files must reside in `path` The first mapping contains the kickstarts and the second one, the extensions (for CD32/CDTV) """ bios_sizes = [262144, 524288] hashes, ext_hashes = get_bios_hashes() found_bios = {} found_ext = {} incomplete_bios = [] for file_name in os.listdir(path): abs_path = os.path.join(path, file_name) file_size = os.path.getsize(abs_path) if file_size not in bios_sizes: continue checksum = system.get_file_checksum(abs_path, "sha1") if checksum in hashes: for model in hashes[checksum]: found_bios[model] = abs_path if checksum in ext_hashes: for model in ext_hashes[checksum]: found_ext[model] = abs_path for model in found_bios: if "bios_ext_sha1" in AMIGAS[model] and model not in found_ext: incomplete_bios.append(model) found_bios = {k: v for k, v in found_bios.items() if k not in incomplete_bios} return found_bios, found_ext class fsuae(Runner): human_name = _("FS-UAE") description = _("Amiga emulator") flatpak_id = "net.fsuae.FS-UAE" platforms = [ AMIGAS["A500"]["name"], AMIGAS["A500+"]["name"], AMIGAS["A600"]["name"], AMIGAS["A1200"]["name"], AMIGAS["A3000"]["name"], AMIGAS["A4000"]["name"], AMIGAS["A1000"]["name"], AMIGAS["CD32"]["name"], AMIGAS["CDTV"]["name"], ] model_choices = [(model["name"], key) for key, model in AMIGAS.items()] cpumodel_choices = [ (_("68000"), "68000"), (_("68010"), "68010"), (_("68020 with 24-bit addressing"), "68EC020"), (_("68020"), "68020"), (_("68030 without internal MMU"), "68EC030"), (_("68030"), "68030"), (_("68040 without internal FPU and MMU"), "68EC040"), (_("68040 without internal FPU"), "68LC040"), (_("68040 without internal MMU"), "68040-NOMMU"), (_("68040"), "68040"), (_("68060 without internal FPU and MMU"), "68EC060"), (_("68060 without internal FPU"), "68LC060"), (_("68060 without internal MMU"), "68060-NOMMU"), (_("68060"), "68060"), (_("Auto"), "auto"), ] memory_choices = [ (_("0"), "0"), (_("1 MB"), "1024"), (_("2 MB"), "2048"), (_("4 MB"), "4096"), (_("8 MB"), "8192"), ] zorroiii_choices = [ (_("0"), "0"), (_("1 MB"), "1024"), (_("2 MB"), "2048"), (_("4 MB"), "4096"), (_("8 MB"), "8192"), (_("16 MB"), "16384"), (_("32 MB"), "32768"), (_("64 MB"), "65536"), (_("128 MB"), "131072"), (_("256 MB"), "262144"), (_("384 MB"), "393216"), (_("512 MB"), "524288"), (_("768 MB"), "786432"), (_("1 GB"), "1048576"), ] flsound_choices = [ ("0", "0"), ("25", "25"), ("50", "50"), ("75", "75"), ("100", "100"), ] gpucard_choices = [ ("None", "None"), ("UAEGFX", "uaegfx"), ("UAEGFX Zorro II", "uaegfx-z2"), ("UAEGFX Zorro III", "uaegfx-z3"), ("Picasso II Zorro II", "picasso-ii"), ("Picasso II+ Zorro II", "picasso-ii+"), ("Picasso IV", "picasso-iv"), ("Picasso IV Zorro II", "picasso-iv-z2"), ("Picasso IV Zorro III", "picasso-iv-z3"), ] gpumem_choices = [ (_("0"), "0"), (_("1 MB"), "1024"), (_("2 MB"), "2048"), (_("4 MB"), "4096"), (_("8 MB"), "8192"), (_("16 MB"), "16384"), (_("32 MB"), "32768"), (_("64 MB"), "65536"), (_("128 MB"), "131072"), (_("256 MB"), "262144"), ] flspeed_choices = [ (_("Turbo"), "0"), ("100%", "100"), ("200%", "200"), ("400%", "400"), ("800%", "800"), ] runner_executable = "fs-uae/fs-uae" game_options = [ { "option": "main_file", "type": "file", "label": _("Boot disk"), "default_path": "game_path", "help": _( "The main floppy disk file with the game data. \n" "FS-UAE supports floppy images in multiple file formats: " "ADF, IPF, DMS are the most common. ADZ (compressed ADF) " "and ADFs in zip files are a also supported.\n" "Files ending in .hdf will be mounted as hard drives and " "ISOs can be used for Amiga CD32 and CDTV models." ), }, { "option": "disks", "section": _("Media"), "type": "multiple_file", "label": _("Additional floppies"), "default_path": "game_path", "help": _("The additional floppy disk image(s)."), }, { "option": "cdrom_image", "section": _("Media"), "label": _("CD-ROM image"), "type": "file", "help": _("CD-ROM image to use on non CD32/CDTV models"), }, ] runner_options = [ { "option": "model", "label": _("Amiga model"), "type": "choice", "choices": model_choices, "default": "A500", "help": _("Specify the Amiga model you want to emulate."), }, { "option": "kickstart_file", "section": _("Kickstart"), "label": _("Kickstart ROMs location"), "type": "file", "help": _( "Choose the folder containing original Amiga Kickstart " "ROMs. Refer to FS-UAE documentation to find how to " "acquire them. Without these, FS-UAE uses a bundled " "replacement ROM which is less compatible with Amiga " "software." ), }, { "option": "kickstart_ext_file", "section": _("Kickstart"), "label": _("Extended Kickstart location"), "type": "file", "help": _("Location of extended Kickstart used for CD32"), }, { "option": "gfx_fullscreen_amiga", "section": _("Graphics"), "label": _("Fullscreen (F12 + F to switch)"), "type": "bool", "default": False, }, { "option": "scanlines", "section": _("Graphics"), "label": _("Scanlines display style"), "type": "bool", "default": False, "help": _("Activates a display filter adding scanlines to imitate " "the displays of yesteryear."), }, { "option": "grafixcard", "section": _("Graphics"), "label": _("Graphics Card"), "type": "choice", "choices": gpucard_choices, "default": "None", "advanced": True, "help": _( "Use this option to enable a graphics card. This option is none by default, in " "which case only chipset graphics (OCS/ECS/AGA) support is available." ), }, { "option": "grafixmemory", "section": _("Graphics"), "label": _("Graphics Card RAM"), "type": "choice", "choices": gpumem_choices, "default": "0", "advanced": True, "help": _( "Override the amount of graphics memory on the graphics card. The 0 MB option is " "not really valid, but exists for user interface reasons." ), }, { "option": "cpumodel", "label": _("CPU"), "type": "choice", "choices": cpumodel_choices, "default": "auto", "advanced": True, "help": _( "Use this option to override the CPU model in the emulated Amiga. All Amiga " "models imply a default CPU model, so you only need to use this option if you " "want to use another CPU." ), }, { "option": "fmemory", "label": _("Fast Memory"), "type": "choice", "choices": memory_choices, "default": "0", "advanced": True, "help": _("Specify how much Fast Memory the Amiga model should have."), }, { "option": "ziiimem", "label": _("Zorro III RAM"), "type": "choice", "choices": zorroiii_choices, "default": "0", "advanced": True, "help": _( "Override the amount of Zorro III Fast memory, specified in KB. Must be a " "multiple of 1024. The default value depends on [amiga_model]. Requires a " "processor with 32-bit address bus, (use for example the A1200/020 model)." ), }, { "option": "fdvolume", "section": _("Media"), "label": _("Floppy Drive Volume"), "type": "choice", "choices": flsound_choices, "default": "0", "advanced": True, "help": _("Set volume to 0 to disable floppy drive clicks " "when the drive is empty. Max volume is 100."), }, { "option": "fdspeed", "section": _("Media"), "label": _("Floppy Drive Speed"), "type": "choice", "choices": flspeed_choices, "default": "100", "advanced": True, "help": _( "Set the speed of the emulated floppy drives, in percent. " "For example, you can specify 800 to get an 8x increase in " "speed. Use 0 to specify turbo mode. Turbo mode means that " "all floppy operations complete immediately. The default is 100 for most models." ), }, { "option": "jitcompiler", "label": _("JIT Compiler"), "type": "bool", "default": False, "advanced": True, }, { "option": "gamemode", "label": _("Feral GameMode"), "type": "bool", "default": False, "advanced": True, "help": _("Automatically uses Feral GameMode daemon if available. " "Set to true to disable the feature."), }, { "option": "govwarning", "label": _("CPU governor warning"), "type": "bool", "default": False, "advanced": True, "help": _( "Warn if running with a CPU governor other than performance. " "Set to true to disable the warning." ), }, { "option": "bsdsocket", "label": _("UAE bsdsocket.library"), "type": "bool", "default": False, "advanced": True, }, ] @property def directory(self): return os.path.join(settings.RUNNER_DIR, "fs-uae") def get_platform(self): model = self.runner_config.get("model") if model: for index, machine in enumerate(self.model_choices): if machine[1] == model: return self.platforms[index] return "" def get_absolute_path(self, path): """Return the absolute path for a file""" return path if os.path.isabs(path) else os.path.join(self.game_path, path) def insert_floppies(self): disks = [] main_disk = self.game_config.get("main_file") if main_disk: disks.append(main_disk) game_disks = self.game_config.get("disks") or [] for disk in game_disks: if disk not in disks: disks.append(disk) # Make all paths absolute disks = [self.get_absolute_path(disk) for disk in disks] drives = [] floppy_images = [] for drive, disk_path in enumerate(disks): disk_param = self.get_disk_param(disk_path) drives.append("--%s_%d=%s" % (disk_param, drive, disk_path)) if disk_param == "floppy_drive": floppy_images.append("--floppy_image_%d=%s" % (drive, disk_path)) cdrom_image = self.game_config.get("cdrom_image") if cdrom_image: drives.append("--cdrom_drive_0=%s" % self.get_absolute_path(cdrom_image)) return drives + floppy_images def get_disk_param(self, disk_path): amiga_model = self.runner_config.get("model") if amiga_model in ("CD32", "CDTV"): return "cdrom_drive" if disk_path.lower().endswith(".hdf"): return "hard_drive" return "floppy_drive" def get_params(self): # pylint: disable=too-many-branches params = [] option_params = { "kickstart_file": "--kickstart_file=%s", "kickstart_ext_file": "--kickstart_ext_file=%s", "model": "--amiga_model=%s", "cpumodel": "--cpu=%s", "fmemory": "--fast_memory=%s", "ziiimem": "--zorro_iii_memory=%s", "fdvolume": "--floppy_drive_volume=%s", "fdspeed": "--floppy_drive_speed=%s", "grafixcard": "--graphics_card=%s", "grafixmemory": "--graphics_memory=%s", } for option, param in option_params.items(): option_value = self.runner_config.get(option) if option_value: params.append(param % option_value) if self.runner_config.get("gfx_fullscreen_amiga"): width = DISPLAY_MANAGER.get_current_resolution()[0] params.append("--fullscreen") # params.append("--fullscreen_mode=fullscreen-window") params.append("--fullscreen_mode=fullscreen") params.append("--fullscreen_width=%s" % width) if self.runner_config.get("jitcompiler"): params.append("--jit_compiler=1") if self.runner_config.get("bsdsocket"): params.append("--bsdsocket_library=1") if self.runner_config.get("gamemode"): params.append("--game_mode=0") if self.runner_config.get("govwarning"): params.append("--governor_warning=0") if self.runner_config.get("scanlines"): params.append("--scanlines=1") return params def play(self): return {"command": self.get_command() + self.get_params() + self.insert_floppies()} lutris-0.5.19/lutris/runners/json.py0000664000175000017500000000651514756670027016474 0ustar hibbyhibby"""Base class and utilities for JSON based runners""" import json import os import shlex from lutris import settings from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import datapath, system JSON_RUNNER_DIRS = [ os.path.join(datapath.get(), "json"), os.path.join(settings.RUNNER_DIR, "json"), ] class JsonRunner(Runner): json_path = None def __init__(self, config=None): super().__init__(config) if not self.json_path: raise RuntimeError("Create subclasses of JsonRunner with the json_path attribute set") with open(self.json_path, encoding="utf-8") as json_file: self._json_data = json.load(json_file) self.game_options = self._json_data["game_options"] self.runner_options = self._json_data.get("runner_options", []) self.human_name = self._json_data["human_name"] self.description = self._json_data["description"] self.platforms = self._json_data["platforms"] self.runner_executable = self._json_data["runner_executable"] self.system_options_override = self._json_data.get("system_options_override", []) self.entry_point_option = self._json_data.get("entry_point_option", "main_file") self.download_url = self._json_data.get("download_url") self.runnable_alone = self._json_data.get("runnable_alone") self.flatpak_id = self._json_data.get("flatpak_id") def play(self): """Return a launchable command constructed from the options""" arguments = self.get_command() for option in self.runner_options: if option["option"] not in self.runner_config: continue if option["type"] == "bool": if self.runner_config.get(option["option"]): arguments.append(option["argument"]) elif option["type"] == "choice": if self.runner_config.get(option["option"]) != "off": arguments.append(option["argument"]) arguments.append(self.runner_config.get(option["option"])) elif option["type"] == "string": arguments.append(option["argument"]) arguments.append(self.runner_config.get(option["option"])) elif option["type"] == "command_line": arg = option.get("argument") if arg: arguments.append(arg) arguments += shlex.split(self.runner_config.get(option["option"])) else: raise RuntimeError("Unhandled type %s" % option["type"]) main_file = self.game_config.get(self.entry_point_option) if not system.path_exists(main_file): raise MissingGameExecutableError(filename=main_file) arguments.append(main_file) return {"command": arguments} def load_json_runners(): json_runners = {} for json_dir in JSON_RUNNER_DIRS: if not os.path.exists(json_dir): continue for json_path in os.listdir(json_dir): if not json_path.endswith(".json"): continue runner_name = json_path[:-5] runner_class = type(runner_name, (JsonRunner,), {"json_path": os.path.join(json_dir, json_path)}) json_runners[runner_name] = runner_class return json_runners lutris-0.5.19/lutris/runners/dosbox.py0000664000175000017500000001121214756670027017007 0ustar hibbyhibby# Standard Library import os import shlex from gettext import gettext as _ from lutris import settings from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.commands.dosbox import dosexec, makeconfig # NOQA pylint: disable=unused-import from lutris.runners.runner import Runner from lutris.util import system class dosbox(Runner): human_name = _("DOSBox") description = _("MS-DOS emulator") platforms = [_("MS-DOS")] runnable_alone = True runner_executable = "dosbox/dosbox" flatpak_id = "io.github.dosbox-staging" game_options = [ { "option": "main_file", "type": "file", "label": _("Main file"), "help": _( "The CONF, EXE, COM or BAT file to launch.\n" "If the executable is managed in the config file, this should be the config file, " "instead specifying it in 'Configuration file'." ), }, { "option": "config_file", "type": "file", "label": _("Configuration file"), "help": _( "Start DOSBox with the options specified in this file. \n" "It can have a section in which you can put commands " "to execute on startup. Read DOSBox's documentation " "for more information." ), }, { "option": "args", "type": "string", "label": _("Command line arguments"), "help": _("Command line arguments used when launching DOSBox"), "validator": shlex.split, }, { "option": "working_dir", "type": "directory", "label": _("Working directory"), "warn_if_non_writable_parent": True, "help": _( "The location where the game is run from.\n" "By default, Lutris uses the directory of the " "executable." ), }, ] runner_options = [ { "option": "fullscreen", "section": _("Graphics"), "label": _("Open game in fullscreen"), "type": "bool", "default": False, "help": _("Tells DOSBox to launch the game in fullscreen."), }, { "option": "exit", "label": _("Exit DOSBox with the game"), "type": "bool", "default": True, "help": _("Shut down DOSBox when the game is quit."), }, ] def make_absolute(self, path): """Return a guaranteed absolute path""" if not path: return "" path = os.path.expanduser(path) if os.path.isabs(path): return path directory = self.game_data.get("directory") if directory: directory = os.path.expanduser(directory) return os.path.join(directory, path) return "" @property def main_file(self): return self.make_absolute(self.game_config.get("main_file")) @property def libs_dir(self): path = os.path.join(settings.RUNNER_DIR, "dosbox/lib") return path if system.path_exists(path) else "" def get_run_data(self): env = self.get_env() env["LD_LIBRARY_PATH"] = os.pathsep.join(filter(None, [self.libs_dir, env.get("LD_LIBRARY_PATH")])) return {"env": env, "command": self.get_command()} @property def working_dir(self): """Return the working directory to use when running the game.""" option = self.game_config.get("working_dir") if option: return os.path.expanduser(option) if self.main_file: return os.path.dirname(self.main_file) return super().working_dir def play(self): main_file = self.main_file if not system.path_exists(main_file): raise MissingGameExecutableError(filename=main_file) args = shlex.split(self.game_config.get("args") or "") command = self.get_command() if main_file.endswith(".conf"): command.append("-conf") command.append(main_file) else: command.append(main_file) # Options if self.game_config.get("config_file"): command.append("-conf") command.append(self.make_absolute(self.game_config["config_file"])) if self.runner_config.get("fullscreen"): command.append("-fullscreen") if self.runner_config.get("exit"): command.append("-exit") if args: command.extend(args) return {"command": command, "ld_library_path": self.libs_dir} lutris-0.5.19/lutris/runners/atari800.py0000664000175000017500000001243214756670027017046 0ustar hibbyhibbyimport logging import os.path from gettext import gettext as _ from lutris.config import LutrisConfig from lutris.exceptions import MissingBiosError, MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import display, extract, system def get_resolutions(): try: screen_resolutions = [(resolution, resolution) for resolution in display.DISPLAY_MANAGER.get_resolutions()] except OSError: screen_resolutions = [] screen_resolutions.insert(0, (_("Desktop resolution"), "desktop")) return screen_resolutions class atari800(Runner): human_name = _("Atari800") platforms = [_("Atari 8bit computers")] # FIXME try to determine the actual computer used runner_executable = "atari800/bin/atari800" bios_url = "https://netactuate.dl.sourceforge.net/project/atari800/ROM/Original%20XL%20ROM/xf25.zip?viasf=1" description = _("Atari 400, 800 and XL emulator") bios_checksums = { "xlxe_rom": "06daac977823773a3eea3422fd26a703", "basic_rom": "0bac0c6a50104045d902df4503a4c30b", "osa_rom": "", "osb_rom": "a3e8d617c95d08031fe1b20d541434b2", "5200_rom": "", } game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _( "The game data, commonly called a ROM image. \n" "Supported formats: ATR, XFD, DCM, ATR.GZ, XFD.GZ " "and PRO." ), } ] runner_options = [ { "option": "bios_path", "type": "directory", "label": _("BIOS location"), "help": _( "A folder containing the Atari 800 BIOS files.\n" "They are provided by Lutris so you shouldn't have to " "change this." ), }, { "option": "machine", "type": "choice", "choices": [ (_("Emulate Atari 800"), "atari"), (_("Emulate Atari 800 XL"), "xl"), (_("Emulate Atari 320 XE (Compy Shop)"), "320xe"), (_("Emulate Atari 320 XE (Rambo)"), "rambo"), (_("Emulate Atari 5200"), "5200"), ], "default": "atari", "label": _("Machine"), }, { "option": "fullscreen", "type": "bool", "default": False, "section": _("Graphics"), "label": _("Fullscreen"), }, { "option": "resolution", "type": "choice", "choices": get_resolutions(), "default": "desktop", "section": _("Graphics"), "label": _("Fullscreen resolution"), }, ] def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*_args): config_path = system.create_folder("~/.atari800") bios_archive = os.path.join(config_path, "atari800-bioses.zip") install_ui_delegate.download_install_file(self.bios_url, bios_archive) if not system.path_exists(bios_archive): raise RuntimeError(_("Could not download Atari 800 BIOS archive")) extract.extract_archive(bios_archive, config_path) os.remove(bios_archive) config = LutrisConfig(runner_slug="atari800") config.raw_runner_config.update({"bios_path": config_path}) config.save() if callback: callback() super().install(install_ui_delegate, version, on_runner_installed) def find_good_bioses(self, bios_path): """Check for correct bios files""" good_bios = {} for filename in os.listdir(bios_path): real_hash = system.get_md5_hash(os.path.join(bios_path, filename)) for bios_file, checksum in self.bios_checksums.items(): if real_hash == checksum: logging.debug("%s Checksum : OK", filename) good_bios[bios_file] = filename return good_bios def play(self): arguments = self.get_command() if self.runner_config.get("fullscreen"): arguments.append("-fullscreen") else: arguments.append("-windowed") resolution = self.runner_config.get("resolution") if resolution: if resolution == "desktop": width, height = display.DISPLAY_MANAGER.get_current_resolution() else: width, height = resolution.split("x") arguments += ["-fs-width", "%s" % width, "-fs-height", "%s" % height] if self.runner_config.get("machine"): arguments.append("-%s" % self.runner_config["machine"]) bios_path = self.runner_config.get("bios_path") if not system.path_exists(bios_path): raise MissingBiosError() good_bios = self.find_good_bioses(bios_path) for bios, filename in good_bios.items(): arguments.append("-%s" % bios) arguments.append(os.path.join(bios_path, filename)) rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments.append(rom) return {"command": arguments} lutris-0.5.19/lutris/runners/jzintv.py0000664000175000017500000000545514756670027017051 0ustar hibbyhibby# Standard Library import os from gettext import gettext as _ from lutris.exceptions import MissingBiosError, MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class jzintv(Runner): human_name = _("jzIntv") description = _("Intellivision Emulator") platforms = [_("Intellivision")] runner_executable = "jzintv/bin/jzintv" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "default_path": "game_path", "help": _( "The game data, commonly called a ROM image. \n" "Supported formats: ROM, BIN+CFG, INT, ITV \n" "The file extension must be lower-case." ), } ] runner_options = [ { "option": "bios_path", "type": "directory", "label": _("Bios location"), "help": _( "Choose the folder containing the Intellivision BIOS " "files (exec.bin and grom.bin).\n" "These files contain code from the original hardware " "necessary to the emulation." ), }, {"option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen")}, { "option": "resolution", "type": "choice", "section": _("Graphics"), "label": _("Resolution"), "choices": ( ("320 x 200", "0"), ("640 x 480", "1"), ("800 x 400", "5"), ("800 x 600", "2"), ("1024 x 768", "3"), ("1680 x 1050", "4"), ("1600 x 1200", "6"), ), "default": "0", }, ] def play(self): """Run Intellivision game""" arguments = self.get_command() selected_resolution = self.runner_config.get("resolution") if selected_resolution: arguments = arguments + ["-z%s" % selected_resolution] if self.runner_config.get("fullscreen"): arguments = arguments + ["-f"] bios_path = self.runner_config.get("bios_path", "") if system.path_exists(bios_path): arguments.append("--execimg=%s/exec.bin" % bios_path) arguments.append("--gromimg=%s/grom.bin" % bios_path) else: raise MissingBiosError() rom_path = self.game_config.get("main_file") or "" if not system.path_exists(rom_path): raise MissingGameExecutableError(filename=rom_path) romdir = os.path.dirname(rom_path) romfile = os.path.basename(rom_path) arguments += ["--rom-path=%s/" % romdir] arguments += [romfile] return {"command": arguments} lutris-0.5.19/lutris/runners/runner.py0000664000175000017500000005666714756670027017051 0ustar hibbyhibby"""Base module for runners""" import os import signal from gettext import gettext as _ from typing import Any, Callable, Dict, Iterable, Optional, Set from lutris import runtime, settings from lutris.api import format_runner_version, get_default_runner_version_info from lutris.config import LutrisConfig from lutris.database.games import get_game_by_field from lutris.exceptions import MisconfigurationError, MissingExecutableError, UnavailableLibrariesError from lutris.monitored_command import MonitoredCommand from lutris.runners import RunnerInstallationError from lutris.util import flatpak, strings, system from lutris.util.extract import ExtractError, extract_archive from lutris.util.graphics.gpu import GPUS from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.process import Process def kill_processes(sig: int, pids: Iterable[int]) -> None: """Sends a signal to a process list, logging errors without stopping.""" for pid in pids: try: os.kill(int(pid), sig) except ProcessLookupError as ex: logger.debug("Failed to kill game process: %s", ex) except PermissionError: logger.debug("Permission to kill process %s denied", pid) class Runner: # pylint: disable=too-many-public-methods """Generic runner (base class for other runners).""" multiple_versions = False platforms = [] runnable_alone = False game_options = [] runner_options = [] system_options_override = [] context_menu_entries = [] require_libs = [] runner_executable = None entry_point_option = "main_file" download_url = None arch = None # If the runner is only available for an architecture that isn't x86_64 flatpak_id = None def __init__(self, config=None): """Initialize runner.""" if config: self.has_explicit_config = True self._config = config self.game_data = get_game_by_field(config.game_config_id, "configpath") else: self.has_explicit_config = False self._config = None self.game_data = {} def __lt__(self, other): return self.name < other.name @property def description(self): """Return the class' docstring as the description.""" return self.__doc__ @description.setter def description(self, value): """Leave the ability to override the docstring.""" self.__doc__ = value # What the shit @property def runner_warning(self): """Returns a message (as markup) that is displayed in the configuration dialog as a warning.""" return None @property def name(self): return self.__class__.__name__ @property def directory(self): return os.path.join(settings.RUNNER_DIR, self.name) @property def config(self): if not self._config: self._config = LutrisConfig(runner_slug=self.name) return self._config @config.setter def config(self, new_config): self._config = new_config self.has_explicit_config = new_config is not None @property def game_config(self): """Return the cascaded game config as a dict.""" if not self.has_explicit_config: logger.warning("Accessing game config while runner wasn't given one.") return self.config.game_config @property def runner_config(self): """Return the cascaded runner config as a dict.""" return self.config.runner_config @property def system_config(self): """Return the cascaded system config as a dict.""" return self.config.system_config @property def default_path(self): """Return the default path where games are installed.""" return self.system_config.get("game_path") @property def game_path(self): """Return the directory where the game is installed.""" game_path = self.game_data.get("directory") if game_path: return os.path.expanduser(game_path) # expanduser just in case! if self.has_explicit_config: # Default to the directory where the entry point is located. entry_point = self.game_config.get(self.entry_point_option) if entry_point: return os.path.dirname(os.path.expanduser(entry_point)) return "" def resolve_game_path(self): """Returns the path where the game is found; if game_path does not provide a path, this may try to resolve the path by runner-specific means, which can find things like /usr/games when applicable.""" return self.game_path @property def working_dir(self): """Return the working directory to use when running the game.""" return self.game_path or os.path.expanduser("~/") @property def shader_cache_dir(self): """Return the cache directory for this runner to use. We create this if it does not exist.""" path = os.path.join(settings.SHADER_CACHE_DIR, self.name) if not os.path.isdir(path): os.mkdir(path) return path @property def nvidia_shader_cache_path(self): """The path to place in __GL_SHADER_DISK_CACHE_PATH; NVidia will place its cache cache in a subdirectory here.""" return self.shader_cache_dir @property def discord_client_id(self): if self.game_data.get("discord_client_id"): return self.game_data.get("discord_client_id") def get_platform(self): return self.platforms[0] def get_runner_options(self): runner_options = self.runner_options[:] if self.runner_executable: runner_options.append( { "option": "runner_executable", "type": "file", "label": _("Custom executable for the runner"), "advanced": True, } ) runner_options.append( { "section": _("Side Panel"), "option": "visible_in_side_panel", "type": "bool", "label": _("Visible in Side Panel"), "default": True, "advanced": True, "scope": ["runner"], "help": _("Show this runner in the side panel if it is installed or available through Flatpak."), } ) return runner_options def get_executable(self) -> str: if "runner_executable" in self.runner_config: runner_executable = self.runner_config["runner_executable"] if os.path.isfile(runner_executable): return runner_executable if not self.runner_executable: raise MisconfigurationError("runner_executable not set for {}".format(self.name)) exe = os.path.join(settings.RUNNER_DIR, self.runner_executable) if not os.path.isfile(exe): raise MissingExecutableError(_("The executable '%s' could not be found.") % exe) return exe def get_command(self): """Returns the command line to run the runner itself; generally a game will be appended to this by play().""" try: exe = self.get_executable() if not system.path_exists(exe): raise MissingExecutableError(_("The executable '%s' could not be found.") % exe) return [exe] except MisconfigurationError: if flatpak.is_app_installed(self.flatpak_id): return flatpak.get_run_command(self.flatpak_id) raise def get_env(self, os_env=False, disable_runtime=False): """Return environment variables used for a game.""" env = {} if os_env: env = system.get_environment() # Steam compatibility if os.environ.get("SteamAppId"): logger.info("Game launched from steam (AppId: %s)", os.environ["SteamAppId"]) env["LC_ALL"] = "" # Set correct LC_ALL depending on user settings locale = self.system_config.get("locale") if locale: env["LC_ALL"] = locale # By default, we'll set NVidia's shader disk cache to be # per-game, so it overflows less readily. env["__GL_SHADER_DISK_CACHE"] = "1" env["__GL_SHADER_DISK_CACHE_PATH"] = self.nvidia_shader_cache_path # Override SDL2 controller configuration sdl_gamecontrollerconfig = self.system_config.get("sdl_gamecontrollerconfig") if sdl_gamecontrollerconfig: path = os.path.expanduser(sdl_gamecontrollerconfig) if system.path_exists(path): with open(path, "r", encoding="utf-8") as controllerdb_file: sdl_gamecontrollerconfig = controllerdb_file.read() env["SDL_GAMECONTROLLERCONFIG"] = sdl_gamecontrollerconfig # Set monitor to use for SDL 1 games sdl_video_fullscreen = self.system_config.get("sdl_video_fullscreen") if sdl_video_fullscreen and sdl_video_fullscreen != "off": env["SDL_VIDEO_FULLSCREEN_DISPLAY"] = sdl_video_fullscreen if len(GPUS) > 1 and self.system_config.get("gpu") in GPUS: gpu = GPUS[self.system_config["gpu"]] if gpu.driver == "nvidia": env["DRI_PRIME"] = "1" env["__NV_PRIME_RENDER_OFFLOAD"] = "1" env["__GLX_VENDOR_LIBRARY_NAME"] = "nvidia" env["__VK_LAYER_NV_optimus"] = "NVIDIA_only" else: env["DRI_PRIME"] = gpu.pci_id env["VK_ICD_FILENAMES"] = gpu.icd_files # Deprecated env["VK_DRIVER_FILES"] = gpu.icd_files # Current form # Set PulseAudio latency to 60ms if self.system_config.get("pulse_latency"): env["PULSE_LATENCY_MSEC"] = "60" runtime_ld_library_path = None if not disable_runtime and self.use_runtime(): runtime_env = self.get_runtime_env() runtime_ld_library_path = runtime_env.get("LD_LIBRARY_PATH") if runtime_ld_library_path: ld_library_path = env.get("LD_LIBRARY_PATH") env["LD_LIBRARY_PATH"] = os.pathsep.join(filter(None, [runtime_ld_library_path, ld_library_path])) # Apply user overrides at the end env.update(self.system_config.get("env") or {}) return env def finish_env(self, env: Dict[str, str], game) -> None: """This is called by the Game after setting up the environment to allow the runner to make final adjustments, which may be based on the environment so far.""" pass def get_runtime_env(self): """Return runtime environment variables. This method may be overridden in runner classes. (Notably for Lutris wine builds) Returns: dict """ return runtime.get_env(prefer_system_libs=self.system_config.get("prefer_system_libs", True)) def apply_launch_config(self, gameplay_info, launch_config): """Updates the gameplay_info to reflect a launch_config section. Called only if a non-default config is chosen.""" gameplay_info["command"] = self.get_launch_config_command(gameplay_info, launch_config) config_working_dir = self.get_launch_config_working_dir(launch_config) if config_working_dir: gameplay_info["working_dir"] = config_working_dir def get_launch_config_command(self, gameplay_info, launch_config): """Generates a new command for the gameplay_info, to implement the launch_config. Returns a new list of strings; the caller can modify it further. If launch_config has no command, this builds one from the gameplay_info command and the 'exe' value in the launch_config. Runners override this when required to control the command used.""" if "command" in launch_config: command = strings.split_arguments(launch_config["command"]) else: command = self.get_command() exe = self.get_launch_config_exe(launch_config) if exe: command.append(exe) if launch_config.get("args"): command += strings.split_arguments(launch_config["args"]) return command def get_launch_config_exe(self, launch_config): """Locates the "exe" of the launch config. If it appears to be relative to the game's working_dir, this will try to adjust it to be relative to the config's instead. """ exe = launch_config.get("exe") config_working_dir = self.get_launch_config_working_dir(launch_config) if exe: exe = os.path.expanduser(exe) # just in case! if config_working_dir and not os.path.isabs(exe): exe_from_config = self.resolve_config_path(exe, config_working_dir) exe_from_game = self.resolve_config_path(exe) if os.path.exists(exe_from_game) and not os.path.exists(exe_from_config): relative = os.path.relpath(exe_from_game, start=config_working_dir) if not relative.startswith("../"): return relative return exe def get_launch_config_working_dir(self, launch_config): """Extracts the "working_dir" from the config, and resolves this relative to the game's working directory, so that an absolute path results. This returns None if no working_dir is present, or if it found to be missing. """ config_working_dir = launch_config.get("working_dir") if config_working_dir: config_working_dir = self.resolve_config_path(config_working_dir) if os.path.isdir(config_working_dir): return config_working_dir return None def resolve_config_path(self, path, relative_to=None): """Interpret a path taken from the launch_config relative to a working directory, using the game's working_dir if that is omitted, and expanding the '~' if we get one. This is provided as a method so the WINE runner can try to convert Windows-style paths to usable paths. """ path = os.path.expanduser(path) if not os.path.isabs(path): if not relative_to: relative_to = self.working_dir if relative_to: return os.path.join(relative_to, path) return path def prelaunch(self): """Run actions before running the game, override this method in runners; raise an exception if prelaunch fails, and it will be reported to the user, and then the game won't start.""" available_libs = set() for lib in set(self.require_libs): if lib in LINUX_SYSTEM.shared_libraries: if self.arch: if self.arch in [_lib.arch for _lib in LINUX_SYSTEM.shared_libraries[lib]]: available_libs.add(lib) else: available_libs.add(lib) unavailable_libs = set(self.require_libs) - available_libs if unavailable_libs: raise UnavailableLibrariesError(unavailable_libs, self.arch) def get_run_data(self): """Return dict with command (exe & args list) and env vars (dict). Reimplement in derived runner if need be.""" return {"command": self.get_command(), "env": self.get_env()} def run(self, ui_delegate): """Run the runner alone.""" if not self.runnable_alone: return if not self.is_installed(): if not self.install_dialog(ui_delegate): logger.info("Runner install cancelled") return command_data = self.get_run_data() command = command_data.get("command") env = (command_data.get("env") or {}).copy() self.prelaunch() command_runner = MonitoredCommand(command, runner=self, env=env) command_runner.start() def use_runtime(self): if runtime.RUNTIME_DISABLED: logger.info("Runtime disabled by environment") return False if self.system_config.get("disable_runtime"): logger.info("Runtime disabled by system configuration") return False return True def filter_game_pids(self, candidate_pids: Iterable[int], game_uuid: str, game_folder: str) -> Set[int]: """Checks the pids given and returns a set containing only those that are part of the running game, identified by its UUID and directory.""" folder_pids = set() for pid in candidate_pids: cmdline = Process(pid).cmdline or "" # pressure-vessel: This could potentially pick up PIDs not started by lutris? if game_folder in cmdline: folder_pids.add(pid) uuid_pids = set(pid for pid in candidate_pids if Process(pid).environ.get("LUTRIS_GAME_UUID") == game_uuid) return folder_pids & uuid_pids def install_dialog(self, ui_delegate): """Ask the user if they want to install the runner. Return success of runner installation. """ if ui_delegate.show_install_yesno_inquiry( question=_("The required runner is not installed.\n" "Do you wish to install it now?"), title=_("Required runner unavailable"), ): if hasattr(self, "get_version"): version = self.get_version(use_default=False) # pylint: disable=no-member self.install(ui_delegate, version=version) else: self.install(ui_delegate) return self.is_installed() return False def is_installed(self, flatpak_allowed: bool = True) -> bool: """Return whether the runner is installed""" try: # Don't care where the exe is, only if we can find it. exe = self.get_executable() if system.path_exists(exe): return True except MisconfigurationError: pass # We can still try flatpak even if 'which' fails us! return bool(flatpak_allowed and self.flatpak_id and flatpak.is_app_installed(self.flatpak_id)) def is_installed_for(self, interpreter): """Returns whether the runner is installed. Specific runners can extract additional script settings, to determine more precisely what must be installed.""" return self.is_installed() def get_installer_runner_version(self, installer, use_runner_config: bool = True) -> Optional[str]: return None def adjust_installer_runner_config(self, installer_runner_config: Dict[str, Any]) -> None: """This is called during installation to let to run fix up in the runner's section of the confliguration before it is saved. This method should modify the dict given.""" pass def get_runner_version(self, version: str = None) -> Optional[Dict[str, str]]: """Get the appropriate version for a runner, as with get_default_runner_version(), but this method allows the runner to apply its configuration.""" return get_default_runner_version_info(self.name, version) def install(self, install_ui_delegate, version=None, callback=None): """Install runner using package management systems.""" logger.debug( "Installing %s (version=%s, callback=%s)", self.name, version, callback, ) opts = {"install_ui_delegate": install_ui_delegate, "callback": callback} if self.download_url: opts["dest"] = self.directory return self.download_and_extract(self.download_url, **opts) runner_version_info = self.get_runner_version(version) if not runner_version_info: raise RunnerInstallationError(_("Failed to retrieve {} ({}) information").format(self.name, version)) if "url" not in runner_version_info: if version: raise RunnerInstallationError( _("The '%s' version of the '%s' runner can't be downloaded." % (version, self.name)) ) else: raise RunnerInstallationError(_("The the '%s' runner can't be downloaded." % self.name)) if "wine" in self.name: opts["merge_single"] = True opts["dest"] = os.path.join(self.directory, format_runner_version(runner_version_info)) if self.name == "libretro" and version: opts["merge_single"] = False opts["dest"] = os.path.join(settings.RUNNER_DIR, "retroarch/cores") self.download_and_extract(runner_version_info["url"], **opts) def download_and_extract(self, url, dest=None, **opts): install_ui_delegate = opts["install_ui_delegate"] merge_single = opts.get("merge_single", False) callback = opts.get("callback") tarball_filename = os.path.basename(url) runner_archive = os.path.join(settings.CACHE_DIR, tarball_filename) if not dest: dest = settings.RUNNER_DIR download_successful = install_ui_delegate.download_install_file(url, runner_archive) if download_successful: self.extract(archive=runner_archive, dest=dest, merge_single=merge_single, callback=callback) else: logger.info("Download canceled by the user.") def extract(self, archive=None, dest=None, merge_single=None, callback=None): if not system.path_exists(archive, exclude_empty=True): raise RunnerInstallationError(_("Failed to extract {}").format(archive)) try: extract_archive(archive, dest, merge_single=merge_single) except ExtractError as ex: logger.error("Failed to extract the archive %s file may be corrupt", archive) raise RunnerInstallationError(_("Failed to extract {}: {}").format(archive, ex)) from ex os.remove(archive) if self.name == "wine": logger.debug("Clearing wine version cache") from lutris.util.wine.wine import clear_wine_version_cache clear_wine_version_cache() if self.runner_executable: runner_executable = os.path.join(settings.RUNNER_DIR, self.runner_executable) if os.path.isfile(runner_executable): system.make_executable(runner_executable) if callback: callback() def remove_game_data(self, app_id=None, game_path=None): system.remove_folder(game_path) def can_uninstall(self): return os.path.isdir(self.directory) def uninstall(self, uninstall_callback: Callable[[], None]) -> None: runner_path = self.directory if os.path.isdir(runner_path): system.remove_folder(runner_path, completion_function=uninstall_callback) else: uninstall_callback() def find_option(self, options_group, option_name): """Retrieve an option dict if it exists in the group""" if options_group not in ["game_options", "runner_options"]: return None output = None for item in getattr(self, options_group): if item["option"] == option_name: output = item break return output def force_stop_game(self, game_pids: Iterable[int]) -> None: """Stop the running game. If this leaves any game processes running, the caller will SIGKILL them (after a delay).""" kill_processes(signal.SIGTERM, game_pids) def extract_icon(self, game_slug): """The config UI calls this to extract the game icon. Most runners do not support this and do nothing. This is not called if a custom icon is installed for the game.""" lutris-0.5.19/lutris/runners/steam.py0000664000175000017500000002226514756670027016634 0ustar hibbyhibby"""Steam for Linux runner""" import os from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError, UnavailableRunnerError from lutris.monitored_command import MonitoredCommand from lutris.runners import NonInstallableRunnerError from lutris.runners.runner import Runner from lutris.util import linux, system from lutris.util.log import logger from lutris.util.steam.appmanifest import get_appmanifest_from_appid, get_path_from_appmanifest from lutris.util.steam.config import get_default_acf, get_steam_dir, get_steamapps_dirs from lutris.util.steam.vdfutils import to_vdf from lutris.util.strings import split_arguments def get_steam_pid(): """Return pid of Steam process.""" return system.get_pid("steam$") def is_running(): """Checks if Steam is running.""" return bool(get_steam_pid()) class steam(Runner): description = _("Runs Steam for Linux games") human_name = _("Steam") platforms = [_("Linux")] runner_executable = "steam" flatpak_id = "com.valvesoftware.Steam" game_options = [ { "option": "appid", "label": _("Application ID"), "type": "string", "help": _( "The application ID can be retrieved from the game's " "page at steampowered.com. Example: 235320 is the " "app ID for Original War in: \n" "http://store.steampowered.com/app/235320/" ), }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _( "Command line arguments used when launching the game.\n" "Ignored when Steam Big Picture mode is enabled." ), }, { "option": "run_without_steam", "label": _("DRM free mode (Do not launch Steam)"), "type": "bool", "default": False, "advanced": True, "help": _("Run the game directly without Steam, requires the game binary path to be set"), }, { "option": "steamless_binary", "type": "file", "label": _("Game binary path"), "advanced": True, "help": _("Path to the game executable (Required by DRM free mode)"), }, ] runner_options = [ { "option": "start_in_big_picture", "label": _("Start Steam in Big Picture mode"), "type": "bool", "default": False, "help": _( "Launches Steam in Big Picture mode.\n" "Only works if Steam is not running or " "already running in Big Picture mode.\n" "Useful when playing with a Steam Controller." ), }, { "option": "lsi_steam", "label": _("Start Steam with LSI"), "type": "bool", "default": False, "help": _( "Launches steam with LSI patches enabled. " "Make sure Lutris Runtime is disabled and " "you have LSI installed. " "https://github.com/solus-project/linux-steam-integration" ), }, { "option": "args", "type": "string", "label": _("Arguments"), "advanced": True, "help": _("Extra command line arguments used when launching Steam"), }, ] system_options_override = [ {"option": "disable_runtime", "default": True}, {"option": "gamemode", "default": False}, ] @property def runnable_alone(self): return not linux.LINUX_SYSTEM.is_flatpak() @property def appid(self): return self.game_config.get("appid") or "" @property def game_path(self): if not self.appid: return None return self.get_game_path_from_appid(self.appid) @property def steam_data_dir(self): """Main installation directory for Steam""" return get_steam_dir() def get_appmanifest(self): """Return an AppManifest instance for the game""" appmanifests = [] for apps_path in get_steamapps_dirs(): appmanifest = get_appmanifest_from_appid(apps_path, self.appid) if appmanifest: appmanifests.append(appmanifest) if len(appmanifests) > 1: logger.warning("More than one AppManifest for %s returning only 1st", self.appid) if appmanifests: return appmanifests[0] def get_executable(self) -> str: if linux.LINUX_SYSTEM.is_flatpak(): # Fallback to xgd-open for Steam URIs in Flatpak return system.find_required_executable("xdg-open") if self.runner_config.get("lsi_steam"): lsi_steam_path = system.find_executable("lsi-steam") if lsi_steam_path: return lsi_steam_path runner_executable = self.runner_config.get("runner_executable") if runner_executable and os.path.isfile(runner_executable): return runner_executable return system.find_required_executable(self.runner_executable) @property def working_dir(self): """Return the working directory to use when running the game.""" if self.game_config.get("run_without_steam"): steamless_binary = self.game_config.get("steamless_binary") if steamless_binary and os.path.isfile(steamless_binary): return os.path.dirname(steamless_binary) return super().working_dir @property def launch_args(self): """Provide launch arguments for Steam""" command = self.get_command() if self.runner_config.get("start_in_big_picture"): command.append("-bigpicture") return command + split_arguments(self.runner_config.get("args") or "") def get_game_path_from_appid(self, appid): """Return the game directory.""" for apps_path in get_steamapps_dirs(): game_path = get_path_from_appmanifest(apps_path, appid) if game_path: return game_path logger.info("Data path for SteamApp %s not found.", appid) return "" def get_default_steamapps_path(self): steamapps_paths = get_steamapps_dirs() if steamapps_paths: return steamapps_paths[0] return "" def install(self, install_ui_delegate, version=None, callback=None): raise NonInstallableRunnerError( _( "Steam for Linux installation is not handled by Lutris.\n" "Please go to " "http://steampowered.com" " or install Steam with the package provided by your distribution." ) ) def install_game(self, appid, generate_acf=False): logger.debug("Installing steam game %s", appid) if generate_acf: acf_data = get_default_acf(appid, appid) acf_content = to_vdf(acf_data) steamapps_path = self.get_default_steamapps_path() if not steamapps_path: raise UnavailableRunnerError(_("Could not find Steam path, is Steam installed?")) acf_path = os.path.join(steamapps_path, "appmanifest_%s.acf" % appid) with open(acf_path, "w", encoding="utf-8") as acf_file: acf_file.write(acf_content) system.spawn(self.get_command() + [f"steam://install/{appid}"]) def get_run_data(self): return {"command": self.launch_args, "env": self.get_env()} def play(self): game_args = self.game_config.get("args") or "" binary_path = self.game_config.get("steamless_binary") if self.game_config.get("run_without_steam") and binary_path: # Start without steam if not system.path_exists(binary_path): raise MissingGameExecutableError(filename=binary_path) command = [binary_path] else: # Start through steam if linux.LINUX_SYSTEM.is_flatpak(): if game_args: steam_uri = "steam://run/%s//%s/" % (self.appid, game_args) else: steam_uri = "steam://rungameid/%s" % self.appid return { "command": self.launch_args + [steam_uri], "env": self.get_env(), } command = self.launch_args if self.runner_config.get("start_in_big_picture") or not game_args: command.append("steam://rungameid/%s" % self.appid) else: command.append("-applaunch") command.append(self.appid) if game_args: for arg in split_arguments(game_args): command.append(arg) return { "command": command, "env": self.get_env(), } def remove_game_data(self, app_id=None, **kwargs): if not self.is_installed(): return False app_id = app_id or self.appid command = MonitoredCommand( self.get_command() + [f"steam://uninstall/{app_id}"], runner=self, env=self.get_env(), ) command.start() lutris-0.5.19/lutris/runners/hatari.py0000664000175000017500000001450714756670027016773 0ustar hibbyhibby# Standard Library import os import shutil from gettext import gettext as _ # Lutris Modules from lutris.config import LutrisConfig from lutris.exceptions import MissingBiosError, MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system class hatari(Runner): human_name = _("Hatari") description = _("Atari ST computers emulator") platforms = [_("Atari ST")] runnable_alone = True flatpak_id = "org.tuxfamily.hatari" runner_executable = "hatari/bin/hatari" entry_point_option = "disk-a" game_options = [ { "option": "disk-a", "type": "file", "label": _("Floppy Disk A"), "help": _( "Hatari supports floppy disk images in the following " "formats: ST, DIM, MSA, STX, IPF, RAW and CRT. The last " "three require the caps library (capslib). ZIP is " "supported, you don't need to uncompress the file." ), }, { "option": "disk-b", "type": "file", "label": _("Floppy Disk B"), "help": _( "Hatari supports floppy disk images in the following " "formats: ST, DIM, MSA, STX, IPF, RAW and CRT. The last " "three require the caps library (capslib). ZIP is " "supported, you don't need to uncompress the file." ), }, ] joystick_choices = [(_("None"), "none"), (_("Keyboard"), "keys"), (_("Joystick"), "real")] runner_options = [ { "option": "bios_file", "type": "file", "label": _("Bios file (TOS)"), "help": _( "TOS is the operating system of the Atari ST " "and is necessary to run applications with the best " "fidelity, minimizing risks of issues.\n" "TOS 1.02 is recommended for games." ), }, { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": False, }, { "option": "zoom", "type": "bool", "section": _("Graphics"), "label": _("Scale up display by 2 (Atari ST/STE)"), "default": True, "help": _("Double the screen size in windowed mode."), }, { "option": "borders", "type": "bool", "section": _("Graphics"), "label": _("Add borders to display"), "default": False, "help": _( "Useful for some games and demos using the overscan " "technique. The Atari ST displayed borders around the " "screen because it was not powerful enough to display " "graphics in fullscreen. But people from the demo scene " "were able to remove them and some games made use of " "this technique." ), }, { "option": "status", "type": "bool", "section": _("Graphics"), "label": _("Display status bar"), "default": False, "help": _( "Displays a status bar with some useful information, " "like green leds lighting up when the floppy disks are " "read." ), }, { "option": "joy0", "type": "choice", "section": _("Joysticks"), "label": _("Joystick 0"), "choices": joystick_choices, "default": "none", }, { "option": "joy1", "type": "choice", "section": _("Joysticks"), "label": _("Joystick 1"), "choices": joystick_choices, "default": "real", }, ] def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*args): bios_path = system.create_folder("~/.hatari/bios") bios_filename = install_ui_delegate.show_install_file_inquiry( question=_("Do you want to select an Atari ST BIOS file?"), title=_("Use BIOS file?"), message=_("Select a BIOS file"), ) if bios_filename: shutil.copy(bios_filename, bios_path) bios_path = os.path.join(bios_path, os.path.basename(bios_filename)) config = LutrisConfig(runner_slug="hatari") config.raw_runner_config.update({"bios_file": bios_path}) config.save() if callback: callback() super().install(install_ui_delegate, version=version, callback=on_runner_installed) def play(self): # pylint: disable=too-many-branches params = self.get_command() if self.runner_config.get("fullscreen"): params.append("--fullscreen") else: params.append("--window") params.append("--zoom") if self.runner_config.get("zoom"): params.append("2") else: params.append("1") params.append("--borders") if self.runner_config.get("borders"): params.append("true") else: params.append("false") params.append("--statusbar") if self.runner_config.get("status"): params.append("true") else: params.append("false") if self.runner_config.get("joy0"): params.append("--joy0") params.append(self.runner_config["joy0"]) if self.runner_config.get("joy1"): params.append("--joy1") params.append(self.runner_config["joy1"]) if system.path_exists(self.runner_config.get("bios_file", "")): params.append("--tos") params.append(self.runner_config["bios_file"]) else: raise MissingBiosError() diska = self.game_config.get("disk-a") if not system.path_exists(diska): raise MissingGameExecutableError(filename=diska) params.append("--disk-a") params.append(diska) diskb = self.game_config.get("disk-b") if diskb: params.append("--disk-b") params.append(diskb) return {"command": params} lutris-0.5.19/lutris/runners/cemu.py0000664000175000017500000000553114756670027016451 0ustar hibbyhibby# Standard Library from gettext import gettext as _ from lutris.exceptions import DirectoryNotFoundError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class cemu(Runner): human_name = _("Cemu") platforms = [_("Wii U")] description = _("Wii U emulator") runnable_alone = True runner_executable = "cemu/Cemu" flatpak_id = "info.cemu.Cemu" game_options = [ { "option": "main_file", "type": "directory", "label": _("Game directory"), "help": _( "The directory in which the game lives. " "If installed into Cemu, this will be in the mlc directory, such as mlc/usr/title/00050000/101c9500." ), }, { "option": "wua_rom", "type": "file", "label": _("Compressed ROM"), "help": _("A game compressed into a single file (WUA format), only use if not using game directory"), }, ] runner_options = [ { "option": "fullscreen", "label": _("Fullscreen"), "type": "bool", "default": True, }, { "option": "mlc", "label": _("Custom mlc folder location"), "type": "directory", }, { "option": "ud", "label": _("Render in upside down mode"), "type": "bool", "default": False, "advanced": True, }, { "option": "nsight", "label": _("NSight debugging options"), "type": "bool", "default": False, "advanced": True, }, { "option": "legacy", "label": _("Intel legacy graphics mode"), "type": "bool", "default": False, "advanced": True, }, ] def play(self): """Run the game.""" arguments = self.get_command() fullscreen = self.runner_config.get("fullscreen") if fullscreen: arguments.append("-f") mlc = self.runner_config.get("mlc") if mlc: if not system.path_exists(mlc): raise DirectoryNotFoundError(directory=mlc) arguments += ["-m", mlc] ud = self.runner_config.get("ud") if ud: arguments.append("-u") nsight = self.runner_config.get("nsight") if nsight: arguments.append("--nsight") legacy = self.runner_config.get("legacy") if legacy: arguments.append("--legacy") gamedir = self.game_config.get("main_file") or self.game_config.get("wua_rom") or "" if not system.path_exists(gamedir): raise DirectoryNotFoundError(directory=gamedir) arguments += ["-g", gamedir] return {"command": arguments} lutris-0.5.19/lutris/runners/web.py0000664000175000017500000002035314756670027016274 0ustar hibbyhibby"""Run web based games""" import os import string from gettext import gettext as _ from urllib.parse import urlparse from lutris import settings from lutris.database.games import get_game_by_field from lutris.exceptions import GameConfigError from lutris.runners.runner import Runner from lutris.util import datapath, linux, resources, system from lutris.util.strings import split_arguments DEFAULT_ICON = os.path.join(datapath.get(), "media/default_icon.png") class web(Runner): human_name = _("Web") description = _("Runs web based games") platforms = [_("Web")] game_options = [ { "option": "main_file", "type": "string", "label": _("Full URL or HTML file path"), "help": _("The full address of the game's web page or path to a HTML file."), } ] runner_options = [ { "option": "fullscreen", "label": _("Open in fullscreen"), "type": "bool", "default": False, "help": _("Launch the game in fullscreen."), }, { "option": "maximize_window", "label": _("Open window maximized"), "type": "bool", "default": False, "help": _("Maximizes the window when game starts."), }, { "option": "window_size", "label": _("Window size"), "type": "choice_with_entry", "choices": [ "640x480", "800x600", "1024x768", "1280x720", "1280x1024", "1920x1080", ], "default": "800x600", "help": _("The initial size of the game window when not opened."), }, { "option": "disable_resizing", "label": _("Disable window resizing (disables fullscreen and maximize)"), "type": "bool", "default": False, "help": _("You can't resize this window."), }, { "option": "frameless", "label": _("Borderless window"), "type": "bool", "default": False, "help": _("The window has no borders/frame."), }, { "option": "disable_menu_bar", "label": _("Disable menu bar and default shortcuts"), "type": "bool", "default": False, "help": _("This also disables default keyboard shortcuts, " "like copy/paste and fullscreen toggling."), }, { "option": "disable_scrolling", "label": _("Disable page scrolling and hide scrollbars"), "type": "bool", "default": False, "help": _("Disables scrolling on the page."), }, { "option": "hide_cursor", "label": _("Hide mouse cursor"), "type": "bool", "default": False, "help": _("Prevents the mouse cursor from showing " "when hovering above the window."), }, { "option": "open_links", "label": _("Open links in game window"), "type": "bool", "default": False, "help": _( "Enable this option if you want clicked links to open inside the " "game window. By default all links open in your default web browser." ), }, { "option": "remove_margin", "label": _("Remove default margin & padding"), "type": "bool", "default": False, "help": _("Sets margin and padding to zero " "on <html> and <body> elements."), }, { "option": "enable_flash", "label": _("Enable Adobe Flash Player"), "type": "bool", "default": False, "help": _("Enable Adobe Flash Player."), }, { "option": "user_agent", "label": _("Custom User-Agent"), "type": "string", "default": "", "help": _("Overrides the default User-Agent header used by the runner."), "advanced": True, }, { "option": "devtools", "label": _("Debug with Developer Tools"), "type": "bool", "default": False, "help": _("Let's you debug the page."), "advanced": True, }, { "option": "external_browser", "label": _("Open in web browser (old behavior)"), "type": "bool", "default": False, "help": _("Launch the game in a web browser."), }, { "option": "custom_browser_executable", "label": _("Custom web browser executable"), "type": "file", "help": _( "Select the executable of a browser on your system.\n" "If left blank, Lutris will launch your default browser (xdg-open)." ), }, { "option": "custom_browser_args", "label": _("Web browser arguments"), "type": "string", "default": '"$GAME"', "help": _( "Command line arguments to pass to the executable.\n" "$GAME or $URL inserts the game url.\n\n" 'For Chrome/Chromium app mode use: --app="$GAME"' ), }, ] runner_executable = "web/electron/electron" def get_env(self, os_env=True, disable_runtime=False): env = super().get_env(os_env, disable_runtime=disable_runtime) enable_flash_player = self.runner_config.get("enable_flash") env["ENABLE_FLASH_PLAYER"] = "1" if enable_flash_player else "0" return env def play(self): url = self.game_config.get("main_file") if not url: raise GameConfigError(_("The web address is empty, \n" "verify the game's configuration.")) # check if it's an url or a file is_url = urlparse(url).scheme != "" if not is_url: if not system.path_exists(url): raise GameConfigError(_("The file %s does not exist, \n" "verify the game's configuration.") % url) url = "file://" + url game_data = get_game_by_field(self.config.game_config_id, "configpath") # keep the old behavior from browser runner, but with support for extra arguments! if self.runner_config.get("external_browser"): # is it possible to disable lutris runtime here? browser = self.runner_config.get("custom_browser_executable") or "xdg-open" args = self.runner_config.get("custom_browser_args") args = args or '"$GAME"' arguments = string.Template(args).safe_substitute({"GAME": url, "URL": url}) command = [browser] for arg in split_arguments(arguments): command.append(arg) return {"command": command} icon = resources.get_icon_path(game_data.get("slug")) if not system.path_exists(icon): icon = DEFAULT_ICON command = [ self.get_executable(), os.path.join(settings.RUNNER_DIR, "web/electron/resources/app.asar"), url, "--name", game_data.get("name"), "--icon", icon, ] for key in [ "fullscreen", "frameless", "devtools", "disable_resizing", "disable_menu_bar", "maximize_window", "disable_scrolling", "hide_cursor", "open_links", "remove_margin", ]: if self.runner_config.get(key): converted_opt_name = key.replace("_", "-") command.append("--{option}".format(option=converted_opt_name)) if self.runner_config.get("window_size"): command.append("--window-size") command.append(self.runner_config.get("window_size")) if self.runner_config.get("user_agent"): command.append("--user-agent") command.append(self.runner_config.get("user_agent")) if linux.LINUX_SYSTEM.is_flatpak(): command.append("--no-sandbox") return {"command": command, "env": self.get_env(False)} lutris-0.5.19/lutris/runners/vita3k.py0000664000175000017500000000766114756670027016727 0ustar hibbyhibbyfrom gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner class MissingVitaTitleIDError(MissingGameExecutableError): """Raise when the Title ID field has not be supplied to the Vita runner game options""" def __init__(self, message=None, *args, **kwargs): if not message: message = _("The Vita App has no Title ID set") super().__init__(message, *args, **kwargs) class vita3k(Runner): human_name = _("Vita3K") platforms = [_("Sony PlayStation Vita")] description = _("Sony PlayStation Vita emulator") runnable_alone = True runner_executable = "vita3k/Vita3K-x86_64.AppImage" flatpak_id = None download_url = "https://github.com/Vita3K/Vita3K/releases/download/continuous/Vita3K-x86_64.AppImage" game_options = [ { "option": "main_file", "type": "string", "label": _("Title ID of Installed Application"), "argument": "-r", "help": _( 'Title ID of installed application. Eg."PCSG00042". User installed apps are located in ' "ux0:/app/<title-id>." ), } ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": True, "argument": "-F", "help": _("Start the emulator in fullscreen mode."), }, { "option": "config", "type": "file", "label": _("Config location"), "argument": "-c", "help": _( 'Get a configuration file from a given location. If a filename is given, it must end with ".yml", ' "otherwise it will be assumed to be a directory." ), }, { "option": "load-config", "label": _("Load configuration file"), "type": "bool", "argument": "-f", "help": _('If trues, informs the emualtor to load the config file from the "Config location" option.'), }, ] # Vita3k uses an AppImage and doesn't require the Lutris runtime. system_options_override = [{"option": "disable_runtime", "default": True}] def play(self): """Run the game.""" arguments = self.get_command() # adds arguments from the supplied option dictionary to the arguments list def append_args(option_dict, config): for option in option_dict: if option["option"] not in config: continue if option["type"] == "bool": if self.runner_config.get(option["option"]): if "argument" in option: arguments.append(option["argument"]) elif option["type"] == "choice": if self.runner_config.get(option["option"]) != "off": if "argument" in option: arguments.append(option["argument"]) arguments.append(config.get(option["option"])) elif option["type"] in ("string", "file"): if "argument" in option: arguments.append(option["argument"]) arguments.append(config.get(option["option"])) else: raise RuntimeError("Unhandled type %s" % option["type"]) # Append the runner arguments first, and game arguments afterwards append_args(self.runner_options, self.runner_config) title_id = self.game_config.get("main_file") or "" if not title_id: raise MissingVitaTitleIDError(_("The Vita App has no Title ID set")) append_args(self.game_options, self.game_config) return {"command": arguments} @property def game_path(self): return self.game_config.get(self.entry_point_option, "") lutris-0.5.19/lutris/runners/flatpak.py0000664000175000017500000001255414756670027017145 0ustar hibbyhibbyimport os import shutil from gettext import gettext as _ from pathlib import Path from typing import Callable from lutris.exceptions import GameConfigError, MissingExecutableError from lutris.monitored_command import MonitoredCommand from lutris.runners import NonInstallableRunnerError from lutris.runners.runner import Runner from lutris.util import flatpak as _flatpak from lutris.util import system from lutris.util.strings import split_arguments class flatpak(Runner): """ Runner for Flatpak applications. """ description = _("Runs Flatpak applications") platforms = [_("Linux")] entry_point_option = "application" human_name = _("Flatpak") runnable_alone = False system_options_override = [{"option": "disable_runtime", "default": True}] install_locations = {"system": "var/lib/flatpak/app/", "user": f"{Path.home()}/.local/share/flatpak/app/"} game_options = [ { "option": "appid", "type": "string", "label": _("Application ID"), "help": _("The application's unique three-part identifier (tld.domain.app)."), }, { "option": "arch", "type": "string", "label": _("Architecture"), "help": _( "The architecture to run. " "See flatpak --supported-arches for architectures supported by the host." ), "advanced": True, }, {"option": "branch", "type": "string", "label": _("Branch"), "help": _("The branch to use."), "advanced": True}, { "option": "install_type", "type": "string", "label": _("Install type"), "help": _("Can be system or user."), "advanced": True, }, { "option": "args", "type": "string", "label": _("Args"), "help": _("Arguments to be passed to the application."), }, { "option": "fcommand", "type": "string", "label": _("Command"), "help": _("The command to run instead of the one listed in the application metadata."), "advanced": True, }, { "option": "working_dir", "type": "directory", "label": _("Working directory"), "warn_if_non_writable_parent": True, "help": _("The directory to run the command in. Note that this must be a directory inside the sandbox."), "advanced": True, }, { "option": "env_vars", "type": "string", "label": _("Environment variables"), "help": _( "Set an environment variable in the application. " "This overrides to the Context section from the application metadata." ), "advanced": True, }, ] def is_installed(self, flatpak_allowed: bool = True) -> bool: return _flatpak.is_installed() def get_executable(self) -> str: exe = _flatpak.get_executable() if not system.path_exists(exe): raise MissingExecutableError(_("The Flatpak executable could not be found.")) return exe def install(self, install_ui_delegate, version=None, callback=None): raise NonInstallableRunnerError( _( "Flatpak installation is not handled by Lutris.\n" "Install Flatpak with the package provided by your distribution." ) ) def can_uninstall(self): return False def uninstall(self, uninstall_callback: Callable[[], None]) -> None: raise RuntimeError("Flatpak can't be uninstalled from Lutris") @property def game_path(self): if shutil.which("flatpak-spawn"): return "/" install_type, application, arch, fcommand, branch = ( self.game_config.get(key, "") for key in ("install_type", "appid", "arch", "fcommand", "branch") ) return os.path.join(self.install_locations[install_type or "user"], application, arch, fcommand, branch) def remove_game_data(self, app_id=None, game_path=None, **kwargs): if not self.is_installed(): return False command = MonitoredCommand( [self.get_executable(), f"uninstall --app --noninteractive {app_id}"], runner=self, env=self.get_env(), title=f"Uninstalling Flatpak App: {app_id}", ) command.start() def get_command(self): arch = self.game_config.get("arch", "") branch = self.game_config.get("branch", "") fcommand = self.game_config.get("fcommand", "") return _flatpak.get_bare_run_command(arch, fcommand, branch) def play(self): appid = self.game_config.get("appid", "") args = self.game_config.get("args", "") if not appid: raise GameConfigError(_("No application specified.")) if appid.count(".") < 2: raise GameConfigError( _("Application ID is not specified in correct format." "Must be something like: tld.domain.app") ) if any(x in appid for x in ("--", "/")): raise GameConfigError(_("Application ID field must not contain options or arguments.")) command = self.get_command() + [appid] if args: command.extend(split_arguments(args)) return {"command": command} lutris-0.5.19/lutris/startup.py0000664000175000017500000001131714756670027015525 0ustar hibbyhibby"""Check to run at program start""" import os import sqlite3 from gettext import gettext as _ import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import GdkPixbuf from lutris import runners, settings from lutris.database.games import get_games from lutris.database.schema import syncdb from lutris.game import Game from lutris.runners.json import load_json_runners from lutris.services import DEFAULT_SERVICES from lutris.util.graphics import vkquery from lutris.util.graphics.drivers import get_gpu_cards from lutris.util.graphics.gpu import GPU, GPUS from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.path_cache import build_path_cache from lutris.util.system import create_folder from lutris.util.wine.dxvk import REQUIRED_VULKAN_API_VERSION def init_dirs(): """Creates Lutris directories""" directories = [ settings.CONFIG_DIR, settings.RUNNERS_CONFIG_DIR, settings.GAME_CONFIG_DIR, settings.DATA_DIR, settings.ICON_PATH, settings.BANNER_PATH, settings.COVERART_PATH, settings.RUNNER_DIR, settings.RUNTIME_DIR, settings.CACHE_DIR, settings.SHADER_CACHE_DIR, settings.INSTALLER_CACHE_DIR, settings.TMP_DIR, ] for directory in directories: create_folder(directory) def check_libs(all_components=False): """Checks that required libraries are installed on the system""" missing_libs = LINUX_SYSTEM.get_missing_libs() if all_components: components = LINUX_SYSTEM.requirements else: components = LINUX_SYSTEM.critical_requirements for req in components: for index, arch in enumerate(LINUX_SYSTEM.runtime_architectures): for lib in missing_libs[req][index]: logger.error("%s %s missing (needed by %s)", arch, lib, req.lower()) def check_vulkan(): """Reports if Vulkan is enabled on the system""" if os.environ.get("LUTRIS_NO_VKQUERY"): return if not vkquery.is_vulkan_supported(): logger.warning("Vulkan is not available or your system isn't Vulkan capable") else: required_api_version = REQUIRED_VULKAN_API_VERSION library_api_version = vkquery.get_vulkan_api_version() if library_api_version and library_api_version < required_api_version: logger.warning( "Vulkan reports an API version of %s. " "%s is required for the latest DXVK.", vkquery.format_version(library_api_version), vkquery.format_version(required_api_version), ) devices = vkquery.get_device_info() if devices and devices[0].api_version < required_api_version: logger.warning( "Vulkan reports that the '%s' device has API version of %s. " "%s is required for the latest DXVK.", devices[0].name, vkquery.format_version(devices[0].api_version), vkquery.format_version(required_api_version), ) def check_gnome(): required_names = ["svg", "png", "jpeg"] format_names = [f.get_name() for f in GdkPixbuf.Pixbuf.get_formats()] for required in required_names: if required not in format_names: logger.error("'%s' PixBuf support is not installed.", required.upper()) def fill_missing_platforms(): """Sets the platform on games where it's missing. This should never happen. """ pga_games = get_games(filters={"installed": 1}) for pga_game in pga_games: if pga_game.get("platform") or not pga_game["runner"]: continue game = Game(game_id=pga_game["id"]) game.set_platform_from_runner() if game.platform: logger.info("Platform for %s set to %s", game.name, game.platform) game.save_platform() def run_all_checks() -> None: """Run all startup checks""" for card in get_gpu_cards(): gpu = GPU(card) driver_info = gpu.get_driver_info() logger.info('"%s" is %s Driver %s', card, gpu, driver_info.get("version")) GPUS[card] = gpu check_libs() check_vulkan() check_gnome() fill_missing_platforms() build_path_cache() def init_lutris(): """Run full initialization of Lutris""" runners.inject_runners(load_json_runners()) init_dirs() try: syncdb() except sqlite3.DatabaseError as err: raise RuntimeError( _("Failed to open database file in %s. Try renaming this file and relaunch Lutris") % settings.DB_PATH ) from err for service in DEFAULT_SERVICES: if not settings.read_setting(service, section="services"): settings.write_setting(service, True, section="services") lutris-0.5.19/lutris/migrations/0000775000175000017500000000000014756670027015622 5ustar hibbyhibbylutris-0.5.19/lutris/migrations/migrate_sortname.py0000664000175000017500000000124614756670027021537 0ustar hibbyhibbyfrom lutris import settings from lutris.api import get_api_games from lutris.database import sql from lutris.database.games import get_games from lutris.util.log import logger def migrate(): """Add blank sortname field to games that do not yet have one""" logger.info("Adding blank sortname field to database") slugs_to_update = [game["slug"] for game in get_games()] games = get_api_games(slugs_to_update) for game in games: if "sortname" not in game.keys() or game["sortname"] is None: sql.db_update(settings.DB_PATH, "games", {"sortname": ""}, {"slug": game["slug"]}) logger.info("Added blank sortname for %s", game["name"]) lutris-0.5.19/lutris/migrations/__init__.py0000664000175000017500000000205014756670027017730 0ustar hibbyhibbyimport importlib from lutris import settings from lutris.util.log import logger MIGRATION_VERSION = 15 # Never decrease this number # Replace deprecated migrations with empty lists MIGRATIONS = [ [], [], [], [], [], [], [], ["mess_to_mame"], ["migrate_hidden_ids"], ["migrate_steam_appids"], [], ["retrieve_discord_appids"], ["migrate_sortname"], ["migrate_hidden_category"], ["migrate_ge_proton"], ] def get_migration_module(migration_name): return importlib.import_module("lutris.migrations.%s" % migration_name) def migrate(): current_version = int(settings.read_setting("migration_version") or 0) if current_version >= MIGRATION_VERSION: return for i in range(current_version, MIGRATION_VERSION): for migration_name in MIGRATIONS[i]: logger.info("Running migration: %s", migration_name) migration = get_migration_module(migration_name) migration.migrate() settings.write_setting("migration_version", MIGRATION_VERSION) lutris-0.5.19/lutris/migrations/migrate_steam_appids.py0000664000175000017500000000101014756670027022345 0ustar hibbyhibby"""Set service ID for Steam games""" from lutris import settings from lutris.database.games import get_games, sql def migrate(): """Run migration""" for game in get_games(): if not game.get("steamid"): continue if game["runner"] and game["runner"] != "steam": continue print("Migrating Steam game %s" % game["name"]) sql.db_update( settings.DB_PATH, "games", {"service": "steam", "service_id": game["steamid"]}, {"id": game["id"]} ) lutris-0.5.19/lutris/migrations/mess_to_mame.py0000664000175000017500000000071014756670027020642 0ustar hibbyhibby"""Migrate MESS games to MAME""" from lutris.database.games import get_games from lutris.game import Game def migrate(): """Run migration""" for pga_game in get_games(): game = Game(pga_game["id"]) if game.runner_name != "mess": continue if "mess" in game.config.game_level: game.config.game_level["mame"] = game.config.game_level.pop("mess") game.runner_name = "mame" game.save() lutris-0.5.19/lutris/migrations/migrate_hidden_category.py0000664000175000017500000000126714756670027023042 0ustar hibbyhibbyfrom sqlite3 import OperationalError from lutris.database.games import get_games from lutris.game import Game from lutris.util.log import logger def migrate(): """Put all previously hidden games into the new '.hidden' category.""" logger.info("Moving hidden games to the '.hidden' category") try: game_ids = [g["id"] for g in get_games(filters={"hidden": 1})] except OperationalError: # A brand-new DB will not have the hidden column at all, # so no migration is required. return for game_id in game_ids: game = Game(game_id) game.mark_as_hidden(True) logger.info("Migrated '%s' to '.hidden' category.", game.name) lutris-0.5.19/lutris/migrations/migrate_hidden_ids.py0000664000175000017500000000134314756670027021777 0ustar hibbyhibby"""Move hidden games from settings to database""" from lutris import settings from lutris.game import Game def get_hidden_ids(): """Return a list of game IDs to be excluded from the library view""" # Load the ignore string and filter out empty strings to prevent issues ignores_raw = settings.read_setting("library_ignores").split(",") return [ignore.strip() for ignore in ignores_raw if ignore] def migrate(): """Run migration""" try: game_ids = get_hidden_ids() except: print("Failed to read hidden game IDs") return [] for game_id in game_ids: game = Game(game_id) game.mark_as_hidden(True) settings.write_setting("library_ignores", "", section="lutris") lutris-0.5.19/lutris/migrations/retrieve_discord_appids.py0000664000175000017500000000124714756670027023074 0ustar hibbyhibbyfrom lutris import settings from lutris.api import get_api_games from lutris.database.games import get_games, sql from lutris.util.log import logger def migrate(): """ Update Games that does not have a Discord ID """ logger.info("Updating Games Discord APP ID's") # Get Slugs from all games slugs_to_update = [game["slug"] for game in get_games()] # Retrieve game data games = get_api_games(slugs_to_update) for game in games: if not game["discord_id"]: continue sql.db_update(settings.DB_PATH, "games", {"discord_id": game["discord_id"]}, {"slug": game["slug"]}) logger.info("Updated %s", game["name"]) lutris-0.5.19/lutris/migrations/migrate_ge_proton.py0000664000175000017500000000250114756670027021676 0ustar hibbyhibby"""Replace the Wine version 'GE-Proton (Latest)' with 'ge-proton'.""" import os from lutris import settings from lutris.database.games import get_games_by_runner from lutris.util.yaml import read_yaml_from_file, write_yaml_to_file def migrate(): """Run migration""" try: config_paths = [os.path.join(settings.CONFIG_DIR, "runners/wine.yml")] for db_game in get_games_by_runner("wine"): config_filename = db_game.get("configpath") config_paths.append(os.path.join(settings.CONFIG_DIR, "games/%s.yml" % config_filename)) for config_path in config_paths: try: if os.path.isfile(config_path): config = read_yaml_from_file(config_path) wine = config.get("wine") if wine: version = wine.get("version") if version and version.casefold() == "ge-proton (latest)": wine["version"] = "ge-proton" write_yaml_to_file(config, filepath=config_path) except Exception as ex: print(f"Failed to convert GE-Proton (Latest) to ge-proton in '{config_path}': {ex}") except Exception as ex: print(f"Failed to convert GE-Proton (Latest) to ge-proton: {ex}") return [] lutris-0.5.19/lutris/__init__.py0000664000175000017500000000006214756670027015555 0ustar hibbyhibby"""Main Lutris package""" __version__ = "0.5.19" lutris-0.5.19/lutris/game_actions.py0000664000175000017500000005020714756670027016455 0ustar hibbyhibby"""Handle game specific actions""" # Standard Library # pylint: disable=too-many-public-methods import os from gettext import gettext as _ from typing import List from gi.repository import Gio, Gtk from lutris.config import duplicate_game_config from lutris.database.games import add_game, get_game_by_field from lutris.game import Game from lutris.gui import dialogs from lutris.gui.config.add_game_dialog import AddGameDialog from lutris.gui.config.edit_game import EditGameConfigDialog from lutris.gui.config.edit_game_categories import EditGameCategoriesDialog from lutris.gui.dialogs import InputDialog from lutris.gui.dialogs.log import LogWindow from lutris.gui.dialogs.uninstall_dialog import UninstallDialog from lutris.gui.widgets.utils import open_uri from lutris.monitored_command import MonitoredCommand from lutris.services.lutris import download_lutris_media from lutris.util import xdgshortcuts from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.steam import shortcut as steam_shortcut from lutris.util.strings import gtk_safe, slugify from lutris.util.system import path_exists class GameActions: """These classes provide a set of action to apply to a game or list of games, and can be used to populate menus. The base class handles the no-games case, for which there are no actions. But it also includes the code for actions that are shared between the subclasses. It also has methods for actions that are invokes externally by the GameBar.""" def __init__(self, window: Gtk.Window, application=None): self.application = application or Gio.Application.get_default() self.window = window # also used as a LaunchUIDelegate and InstallUIDelegate def get_games(self): """Return the list of games that the actions apply to.""" return [] def get_game_actions(self): """Return a list of game actions and their callbacks, Each item is a tuple of two strs and a callable, the action ID, it's human-readable name, and a callback to invoke to perform it. Menu separators are represented hre as (None, "-", None). """ return [] def get_displayed_entries(self): """Return a dictionary of flags indicating which actions are visible; the keys are the action ids from get_game_actions(), and the values are booleans indicating the action's visibility.""" return {} @property def is_game_launchable(self): for game in self.get_games(): if game.is_installed and not self.is_game_running: return True return False def on_game_launch(self, *_args): """Launch a game""" @property def is_game_running(self): for game in self.get_games(): if game.is_db_stored and self.application.is_game_running_by_id(game.id): return True return False def on_game_stop(self, *_args): """Stops the game""" games = self.get_running_games() for game in games: game.force_stop() def get_running_games(self): running_games = [] for game in self.get_games(): if game and game.is_db_stored: ids = self.application.get_running_game_ids() for game_id in ids: if str(game_id) == game.id: running_games.append(game) return running_games @property def is_installable(self): for game in self.get_games(): if not game.is_installed: return True return False def on_install_clicked(self, *_args): """Install a game""" # Install the currently selected game in the UI for game in self.get_games(): if not game.slug: game_id = game.id if game.is_db_stored else game.name raise RuntimeError("No game to install: %s" % game_id) game.install(launch_ui_delegate=self.window) def on_add_favorite_game(self, _widget): """Add to favorite Games list""" for game in self.get_games(): game.mark_as_favorite(True) def on_delete_favorite_game(self, _widget): """delete from favorites""" for game in self.get_games(): game.mark_as_favorite(False) def on_hide_game(self, _widget): """Add a game to the list of hidden games""" for game in self.get_games(): game.mark_as_hidden(True) def on_unhide_game(self, _widget): """Removes a game from the list of hidden games""" for game in self.get_games(): game.mark_as_hidden(False) def on_locate_installed_game(self, *_args): """Show the user a dialog to import an existing install to a DRM free service Params: games ([Game]): List of Game instances without a database ID, populated with fields the service can provides """ for game in self.get_games(): AddGameDialog(self.window, game=game, runner=game.runner_name) def on_view_game(self, _widget): """Callback to open a game on lutris.net""" for game in self.get_games(): open_uri("https://lutris.net/games/%s" % game.slug.replace("_", "-")) @property def is_game_removable(self): for game in self.get_games(): if game.is_installed or game.is_db_stored: return True return False def on_remove_game(self, *_args): """Callback that present the uninstall dialog to the user""" game_ids = [g.id for g in self.get_games() if g.is_installed or g.is_db_stored] application = Gio.Application.get_default() dlg = application.show_window(UninstallDialog, parent=self.window) dlg.add_games(game_ids) def on_edit_game_categories(self, _widget): """Edit game categories""" games = self.get_games() if len(games) == 1: # Individual games get individual separate windows self.application.show_window(EditGameCategoriesDialog, game=games[0], parent=self.window) else: def add_games(window): window.add_games(self.get_games()) # Multi-select means a common categories window for all of them; we can wind # up adding games to it if it's already open self.application.show_window(EditGameCategoriesDialog, update_function=add_games, parent=self.window) class MultiGameActions(GameActions): """This actions class handles actions on multiple games together, and only iof they are 'db stored' games, not service games. This supports a subset of the actions of SingleGameActions.""" def __init__(self, games: List[Game], window: Gtk.Window, application=None): super().__init__(window, application) self.games = games def get_games(self): return self.games def get_game_actions(self): return [ ("stop", _("Stop"), self.on_game_stop), (None, "-", None), ("category", _("Categories"), self.on_edit_game_categories), ("favorite", _("Add to favorites"), self.on_add_favorite_game), ("deletefavorite", _("Remove from favorites"), self.on_delete_favorite_game), ("hide", _("Hide game from library"), self.on_hide_game), ("unhide", _("Unhide game from library"), self.on_unhide_game), (None, "-", None), ("remove", _("Remove"), self.on_remove_game), ] def get_displayed_entries(self): return { "stop": self.is_game_running, "category": True, "favorite": any(g for g in self.games if not g.is_favorite), "deletefavorite": any(g for g in self.games if g.is_favorite), "hide": any(g for g in self.games if g.is_installed and not g.is_hidden), "unhide": any(g for g in self.games if g.is_hidden), "remove": self.is_game_removable, } class SingleGameActions(GameActions): """This actions class handles actions on a single game, which is a 'db stored' game, not a service game. This provides the largest selection of actions, including many that are unique to it.""" def __init__(self, game: Game, window: Gtk.Window, application=None): super().__init__(window, application) self.game = game def get_games(self): return [self.game] def get_game_actions(self): return [ ("play", _("Play"), self.on_game_launch), ("stop", _("Stop"), self.on_game_stop), ("execute-script", _("Execute script"), self.on_execute_script_clicked), ("show_logs", _("Show logs"), self.on_show_logs), (None, "-", None), ("configure", _("Configure"), self.on_edit_game_configuration), ("category", _("Categories"), self.on_edit_game_categories), ("browse", _("Browse files"), self.on_browse_files), ("favorite", _("Add to favorites"), self.on_add_favorite_game), ("deletefavorite", _("Remove from favorites"), self.on_delete_favorite_game), ("hide", _("Hide game from library"), self.on_hide_game), ("unhide", _("Unhide game from library"), self.on_unhide_game), (None, "-", None), ("install", _("Install"), self.on_install_clicked), ("install_more", _("Install another version"), self.on_install_clicked), ("install_dlcs", _("Install DLCs"), self.on_install_dlc_clicked), ("update", _("Install updates"), self.on_update_clicked), ("add", _("Locate installed game"), self.on_locate_installed_game), ("desktop-shortcut", _("Create desktop shortcut"), self.on_create_desktop_shortcut), ("rm-desktop-shortcut", _("Delete desktop shortcut"), self.on_remove_desktop_shortcut), ("menu-shortcut", _("Create application menu shortcut"), self.on_create_menu_shortcut), ("rm-menu-shortcut", _("Delete application menu shortcut"), self.on_remove_menu_shortcut), ("steam-shortcut", _("Create Steam shortcut"), self.on_create_steam_shortcut), ("rm-steam-shortcut", _("Delete Steam shortcut"), self.on_remove_steam_shortcut), ("view", _("View on Lutris.net"), self.on_view_game), ("duplicate", _("Duplicate"), self.on_game_duplicate), (None, "-", None), ("remove", _("Remove"), self.on_remove_game), ] def get_displayed_entries(self): """Return a dictionary of actions that should be shown for a game""" game = self.game has_steam = steam_shortcut.vdf_file_exists() if has_steam: has_steam_shortcut = steam_shortcut.shortcut_exists(game) is_steam_game = steam_shortcut.is_steam_game(game) else: has_steam_shortcut = False is_steam_game = False return { "duplicate": game.is_installed, "install": self.is_installable, "add": not game.is_installed, "play": self.is_game_launchable, "update": game.is_updatable, "install_dlcs": game.is_updatable, "stop": self.is_game_running, "configure": bool(game.is_installed), "browse": game.is_installed and game.runner_name != "browser", "show_logs": game.is_installed, "category": True, "favorite": not game.is_favorite, "deletefavorite": game.is_favorite, "install_more": not game.service and game.is_installed, "execute-script": bool( game.is_installed and game.has_runner and game.runner.system_config.get("manual_command") ), "desktop-shortcut": bool( game.is_installed and not xdgshortcuts.desktop_launcher_exists(game.slug, game.id) ), "menu-shortcut": bool(game.is_installed and not xdgshortcuts.menu_launcher_exists(game.slug, game.id)), "steam-shortcut": bool(has_steam and game.is_installed and not has_steam_shortcut and not is_steam_game), "rm-desktop-shortcut": bool(game.is_installed and xdgshortcuts.desktop_launcher_exists(game.slug, game.id)), "rm-menu-shortcut": bool(game.is_installed and xdgshortcuts.menu_launcher_exists(game.slug, game.id)), "rm-steam-shortcut": bool(game.is_installed and has_steam_shortcut and not is_steam_game), "remove": self.is_game_removable, "view": True, "hide": game.is_installed and not game.is_hidden, "unhide": game.is_hidden, } def on_game_launch(self, *_args): """Launch a game""" game = self.game if game.is_installed and game.is_db_stored: if not self.application.is_game_running_by_id(game.id): game.launch(launch_ui_delegate=self.window) def on_execute_script_clicked(self, _widget): """Execute the game's associated script""" game = self.game manual_command = game.runner.system_config.get("manual_command") if path_exists(manual_command): MonitoredCommand( [manual_command], include_processes=[os.path.basename(manual_command)], cwd=game.directory, ).start() logger.info("Running %s in the background", manual_command) def on_show_logs(self, _widget): """Display game log""" game = self.game _buffer = game.log_buffer if not _buffer: logger.info("No log for game %s", game) return LogWindow(game=game, buffer=_buffer, application=self.application) def on_edit_game_configuration(self, _widget): """Edit game preferences""" self.application.show_window(EditGameConfigDialog, game=self.game, parent=self.window) def on_browse_files(self, _widget): """Callback to open a game folder in the file browser""" path = self.game.get_browse_dir() if not path: dialogs.NoticeDialog(_("This game has no installation directory")) elif path_exists(path): open_uri("file://%s" % path) else: dialogs.NoticeDialog(_("Can't open %s \nThe folder doesn't exist.") % path) def on_install_dlc_clicked(self, _widget): self.game.install_dlc(install_ui_delegate=self.window) def on_update_clicked(self, _widget): self.game.install_updates(install_ui_delegate=self.window) def on_create_menu_shortcut(self, *_args): """Add the selected game to the system's Games menu.""" game = self.game launch_config_name = self._select_game_launch_config_name(game) if launch_config_name is not None: xdgshortcuts.create_launcher(game.slug, game.id, game.name, launch_config_name, menu=True) def on_create_steam_shortcut(self, *_args): """Add the selected game to steam as a nonsteam-game.""" game = self.game launch_config_name = self._select_game_launch_config_name(game) if launch_config_name is not None: steam_shortcut.create_shortcut(game, launch_config_name) def on_create_desktop_shortcut(self, *_args): """Create a desktop launcher for the selected game.""" game = self.game launch_config_name = self._select_game_launch_config_name(game) if launch_config_name is not None: xdgshortcuts.create_launcher(game.slug, game.id, game.name, launch_config_name, desktop=True) def on_remove_menu_shortcut(self, *_args): """Remove an XDG menu shortcut""" game = self.game xdgshortcuts.remove_launcher(game.slug, game.id, menu=True) def on_remove_steam_shortcut(self, *_args): """Remove the selected game from list of non-steam apps.""" steam_shortcut.remove_shortcut(self.game) def on_remove_desktop_shortcut(self, *_args): """Remove a .desktop shortcut""" game = self.game xdgshortcuts.remove_launcher(game.slug, game.id, desktop=True) def on_game_duplicate(self, _widget): game = self.game duplicate_game_dialog = InputDialog( { "parent": self.window, "question": _( "Do you wish to duplicate %s?\nThe configuration will be duplicated, " "but the games files will not be duplicated.\n" "Please enter the new name for the copy:" ) % gtk_safe(game.name), "title": _("Duplicate game?"), "initial_value": game.name, } ) result = duplicate_game_dialog.run() if result != Gtk.ResponseType.OK: duplicate_game_dialog.destroy() return new_name = duplicate_game_dialog.user_value old_config_id = game.game_config_id if old_config_id: new_config_id = duplicate_game_config(game.slug, old_config_id) else: new_config_id = None categories = game.get_categories() duplicate_game_dialog.destroy() db_game = get_game_by_field(game.id, "id") db_game["name"] = new_name db_game["slug"] = slugify(new_name) if new_name != game.name else game.slug db_game["lastplayed"] = None db_game["playtime"] = 0.0 db_game["configpath"] = new_config_id db_game.pop("id") # Disconnect duplicate from service- there should be at most 1 database game for a service game. db_game.pop("service", None) db_game.pop("service_id", None) game_id = add_game(**db_game) new_game = Game(game_id) # add categories before the save, so it can emit the signal. add_game() # means the game is already on the database, so this is legit. for cat in categories: new_game.add_category(cat, no_signal=True) new_game.save() # Download in the background; we'll update the LutrisWindow when this # completes, no need to wait for it. AsyncCall(download_lutris_media, None, db_game["slug"]) def _select_game_launch_config_name(self, game): game_config = game.config.game_level.get("game", {}) configs = game_config.get("launch_configs") if not configs: return "" # use primary configuration dlg = dialogs.LaunchConfigSelectDialog(game, configs, title=_("Select shortcut target"), parent=self.window) if not dlg.confirmed: return None # no error here- the user cancelled out config_index = dlg.config_index return configs[config_index - 1]["name"] if config_index > 0 else "" class ServiceGameActions(GameActions): """This actions class supports a single service game, which has an idiosyncratic set of actions.""" def __init__(self, game: Game, window: Gtk.Window, application=None): super().__init__(window, application) self.game = game def get_games(self): return [self.game] def get_game_actions(self): return [ ("install", _("Install"), self.on_install_clicked), ("add", _("Locate installed game"), self.on_locate_installed_game), ("view", _("View on Lutris.net"), self.on_view_game), ] def get_displayed_entries(self): """Return a dictionary of actions that should be shown for a game""" return {"install": self.is_installable, "add": self.is_installable, "view": True} def get_game_actions(games: List[Game], window: Gtk.Window, application=None) -> GameActions: """Creates a GameActions instance (which may be a subclass) for the list of games given. If it can't figure out a suitable class, it falls back to the base GameActions class, which provides no actions.""" if games: if len(games) == 1: game = games[0] if game.is_db_stored: return SingleGameActions(game, window, application) if game.service: return ServiceGameActions(game, window, application) elif all(g.is_db_stored for g in games): return MultiGameActions(games, window) # If given no games, or the games are not of a kind we can handle, # the base class acts as an empty set of actions. return GameActions(window, application) lutris-0.5.19/lutris/installer/0000775000175000017500000000000014756670027015443 5ustar hibbyhibbylutris-0.5.19/lutris/installer/steam_installer.py0000664000175000017500000000770714756670027021216 0ustar hibbyhibby"""Collection of installer files""" import os import time from gettext import gettext as _ from lutris.config import LutrisConfig from lutris.gui.widgets import NotificationSource from lutris.installer.errors import ScriptingError from lutris.runners import steam from lutris.util.jobs import AsyncCall, schedule_repeating_at_idle from lutris.util.log import logger from lutris.util.steam.log import get_app_state_log class SteamInstaller: """Handles installation of Steam games""" def __init__(self, steam_uri, file_id): """ Params: steam_uri: Colon separated game info containing: - $STEAM - The Steam appid - The relative path of files to retrieve file_id: The lutris installer internal id for the game files """ self.game_installed = NotificationSource() self.game_state_changed = NotificationSource() self.steam_poll = None self.prev_states = [] # Previous states for the Steam installer self.state = "" self.install_start_time = None self.steam_uri = steam_uri self.stop_func = None self.cancelled = False self._runner = None self.file_id = file_id try: _steam, appid, path = self.steam_uri.split(":", 2) except ValueError as err: raise ScriptingError(_("Malformed steam path: %s") % self.steam_uri) from err self.appid = appid self.path = path self.platform = "linux" self.runner = steam.steam() @property def steam_rel_path(self): """Return the relative path for data files""" _steam_rel_path = self.path.strip() if _steam_rel_path == "/": _steam_rel_path = "." return _steam_rel_path @staticmethod def on_steam_game_installed(_data, error): """Callback for Steam game installer, mostly for error handling since install progress is handled by _monitor_steam_game_install """ if error: raise ScriptingError(str(error)) def install_steam_game(self) -> None: """Launch installation of a steam game""" if self.runner.get_game_path_from_appid(appid=self.appid): logger.info("Steam game %s is already installed", self.appid) self.game_installed.fire(self) else: logger.debug("Installing steam game %s", self.appid) self.runner.config = LutrisConfig(runner_slug=self.runner.name) AsyncCall(self.runner.install_game, self.on_steam_game_installed, self.appid) self.install_start_time = time.localtime() self.steam_poll = schedule_repeating_at_idle(self._monitor_steam_game_install, interval_seconds=2.0) self.stop_func = lambda: self.runner.remove_game_data(appid=self.appid) def get_steam_data_path(self): """Return path of Steam files""" data_path = self.runner.get_game_path_from_appid(appid=self.appid) if not data_path or not os.path.exists(data_path): logger.info("No path found for Steam game %s", self.appid) return "" return os.path.abspath(os.path.join(data_path, self.steam_rel_path)) def _monitor_steam_game_install(self) -> bool: if self.cancelled: return False states = get_app_state_log(self.runner.steam_data_dir, self.appid, self.install_start_time) if states and states != self.prev_states: self.state = states[-1].split(",")[-1] logger.debug("Steam installation status: %s", states) self.game_state_changed.fire(self) # Broadcast new state to listeners self.prev_states = states logger.debug(self.state) logger.debug(states) if self.state == "Fully Installed": logger.info("Steam game %s has been installed successfully", self.appid) self.game_installed.fire(self) return False return True lutris-0.5.19/lutris/installer/interpreter.py0000664000175000017500000004723614756670027020374 0ustar hibbyhibby"""Install a game by following its install script.""" import os from gettext import gettext as _ from gi.repository import GObject from lutris import settings from lutris.config import LutrisConfig from lutris.database.games import get_game_by_field from lutris.exceptions import AuthenticationError, MisconfigurationError, UnavailableGameError from lutris.gui.dialogs.delegates import Delegate from lutris.installer import AUTO_EXE_PREFIX from lutris.installer.commands import CommandsMixin from lutris.installer.errors import MissingGameDependencyError, ScriptingError from lutris.installer.installer import LutrisInstaller from lutris.runners import NonInstallableRunnerError, RunnerInstallationError, steam, wine from lutris.services.lutris import download_lutris_media from lutris.util import system from lutris.util.display import DISPLAY_MANAGER from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.strings import unpack_dependencies class ScriptInterpreter(GObject.Object, CommandsMixin): """Control the execution of an installer""" __gsignals__ = { "runners-installed": (GObject.SIGNAL_RUN_FIRST, None, ()), } class InterpreterUIDelegate(Delegate): """This is a base class for objects that provide UI services for running scripts. The InstallerWindow inherits from this.""" def __init__(self, service=None, appid=None): self.service = service self.appid = appid def report_error(self, error): """Called to report an error during installation. The installation will then stop.""" pass def report_status(self, status): """Called to report the current activity of the installer.""" def attach_log(self, command): """Called to attach the command to a log UI, so its log output can be viewed.""" def begin_disc_prompt(self, message, requires, installer, callback): """Called to prompt for a disc. When the disc is provided, the callback is invoked. The method returns immediately, however.""" raise NotImplementedError() def begin_input_menu(self, alias, options, preselect, callback): """Called to prompt the user to select among a list of options. When the user does so, the callback is invoked. The method returns immediately, however.""" raise NotImplementedError() def report_finished(self, game_id, status): """Called to report the successful completion of the installation.""" logger.info("Installation of game %s completed.", game_id) def __init__(self, installer, interpreter_ui_delegate=None): super().__init__() self.target_path = None self.interpreter_ui_delegate = interpreter_ui_delegate or ScriptInterpreter.InterpreterUIDelegate() self.service = self.interpreter_ui_delegate.service _appid = self.interpreter_ui_delegate.appid self.game_dir_created = False # Whether a game folder was created during the install # Extra files for installers, either None if the extras haven't been checked yet. # Or a list of IDs of extras to be downloaded during the install self.game_disc = None self.game_files = {} self.cancelled = False self.abort_current_task = None self.user_inputs = [] self.current_command = 0 # Current installer command when iterating through them self.runners_to_install = [] self.current_resolution = DISPLAY_MANAGER.get_current_resolution() self.installer = LutrisInstaller(installer, self, service=self.service, appid=_appid) if not self.installer.script: raise ScriptingError(_("This installer doesn't have a 'script' section")) if not self.service and self.installer.service: self.service = self.installer.service script_errors = self.installer.get_errors() if script_errors: raise ScriptingError(_("Invalid script: \n{}").format("\n".join(script_errors)), self.installer.script) self._check_binary_dependencies() self._check_dependency() if self.installer.creates_game_folder: self.target_path = self.get_default_target() def on_timeout_error(self, error): self.interpreter_ui_delegate.report_error(error) def on_idle_error(self, error): self.interpreter_ui_delegate.report_error(error) def on_signal_error(self, error): self.interpreter_ui_delegate.report_error(error) def on_emission_hook_error(self, error): self.interpreter_ui_delegate.report_error(error) @property def appid(self): logger.warning("Do not access appid from interpreter") return self.installer.service_appid def get_default_target(self): """Return default installation dir""" config = LutrisConfig(runner_slug=self.installer.runner) games_dir = config.system_config.get("game_path", os.path.expanduser("~")) if self.service: service_dir = self.service.id else: service_dir = "" return os.path.expanduser(os.path.join(games_dir, service_dir, self.installer.game_slug)) @property def cache_path(self): """Return the directory used as a cache for the duration of the installation""" return os.path.join(settings.INSTALLER_CACHE_DIR, "%s" % self.installer.game_slug) @property def script_env(self): """Return the script's own environment variable with values susbtituted. This value can be used to provide the same environment variable as set for the game during the install process. """ return { key: self._substitute(value) for key, value in self.installer.script.get("system", {}).get("env", {}).items() } @staticmethod def _get_game_dependency(dependency): """Return a game database row from a dependency name""" game = get_game_by_field(dependency, field="installer_slug") if not game: game = get_game_by_field(dependency, "slug") # Game must be installed and have a directory # set so we can use that as the destination if game and game["installed"] and game["directory"]: return game def _check_binary_dependencies(self): """Check if all required binaries are installed on the system. This reads a `require-binaries` entry in the script, parsed the same way as the `requires` entry. """ binary_dependencies = unpack_dependencies(self.installer.script.get("require-binaries")) for dependency in binary_dependencies: if isinstance(dependency, tuple): installed_binaries = { dependency_option: system.can_find_executable(dependency_option) for dependency_option in dependency } if not any(installed_binaries.values()): raise ScriptingError(_("This installer requires %s on your system") % _(" or ").join(dependency)) else: if not system.can_find_executable(dependency): raise ScriptingError(_("This installer requires %s on your system") % dependency) def _check_dependency(self): """When a game is a mod or an extension of another game, check that the base game is installed. If the game is available, install the game in the base game folder. The first game available listed in the dependencies is the one picked to base the installed on. """ if self.installer.extends: dependencies = [self.installer.extends] else: dependencies = unpack_dependencies(self.installer.requires) error_message = _("You need to install {} before") for index, dependency in enumerate(dependencies): if isinstance(dependency, tuple): installed_games = [dep for dep in [self._get_game_dependency(dep) for dep in dependency] if dep] if not installed_games: if len(dependency) == 1: raise MissingGameDependencyError(slug=dependency) raise ScriptingError(error_message.format(_(" or ").join(dependency))) if index == 0: self.target_path = installed_games[0]["directory"] self.requires = installed_games[0]["installer_slug"] else: game = self._get_game_dependency(dependency) if not game: raise MissingGameDependencyError(slug=dependency) if index == 0: self.target_path = game["directory"] self.requires = game["installer_slug"] def get_extras(self): """Get extras and store them to move them at the end of the install""" if not self.service or not self.service.has_extras or not self.installer.service_appid: return [] try: return self.service.get_extras(self.installer.service_appid) except (AuthenticationError, UnavailableGameError) as ex: logger.exception("Unable to download list of extras: %s", ex) return [] def launch_install(self, ui_delegate): """Launch the install process; returns False if cancelled by the user.""" self.runners_to_install = self.get_runners_to_install() self.install_runners(ui_delegate) return True def create_game_folder(self): """Create the game folder if needed and store if is was created""" if ( self.installer.files and self.target_path and not system.path_exists(self.target_path) and self.installer.creates_game_folder ): try: logger.debug("Creating destination path %s", self.target_path) os.makedirs(self.target_path) self.game_dir_created = True except PermissionError as err: raise ScriptingError( _("Lutris does not have the necessary permissions to install to path:"), self.target_path, ) from err except FileNotFoundError as err: raise ScriptingError( _("Path %s not found, unable to create game folder. Is the disk mounted?"), self.target_path, ) from err def get_runners_to_install(self): """Check if the runner is installed before starting the installation Install the required runner(s) if necessary. This should handle runner dependencies or runners used for installer tasks. """ runners_to_install = [] required_runners = [] runner = self.get_runner_class(self.installer.runner) required_runners.append(runner()) for command in self.installer.script.get("installer", []): command_name, command_params = self._get_command_name_and_params(command) if command_name == "task": runner_name, _task_name = self._get_task_runner_and_name(command_params["name"]) runner_names = [r.name for r in required_runners] if runner_name not in runner_names: required_runners.append(self.get_runner_class(runner_name)()) for runner in required_runners: if not runner.is_installed_for(self): logger.info("Runner %s needs to be installed", runner.name) runners_to_install.append(runner) return runners_to_install def install_runners(self, ui_delegate): """Install required runners for a game""" if self.runners_to_install: self.install_runner(self.runners_to_install.pop(0), ui_delegate) return # install_runner calls back into this method to get the next one self.emit("runners-installed") def install_runner(self, runner, ui_delegate): """Install runner required by the install script""" def install_more_runners(): self.install_runners(ui_delegate) logger.debug("Installing %s", runner.name) try: runner.install( ui_delegate, version=runner.get_installer_runner_version(self.installer), callback=install_more_runners, ) except (NonInstallableRunnerError, RunnerInstallationError) as ex: logger.error(ex.message) raise ScriptingError(ex.message) from ex def launch_installer_commands(self): """Run the pre-installation steps and launch install.""" self.create_game_folder() os.makedirs(self.cache_path, exist_ok=True) self._iter_commands() def _iter_commands(self, result=None, exception=None): if result == "STOP" or self.cancelled: return try: commands = self.installer.script.get("installer", []) if exception: logger.error("Last install command failed, show error") self.interpreter_ui_delegate.report_error(exception) elif self.current_command < len(commands): try: command = commands[self.current_command] except KeyError as err: raise ScriptingError(_("Installer commands are not formatted correctly")) from err self.current_command += 1 method, params = self._map_command(command) if isinstance(params, dict): status_text = params.pop("description", None) else: status_text = None if status_text: self.interpreter_ui_delegate.report_status(status_text) logger.debug("Installer command: %s", command) if self.target_path and os.path.exists(self.target_path): # Establish a CWD for the command, but remove it afterwards # for safety. We'd better not rely on this, many tasks can be # fiddling with the CWD at the same time. def dispatch(): prev_cwd = os.getcwd() os.chdir(self.target_path) try: return method(params) finally: os.chdir(prev_cwd) AsyncCall(dispatch, self._iter_commands) else: AsyncCall(method, self._iter_commands, params) else: logger.debug("Commands %d out of %s completed", self.current_command, len(commands)) self._finish_install() except Exception as ex: # Redirect errors to the delegate, instead of the default ErrorDialog. logger.exception("Error during installation: %s", ex) self.interpreter_ui_delegate.report_error(ex) @staticmethod def _get_command_name_and_params(command_data): if isinstance(command_data, dict): command_name = list(command_data.keys())[0] command_params = command_data[command_name] else: command_name = command_data command_params = {} command_name = command_name.replace("-", "_") # Prevent private methods from being accessed as commands command_name = command_name.strip("_") return command_name, command_params def _map_command(self, command_data): """Map a directive from the `installer` section to an internal method.""" command_name, command_params = self._get_command_name_and_params(command_data) if not hasattr(self, command_name): raise ScriptingError(_('The command "%s" does not exist.') % command_name) return getattr(self, command_name), command_params def _finish_install(self): game_id = self.installer.save() path = None if path and AUTO_EXE_PREFIX not in path and not os.path.isfile(path) and self.installer.runner != "web": status = ( _( "The executable at path %s can't be found, please check the destination folder.\n" "Some parts of the installation process may have not completed successfully." ) % path ) logger.warning("No executable found at specified location %s", path) else: status = self.installer.script.get("install_complete_text") or _("Installation completed!") AsyncCall(download_lutris_media, None, self.installer.game_slug) self.interpreter_ui_delegate.report_finished(game_id, status) def cleanup(self): """Clean up install dir after a successful install""" os.chdir(os.path.expanduser("~")) system.delete_folder(self.cache_path) def revert(self, remove_game_dir=True): """Revert installation in case of an error""" logger.info("Cancelling installation of %s", self.installer.game_name) if self.installer.runner.startswith("wine"): self.task({"name": "winekill"}) self.cancelled = True if self.abort_current_task: self.abort_current_task() if self.target_path and remove_game_dir: system.remove_folder(self.target_path) def _get_string_replacements(self): """Return a mapping of variables to their actual value""" current_res = self.current_resolution replacements = { "GAMEDIR": self.target_path, "CACHE": self.cache_path, "HOME": os.path.expanduser("~"), "STEAM_DATA_DIR": steam.steam().steam_data_dir, "DISC": self.game_disc, "USER": os.getenv("USER"), "INPUT": self.user_inputs[-1]["value"] if self.user_inputs else "", "VERSION": self.installer.version, "RESOLUTION": "x".join(current_res), "RESOLUTION_WIDTH": current_res[0], "RESOLUTION_HEIGHT": current_res[1], } try: replacements["RESOLUTION_WIDTH_HEX"] = hex(int(current_res[0])) replacements["RESOLUTION_HEIGHT_HEX"] = hex(int(current_res[1])) except (ValueError, TypeError): pass # If we can't generate hex, just omit the vars try: replacements["WINEBIN"] = self.get_wine_path() except MisconfigurationError: pass # If we can't get the path, just omit it # None values stringify as 'None', which is not what you want, so we'll # remove then pre-emptively. This happens for game install scripts that have # no 'self.target_path'. for key in [key for key, value in replacements.items() if value is None]: del replacements[key] replacements.update(self.installer.variables) # Add 'INPUT_' replacements for user inputs with an id for input_data in self.user_inputs: alias = input_data["alias"] if alias: replacements[alias] = input_data["value"] replacements.update(self.game_files) return replacements def _substitute(self, template_string): """Replace path aliases with real paths.""" if template_string is None: logger.warning("No template string given") return "" if str(template_string).replace("-", "_") in self.game_files: template_string = template_string.replace("-", "_") return system.substitute(template_string, self._get_string_replacements()) def eject_wine_disc(self): """Use Wine to eject a CD, otherwise Wine can have problems detecting disc changes""" wine_path = self.get_wine_path() wine.eject_disc(wine_path, self.target_path) lutris-0.5.19/lutris/installer/__init__.py0000664000175000017500000000212514756670027017554 0ustar hibbyhibby"""Install script interpreter package.""" import enum import yaml from lutris.api import get_game_installers, normalize_installer from lutris.util import system from lutris.util.log import logger AUTO_EXE_PREFIX = "_xXx_AUTO_" AUTO_ELF_EXE = AUTO_EXE_PREFIX + "ELF_xXx_" AUTO_WIN32_EXE = AUTO_EXE_PREFIX + "WIN32_xXx_" class InstallationKind(enum.Enum): INSTALL = 0 UPDATE = 1 DLC = 2 def read_script(filename): """Return scripts from a local file""" logger.debug("Loading script(s) from %s", filename) with open(filename, "r", encoding="utf-8") as local_file: script = yaml.safe_load(local_file.read()) if isinstance(script, list): return script if "results" in script: return script["results"] return [script] def get_installers(game_slug=None, installer_file=None, revision=None): # check if installer is local or online if system.path_exists(installer_file): return [normalize_installer(i) for i in read_script(installer_file)] return get_game_installers(game_slug=game_slug, revision=revision) lutris-0.5.19/lutris/installer/errors.py0000664000175000017500000000317614756670027017340 0ustar hibbyhibby"""Installer specific exceptions""" import sys from gettext import gettext as _ from lutris.exceptions import LutrisError from lutris.gui.dialogs import ErrorDialog from lutris.util.log import logger from lutris.util.strings import gtk_safe class ScriptingError(LutrisError): """Custom exception for scripting errors, can be caught by modifying excepthook.""" def __init__(self, message, faulty_data=None): self.faulty_data = faulty_data super().__init__(message) logger.error(self.__str__()) def __str__(self): if self.faulty_data is None: return self.message faulty_data = repr(self.faulty_data) if not faulty_data: return faulty_data return self.message + "\n%s" % faulty_data def __repr__(self): return self.message class MissingGameDependencyError(LutrisError): """Raise when a game requires another game that isn't installed""" def __init__(self, *args, message=None, slug=None, **kwargs): self.slug = slug if not message: message = _("This game requires %s.") % slug super().__init__(message, *args, **kwargs) _excepthook = sys.excepthook # pylint: disable=invalid-name def error_handler(error_type, value, traceback): """Intercept all possible exceptions and raise them as ScriptingErrors""" if error_type == ScriptingError: message = value.message if value.faulty_data: message += "\n%s" % gtk_safe(value.faulty_data) ErrorDialog(message) else: _excepthook(error_type, value, traceback) sys.excepthook = error_handler lutris-0.5.19/lutris/installer/installer.py0000664000175000017500000003545514756670027020026 0ustar hibbyhibby"""Lutris installer class""" import json from gettext import gettext as _ from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_or_update, get_game_by_field from lutris.exceptions import AuthenticationError, UnavailableGameError from lutris.installer import AUTO_ELF_EXE, AUTO_WIN32_EXE from lutris.installer.errors import ScriptingError from lutris.installer.installer_file import InstallerFile from lutris.runners import import_runner from lutris.services import SERVICES from lutris.util.game_finder import find_linux_game_executable, find_windows_game_executable from lutris.util.gog import convert_gog_config_to_lutris, get_gog_config_from_path, get_gog_game_path from lutris.util.log import logger from lutris.util.moddb import ModDB, is_moddb_url from lutris.util.system import fix_path_case class LutrisInstaller: # pylint: disable=too-many-instance-attributes """Represents a Lutris installer""" def __init__(self, installer, interpreter, service, appid): self.interpreter = interpreter self.installer = installer self.is_update = False try: self.version = installer["version"] self.slug = installer["slug"] self.year = installer.get("year") self.runner = installer["runner"] self.script = installer.get("script") self.game_name = installer["name"] self.game_slug = installer["game_slug"] except KeyError as ex: raise ScriptingError(_("The script was missing the '%s' key, which is required.") % ex.args[0]) from ex self.service = self.get_service(initial=service) self.service_appid = self.get_appid(installer, initial=appid) self.variables = self.script.get("variables", {}) self.script_files = [ InstallerFile(self.game_slug, file_id, file_meta) for file_desc in self.script.get("files", []) for file_id, file_meta in file_desc.items() ] self.files = [] self.extra_file_paths = [] self.requires = self.script.get("requires") self.extends = self.script.get("extends") self.game_id = self.get_game_id() self.is_gog = False self.discord_id = installer.get("discord_id") def get_service(self, initial=None): if initial: return initial if "steam" in self.runner and "steam" in SERVICES: return SERVICES["steam"]() version = self.version.lower() if "humble" in version and "humblebundle" in SERVICES: return SERVICES["humblebundle"]() if "gog" in version and "gog" in SERVICES: return SERVICES["gog"]() if "itch.io" in version and "itchio" in SERVICES: return SERVICES["itchio"]() def get_appid(self, installer, initial=None): if installer.get("is_dlc"): return installer.get("dlcid") if initial: return initial if not self.service: return service_id = None if self.service.id == "steam": service_id = installer.get("steamid") or installer.get("service_id") game_config = self.script.get("game", {}) if self.service.id == "gog": service_id = game_config.get("gogid") or installer.get("gogid") or installer.get("service_id") if self.service.id == "humblebundle": service_id = game_config.get("humbleid") or installer.get("humblestoreid") or installer.get("service_id") if self.service.id == "itchio": service_id = game_config.get("itchid") or installer.get("itchid") or installer.get("service_id") if service_id: return service_id return @property def script_pretty(self): """Return a pretty print of the script""" return json.dumps(self.script, indent=4) def get_game_id(self): """Return the ID of the game in the local DB if one exists""" # If the game is in the library and uninstalled, the first installation # updates it existing_game = get_game_by_field(self.game_slug, "slug") if existing_game and (self.extends or not existing_game["installed"]): return existing_game["id"] @property def creates_game_folder(self): """Determines if an install script should create a game folder for the game""" if self.requires or self.extends: # Game is an extension of an existing game, folder exists return False if self.runner == "steam": # Steam games installs in their steamapps directory return False if not self.script.get("installer"): # No command can affect files return False if self.script_files or self.script.get("game", {}).get("gog") or self.script.get("game", {}).get("prefix"): return True command_names = [self.interpreter._get_command_name_and_params(c)[0] for c in self.script.get("installer", [])] if "insert_disc" in command_names: return True return False def get_errors(self): """Return potential errors in the script""" errors = [] if not isinstance(self.script, dict): errors.append("Script must be a dictionary") # Return early since the method assumes a dict return errors # Check that installers contains all required fields for field in ("runner", "game_name", "game_slug"): if not hasattr(self, field) or not getattr(self, field): errors.append("Missing field '%s'" % field) # Check that libretro installers have a core specified if self.runner == "libretro": if "game" not in self.script or "core" not in self.script["game"]: errors.append("Missing libretro core in game section") # Check that Steam games have an AppID if self.runner == "steam": if not self.script.get("game", {}).get("appid"): errors.append("Missing appid for Steam game") # Check that installers don't contain both 'requires' and 'extends' if self.script.get("requires") and self.script.get("extends"): errors.append("Scripts can't have both extends and requires") return errors def prepare_game_files(self, extras, patch_version=None): """Gathers necessary files before iterating through them.""" if not self.script_files: return installer_file_id = None installer_file_url = None if self.service: for file in self.script_files: if file.url.startswith("N/A"): installer_file_id = file.id installer_file_url = file.url break files = [file.copy() for file in self.script_files if file.id != installer_file_id] extra_file_paths = [] # Run variable substitution on the URLs from the script for file in files: file.set_url(self.interpreter._substitute(file.url)) if is_moddb_url(file.url): file.set_url(ModDB().transform_url(file.url)) if installer_file_id and self.service: logger.info("Getting files for %s", installer_file_id) try: if patch_version: # If a patch version is given download the patch files instead of the installer installer_files = self.service.get_patch_files(self, installer_file_id) else: content_files, extra_files = self.service.get_installer_files(self, installer_file_id, extras) extra_file_paths = [path for f in extra_files for path in f.get_dest_files_by_id().values()] installer_files = content_files + extra_files except (AuthenticationError, UnavailableGameError) as ex: logger.exception("Game not available: %s", ex) installer_files = None if installer_files: for installer_file in installer_files: files.append(installer_file) else: # Failed to get the service game, put back a user provided file logger.debug("Unable to get files from service. Setting %s to manual.", installer_file_id) files.insert( 0, InstallerFile(self.game_slug, installer_file_id, {"url": installer_file_url, "filename": ""}) ) # Commit changes only at the end; this is more robust in this method is runner # my two threads concurrently- the GIL can probably save us. It's not desirable # to do this, but this is the easiest workaround. self.files = files self.extra_file_paths = extra_file_paths def install_extras(self): # Copy extras to game folder; this updates the installer script, so it needs # be called just once, before launching the installers commands. if self.extra_file_paths and len(self.extra_file_paths) == len(self.files): # Reset the install script in case there are only extras. logger.warning("Installer with only extras and no game files") self.script["installer"] = [] for extra_file in self.extra_file_paths: self.script["installer"].append({"copy": {"src": extra_file, "dst": "$GAMEDIR/extras"}}) def _substitute_config(self, script_config): """Substitute values such as $GAMEDIR in a config dict.""" config = {} for key in script_config: if not isinstance(key, str): raise ScriptingError(_("Game config key must be a string"), key) value = script_config[key] if str(value).lower() == "true": value = True if str(value).lower() == "false": value = False if key == "launch_configs": config[key] = [{k: self.interpreter._substitute(v) for (k, v) in _conf.items()} for _conf in value] elif isinstance(value, list): config[key] = [self.interpreter._substitute(i) for i in value] elif isinstance(value, dict): config[key] = {k: self.interpreter._substitute(v) for (k, v) in value.items()} elif isinstance(value, bool): config[key] = value else: config[key] = self.interpreter._substitute(value) return config def get_game_config(self): """Return the game configuration""" if self.requires: # Load the base game config required_game = get_game_by_field(self.requires, field="installer_slug") if not required_game: required_game = get_game_by_field(self.requires, field="slug") if not required_game: raise ValueError("No game matched '%s' on installer_slug or slug" % self.requires) base_config = LutrisConfig(runner_slug=self.runner, game_config_id=required_game["configpath"]) config = base_config.game_level else: config = {"game": {}} # Config update if "system" in self.script: config["system"] = self._substitute_config(self.script["system"]) if self.script.get(self.runner): installer_runner_config = self._substitute_config(self.script[self.runner]) import_runner(self.runner)().adjust_installer_runner_config(installer_runner_config) config[self.runner] = installer_runner_config game_config = config["game"] entry_point_keys = ("iso", "rom", "main_file", "exe") if "game" in self.script: try: game_config.update(self.script["game"]) except ValueError as err: raise ScriptingError(_("Invalid 'game' section"), self.script["game"]) from err # Obsolete install scripts may have the entry point key at root level; # we'll move them into the game-config if so, and if they are not already # there. Add a warning because I'm sure this compatibility ship will get lost, # and the scripts would be better updated. for entry_point_key in entry_point_keys: if entry_point_key in self.script and entry_point_key not in game_config: logger.warning("Moving entry point '%s' from script root level to the game config", entry_point_key) game_config[entry_point_key] = self.script[entry_point_key] game_config = self._substitute_config(game_config) if AUTO_ELF_EXE in game_config.get("exe", ""): game_config["exe"] = find_linux_game_executable(self.interpreter.target_path, make_executable=True) elif AUTO_WIN32_EXE in game_config.get("exe", ""): game_config["exe"] = find_windows_game_executable(self.interpreter.target_path) # Fix possible case differences for key in entry_point_keys: entry_point = game_config.get(key) if entry_point: game_config[key] = fix_path_case(entry_point) config["game"] = game_config config["name"] = self.game_name config["script"] = self.script config["variables"] = self.variables config["version"] = self.version config["requires"] = self.requires config["slug"] = self.slug config["game_slug"] = self.game_slug config["year"] = self.year if self.service: config["service"] = self.service.id config["service_id"] = self.service_appid return config def save(self): """Write the game configuration in the DB and config file""" if self.extends: logger.info( "This is an extension to %s, not creating a new game entry", self.extends, ) return self.game_id if self.is_gog: gog_config = get_gog_config_from_path(self.interpreter.target_path) if gog_config: gog_game_path = get_gog_game_path(self.interpreter.target_path) lutris_config = convert_gog_config_to_lutris(gog_config, gog_game_path) self.script["game"].update(lutris_config) configpath = write_game_config(self.slug, self.get_game_config()) self.game_id = add_or_update( name=self.game_name, runner=self.runner, slug=self.game_slug, platform=import_runner(self.runner)().get_platform(), directory=self.interpreter.target_path, installed=1, installer_slug=self.slug, parent_slug=self.requires, year=self.year, configpath=configpath, service=self.service.id if self.service else None, service_id=self.service_appid, id=self.game_id, discord_id=self.discord_id, ) return self.game_id lutris-0.5.19/lutris/installer/commands.py0000664000175000017500000006642214756670027017630 0ustar hibbyhibby"""Commands for installer scripts""" import glob import json import multiprocessing import os import shlex import shutil from gettext import gettext as _ from pathlib import Path from lutris import runtime from lutris.cache import is_file_in_custom_cache from lutris.exceptions import MissingExecutableError, UnspecifiedVersionError from lutris.installer.errors import ScriptingError from lutris.installer.installer import LutrisInstaller from lutris.monitored_command import MonitoredCommand from lutris.runners import InvalidRunnerError, import_runner, import_task from lutris.runners.wine import wine from lutris.util import extract, linux, selective_merge, system from lutris.util.fileio import EvilConfigParser, MultiOrderedDict from lutris.util.jobs import schedule_repeating_at_idle from lutris.util.log import logger from lutris.util.wine.wine import WINE_DEFAULT_ARCH, get_default_wine_version, get_wine_path_for_version class CommandsMixin: """The directives for the `installer:` part of the install script.""" # pylint: disable=no-member installer: LutrisInstaller = NotImplemented def get_wine_path(self) -> str: """Return absolute path of wine version used during the installation, but None if the wine exe can't be located.""" runner = self.get_runner_class(self.installer.runner)() version = runner.get_installer_runner_version(self.installer, use_runner_config=False) if version: wine_path = get_wine_path_for_version(version) return wine_path # Special case that lets the Wine configuration explicit specify the path # to the Wine executable, not just a version number. if self.installer.runner == "wine": try: config_version, runner_config = wine.get_runner_version_and_config() wine_path = get_wine_path_for_version(config_version, config=runner_config.runner_level["wine"]) return wine_path except UnspecifiedVersionError: pass version = get_default_wine_version() wine_path = get_wine_path_for_version(version) return wine_path def get_runner_class(self, runner_name): """Runner the runner class from its name""" try: runner = import_runner(runner_name) except InvalidRunnerError as err: raise ScriptingError(_("Invalid runner provided %s") % runner_name) from err return runner @staticmethod def _check_required_params(params, command_data, command_name): """Verify presence of a list of parameters required by a command.""" if isinstance(params, str): params = [params] for param in params: if isinstance(param, tuple): param_present = False for key in param: if key in command_data: param_present = True if not param_present: raise ScriptingError( _("One of {params} parameter is mandatory for the {cmd} command").format( params=_(" or ").join(param), cmd=command_name ), command_data, ) else: if param not in command_data: raise ScriptingError( _("The {param} parameter is mandatory for the {cmd} command").format( param=param, cmd=command_name ), command_data, ) def chmodx(self, filename): """Make filename executable""" filename = self._substitute(filename) if not system.path_exists(filename): raise ScriptingError(_("Invalid file '%s'. Can't make it executable") % filename) system.make_executable(filename) def execute(self, data): """Run an executable file.""" args = [] terminal = None working_dir = None env = {} if isinstance(data, dict): self._check_required_params([("file", "command")], data, "execute") if "command" in data and "file" in data: raise ScriptingError( _("Parameters file and command can't be used " "at the same time for the execute command"), data, ) # Accept return codes other than 0 if "return_code" in data: return_code = data.pop("return_code") else: return_code = "0" exec_path = data.get("file", "") command = data.get("command", "") args_string = data.get("args", "") for arg in shlex.split(args_string): args.append(self._substitute(arg)) terminal = data.get("terminal") working_dir = data.get("working_dir") if not data.get("disable_runtime"): # Possibly need to handle prefer_system_libs here env.update(runtime.get_env()) # Loading environment variables set in the script env.update(self.script_env) # Environment variables can also be passed to the execute command local_env = data.get("env") or {} env.update({key: self._substitute(value) for key, value in local_env.items()}) include_processes = shlex.split(data.get("include_processes", "")) exclude_processes = shlex.split(data.get("exclude_processes", "")) elif isinstance(data, str): command = data include_processes = [] exclude_processes = [] else: raise ScriptingError(_("No parameters supplied to execute command."), data) if command: exec_path = "bash" args = ["-c", self._get_file_path(command.strip())] include_processes.append("bash") else: # Determine whether 'file' value is a file id or a path exec_path = self._get_file_path(exec_path) if system.path_exists(exec_path) and not system.is_executable(exec_path): logger.warning("Making %s executable", exec_path) system.make_executable(exec_path) try: exec_abs_path = system.find_required_executable(exec_path) except MissingExecutableError as ex: raise ScriptingError(_("Unable to find executable %s") % exec_path) from ex if terminal: terminal = linux.get_default_terminal() if not working_dir or not os.path.exists(working_dir): working_dir = self.target_path command = MonitoredCommand( [exec_abs_path] + args, env=env, term=terminal, cwd=working_dir, include_processes=include_processes, exclude_processes=exclude_processes, ) command.accepted_return_code = return_code command.start() self.interpreter_ui_delegate.attach_log(command) schedule_repeating_at_idle(self._monitor_task, command, interval_seconds=1.0) return "STOP" def extract(self, data): """Extract a file, guessing the compression method.""" self._check_required_params([("file", "src")], data, "extract") src_param = data.get("file") or data.get("src") filespec = self._get_file_path(src_param) if os.path.exists(filespec): filenames = [filespec] else: filenames = glob.glob(filespec) if not filenames: raise ScriptingError(_("%s does not exist") % filespec) if "dst" in data: dest_path = self._substitute(data["dst"]) else: dest_path = self.target_path for filename in filenames: msg = _("Extracting %s") % os.path.basename(filename) logger.debug(msg) self.interpreter_ui_delegate.report_status(msg) merge_single = "nomerge" not in data extractor = data.get("format") logger.debug("extracting file %s to %s", filename, dest_path) self._killable_process(extract.extract_archive, filename, dest_path, merge_single, extractor) logger.debug("Extract done") def input_menu(self, data): """Display an input request as a dropdown menu with options.""" self._check_required_params("options", data, "input_menu") identifier = data.get("id") alias = "INPUT_%s" % identifier if identifier else None options = data["options"] preselect = self._substitute(data.get("preselect", "")) self.interpreter_ui_delegate.begin_input_menu(alias, options, preselect, self._on_input_menu_validated) return "STOP" def _on_input_menu_validated(self, alias, menu): choosen_option = menu.get_active_id() if choosen_option: self.user_inputs.append({"alias": alias, "value": choosen_option}) self._iter_commands() else: raise RuntimeError("A required input option was not selected, so the installation can't continue.") def insert_disc(self, data): """Request user to insert an optical disc""" self._check_required_params("requires", data, "insert_disc") requires = data.get("requires") message = data.get( "message", _( "Insert or mount game disc and click Autodetect or\n" "use Browse if the disc is mounted on a non standard location." ), ) message += ( _( "\n\nLutris is looking for a mounted disk drive or image \n" "containing the following file or folder:\n" "%s" ) % requires ) self.interpreter_ui_delegate.begin_disc_prompt(message, requires, self.installer, self._find_matching_disc) return "STOP" def _find_matching_disc(self, _widget, requires, extra_path=None): if extra_path: drives = [extra_path] else: drives = system.get_mounted_discs() for drive in drives: required_abspath = os.path.join(drive, requires) required_abspath = system.fix_path_case(required_abspath) if system.path_exists(required_abspath): logger.debug("Found %s on cdrom %s", requires, drive) self.game_disc = drive self._iter_commands() return raise RuntimeError(_("The required file '%s' could not be located.") % requires) def mkdir(self, directory): """Create directory""" directory = self._substitute(directory) try: os.makedirs(directory) except OSError: logger.debug("Directory %s already exists", directory) else: logger.debug("Created directory %s", directory) def merge(self, params): """Merge the contents given by src to destination folder dst""" self._check_required_params(["src", "dst"], params, "merge") src, dst = self._get_move_paths(params) logger.debug("Merging %s into %s", src, dst) if not os.path.exists(src): if params.get("optional"): logger.info("Optional path %s not present", src) return raise ScriptingError(_("Source does not exist: %s") % src, params) os.makedirs(dst, exist_ok=True) if os.path.isfile(src): # If single file, copy it and change reference in game file so it # can be used as executable. Skip copying if the source is the same # as destination. if os.path.dirname(src) != dst: self._killable_process(shutil.copy, src, dst) if params["src"] in self.game_files.keys(): self.game_files[params["src"]] = os.path.join(dst, os.path.basename(src)) return self._killable_process(system.merge_folders, src, dst) def copy(self, params): """Alias for merge""" self.merge(params) def move(self, params): """Move a file or directory into a destination folder.""" self._check_required_params(["src", "dst"], params, "move") src, dst = self._get_move_paths(params) logger.debug("Moving %s to %s", src, dst) if not os.path.exists(src): if params.get("optional"): logger.info("Optional path %s not present", src) return raise ScriptingError(_("Invalid source for 'move' operation: %s") % src) if os.path.isfile(src): if os.path.dirname(src) == dst: logger.info("Source file is the same as destination, skipping") return if os.path.exists(os.path.join(dst, os.path.basename(src))): # May not be the best choice, but it's the safest. # Maybe should display confirmation dialog (Overwrite / Skip) ? logger.info("Destination file exists, skipping") return try: if is_file_in_custom_cache(src): action = shutil.copy else: action = shutil.move self._killable_process(action, src, dst) except shutil.Error as err: raise ScriptingError(_("Can't move {src} \nto destination {dst}").format(src=src, dst=dst)) from err def rename(self, params): """Rename file or folder.""" self._check_required_params(["src", "dst"], params, "rename") src, dst = self._get_move_paths(params) if not os.path.exists(src): raise ScriptingError(_("Rename error, source path does not exist: %s") % src) if os.path.isdir(dst): try: os.rmdir(dst) # Remove if empty except OSError: pass if os.path.exists(dst): raise ScriptingError(_("Rename error, destination already exists: %s") % src) dst_dir = os.path.dirname(dst) # Pre-move on dest filesystem to avoid error with # os.rename through different filesystems temp_dir = os.path.join(dst_dir, "lutris_rename_temp") os.makedirs(temp_dir) self._killable_process(shutil.move, src, temp_dir) src = os.path.join(temp_dir, os.path.basename(src)) os.renames(src, dst) def _get_move_paths(self, params): """Process raw 'src' and 'dst' data.""" try: src_ref = params["src"] except KeyError as err: raise ScriptingError(_("Missing parameter src")) from err src = self.game_files.get(src_ref) or self._substitute(src_ref) if not src: raise ScriptingError(_("Wrong value for 'src' param"), src_ref) dst_ref = params["dst"] dst = self._substitute(dst_ref) if not dst: raise ScriptingError(_("Wrong value for 'dst' param"), dst_ref) return src.rstrip("/"), dst.rstrip("/") def substitute_vars(self, data): """Substitute variable names found in given file.""" self._check_required_params("file", data, "substitute_vars") filename = self._substitute(data["file"]) logger.debug("Substituting variables for file %s", filename) tmp_filename = filename + ".tmp" with open(filename, "r", encoding="utf-8") as source_file: with open(tmp_filename, "w", encoding="utf-8") as dest_file: line = "." while line: line = source_file.readline() line = self._substitute(line) dest_file.write(line) os.rename(tmp_filename, filename) def _get_task_runner_and_name(self, task_name): if "." in task_name: # Run a task from a different runner # than the one for this installer runner_name, task_name = task_name.split(".") else: runner_name = self.installer.runner return runner_name, task_name def task(self, data): """Directive triggering another function specific to a runner. The 'name' parameter is mandatory. If 'args' is provided it will be passed to the runner task. """ self._check_required_params("name", data, "task") runner_name, task_name = self._get_task_runner_and_name(data.pop("name")) # Accept return codes other than 0 if "return_code" in data: return_code = data.pop("return_code") else: return_code = "0" if runner_name.startswith("wine"): data["wine_path"] = self.get_wine_path() data["prefix"] = data.get("prefix") or self.installer.script.get("game", {}).get("prefix") or "$GAMEDIR" data["arch"] = data.get("arch") or self.installer.script.get("game", {}).get("arch") or WINE_DEFAULT_ARCH if task_name == "wineexec": data["env"] = self.script_env for key in data: value = data[key] if isinstance(value, dict): for inner_key in value: value[inner_key] = self._substitute(value[inner_key]) elif isinstance(value, list): for index, elem in enumerate(value): value[index] = self._substitute(elem) else: value = self._substitute(data[key]) data[key] = value task = import_task(runner_name, task_name) command = task(**data) if isinstance(command, MonitoredCommand): # Monitor thread and continue when task has executed self.interpreter_ui_delegate.attach_log(command) command.accepted_return_code = return_code schedule_repeating_at_idle(self._monitor_task, command, interval_seconds=1.0) return "STOP" return None def _monitor_task(self, command): if not command.is_running: logger.debug("Return code: %s", command.return_code) if command.return_code not in (str(command.accepted_return_code), "0"): raise ScriptingError(_("Command exited with code %s") % command.return_code) self._iter_commands() return False return True # keep checking def write_file(self, params): """Write text to a file.""" self._check_required_params(["file", "content"], params, "write_file") # Get file dest_file_path = self._get_file_path(params["file"]) # Create dir if necessary basedir = os.path.dirname(dest_file_path) os.makedirs(basedir, exist_ok=True) mode = params.get("mode", "w") if not mode.startswith(("a", "w")): raise ScriptingError(_("Wrong value for write_file mode: '%s'") % mode) with open(dest_file_path, mode, encoding="utf-8") as dest_file: dest_file.write(self._substitute(params["content"])) def write_json(self, params): """Write data into a json file.""" self._check_required_params(["file", "data"], params, "write_json") # Get file filename = self._get_file_path(params["file"]) # Create dir if necessary basedir = os.path.dirname(filename) os.makedirs(basedir, exist_ok=True) merge = params.get("merge", True) # create an empty file if it doesn't exist Path(filename).touch(exist_ok=True) with open(filename, "r+" if merge else "w", encoding="utf-8") as json_file: json_data = {} if merge: try: json_data = json.load(json_file) except ValueError: logger.error("Failed to parse JSON from file %s", filename) json_data = selective_merge(json_data, params.get("data", {})) json_file.seek(0) json_file.truncate() json_file.write(json.dumps(json_data, indent=2)) def write_config(self, params): """Write a key-value pair into an INI type config file.""" if params.get("data"): self._check_required_params(["file", "data"], params, "write_config") else: self._check_required_params(["file", "section", "key", "value"], params, "write_config") # Get file config_file_path = self._get_file_path(params["file"]) # Create dir if necessary basedir = os.path.dirname(config_file_path) os.makedirs(basedir, exist_ok=True) merge = params.get("merge", True) parser = EvilConfigParser(allow_no_value=True, dict_type=MultiOrderedDict, strict=False) parser.optionxform = str # Preserve text case if merge: parser.read(config_file_path) data = {} if params.get("data"): data = params["data"] else: data[params["section"]] = {} data[params["section"]][params["key"]] = params["value"] for section, keys in data.items(): if not parser.has_section(section): parser.add_section(section) for key, value in keys.items(): value = self._substitute(value) parser.set(section, key, value) with open(config_file_path, "wb") as config_file: parser.write(config_file) def _get_file_path(self, fileid): file_path = self.game_files.get(fileid) if not file_path: file_path = self._substitute(fileid) return file_path def _killable_process(self, func, *args, **kwargs): """Run function `func` in a separate, killable process.""" with multiprocessing.Pool(1) as process: result_obj = process.apply_async(func, args, kwargs) self.abort_current_task = process.terminate result = result_obj.get() # Wait process end & re-raise exceptions self.abort_current_task = None logger.debug("Process %s returned: %s", func, result) return result def _extract_gog_game(self, file_id): self.extract({"src": file_id, "dst": "$GAMEDIR", "extractor": "innoextract"}) app_path = os.path.join(self.target_path, "app") if system.path_exists(app_path): for app_content in os.listdir(app_path): source_path = os.path.join(app_path, app_content) if os.path.exists(os.path.join(self.target_path, app_content)): self.merge({"src": source_path, "dst": self.target_path}) else: self.move({"src": source_path, "dst": self.target_path}) support_path = os.path.join(self.target_path, "__support/app") if system.path_exists(support_path): self.merge({"src": support_path, "dst": self.target_path}) def _get_scummvm_arguments(self, gog_config_path): """Return a ScummVM configuration from the GOG config files""" with open(gog_config_path, encoding="utf-8") as gog_config_file: gog_config = json.loads(gog_config_file.read()) game_tasks = [task for task in gog_config["playTasks"] if task["category"] == "game"] arguments = game_tasks[0]["arguments"] game_id = arguments.split()[-1] arguments = " ".join(arguments.split()[:-1]) base_dir = os.path.dirname(gog_config_path) return {"game_id": game_id, "path": base_dir, "arguments": arguments} def autosetup_gog_game(self, file_id, silent=False): """Automatically guess the best way to install a GOG game by inspecting its contents. This chooses the right runner (DOSBox, Wine) for Windows game files. Linux setup files don't use innosetup, they can be unzipped instead. """ file_path = self.game_files[file_id] file_list = extract.get_innoextract_list(file_path) dosbox_found = False scummvm_found = False windows_override_found = False # DOS games that also have a Windows executable for filename in file_list: if "dosbox.exe" in filename.lower(): dosbox_found = True if "scummvm.exe" in filename.lower(): scummvm_found = True if "_some_windows.exe" in filename.lower(): # There's not a good way to handle exceptions without extracting the .info file # before extracting the game. Added for Quake but GlQuake.exe doesn't run on modern wine windows_override_found = True if dosbox_found and not windows_override_found: self._extract_gog_game(file_id) if "DOSBOX" in os.listdir(self.target_path): dosbox_config = { "working_dir": "$GAMEDIR/DOSBOX", } else: dosbox_config = {} single_conf = None config_file = None for filename in os.listdir(self.target_path): if filename == "dosbox.conf": dosbox_config["main_file"] = filename elif filename.endswith("_single.conf"): single_conf = filename elif filename.endswith(".conf"): config_file = filename if single_conf: dosbox_config["main_file"] = single_conf if config_file: if dosbox_config.get("main_file"): dosbox_config["config_file"] = config_file else: dosbox_config["main_file"] = config_file self.installer.script["game"] = dosbox_config self.installer.runner = "dosbox" elif scummvm_found: self._extract_gog_game(file_id) arguments = None for filename in os.listdir(self.target_path): if filename.startswith("goggame") and filename.endswith(".info"): arguments = self._get_scummvm_arguments(os.path.join(self.target_path, filename)) if not arguments: raise RuntimeError("Unable to get ScummVM arguments") logger.info("ScummVM config: %s", arguments) self.installer.script["game"] = arguments self.installer.runner = "scummvm" else: args = "/SP- /NOCANCEL" if silent: args += " /SUPPRESSMSGBOXES /VERYSILENT /NOGUI" self.installer.is_gog = True return self.task({"name": "wineexec", "prefix": "$GAMEDIR", "executable": file_id, "args": args}) def autosetup_amazon(self, file_and_dir_dict): files = file_and_dir_dict["files"] directories = file_and_dir_dict["directories"] # create directories for directory in directories: self.mkdir(f"$GAMEDIR/drive_c/game/{directory}") # move installed files from CACHE to game folder for file_hash, file in self.game_files.items(): file_dir = os.path.dirname(files[file_hash]["path"]) self.move({"src": file, "dst": f"$GAMEDIR/drive_c/game/{file_dir}"}) def install_or_extract(self, file_id): """Runs if file is executable or extracts if file is archive""" file_path = self._get_file_path(file_id) runner = self.installer.runner if runner != "wine": raise ScriptingError(_("install_or_extract only works with wine!")) if file_path.endswith(".exe"): params = {"name": "wineexec", "executable": file_id} return self.task(params) slug = self.installer.game_slug params = {"file": file_id, "dst": f"$GAMEDIR/drive_c/{slug}"} return self.extract(params) lutris-0.5.19/lutris/installer/installer_file_collection.py0000664000175000017500000001363714756670027023236 0ustar hibbyhibby"""Manipulates installer files""" from gettext import gettext as _ from urllib.parse import urlparse from lutris.cache import has_valid_custom_cache_path from lutris.gui.widgets.download_collection_progress_box import DownloadCollectionProgressBox from lutris.util import system from lutris.util.strings import gtk_safe_urls AMAZON_DOMAIN = "a2z.com" class InstallerFileCollection: """Representation of a collection of files in the `files` sections of an installer. Store files in a folder""" def __init__(self, game_slug, file_id, files_list): self.game_slug = game_slug self.id = file_id.replace("-", "_") # pylint: disable=invalid-name self.num_files = len(files_list) self.files_list = files_list self._dest_file_override = None # Used to override the destination self.full_size = 0 self._get_files_size() self._get_service() def _get_files_size(self): if len(self.files_list) > 0: if self.files_list[0].total_size: self.full_size = self.files_list[0].total_size else: self.full_size = sum(f.size or 0 for f in self.files_list) def _get_service(self): """Try to get the service using the url of an InstallerFile""" self.service = None if len(self.files_list) < 1: return url = self.files_list[0].url url_parts = urlparse(url) if url_parts.netloc.endswith(AMAZON_DOMAIN): self.service = "amazon" def copy(self): """Copy InstallerFileCollection""" # copy all InstallerFile inside file list new_file_list = [] for file in self.files_list: new_file_list.append(file.copy()) collection = InstallerFileCollection(self.game_slug, self.id, new_file_list) collection._dest_file_override = self._dest_file_override return collection def override_dest_file(self, new_dest_file): """Called by the UI when the user selects a file path; this causes the collection to be ready if this one file is there, and we'll special case GOG here too.""" self._dest_file_override = new_dest_file if len(self.files_list) == 1: self.files_list[0].override_dest_file(new_dest_file) else: # try to set main gog file to dest_file for installer_file in self.files_list: if installer_file.id == "goginstaller": installer_file.dest_file = new_dest_file @property def is_dest_file_overridden(self): return bool(self._dest_file_override) def get_dest_files_by_id(self): files = {} for file in self.files_list: files.update({file.id: file.dest_file}) return files def __str__(self): return "%s/%s" % (self.game_slug, self.id) @property def auxiliary_info(self): """Provides a small bit of additional descriptive texts to show in the UI.""" if self.num_files == 1: return f"{self.num_files} {_('File')}" return f"{self.num_files} {_('Files')}" @property def human_url(self): """Return game_slug""" return self.game_slug def get_label(self): """Return a human readable label for installer files""" return gtk_safe_urls(self.game_slug) @property def default_provider(self): """Return file provider used. File Collection only supports 'pga' and 'download'""" if self.is_cached: return "pga" return "download" @property def providers(self): """Return all supported providers. File Collection only supports 'pga' and 'download'""" _providers = set() if self.is_cached: _providers.add("pga") _providers.add("download") return _providers def uses_pga_cache(self): """Determines whether the installer files are stored in a PGA cache Returns: bool """ return has_valid_custom_cache_path() @property def is_user_pga_caching_allowed(self): return len(self.files_list) == 1 and self.files_list[0].is_user_pga_caching_allowed def prepare(self): """Prepare the file for download, if we've not been redirected to an existing file.""" if not self.is_dest_file_overridden or len(self.files_list) == 1: for installer_file in self.files_list: installer_file.prepare() def create_download_progress_box(self): return DownloadCollectionProgressBox(self) def is_ready(self, provider): """Is the file already present at the destination (if applicable)?""" if provider not in ("user", "pga"): return True if self._dest_file_override: return system.path_exists(self._dest_file_override) for installer_file in self.files_list: if not installer_file.is_ready(provider): return False return True def check_hash(self): """Check the hash of all the files (if available)""" for installer_file in self.files_list: installer_file.check_hash() @property def is_cached(self): """Are the files available in the local PGA cache?""" if self.uses_pga_cache(): # check if every file is in cache, without checking # uses_pga_cache() on each. for installer_file in self.files_list: if not system.path_exists(installer_file.dest_file): return False return True return False def save_to_cache(self): """Copy the files into the PGA cache.""" for installer_file in self.files_list: installer_file.save_to_cache() def remove_previous(self): """Remove file already at destination, prior to starting the download.""" for installer_file in self.files_list: installer_file.remove_previous() lutris-0.5.19/lutris/installer/installer_file.py0000664000175000017500000002573514756670027021025 0ustar hibbyhibby"""Manipulates installer files""" import os from gettext import gettext as _ from typing import Optional from urllib.parse import urlparse from lutris.cache import get_url_cache_path, has_valid_custom_cache_path, save_to_cache from lutris.gui.widgets.download_progress_box import DownloadProgressBox from lutris.installer.errors import ScriptingError from lutris.util import system from lutris.util.downloader import Downloader from lutris.util.log import logger from lutris.util.strings import gtk_safe_urls class InstallerFile: """Representation of a file in the `files` sections of an installer""" def __init__(self, game_slug, file_id, file_meta): self.game_slug = game_slug self.id = file_id.replace("-", "_") # pylint: disable=invalid-name self._file_meta = file_meta self._dest_file_override = None # Used to override the destination self._dest_file_found = None # Lazy storage for the resolved destination file if isinstance(self._file_meta, dict): self._downloader = self._file_meta.get("downloader") else: self._downloader = None def copy(self): """Copies this file object, so the copy can be modified safely.""" _file_meta = self._file_meta if isinstance(_file_meta, dict): _file_meta = _file_meta.copy() file = InstallerFile(self.game_slug, self.id, _file_meta) file._dest_file_override = self._dest_file_override file._dest_file_found = self._dest_file_found file._downloader = self._downloader return file @property def url(self): _url = "" if isinstance(self._file_meta, dict): if "url" not in self._file_meta: raise ScriptingError(_("missing field `url` for file `%s`") % self.id) _url = self._file_meta["url"] else: _url = self._file_meta if _url.startswith("/"): return "file://" + _url return _url def set_url(self, url): """Change the internal value of the URL""" if isinstance(self._file_meta, dict): self._file_meta["url"] = url else: self._file_meta = url @property def filename(self): if isinstance(self._file_meta, dict): if "filename" not in self._file_meta: raise ScriptingError(_("missing field `filename` in file `%s`") % self.id) return self._file_meta["filename"] if self._file_meta.startswith("N/A"): if self.uses_pga_cache() and os.path.isdir(self._cache_path): return self.cached_filename return "" if self.url.startswith("$STEAM"): return self.url return os.path.basename(self._file_meta) def get_alternate_filenames(self): if isinstance(self._file_meta, dict): return self._file_meta.get("alternate_filenames") or [] return [] @property def referer(self): if isinstance(self._file_meta, dict): return self._file_meta.get("referer") @property def downloader(self) -> Optional[Downloader]: if callable(self._downloader): self._downloader = self._downloader(self) return self._downloader @property def checksum(self): if isinstance(self._file_meta, dict): return self._file_meta.get("checksum") @property def dest_file(self): def find_dest_file(): for alt_name in self.get_alternate_filenames(): alt_path = os.path.join(self._cache_path, alt_name) if os.path.isfile(alt_path): return alt_path return os.path.join(self._cache_path, self.filename) if self._dest_file_override: return self._dest_file_override if not self._dest_file_found: self._dest_file_found = find_dest_file() return self._dest_file_found @dest_file.setter def dest_file(self, value): self._dest_file_override = value @property def download_file(self): """This is the actual path to download to; this file is renamed to the dest_file when complete.""" return self.dest_file + ".tmp" def override_dest_file(self, new_dest_file): """Called by the UI when the user selects a file path.""" self.dest_file = new_dest_file @property def is_dest_file_overridden(self): return bool(self._dest_file_override) def get_dest_files_by_id(self): return {self.id: self.dest_file} def __str__(self): return "%s/%s" % (self.game_slug, self.id) @property def auxiliary_info(self): """Provides a small bit of additional descriptive texts to show in the UI.""" return None @property def human_url(self): """Return the url in human-readable format""" if self.url.startswith("N/A"): # Ask the user where the file is located parts = self.url.split(":", 1) if len(parts) == 2: return parts[1] return "Please select file '%s'" % self.id return self.url def get_label(self): """Return a human readable label for installer files""" url = self.url if url.startswith("http"): parsed = urlparse(url) label = _("{file} on {host}").format(file=self.filename, host=parsed.netloc) elif url.startswith("N/A"): label = url[3:].lstrip(":") else: label = url return gtk_safe_urls(label) @property def cached_filename(self): """Return the filename of the first file in the cache path""" cache_files = os.listdir(self._cache_path) if cache_files: return cache_files[0] return "" @property def default_provider(self): """Return file provider used""" if self.url.startswith("$STEAM"): return "steam" if self.is_cached: return "pga" if self.url.startswith("N/A"): return "user" if self.is_downloadable(): return "download" raise ValueError("Unsupported provider for %s" % self.url) @property def providers(self): """Return all supported providers""" _providers = set() if self.url.startswith("$STEAM"): _providers.add("steam") if self.is_cached: _providers.add("pga") if self.url.startswith("N/A"): _providers.add("user") if self.is_downloadable(): _providers.add("download") return _providers def is_downloadable(self): """Return True if the file can be downloaded (even from the local filesystem)""" return self.url.startswith(("http", "file")) def uses_pga_cache(self): """Determines whether the installer files are stored in a PGA cache Returns: bool """ if self.url.startswith("N/A"): return False return has_valid_custom_cache_path() @property def is_user_pga_caching_allowed(self): """Returns true if this file can be transferred to the cache, if the user provides it.""" return self.uses_pga_cache() @property def _cache_path(self): """Return the directory used as a cache for the duration of the installation""" return get_url_cache_path(self.url, self.id, self.game_slug) def prepare(self): """Prepare the file for download. If we've not been redirected to an existing file, anwe will create directories to contain the cached file.""" if not self.is_dest_file_overridden: get_url_cache_path(self.url, self.id, self.game_slug, prepare=True) def create_download_progress_box(self): return DownloadProgressBox( url=self.url, dest=self.dest_file, temp=self.download_file, referer=self.referer, downloader=self.downloader ) def check_hash(self): """Checks the checksum of `file` and compare it to `value` Args: checksum (str): The checksum to look for (type:hash) dest_file (str): The path to the destination file dest_file_uri (str): The uri for the destination file """ if not self.checksum or not self.dest_file: return try: hash_type, expected_hash = self.checksum.split(":", 1) except ValueError as err: raise ScriptingError(_("Invalid checksum, expected format (type:hash) "), self.checksum) from err logger.info("Checking hash %s for %s", hash_type, self.dest_file) calculated_hash = system.get_file_checksum(self.dest_file, hash_type) if calculated_hash != expected_hash: raise ScriptingError( hash_type.capitalize() + _(" checksum mismatch "), f"{expected_hash} != {calculated_hash}" ) @property def size(self) -> Optional[int]: if isinstance(self._file_meta, dict) and "size" in self._file_meta: try: size = int(self._file_meta["size"]) if size >= 0: return size except (ValueError, TypeError): return None return None @property def total_size(self) -> Optional[int]: if isinstance(self._file_meta, dict) and "total_size" in self._file_meta: try: total_size = int(self._file_meta["total_size"]) if total_size >= 0: return total_size except (ValueError, TypeError): return None return None def is_ready(self, provider): """Is the file already present at the destination (if applicable)?""" return provider not in ("user", "pga") or system.path_exists(self.dest_file) @property def is_cached(self): """Is the file available in the local PGA cache?""" return self.uses_pga_cache() and system.path_exists(self.dest_file) def save_to_cache(self): """Copy the file into the PGA cache.""" cache_path = self._cache_path try: if not os.path.isdir(cache_path): logger.debug("Creating cache path %s", self._cache_path) os.makedirs(cache_path) except (OSError, PermissionError) as ex: logger.error("Failed to created cache path: %s", ex) return save_to_cache(self.dest_file, cache_path) def remove_previous(self): """Remove file at already at destination, prior to starting the download.""" if not self.uses_pga_cache() and system.path_exists(self.dest_file): # If we've previously downloaded a directory, we'll need to get rid of it # to download a file now. Since we are not using the cache, we don't keep # these files anyway - so it should be safe to just nuke and pave all this. if os.path.isdir(self.dest_file): system.delete_folder(self.dest_file) else: os.remove(self.dest_file) lutris-0.5.19/lutris/database/0000775000175000017500000000000014756670027015212 5ustar hibbyhibbylutris-0.5.19/lutris/database/sql.py0000664000175000017500000001163214756670027016366 0ustar hibbyhibbyimport sqlite3 import threading # Prevent multiple access to the database (SQLite limitation) DB_LOCK = threading.RLock() class db_cursor(object): def __init__(self, db_path): self.db_path = db_path self.db_conn = None def __enter__(self): self.db_conn = sqlite3.connect(self.db_path) cursor = self.db_conn.cursor() return cursor def __exit__(self, _type, value, traceback): self.db_conn.commit() self.db_conn.close() def cursor_execute(cursor, query, params=None): """Execute a SQL query, run it in a lock block""" params = params or () lock = DB_LOCK.acquire(timeout=5) # pylint: disable=consider-using-with if not lock: raise RuntimeError(f"Database is busy. Not executing {query}") try: return cursor.execute(query, params) finally: DB_LOCK.release() def db_insert(db_path, table, fields): columns = ", ".join(list(fields.keys())) placeholders = ("?, " * len(fields))[:-2] field_values = tuple(fields.values()) with db_cursor(db_path) as cursor: cursor_execute( cursor, "insert into {0}({1}) values ({2})".format(table, columns, placeholders), field_values, ) inserted_id = cursor.lastrowid return inserted_id def db_update(db_path, table, updated_fields, conditions): """Update `table` with the values given in the dict `values` on the condition given with the `row` tuple. """ columns = "=?, ".join(list(updated_fields.keys())) + "=?" field_values = tuple(updated_fields.values()) condition_field = " AND ".join(["%s=?" % field for field in conditions]) condition_value = tuple(conditions.values()) with db_cursor(db_path) as cursor: query = "UPDATE {0} SET {1} WHERE {2}".format(table, columns, condition_field) result = cursor_execute(cursor, query, field_values + condition_value) return result def db_delete(db_path, table, field, value): with db_cursor(db_path) as cursor: cursor_execute(cursor, "delete from {0} where {1}=?".format(table, field), (value,)) def db_select(db_path, table, fields=None, condition=None): if fields: columns = ", ".join(fields) else: columns = "*" with db_cursor(db_path) as cursor: query = "SELECT {} FROM {}" if condition: condition_field, condition_value = condition if isinstance(condition_value, (list, tuple, set)): condition_value = tuple(condition_value) placeholders = ", ".join("?" * len(condition_value)) where_condition = " where {} in (" + placeholders + ")" else: condition_value = (condition_value,) where_condition = " where {}=?" query = query + where_condition query = query.format(columns, table, condition_field) params = condition_value else: query = query.format(columns, table) params = () cursor_execute(cursor, query, params) rows = cursor.fetchall() column_names = [column[0] for column in cursor.description] results = [] for row in rows: row_data = {} for index, column in enumerate(column_names): row_data[column] = row[index] results.append(row_data) return results def db_query(db_path, query, params=()): with db_cursor(db_path) as cursor: cursor_execute(cursor, query, params) rows = cursor.fetchall() column_names = [column[0] for column in cursor.description] results = [] for row in rows: row_data = {} for index, column in enumerate(column_names): row_data[column] = row[index] results.append(row_data) return results def add_field(db_path, tablename, field): query = "ALTER TABLE %s ADD COLUMN %s %s" % ( tablename, field["name"], field["type"], ) with db_cursor(db_path) as cursor: cursor.execute(query) def filtered_query(db_path, table, searches=None, filters=None, excludes=None, sorts=None): query = "select * from %s" % table params = [] sql_filters = [] for field in searches or {}: sql_filters.append("%s LIKE ?" % field) params.append("%" + searches[field] + "%") for field in filters or {}: if filters[field] is not None: # but 0 or False are okay! sql_filters.append("%s = ?" % field) params.append(filters[field]) for field in excludes or {}: if excludes[field]: sql_filters.append("%s IS NOT ?" % field) params.append(excludes[field]) if sql_filters: query += " WHERE " + " AND ".join(sql_filters) if sorts: query += " ORDER BY %s" % ", ".join(["%s %s" % (sort[0], sort[1]) for sort in sorts]) else: query += " ORDER BY slug ASC" return db_query(db_path, query, tuple(params)) lutris-0.5.19/lutris/database/__init__.py0000664000175000017500000000000014756670027017311 0ustar hibbyhibbylutris-0.5.19/lutris/database/games.py0000664000175000017500000002034414756670027016663 0ustar hibbyhibbyimport math import time from itertools import chain from lutris import settings from lutris.database import sql from lutris.util.log import logger from lutris.util.strings import slugify _SERVICE_CACHE = {} _SERVICE_CACHE_ACCESSED = False # Keep time of last access to have a self degrading cache def get_games(searches=None, filters=None, excludes=None, sorts=None): return sql.filtered_query( settings.DB_PATH, "games", searches=searches, filters=filters, excludes=excludes, sorts=sorts ) def get_games_where(**conditions): """ Query games table based on conditions Args: conditions (dict): named arguments with each field matches its desired value. Special values for field names can be used: __lessthan will return rows where `field` is less than the value __isnull will return rows where `field` is NULL if the value is True __not will invert the condition using `!=` instead of `=` __in will match rows for every value of `value`, which should be an iterable Returns: list: Rows matching the query """ query = "select * from games" condition_fields = [] condition_values = [] for field, value in conditions.items(): field, *extra_conditions = field.split("__") if extra_conditions: extra_condition = extra_conditions[0] if extra_condition == "lessthan": condition_fields.append("{} < ?".format(field)) condition_values.append(value) if extra_condition == "isnull": condition_fields.append("{} is {} null".format(field, "" if value else "not")) if extra_condition == "not": condition_fields.append("{} != ?".format(field)) condition_values.append(value) if extra_condition == "in": if not hasattr(value, "__iter__"): raise ValueError("Value should be an iterable (%s given)" % value) if len(value) > 999: raise ValueError("SQLite limited to a maximum of 999 parameters.") if value: condition_fields.append("{} in ({})".format(field, ", ".join("?" * len(value)) or "")) condition_values = list(chain(condition_values, value)) else: condition_fields.append("{} = ?".format(field)) condition_values.append(value) condition = " AND ".join(condition_fields) if condition: query = " WHERE ".join((query, condition)) else: # Inspect and document why we should return # an empty list when no condition is present. return [] return sql.db_query(settings.DB_PATH, query, tuple(condition_values)) def get_games_by_ids(game_ids): # sqlite limits the number of query parameters to 999, to # bypass that limitation, divide the query in chunks size = 999 return list( chain.from_iterable( [ get_games_where(id__in=list(game_ids)[page * size : page * size + size]) for page in range(math.ceil(len(game_ids) / size)) ] ) ) def get_game_for_service(service, appid): if service == "lutris": return get_game_by_field(appid, field="slug") existing_games = get_games(filters={"service_id": appid, "service": service}) if existing_games: return existing_games[0] def get_all_installed_game_for_service(service): if service == "lutris": db_games = get_games(filters={"installed": 1}) return {g["slug"]: g for g in db_games} db_games = get_games(filters={"service": service, "installed": 1}) return {g["service_id"]: g for g in db_games} def get_service_games(service): """Return the list of all installed games for a service""" global _SERVICE_CACHE_ACCESSED previous_cache_accessed = _SERVICE_CACHE_ACCESSED or 0 _SERVICE_CACHE_ACCESSED = time.time() if service not in _SERVICE_CACHE or _SERVICE_CACHE_ACCESSED - previous_cache_accessed > 1: if service == "lutris": _SERVICE_CACHE[service] = [game["slug"] for game in get_games(filters={"installed": "1"})] else: _SERVICE_CACHE[service] = [ game["service_id"] for game in get_games(filters={"service": service, "installed": "1"}) ] return _SERVICE_CACHE[service] def get_game_by_field(value, field="slug"): """Query a game based on a database field""" if field not in ("slug", "installer_slug", "id", "configpath", "name"): raise ValueError("Can't query by field '%s'" % field) game_result = sql.db_select(settings.DB_PATH, "games", condition=(field, value)) if game_result: return game_result[0] return {} def get_games_by_runner(runner): """Return all games using a specific runner""" return sql.db_select(settings.DB_PATH, "games", condition=("runner", runner)) def get_games_by_slug(slug): """Return all games using a specific slug""" return sql.db_select(settings.DB_PATH, "games", condition=("slug", slug)) def add_game(**game_data): """Add a game to the database.""" game_data["installed_at"] = int(time.time()) if "slug" not in game_data: game_data["slug"] = slugify(game_data["name"]) return sql.db_insert(settings.DB_PATH, "games", game_data) def add_games_bulk(games): """ Add a list of games to the database. The dicts must have an identical set of keys. Args: games (list): list of games in dict format Returns: list: List of inserted game ids """ return [sql.db_insert(settings.DB_PATH, "games", game) for game in games] def add_or_update(**params): """Add a game to the database or update an existing one If an 'id' is provided in the parameters then it will try to match it, otherwise it will try matching by slug, creating one when possible. """ game_id = update_existing(**params) if game_id: return game_id return add_game(**params) def update_existing(**params): """Updates a game, but do not add one. If the game exists, this returns its ID; if not it returns None and makes no changes.""" game_id = get_matching_game(params) if game_id: params["id"] = game_id sql.db_update(settings.DB_PATH, "games", params, {"id": game_id}) return game_id return None def get_matching_game(params): """Tries to match given parameters with an existing game""" # Always match by ID if provided if params.get("id"): game = get_game_by_field(params["id"], "id") if game: return game["id"] logger.warning("Game ID %s provided but couldn't be matched", params["id"]) slug = params.get("slug") or slugify(params.get("name")) if not slug: raise ValueError("Can't add or update without an identifier") for game in get_games_by_slug(slug): if game["installed"]: if game["configpath"] == params.get("configpath"): return game["id"] else: if game["runner"] == params.get("runner") or not all([params.get("runner"), game["runner"]]): return game["id"] return None def delete_game(game_id): """Delete a game from the PGA.""" sql.db_delete(settings.DB_PATH, "games", "id", game_id) def get_used_runners(): """Return a list of the runners in use by installed games.""" with sql.db_cursor(settings.DB_PATH) as cursor: query = "select distinct runner from games where runner is not null order by runner" rows = cursor.execute(query) results = rows.fetchall() return [result[0] for result in results if result[0]] def get_used_platforms(): """Return a list of platforms currently in use""" with sql.db_cursor(settings.DB_PATH) as cursor: query = ( "select distinct platform from games " "where platform is not null and platform is not '' order by platform" ) rows = cursor.execute(query) results = rows.fetchall() return [result[0] for result in results if result[0]] def get_game_count(param, value): res = sql.db_select(settings.DB_PATH, "games", fields=("COUNT(id)",), condition=(param, value)) if res: return res[0]["COUNT(id)"] lutris-0.5.19/lutris/database/schema.py0000664000175000017500000001136314756670027017030 0ustar hibbyhibbyfrom lutris import settings from lutris.database import sql from lutris.util.log import logger DATABASE = { "games": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "name", "type": "TEXT"}, { "name": "sortname", "type": "TEXT", }, {"name": "slug", "type": "TEXT"}, {"name": "installer_slug", "type": "TEXT"}, {"name": "parent_slug", "type": "TEXT"}, {"name": "platform", "type": "TEXT"}, {"name": "runner", "type": "TEXT"}, {"name": "executable", "type": "TEXT"}, {"name": "directory", "type": "TEXT"}, {"name": "updated", "type": "DATETIME"}, {"name": "lastplayed", "type": "INTEGER"}, {"name": "installed", "type": "INTEGER"}, {"name": "installed_at", "type": "INTEGER"}, {"name": "year", "type": "INTEGER"}, {"name": "configpath", "type": "TEXT"}, {"name": "has_custom_banner", "type": "INTEGER"}, {"name": "has_custom_icon", "type": "INTEGER"}, {"name": "has_custom_coverart_big", "type": "INTEGER"}, {"name": "playtime", "type": "REAL"}, {"name": "service", "type": "TEXT"}, {"name": "service_id", "type": "TEXT"}, { "name": "discord_id", "type": "TEXT", }, ], "service_games": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "service", "type": "TEXT"}, {"name": "appid", "type": "TEXT"}, {"name": "name", "type": "TEXT"}, {"name": "slug", "type": "TEXT"}, {"name": "icon", "type": "TEXT"}, {"name": "logo", "type": "TEXT"}, {"name": "url", "type": "TEXT"}, {"name": "details", "type": "TEXT"}, {"name": "lutris_slug", "type": "TEXT"}, ], "sources": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "uri", "type": "TEXT UNIQUE"}, ], "categories": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "name", "type": "TEXT", "unique": True}, ], "games_categories": [ {"name": "game_id", "type": "INTEGER", "indexed": False}, {"name": "category_id", "type": "INTEGER", "indexed": False}, ], "saved_searches": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "name", "type": "TEXT", "unique": True}, {"name": "search", "type": "TEXT", "unique": False}, ], } def get_schema(tablename): """ Fields: - position - name - type - not null - default - indexed """ tables = [] query = "pragma table_info('%s')" % tablename with sql.db_cursor(settings.DB_PATH) as cursor: for row in cursor.execute(query).fetchall(): field = { "name": row[1], "type": row[2], "not_null": row[3], "default": row[4], "indexed": row[5], } tables.append(field) return tables def field_to_string(name="", type="", indexed=False, unique=False): # pylint: disable=redefined-builtin """Converts a python based table definition to it's SQL statement""" field_query = "%s %s" % (name, type) if indexed: field_query += " PRIMARY KEY" if unique: field_query += " UNIQUE" return field_query def create_table(name, schema): """Creates a new table in the database""" fields = ", ".join([field_to_string(**f) for f in schema]) query = "CREATE TABLE IF NOT EXISTS %s (%s)" % (name, fields) logger.debug("[Query] %s", query) with sql.db_cursor(settings.DB_PATH) as cursor: cursor.execute(query) def migrate(table, schema): """Compare a database table with the reference model and make necessary changes This is very basic and only the needed features have been implemented (adding columns) Args: table (str): Name of the table to migrate schema (dict): Reference schema for the table Returns: list: The list of column names that have been added """ existing_schema = get_schema(table) migrated_fields = [] if existing_schema: columns = [col["name"] for col in existing_schema] for field in schema: if field["name"] not in columns: logger.info("Migrating %s field %s", table, field["name"]) migrated_fields.append(field["name"]) sql.add_field(settings.DB_PATH, table, field) else: create_table(table, schema) return migrated_fields def syncdb(): """Update the database to the current version, making necessary changes for backwards compatibility.""" for table_name, table_data in DATABASE.items(): migrate(table_name, table_data) lutris-0.5.19/lutris/database/sources.py0000664000175000017500000000304114756670027017245 0ustar hibbyhibbyimport os from lutris import settings from lutris.database import sql from lutris.util import system from lutris.util.log import logger def add_source(uri): sql.db_insert(settings.DB_PATH, "sources", {"uri": uri}) def delete_source(uri): sql.db_delete(settings.DB_PATH, "sources", "uri", uri) def read_sources(): with sql.db_cursor(settings.DB_PATH) as cursor: rows = cursor.execute("select uri from sources") results = rows.fetchall() return [row[0] for row in results] def write_sources(sources): db_sources = read_sources() for uri in db_sources: if uri not in sources: sql.db_delete(settings.DB_PATH, "sources", "uri", uri) for uri in sources: if uri not in db_sources: sql.db_insert(settings.DB_PATH, "sources", {"uri": uri}) def check_for_file(game, file_id): for source in read_sources(): if source.startswith("file://"): source = source[7:] else: protocol = source[:7] logger.warning("PGA source protocol %s not implemented", protocol) continue if not system.path_exists(source): logger.info("PGA source %s unavailable", source) continue game_dir = os.path.join(source, game) if not system.path_exists(game_dir): continue for game_file in os.listdir(game_dir): game_base, _ext = os.path.splitext(game_file) if game_base == file_id: return os.path.join(game_dir, game_file) return False lutris-0.5.19/lutris/database/services.py0000664000175000017500000000224014756670027017405 0ustar hibbyhibbyfrom lutris import settings from lutris.database import sql from lutris.util.log import logger class ServiceGameCollection: @classmethod def get_service_games(cls, searches=None, filters=None, excludes=None, sorts=None): return sql.filtered_query( settings.DB_PATH, "service_games", searches=searches, filters=filters, excludes=excludes, sorts=sorts ) @classmethod def get_for_service(cls, service): if not service: raise ValueError("No service provided") return sql.filtered_query(settings.DB_PATH, "service_games", filters={"service": service}) @classmethod def get_game(cls, service, appid): """Return a single game referred by its appid""" if not service: raise ValueError("No service provided") if not appid: raise ValueError("No appid provided") results = sql.filtered_query(settings.DB_PATH, "service_games", filters={"service": service, "appid": appid}) if not results: return if len(results) > 1: logger.warning("More than one game found for %s on %s", appid, service) return results[0] lutris-0.5.19/lutris/database/categories.py0000664000175000017500000002075714756670027017724 0ustar hibbyhibbyimport abc import re from collections import defaultdict from itertools import repeat from typing import Dict, List, Union from lutris import settings from lutris.database import sql from lutris.gui.widgets import NotificationSource CATEGORIES_UPDATED = NotificationSource() class _SmartCategory(abc.ABC): """Abstract class to define smart categories. Smart categories are automatically defined based on a rule.""" @abc.abstractmethod def get_name(self) -> str: pass @abc.abstractmethod def get_games(self) -> List[str]: pass class _SmartUncategorizedCategory(_SmartCategory): """A SmartCategory that resolves to all uncategorized games.""" def get_name(self) -> str: return ".uncategorized" def get_games(self) -> List[str]: return get_uncategorized_game_ids() # All smart categories should be added to this variable. # TODO: Expose a way for the users to define new smart categories. _SMART_CATEGORIES: List[_SmartCategory] = [_SmartUncategorizedCategory()] def strip_category_name(name): """ "This strips the name given, and also removes extra internal whitespace.""" name = (name or "").strip() if not is_reserved_category(name): name = re.sub(" +", " ", name) # Remove excessive whitespaces return name def is_reserved_category(name): """True if name is None, blank or is a name Lutris uses internally, or starts with '.' for future expansion.""" return not name or name[0] == "." or name in ["all", "favorite"] def get_categories() -> List[Dict[str, Union[int, str]]]: """Get the list of every category in database.""" # Categories look like [{"id": 1, "name": "My Category"}, ...] return sql.db_select(settings.DB_PATH, "categories") def get_all_games_categories(): games_categories = defaultdict(list) for row in sql.db_select(settings.DB_PATH, "games_categories"): games_categories[row["game_id"]].append(row["category_id"]) return games_categories def get_category_by_name(name): """Return a category by name""" categories = sql.db_select(settings.DB_PATH, "categories", condition=("name", name)) if categories: return categories[0] def get_category_by_id(category_id): """Return a category by name""" categories = sql.db_select(settings.DB_PATH, "categories", condition=("id", category_id)) if categories: return categories[0] def normalized_category_names(name: str, subname_allowed: bool = False) -> List[str]: """Searches for a category name case-insensitively and returns all matching names; if none match, it just returns 'name' as is. If subname_allowed is true but name is not a match for any category, we'll look for any category that contains the name as a substring instead before falling back to 'name' itself.""" query = "SELECT name FROM categories WHERE name=? COLLATE NOCASE" parameters = (name,) names = [cat["name"] for cat in sql.db_query(settings.DB_PATH, query, parameters)] if not names and subname_allowed: query = "SELECT name FROM categories WHERE name LIKE ? COLLATE NOCASE" parameters = (f"%{name}%",) names = [cat["name"] for cat in sql.db_query(settings.DB_PATH, query, parameters)] return names or [name] def get_game_ids_for_categories(included_category_names=None, excluded_category_names=None): """Get the ids of games in database.""" filters = [] parameters = [] if included_category_names: # Query that finds games in the included categories query = ( "SELECT games.id FROM games " "INNER JOIN games_categories ON games.id = games_categories.game_id " "INNER JOIN categories ON categories.id = games_categories.category_id" ) filters.append("categories.name IN (%s)" % ", ".join(repeat("?", len(included_category_names)))) parameters.extend(included_category_names) else: # Or, if you listed none, we fall back to all games query = "SELECT games.id FROM games" if excluded_category_names: # Sub-query to exclude the excluded categories, if any. exclude_filter = ( "NOT EXISTS(SELECT * FROM games_categories AS gc " "INNER JOIN categories AS c ON gc.category_id = c.id " "WHERE gc.game_id = games.id " "AND c.name IN (%s))" % ", ".join(repeat("?", len(excluded_category_names))) ) filters.append(exclude_filter) parameters.extend(excluded_category_names) if filters: query += " WHERE %s" % " AND ".join(filters) result = set(game["id"] for game in sql.db_query(settings.DB_PATH, query, tuple(parameters))) for smart_cat in _SMART_CATEGORIES: if excluded_category_names is not None and smart_cat.get_name() in excluded_category_names: continue if included_category_names is not None and smart_cat.get_name() not in included_category_names: continue result |= set(smart_cat.get_games()) return list(sorted(result)) def get_uncategorized_game_ids() -> List[str]: """Returns the ids of games that are in no categories. We do not count the 'favorites' category, but we do count '.hidden'- hidden games are hidden from this too.""" query = ( "SELECT games.id FROM games WHERE NOT EXISTS(" "SELECT * FROM games_categories " "INNER JOIN categories ON categories.id = games_categories.category_id " "AND categories.name NOT IN ('all', 'favorite') " "WHERE games.id = games_categories.game_id)" ) uncategorized = sql.db_query(settings.DB_PATH, query) return [row["id"] for row in uncategorized] def get_categories_in_game(game_id): """Get the categories of a game in database.""" query = ( "SELECT categories.name FROM categories " "JOIN games_categories ON categories.id = games_categories.category_id " "JOIN games ON games.id = games_categories.game_id " "WHERE games.id=?" ) return [category["name"] for category in sql.db_query(settings.DB_PATH, query, (game_id,))] def add_category(category_name, no_signal: bool = False): """Add a category to the database""" cat = sql.db_insert(settings.DB_PATH, "categories", {"name": category_name}) if not no_signal: CATEGORIES_UPDATED.fire() return cat def redefine_category(category_id: int, new_name: str, no_signal: bool = False) -> None: query = "UPDATE categories SET name=? WHERE id=?" with sql.db_cursor(settings.DB_PATH) as cursor: sql.cursor_execute(cursor, query, (new_name, category_id)) if not no_signal: CATEGORIES_UPDATED.fire() def remove_category(category_id: int, no_signal: bool = False) -> None: queries = ["DELETE FROM games_categories WHERE category_id=?", "DELETE FROM categories WHERE id=?"] for query in queries: with sql.db_cursor(settings.DB_PATH) as cursor: sql.cursor_execute(cursor, query, (category_id,)) if not no_signal: CATEGORIES_UPDATED.fire() def add_game_to_category(game_id, category_id): """Add a category to a game""" return sql.db_insert(settings.DB_PATH, "games_categories", {"game_id": game_id, "category_id": category_id}) def remove_category_from_game(game_id, category_id): """Remove a category from a game""" query = "DELETE FROM games_categories WHERE category_id=? AND game_id=?" with sql.db_cursor(settings.DB_PATH) as cursor: sql.cursor_execute(cursor, query, (category_id, game_id)) def remove_unused_categories(): """Remove all categories that have no games associated with them""" delete_orphaned_games = ( "DELETE FROM games_categories " "WHERE NOT EXISTS(SELECT * FROM games WHERE game_id=id) " "OR NOT EXISTS(SELECT * FROM categories WHERE category_id=id)" ) with sql.db_cursor(settings.DB_PATH) as cursor: sql.cursor_execute(cursor, delete_orphaned_games, ()) find_orphaned_categories = ( "SELECT categories.* FROM categories " "LEFT JOIN games_categories ON categories.id = games_categories.category_id " "WHERE games_categories.category_id IS NULL" ) empty_categories = sql.db_query(settings.DB_PATH, find_orphaned_categories) for category in empty_categories: if category["name"] == "favorite": continue delete_orphaned_categories = "DELETE FROM categories WHERE categories.id=?" with sql.db_cursor(settings.DB_PATH) as cursor: sql.cursor_execute(cursor, delete_orphaned_categories, (category["id"],)) lutris-0.5.19/lutris/database/saved_searches.py0000664000175000017500000000461414756670027020550 0ustar hibbyhibbyimport re from dataclasses import dataclass from typing import Any, Dict, List, Optional from lutris import settings from lutris.database import sql from lutris.gui.widgets import NotificationSource SAVED_SEARCHES_UPDATED = NotificationSource() @dataclass class SavedSearch: saved_search_id: int name: str search: str def add(self, no_signal: bool = False) -> None: """Add a category to the database""" self.saved_search_id = sql.db_insert( settings.DB_PATH, "saved_searches", {"name": self.name, "search": self.search} ) if not no_signal: SAVED_SEARCHES_UPDATED.fire() def update(self, no_signal: bool = False) -> None: query = "UPDATE saved_searches SET name=?, search=? WHERE id=?" with sql.db_cursor(settings.DB_PATH) as cursor: sql.cursor_execute(cursor, query, (self.name, self.search, self.saved_search_id)) if not no_signal: SAVED_SEARCHES_UPDATED.fire() def remove(self, no_signal: bool = False) -> None: query = "DELETE FROM saved_searches WHERE id=?" with sql.db_cursor(settings.DB_PATH) as cursor: sql.cursor_execute(cursor, query, (self.saved_search_id,)) if not no_signal: SAVED_SEARCHES_UPDATED.fire() def _create_search(row: Dict[str, Any]) -> "SavedSearch": return SavedSearch(row["id"], row["name"], row["search"]) def strip_saved_search_name(name): """This strips the name given, and also removes extra internal whitespace.""" name = (name or "").strip() name = re.sub(" +", " ", name) # Remove excessive whitespaces return name def get_saved_searches() -> List[SavedSearch]: """Get the list of every search in database.""" rows = sql.db_select(settings.DB_PATH, "saved_searches") return [_create_search(row) for row in rows] def get_saved_search_by_name(name: str) -> Optional[SavedSearch]: """Return a category by name""" categories = sql.db_select(settings.DB_PATH, "saved_searches", condition=("name", name)) if categories: return _create_search(categories[0]) return None def get_saved_search_by_id(saved_search_id: int) -> Optional[SavedSearch]: """Return a category by name""" categories = sql.db_select(settings.DB_PATH, "saved_searches", condition=("id", saved_search_id)) if categories: return _create_search(categories[0]) return None lutris-0.5.19/lutris/style_manager.py0000664000175000017500000001164314756670027016657 0ustar hibbyhibbyfrom gi.repository import Gio, GLib, GObject, Gtk from lutris import settings from lutris.gui.widgets import NotificationSource from lutris.util.log import logger PORTAL_BUS_NAME = "org.freedesktop.portal.Desktop" PORTAL_OBJECT_PATH = "/org/freedesktop/portal/desktop" PORTAL_SETTINGS_INTERFACE = "org.freedesktop.portal.Settings" THEME_CHANGED = NotificationSource() class StyleManager(GObject.Object): """Manages the color scheme of the app. Has a single readable GObject property `is_dark` telling whether the app is in dark mode, it is set to True, when either the user preference on the preferences panel or in the system is set to prefer dark mode. """ _dbus_proxy = None _preferred_theme = "default" _system_theme = None _is_dark = False def __init__(self): super().__init__() self.gtksettings = Gtk.Settings.get_default() self.preferred_theme = settings.read_setting("preferred_theme") or "default" Gio.DBusProxy.new_for_bus( Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, PORTAL_BUS_NAME, PORTAL_OBJECT_PATH, PORTAL_SETTINGS_INTERFACE, None, self._new_for_bus_cb, ) def _read_portal_setting(self) -> None: if not self._dbus_proxy: return variant = GLib.Variant.new_tuple( GLib.Variant.new_string("org.freedesktop.appearance"), GLib.Variant.new_string("color-scheme"), ) self._dbus_proxy.call( "Read", variant, Gio.DBusCallFlags.NONE, GObject.G_MAXINT, None, self._call_cb, ) def _new_for_bus_cb(self, obj, result): try: proxy = obj.new_for_bus_finish(result) if proxy: proxy.connect("g-signal", self._on_settings_changed) self._dbus_proxy = proxy self._read_portal_setting() else: raise RuntimeError("Could not start GDBusProxy") except Exception as ex: logger.exception("Error setting up style change monitoring: %s", ex) def _call_cb(self, obj, result): try: values = obj.call_finish(result) if values: value = values[0] self.system_theme = self._read_value(value) else: raise RuntimeError("Could not read color-scheme") except Exception as ex: logger.exception("Error reading color-scheme: %s", ex) def _on_settings_changed(self, _proxy, _sender_name, signal_name, params): if signal_name != "SettingChanged": return namespace, name, value = params if namespace == "org.freedesktop.appearance" and name == "color-scheme": self.system_theme = self._read_value(value) def _read_value(self, value: int) -> str: if value == 1: return "dark" if value == 2: return "light" return "default" @property def is_config_dark(self) -> bool: """True if we override light mode to be dark; if we're defaulting to dark, this does nothing.""" return self._is_config_dark @is_config_dark.setter # type: ignore def is_config_dark(self, is_config_dark: bool) -> None: if self._is_config_dark == is_config_dark: return self._is_config_dark = is_config_dark self._update_is_dark() @property def preferred_theme(self) -> str: """Can be 'light' or 'dark' to override the theme, or 'default' to go with the system's default theme.""" return self._preferred_theme @preferred_theme.setter # type: ignore def preferred_theme(self, preferred_theme: str) -> None: if self._preferred_theme == preferred_theme: return self._preferred_theme = preferred_theme self._update_is_dark() @property def system_theme(self) -> str: return self._system_theme or "default" @system_theme.setter # type: ignore def system_theme(self, system_theme: str) -> None: if self._system_theme == system_theme: return self._system_theme = system_theme self._update_is_dark() @GObject.Property(type=bool, default=False, flags=GObject.ParamFlags.READABLE) def is_dark(self) -> bool: return self._is_dark def _update_is_dark(self) -> None: if self.is_dark_by_default: is_dark = self.preferred_theme != "light" else: is_dark = self.preferred_theme == "dark" if self._is_dark == is_dark: return self._is_dark = is_dark self.notify("is-dark") self.gtksettings.set_property("gtk-application-prefer-dark-theme", is_dark) THEME_CHANGED.fire() @property def is_dark_by_default(self): return self.system_theme != "light" lutris-0.5.19/lutris/config.py0000664000175000017500000002505214756670027015271 0ustar hibbyhibby"""Handle the game, runner and global system configurations.""" import os import time from shutil import copyfile from typing import Set from lutris import settings, sysoptions from lutris.runners import InvalidRunnerError, import_runner from lutris.util.log import logger from lutris.util.system import path_exists from lutris.util.yaml import read_yaml_from_file, write_yaml_to_file def make_game_config_id(game_slug: str) -> str: """Return an unique config id to avoid clashes between multiple games""" return "{}-{}".format(game_slug, int(time.time())) def write_game_config(game_slug: str, config: dict): """Writes a game config to disk""" configpath = make_game_config_id(game_slug) logger.debug("Writing game config to %s", configpath) config_filename = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % configpath) write_yaml_to_file(config, config_filename) return configpath def duplicate_game_config(game_slug: str, source_config_id: str): """Copies an existing configuration file, giving it a new id that this function returns.""" new_config_id = make_game_config_id(game_slug) src_path = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % source_config_id) dest_path = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % new_config_id) copyfile(src_path, dest_path) return new_config_id class LutrisConfig: """Class where all the configuration handling happens. Description =========== Lutris' configuration uses a cascading mechanism where each higher, more specific level overrides the lower ones The levels are (highest to lowest): `game`, `runner` and `system`. Each level has its own set of options (config section), available to and overridden by upper levels: ``` level | Config sections -------|---------------------- game | system, runner, game runner | system, runner system | system ``` Example: if requesting runner options at game level, their returned value will be from the game level config if it's set at this level; if not it will be the value from runner level if available; and if not, the default value set in the runner's module, or None. The config levels are stored in separate YAML format text files. Usage ===== The config level will be auto set depending on what you pass to __init__: - For game level, pass game_config_id and optionally runner_slug (better perfs) - For runner level, pass runner_slug - For system level, pass nothing If need be, you can pass the level manually. To read, use the config sections dicts: game_config, runner_config and system_config. To write, modify the relevant `raw_*_config` section dict, then run `save()`. """ def __init__( self, runner_slug: str = None, game_config_id: str = None, level: str = None, options_supported: Set[str] = None ): self.game_config_id = game_config_id if runner_slug: self.runner_slug = str(runner_slug) else: self.runner_slug = runner_slug self.options_supported = options_supported # Cascaded config sections (for reading) self.game_config = {} self.runner_config = {} self.system_config = {} # Raw (non-cascaded) sections (for writing) self.raw_game_config = {} self.raw_runner_config = {} self.raw_system_config = {} self.raw_config = {} # Set config level self.level = level if not level: if game_config_id: self.level = "game" elif runner_slug: self.level = "runner" else: self.level = "system" self.initialize_config() def __repr__(self): return "LutrisConfig(level=%s, game_config_id=%s, runner=%s)" % ( self.level, self.game_config_id, self.runner_slug, ) @property def system_config_path(self): return os.path.join(settings.CONFIG_DIR, "system.yml") @property def runner_config_path(self): if not self.runner_slug: return None return os.path.join(settings.RUNNERS_CONFIG_DIR, "%s.yml" % self.runner_slug) @property def game_config_path(self): if not self.game_config_id: return None return os.path.join(settings.CONFIG_DIR, "games/%s.yml" % self.game_config_id) def initialize_config(self): """Init and load config files""" self.game_level = {"system": {}, self.runner_slug: {}, "game": {}} self.runner_level = {"system": {}, self.runner_slug: {}} self.system_level = {"system": {}} self.game_level.update(read_yaml_from_file(self.game_config_path)) self.runner_level.update(read_yaml_from_file(self.runner_config_path)) self.system_level.update(read_yaml_from_file(self.system_config_path)) self.update_cascaded_config() self.update_raw_config() def update_cascaded_config(self): if self.system_level.get("system") is None: self.system_level["system"] = {} self.system_config.clear() self.system_config.update(self.get_defaults("system")) self.system_config.update(self.system_level.get("system")) if self.level in ["runner", "game"] and self.runner_slug: if self.runner_level.get(self.runner_slug) is None: self.runner_level[self.runner_slug] = {} if self.runner_level.get("system") is None: self.runner_level["system"] = {} self.runner_config.clear() self.runner_config.update(self.get_defaults("runner")) self.runner_config.update(self.runner_level.get(self.runner_slug)) self.merge_to_system_config(self.runner_level.get("system")) if self.level == "game" and self.runner_slug: if self.game_level.get("game") is None: self.game_level["game"] = {} if self.game_level.get(self.runner_slug) is None: self.game_level[self.runner_slug] = {} if self.game_level.get("system") is None: self.game_level["system"] = {} self.game_config.clear() self.game_config.update(self.get_defaults("game")) self.game_config.update(self.game_level.get("game")) self.runner_config.update(self.game_level.get(self.runner_slug)) self.merge_to_system_config(self.game_level.get("system")) def merge_to_system_config(self, config): """Merge a configuration to the system configuration""" if not config: return existing_env = None if self.system_config.get("env") and "env" in config: existing_env = self.system_config["env"] self.system_config.update(config) if existing_env: self.system_config["env"] = existing_env self.system_config["env"].update(config["env"]) def update_raw_config(self): # Select the right level of config if self.level == "game": raw_config = self.game_level elif self.level == "runner": raw_config = self.runner_level else: raw_config = self.system_level # Load config sections self.raw_system_config = raw_config["system"] if self.level in ["runner", "game"]: self.raw_runner_config = raw_config[self.runner_slug] if self.level == "game": self.raw_game_config = raw_config["game"] self.raw_config = raw_config def remove(self): """Delete the configuration file from disk.""" if not path_exists(self.game_config_path): logger.debug("No config file at %s", self.game_config_path) return os.remove(self.game_config_path) logger.debug("Removed config %s", self.game_config_path) def save(self): """Save configuration file according to its type""" if self.options_supported is not None: raise RuntimeError("LutrisConfig instances that are restricted to only some options can't be saved.") if self.level == "system": config = self.system_level config_path = self.system_config_path elif self.level == "runner": config = self.runner_level config_path = self.runner_config_path elif self.level == "game": config = self.game_level config_path = self.game_config_path else: raise ValueError("Invalid config level '%s'" % self.level) # Remove keys with no values from config before saving config = {key: value for key, value in config.items() if value} logger.debug("Saving %s config to %s", self, config_path) write_yaml_to_file(config, config_path) self.initialize_config() def get_defaults(self, options_type): """Return a dict of options' default value.""" options_dict = self.options_as_dict(options_type) defaults = {} for option, params in options_dict.items(): if "default" in params: default = params["default"] if callable(default): if self.options_supported is None or option in self.options_supported: try: default = default() except Exception as ex: logger.exception("Unable to generate a default for '%s': %s", option, ex) continue else: # Do not evaluate options we aren't supposed to use, in case # this is expensive or unsafe. default = None defaults[option] = default return defaults def options_as_dict(self, options_type: str) -> dict: """Convert the option list to a dict with option name as keys""" if options_type == "system": options = ( sysoptions.with_runner_overrides(self.runner_slug) if self.runner_slug else sysoptions.system_options ) else: if not self.runner_slug: return {} attribute_name = options_type + "_options" try: runner = import_runner(self.runner_slug) except InvalidRunnerError: options = {} else: if not getattr(runner, attribute_name): runner = runner() options = getattr(runner, attribute_name) return dict((opt["option"], opt) for opt in options) lutris-0.5.19/lutris/monitored_command.py0000664000175000017500000002637014756670027017526 0ustar hibbyhibby"""Threading module, used to launch games while monitoring them.""" import contextlib import fcntl import io import os import shlex import subprocess import sys import uuid from copy import copy from typing import List from gi.repository import GLib from lutris import settings from lutris.util import system from lutris.util.log import logger from lutris.util.shell import get_terminal_script def get_wrapper_script_location(): """Return absolute path of lutris-wrapper script""" wrapper_relpath = "share/lutris/bin/lutris-wrapper" candidates = [ os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), "..")), os.path.dirname(os.path.dirname(settings.__file__)), "/usr", "/usr/local", ] for candidate in candidates: wrapper_abspath = os.path.join(candidate, wrapper_relpath) if os.path.isfile(wrapper_abspath): return wrapper_abspath raise FileNotFoundError("Couldn't find lutris-wrapper script in any of the expected locations") WRAPPER_SCRIPT = get_wrapper_script_location() RUNNING_COMMANDS = set() class MonitoredCommand: """Exexcutes a commmand while keeping track of its state""" fallback_cwd = "/tmp" def __init__( self, command, runner=None, env=None, term=None, cwd=None, include_processes=None, exclude_processes=None, log_buffer=None, title=None, ): # pylint: disable=too-many-arguments self.ready_state = True self.env = self.get_environment(env) self.accepted_return_code = "0" self.command = command self.runner = runner self.stop_func = lambda: True self.game_process = None self.prevent_on_stop = False self.return_code = None self.terminal = term self.is_running = True self.error = None self.log_handlers = [ self.log_handler_stdout, self.log_handler_console_output, ] self.set_log_buffer(log_buffer) self.stdout_monitor = None self.include_processes = include_processes or [] self.exclude_processes = exclude_processes or [] self.cwd = self.get_cwd(cwd) self._stdout = io.StringIO() self._title = title if title else command[0] @property def stdout(self): return self._stdout.getvalue() def get_wrapper_command(self) -> List[str]: """Return launch arguments for the wrapper script""" wrapper_command = ( [ WRAPPER_SCRIPT, self._title, str(len(self.include_processes)), str(len(self.exclude_processes)), ] + self.include_processes + self.exclude_processes ) if not self.terminal: return wrapper_command + self.command terminal_path = system.find_required_executable(self.terminal) script_path = get_terminal_script(self.command, self.cwd, self.env) return wrapper_command + [terminal_path, "-e", script_path] def set_log_buffer(self, log_buffer): """Attach a TextBuffer to this command enables the buffer handler""" if not log_buffer: return self.log_buffer = log_buffer if self.log_handler_buffer not in self.log_handlers: self.log_handlers.append(self.log_handler_buffer) def get_cwd(self, cwd): """Return the current working dir of the game""" if not cwd: cwd = self.runner.working_dir if self.runner else None return os.path.expanduser(cwd or "~") @staticmethod def get_environment(user_env): """Process the user provided environment variables for use as self.env""" env = copy(user_env) if user_env else {} # not clear why this needs to be added, the path is already added in # the wrappper script. env["PYTHONPATH"] = ":".join(sys.path) # Drop bad values of environment keys, those will confuse the Python # interpreter. env["LUTRIS_GAME_UUID"] = str(uuid.uuid4()) cleaned = {} for key, value in env.items(): if "=" in key: logger.warning("Environment variable name '%s' contains '=' so it can't be used; skipping.", key) elif value is None: logger.warning("Environment variable '%s' has None for its value; skipping.", key) elif not isinstance(value, str): logger.warning("Environment variable '%s' value '%s' is not a string; converting.", key, value) cleaned[key] = str(value) else: cleaned[key] = value return cleaned def get_child_environment(self): """Returns the calculated environment for the child process.""" env = system.get_environment() env.update(self.env) return env def start(self): """Run the thread.""" if os.environ.get("LUTRIS_DEBUG_ENV") == "1": for key, value in self.env.items(): logger.debug('%s="%s"', key, value) wrapper_command = self.get_wrapper_command() env = self.get_child_environment() self.game_process = self.execute_process(wrapper_command, env) RUNNING_COMMANDS.add(self) if not self.game_process: logger.error("No game process available") return GLib.child_watch_add(self.game_process.pid, self.on_stop) # make stdout nonblocking. fileno = self.game_process.stdout.fileno() fcntl.fcntl(fileno, fcntl.F_SETFL, fcntl.fcntl(fileno, fcntl.F_GETFL) | os.O_NONBLOCK) self.stdout_monitor = GLib.io_add_watch( self.game_process.stdout, GLib.IO_IN | GLib.IO_HUP, self.on_stdout_output, ) def log_filter(self, line: str) -> bool: """Filter out some message we don't want to show to the user.""" if "GStreamer-WARNING **" in line: return False if "Bad file descriptor" in line: return False if "'libgamemodeauto.so.0' from LD_PRELOAD" in line: return False if "Unable to read VR Path Registry" in line: return False return True def log_handler_stdout(self, line): """Add the line to this command's stdout attribute""" if not self.log_filter(line): return self._stdout.write(line) def log_handler_buffer(self, line): """Add the line to the associated LogBuffer object""" self.log_buffer.insert(self.log_buffer.get_end_iter(), line, -1) def log_handler_console_output(self, line): """Print the line to stdout""" if not self.log_filter(line): return with contextlib.suppress(BlockingIOError): sys.stdout.write(line) sys.stdout.flush() def get_return_code(self): """Get the return code from the file written by the wrapper""" return_code_path = "/tmp/lutris-%s" % self.env["LUTRIS_GAME_UUID"] if os.path.exists(return_code_path): with open(return_code_path, encoding="utf-8") as return_code_file: return_code = return_code_file.read() os.unlink(return_code_path) else: return_code = "" logger.warning("No file %s", return_code_path) return return_code def on_stop(self, pid, _user_data): """Callback registered on game process termination""" if self.prevent_on_stop: # stop() already in progress return False self.game_process.wait() self.return_code = self.get_return_code() self.is_running = False logger.debug("Process %s has terminated with code %s", pid, self.return_code) resume_stop = self.stop() if not resume_stop: logger.info("Full shutdown prevented") return False return False def on_stdout_output(self, stdout, condition): """Called by the stdout monitor to dispatch output to log handlers""" if condition == GLib.IO_HUP: self.stdout_monitor = None return False if not self.is_running: return False try: line = stdout.read(262144).decode("utf-8", errors="ignore") except ValueError: # file_desc might be closed return True if "winemenubuilder.exe" in line: return True for log_handler in self.log_handlers: log_handler(line) return True def execute_process(self, command, env=None): """Execute and return a subprocess""" # If a None gets into execute_process, we get annoying errors # that are hard to race. We'll try to repair the bad command or environment # instead, while emitting warnings.abs for i, item in enumerate(command): if not isinstance(item, str): logger.warning("Wrapper command contains a non-string: %s", command) command[i] = str(item) if item else "" if "" in env: del env[""] for key, value in env.items(): if not isinstance(key, str) or key.isspace(): logger.warning("Environment contains a non-string as a key %s=%s: %s", key, value, env) env = copy(env) # can't del while iterating del env[key] continue if not isinstance(value, str): logger.warning("Environment contains a non-string as the value for the key: %s=%s: %s", key, value, env) env[key] = str(value) if value else "" if self.cwd and not system.path_exists(self.cwd): try: os.makedirs(self.cwd) except OSError: logger.error("Failed to create working directory, falling back to %s", self.fallback_cwd) self.cwd = "/tmp" try: return subprocess.Popen( # pylint: disable=consider-using-with command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=self.cwd, env=env, ) except OSError as ex: logger.exception("Failed to execute %s: %s", " ".join(command), ex) self.error = ex.strerror def stop(self): """Stops the current game process and cleans up the instance""" # Prevent stop() being called again by the process exiting self.prevent_on_stop = True try: self.game_process.terminate() except ProcessLookupError: # process already dead. pass resume_stop = self.stop_func() if not resume_stop: logger.warning("Stop execution halted by demand of stop_func") return False if self.stdout_monitor: GLib.source_remove(self.stdout_monitor) self.stdout_monitor = None self.is_running = False self.ready_state = False RUNNING_COMMANDS.discard(self) return True def exec_command(command): """Execute arbitrary command in a MonitoredCommand Used by the --exec command line flag. """ command = MonitoredCommand(shlex.split(command), env={}) # runtime.get_env()) command.start() return command lutris-0.5.19/lutris/search.py0000664000175000017500000003770514756670027015301 0ustar hibbyhibbyimport copy import time from typing import Any, Callable, Optional, Set from lutris.database import games from lutris.database.categories import ( get_game_ids_for_categories, get_uncategorized_game_ids, normalized_category_names, ) from lutris.exceptions import InvalidSearchTermError from lutris.runners import get_runner_human_name from lutris.search_predicate import ( FLAG_TEXTS, TRUE_PREDICATE, AndPredicate, FlagPredicate, FunctionPredicate, MatchPredicate, NotPredicate, OrPredicate, SearchPredicate, TextPredicate, ) from lutris.services import SERVICES from lutris.util.strings import get_formatted_playtime, parse_playtime_parts from lutris.util.tokenization import ( TokenReader, clean_token, tokenize_search, ) ISOLATED_TOKENS = set([":", "-", "(", ")", "<", ">", ">=", "<="]) ITEM_STOP_TOKENS = (ISOLATED_TOKENS | set(["OR", "AND"])) - set(["(", "-"]) def read_flag_token(tokens: TokenReader) -> Optional[bool]: token = tokens.get_cleaned_token() or "" folded = token.casefold() if folded in FLAG_TEXTS: return FLAG_TEXTS[folded] raise InvalidSearchTermError(f"'{token}' was found where a flag was expected.") class BaseSearch: tags: Set[str] = set() def __init__(self, text: str) -> None: self.text = text self.predicate: Optional[SearchPredicate] = None def __str__(self) -> str: return self.text @property def is_empty(self) -> bool: return not self.text and not self.predicate def matches(self, candidate: Any) -> bool: return self.get_predicate().accept(candidate) def get_candidate_text(self, candidate: Any) -> str: return str(candidate) def has_component(self, component_name: str) -> bool: if component_name in self.tags: prev_token = None for token in tokenize_search(self.text, ISOLATED_TOKENS): if not token.isspace(): if token == ":" and prev_token and prev_token.casefold() == component_name: return True prev_token = token return False def get_predicate(self) -> SearchPredicate: if self.predicate is None: if self.text: raw_tokens = tokenize_search(self.text, ISOLATED_TOKENS) tokens = TokenReader(raw_tokens) self.predicate = self._parse_or(tokens) or TRUE_PREDICATE else: self.predicate = TRUE_PREDICATE return self.predicate def _parse_or(self, tokens: TokenReader) -> Optional[SearchPredicate]: parsed = self._parse_items(tokens) parts = [] if parsed: parts.append(parsed) while tokens.consume("OR"): # case-sensitive! more = self._parse_items(tokens) if not more: break parts.append(more) if not parts: return None if len(parts) == 1: return parts[0] return OrPredicate(parts) def _parse_items(self, tokens: TokenReader) -> Optional[SearchPredicate]: buffer = [] while True: parsed = self._parse_item(tokens) if parsed: buffer.append(parsed) else: break if not buffer: return None if len(buffer) == 1: return buffer[0] return AndPredicate(buffer) def _parse_item(self, tokens: TokenReader) -> Optional[SearchPredicate]: # AND is kinda fake - we and together items by default anyway, # so we'll just ignore this conjunction. while tokens.consume("AND"): pass token = tokens.peek_token() if not token or token in ITEM_STOP_TOKENS: return None if token.startswith('"'): tokens.get_token() # consume token return self.get_text_predicate(clean_token(token)) if tokens.consume("("): predicate = self._parse_or(tokens) or TRUE_PREDICATE tokens.consume(")") return predicate if tokens.consume("-"): inner = self._parse_items(tokens) if inner: return NotPredicate(inner) saved_index = tokens.index tokens.get_token() # consume tag name if tokens.consume(":"): name = token.casefold() if name in self.tags: try: return self.get_part_predicate(name, tokens) except InvalidSearchTermError: pass # If the tag is no good, we'll rewind and fall back on a # literal text predicate for the whole thing tokens.index = saved_index text_token = tokens.get_cleaned_token_sequence(stop_function=self.is_stop_token) if text_token: return self.get_text_predicate(text_token) return None def with_predicate(self, predicate: SearchPredicate): old_predicate = self.get_predicate() # force generation of predicate new_search = copy.copy(self) new_search.predicate = AndPredicate([old_predicate, predicate]) return new_search def get_part_predicate(self, name: str, tokens: TokenReader) -> SearchPredicate: raise InvalidSearchTermError(f"'{name}' is not a valid search tag.") def get_text_predicate(self, text: str) -> SearchPredicate: return TextPredicate(text, self.get_candidate_text, tag="") def is_stop_token(self, tokens: TokenReader) -> bool: """This function decides when to stop when reading an item; pass this to tokens.get_cleaned_token_sequence(). It will stop at the end of tokens, any of our stop tokens like AND, or at any tag, which must be a known tag followed by a colon.""" peeked = tokens.peek_tokens(2) if not peeked: return True if peeked[0] in ITEM_STOP_TOKENS: return True if len(peeked) > 1 and peeked[1] == ":" and peeked[0].casefold() in self.tags: return True return False def quote_token(self, text: str) -> str: if text and " " not in text: tokens = list(tokenize_search(text, ISOLATED_TOKENS)) test_reader = TokenReader(tokens) cleaned = test_reader.get_cleaned_token_sequence(self.is_stop_token) if cleaned == text: return text return f'"{text}"' class GameSearch(BaseSearch): """A search for games, which applies to the games database dictionaries, not the Game objects.""" tags = set( [ "installed", "hidden", "favorite", "categorized", "category", "source", "service", # an alias for source "runner", "platform", "playtime", "lastplayed", "directory", ] ) def __init__(self, text: str, service=None) -> None: self.service = service super().__init__(text) def get_candidate_text(self, candidate: Any) -> str: return candidate["name"] def get_part_predicate(self, name: str, tokens: TokenReader) -> SearchPredicate: if name == "category": category = tokens.get_cleaned_token() or "" return self.get_category_predicate(category) if name in ("source", "service"): service_name = tokens.get_cleaned_token() or "" return self.get_service_predicate(service_name) if name == "runner": runner_name = tokens.get_cleaned_token() or "" return self.get_runner_predicate(runner_name) if name == "platform": platform = tokens.get_cleaned_token() or "" return self.get_platform_predicate(platform) if name == "playtime": return self.get_playtime_predicate(tokens) if name == "lastplayed": return self.get_lastplayed_predicate(tokens) if name == "directory": directory = tokens.get_cleaned_token_sequence(stop_function=self.is_stop_token) or "" return self.get_directory_predicate(directory) # All flags handle the 'maybe' option the same way, so we'll # group them at the end. flag = read_flag_token(tokens) flag_predicate = self.get_flag_predicate(name, flag) if flag_predicate: return flag_predicate return super().get_part_predicate(name, tokens) def get_flag_predicate(self, name: str, flag: Optional[bool]) -> Optional[SearchPredicate]: if name == "installed": return self.get_installed_predicate(flag) if name == "hidden": return self.get_category_flag_predicate(".hidden", "hidden", in_category=flag) if name == "favorite": return self.get_category_flag_predicate("favorite", "favorite", in_category=flag) if name == "categorized": return self.get_categorized_predicate(flag) return None def get_playtime_predicate(self, tokens: TokenReader) -> SearchPredicate: def get_game_playtime(db_game): return db_game.get("playtime") return self.get_duration_predicate(get_game_playtime, tokens, tag="playtime") def get_lastplayed_predicate(self, tokens: TokenReader) -> SearchPredicate: now = time.time() def get_game_lastplayed_duration_ago(db_game): lastplayed = db_game.get("lastplayed") if lastplayed: return (now - lastplayed) / (60 * 60) return None return self.get_duration_predicate(get_game_lastplayed_duration_ago, tokens, tag="lastplayed") def get_duration_predicate(self, value_function: Callable, tokens: TokenReader, tag: str) -> SearchPredicate: def match_greater_playtime(db_game): game_playtime = value_function(db_game) return game_playtime and game_playtime > duration def match_lesser_playtime(db_game): game_playtime = value_function(db_game) return game_playtime and game_playtime < duration def match_playtime(db_game): game_playtime = value_function(db_game) return game_playtime and duration_parts.matches(game_playtime) operator = tokens.peek_token() if operator == ">": matcher = match_greater_playtime tokens.get_token() elif operator == "<": matcher = match_lesser_playtime tokens.get_token() elif operator == ">=": matcher = lambda *a: match_greater_playtime(*a) or match_playtime(*a) # noqa: E731 tokens.get_token() elif operator == "<=": matcher = lambda *a: match_lesser_playtime(*a) or match_playtime(*a) # noqa: E731 tokens.get_token() else: matcher = match_playtime # We'll hope none of our tags are ever part of a legit duration duration_text = tokens.get_cleaned_token_sequence(stop_function=self.is_stop_token) if not duration_text: raise InvalidSearchTermError("A blank is not a valid duration.") try: duration_parts = parse_playtime_parts(duration_text) duration = duration_parts.get_total_hours() except ValueError as ex: raise InvalidSearchTermError(f"'{duration_text}' is not a valid playtime.") from ex text = f"{tag}:{operator}{get_formatted_playtime(duration)}" return FunctionPredicate(matcher, text) def get_directory_predicate(self, directory: str) -> SearchPredicate: return TextPredicate(directory, lambda c: c.get("directory"), tag="directory") def get_installed_predicate(self, installed: Optional[bool]) -> SearchPredicate: if self.service: def is_installed(db_game): appid = db_game.get("appid") return bool(appid and appid in games.get_service_games(self.service.id)) return FlagPredicate(installed, is_installed, tag="installed") return FlagPredicate(installed, lambda db_game: bool(db_game["installed"]), tag="installed") def get_categorized_predicate(self, categorized: Optional[bool]) -> SearchPredicate: uncategorized_ids = set(get_uncategorized_game_ids()) def is_categorized(db_game): return db_game["id"] not in uncategorized_ids return FlagPredicate(categorized, is_categorized, tag="categorized") def get_category_predicate(self, category: str) -> SearchPredicate: names = normalized_category_names(category, subname_allowed=True) category_game_ids = set(get_game_ids_for_categories(names)) def match_category(db_game): game_id = db_game["id"] return game_id in category_game_ids text = f"category:{self.quote_token(category)}" return MatchPredicate(match_category, text=text, tag="category", value=category) def get_category_flag_predicate(self, category: str, tag: str, in_category: Optional[bool] = True) -> FlagPredicate: names = normalized_category_names(category, subname_allowed=True) category_game_ids = set(get_game_ids_for_categories(names)) def is_in_category(db_game): game_id = db_game["id"] return game_id in category_game_ids return FlagPredicate(in_category, is_in_category, tag=tag) def get_service_predicate(self, service_name: str) -> SearchPredicate: service_name = service_name.casefold() def match_service(db_game): game_service = db_game.get("service") if not game_service: return False if game_service.casefold() == service_name: return True service = SERVICES.get(game_service) return service and service_name in service.name.casefold() text = f"source:{service_name}" return MatchPredicate(match_service, text=text, tag="source", value=service_name) def get_runner_predicate(self, runner_name: str) -> SearchPredicate: folded_runner_name = runner_name.casefold() def match_runner(db_game): game_runner = db_game.get("runner") if not game_runner: return False if game_runner.casefold() == folded_runner_name: return True runner_human_name = get_runner_human_name(game_runner) return runner_name in runner_human_name.casefold() text = f"runner:{self.quote_token(runner_name)}" return MatchPredicate(match_runner, text=text, tag="runner", value=runner_name) def get_platform_predicate(self, platform: str) -> SearchPredicate: folded_platform = platform.casefold() def match_platform(db_game): game_platform = db_game.get("platform") if game_platform: return folded_platform in game_platform.casefold() if self.service: platforms = [p.casefold() for p in self.service.get_game_platforms(db_game)] matches = [p for p in platforms if folded_platform in p] return any(matches) return False text = f"platform:{self.quote_token(platform)}" return MatchPredicate(match_platform, text=text, tag="platform", value=platform) class RunnerSearch(BaseSearch): """A search for runners, which applies to the runner objects.""" tags = set(["installed"]) def get_candidate_text(self, candidate: Any) -> str: return f"{candidate.name}\n{candidate.description}" def get_part_predicate(self, name: str, tokens: TokenReader) -> SearchPredicate: if name == "installed": flag = read_flag_token(tokens) if flag is None: return TRUE_PREDICATE return self.get_installed_predicate(flag) return super().get_part_predicate(name, tokens) def get_installed_predicate(self, installed: bool) -> SearchPredicate: return FlagPredicate(installed, lambda runner: runner.is_installed(), tag="installed") lutris-0.5.19/lutris/util/0000775000175000017500000000000014756670027014423 5ustar hibbyhibbylutris-0.5.19/lutris/util/mame/0000775000175000017500000000000014756670027015342 5ustar hibbyhibbylutris-0.5.19/lutris/util/mame/ini.py0000664000175000017500000000311314756670027016471 0ustar hibbyhibby"""Manipulate MAME ini files""" # Lutris Modules from lutris.util.system import path_exists class MameIni: """Looks like an ini file and yet it is not one!""" def __init__(self, ini_path): if not path_exists(ini_path): raise OSError("File %s does not exist" % ini_path) self.ini_path = ini_path self.lines = [] self.config = {} def parse(self, line): """Store configuration value from a line""" line = line.strip() if not line or line.startswith("#"): return None, None key, *_value = line.split(maxsplit=1) if _value: return key, _value[0] return key, None def read(self): """Reads the content of the ini file""" with open(self.ini_path, "r", encoding="utf-8") as ini_file: for line in ini_file.readlines(): self.lines.append(line) print(line) config_key, config_value = self.parse(line) if config_key: self.config[config_key] = config_value def write(self): """Writes the file to disk""" with open(self.ini_path, "w", encoding="utf-8") as ini_file: for line in self.lines: config_key, _value = self.parse(line) if config_key and self.config[config_key]: ini_file.write("%-26s%s\n" % (config_key, self.config[config_key])) elif config_key: ini_file.write("%s\n" % config_key) else: ini_file.write(line) lutris-0.5.19/lutris/util/mame/database.py0000664000175000017500000001033314756670027017460 0ustar hibbyhibby"""Utility functions for MAME""" # Standard Library import json import os from xml.etree import ElementTree # Lutris Modules from lutris import settings from lutris.util.log import logger CACHE_DIR = os.path.join(settings.CACHE_DIR, "mame") def simplify_manufacturer(manufacturer): """Give simplified names for some manufacturers""" manufacturer_map = { "Amstrad plc": "Amstrad", "Apple Computer": "Apple", "Commodore Business Machines": "Commodore", } return manufacturer_map.get(manufacturer, manufacturer) def is_game(machine): """Return True if the given machine game is an original arcade game Clones return False """ return ( machine.attrib["isbios"] == "no" and machine.attrib["isdevice"] == "no" and machine.attrib["runnable"] == "yes" and "romof" not in machine.attrib # FIXME: Filter by the machines that accept coins, but not like that # and "coin" in machine.find("input").attrib ) def has_software_list(machine): """Return True if the machine has an associated software list""" _has_software_list = False for elem in machine: if elem.tag == "device_ref" and elem.attrib["name"] == "software_list": _has_software_list = True return _has_software_list def is_system(machine): """Given a machine XML tag, return True if it is a computer, console or handheld. """ if ( machine.attrib.get("runnable") == "no" or machine.attrib.get("isdevice") == "yes" or machine.attrib.get("isbios") == "yes" ): return False return has_software_list(machine) def iter_machines(xml_path, filter_func=None): """Iterate through machine nodes in the MAME XML""" try: root = ElementTree.parse(xml_path).getroot() except Exception as ex: # pylint: disable=broad-except logger.error("Failed to read MAME XML: %s", ex) return [] for machine in root: if filter_func and not filter_func(machine): continue yield machine def get_machine_info(machine): """Return human readable information about a machine node""" return { "description": machine.find("description").text, "manufacturer": simplify_manufacturer(machine.find("manufacturer").text), "year": machine.find("year").text, "roms": [rom.attrib for rom in machine.findall("rom")], "ports": [port.attrib for port in machine.findall("port")], "devices": [ { "info": device.attrib, "name": "".join([instance.attrib["name"] for instance in device.findall("instance")]), "briefname": "".join([instance.attrib["briefname"] for instance in device.findall("instance")]), "extensions": [extension.attrib["name"] for extension in device.findall("extension")], } for device in machine.findall("device") ], "input": machine.find("input").attrib, "driver": machine.find("driver").attrib, } def get_supported_systems(xml_path, force=False): """Return supported systems (computers and consoles) supported. From the full XML list extracted from MAME, filter the systems that are runnable, not clones and have the ability to run software. """ systems_cache_path = os.path.join(CACHE_DIR, "systems.json") if os.path.exists(systems_cache_path) and not force: with open(systems_cache_path, "r", encoding="utf-8") as systems_cache_file: try: systems = json.load(systems_cache_file) except json.JSONDecodeError: logger.error("Failed to read systems cache %s", systems_cache_path) systems = None if systems: return systems systems = {machine.attrib["name"]: get_machine_info(machine) for machine in iter_machines(xml_path, is_system)} if not systems: return {} with open(systems_cache_path, "w", encoding="utf-8") as systems_cache_file: json.dump(systems, systems_cache_file, indent=2) return systems def get_games(xml_path): """Return a list of all games""" return {machine.attrib["name"]: get_machine_info(machine) for machine in iter_machines(xml_path, is_game)} lutris-0.5.19/lutris/util/mame/__init__.py0000664000175000017500000000000014756670027017441 0ustar hibbyhibbylutris-0.5.19/lutris/util/egs/0000775000175000017500000000000014756670027015201 5ustar hibbyhibbylutris-0.5.19/lutris/util/egs/__init__.py0000664000175000017500000000000014756670027017300 0ustar hibbyhibbylutris-0.5.19/lutris/util/egs/egs_launcher.py0000664000175000017500000000173214756670027020215 0ustar hibbyhibby"""Interact with an exiting EGS install""" import json import os from lutris.util.log import logger class EGSLauncher: manifests_paths = "ProgramData/Epic/EpicGamesLauncher/Data/Manifests" def __init__(self, prefix_path): self.prefix_path = prefix_path def iter_manifests(self): manifests_path = os.path.join(self.prefix_path, "drive_c", self.manifests_paths) if not os.path.exists(manifests_path): logger.warning("No valid path for EGS games manifests in %s", manifests_path) return [] for manifest in os.listdir(manifests_path): if not manifest.endswith(".item"): continue with open(os.path.join(manifests_path, manifest), encoding="utf-8") as manifest_file: manifest_content = json.loads(manifest_file.read()) if manifest_content["MainGameAppName"] != manifest_content["AppName"]: continue yield manifest_content lutris-0.5.19/lutris/util/nvidia.py0000664000175000017500000000563214756670027016255 0ustar hibbyhibby"""Nvidia library detection from Proton""" import os from ctypes import CDLL, POINTER, Structure, addressof, c_char_p, c_int, c_void_p, cast from lutris.util.log import logger RTLD_DI_LINKMAP = 2 class LinkMap(Structure): """ from dlinfo(3) struct link_map { ElfW(Addr) l_addr; /* Difference between the address in the ELF file and the address in memory */ char *l_name; /* Absolute pathname where object was found */ ElfW(Dyn) *l_ld; /* Dynamic section of the shared object */ struct link_map *l_next, *l_prev; /* Chain of loaded objects */ /* Plus additional fields private to the implementation */ }; """ _fields_ = [("l_addr", c_void_p), ("l_name", c_char_p), ("l_ld", c_void_p)] def get_nvidia_glx_path(): """Return the absolute path to the libGLX_nvidia library""" try: libdl = CDLL("libdl.so.2") except OSError: logger.error("Unable to load libdl.so.2") return None try: libglx_nvidia = CDLL("libGLX_nvidia.so.0") except OSError: return None # from dlinfo(3) # # int dlinfo (void *restrict handle, int request, void *restrict info) dlinfo_func = libdl.dlinfo dlinfo_func.argtypes = c_void_p, c_int, c_void_p dlinfo_func.restype = c_int # Allocate a LinkMap object glx_nvidia_info_ptr = POINTER(LinkMap)() # Run dlinfo(3) on the handle to libGLX_nvidia.so.0, storing results at the # address represented by glx_nvidia_info_ptr if dlinfo_func(libglx_nvidia._handle, RTLD_DI_LINKMAP, addressof(glx_nvidia_info_ptr)) != 0: logger.error("Unable to read Nvidia information") return None # Grab the contents our of our pointer glx_nvidia_info = cast(glx_nvidia_info_ptr, POINTER(LinkMap)).contents # Decode the path to our library to a str() if glx_nvidia_info.l_name is None: logger.error("Error reading the Nvidia library path") return None try: libglx_nvidia_path = os.fsdecode(glx_nvidia_info.l_name) except UnicodeDecodeError as ex: logger.error("Error decoding the Nvidia library path: %s", ex) return None # Follow any symlinks to the actual file return os.path.realpath(libglx_nvidia_path) def get_nvidia_dll_path(): """Return the path to the location of DLL files for use by Wine/Proton from the NVIDIA Linux driver. See https://gitlab.steamos.cloud/steamrt/steam-runtime-tools/-/issues/71 for background on the chosen method of DLL discovery. """ libglx_path = get_nvidia_glx_path() if not libglx_path: return nvidia_wine_dir = os.path.join(os.path.dirname(libglx_path), "nvidia/wine") if os.path.exists(os.path.join(nvidia_wine_dir, "nvngx.dll")): return nvidia_wine_dir lutris-0.5.19/lutris/util/__init__.py0000664000175000017500000000241614756670027016537 0ustar hibbyhibby"""Misc common functions""" from functools import wraps def selective_merge(base_obj, delta_obj): """used by write_json""" if not isinstance(base_obj, dict): return delta_obj common_keys = set(base_obj).intersection(delta_obj) new_keys = set(delta_obj).difference(common_keys) for k in common_keys: base_obj[k] = selective_merge(base_obj[k], delta_obj[k]) for k in new_keys: base_obj[k] = delta_obj[k] return base_obj def cache_single(function): """A simple replacement for lru_cache, with no LRU behavior. This caches a single result from a function that has no arguments at all. Exceptions are not cached; there's a 'clear_cache()' function on the wrapper like with lru_cache to explicitly clear the cache.""" is_cached = False cached_item = None @wraps(function) def wrapper(*args, **kwargs): nonlocal is_cached, cached_item if args or kwargs: return function(*args, **kwargs) if not is_cached: cached_item = function() is_cached = True return cached_item def cache_clear(): nonlocal is_cached, cached_item is_cached = False cached_item = None wrapper.cache_clear = cache_clear return wrapper lutris-0.5.19/lutris/util/path_cache.py0000664000175000017500000001110314756670027017050 0ustar hibbyhibby"""Keep track of game executables' presence""" import json import os import time from lutris import settings from lutris.database.games import get_games from lutris.game import Game from lutris.gui.widgets import NotificationSource from lutris.util import cache_single from lutris.util.jobs import AsyncCall from lutris.util.log import logger GAME_PATH_CACHE_PATH = os.path.join(settings.CACHE_DIR, "game-paths.json") def get_game_paths(): game_paths = {} all_games = get_games(filters={"installed": 1}) for db_game in all_games: if db_game.get("runner") in ("steam", "web"): continue game = Game(db_game["id"]) path = game.get_path_from_config() if not path: continue game_paths[db_game["id"]] = path return game_paths def build_path_cache(recreate=False): """Generate a new cache path""" if os.path.exists(GAME_PATH_CACHE_PATH) and not recreate: return start_time = time.time() with open(GAME_PATH_CACHE_PATH, "w", encoding="utf-8") as cache_file: game_paths = get_game_paths() json.dump(game_paths, cache_file, indent=2) end_time = time.time() get_path_cache.cache_clear() logger.debug("Game path cache built in %0.2f seconds", end_time - start_time) def add_to_path_cache(game): """Add or update the path of a game in the cache""" logger.debug("Adding %s to path cache", game) path = game.get_path_from_config() if not path: logger.warning("No path for %s", game) return current_cache = read_path_cache() current_cache[game.id] = path with open(GAME_PATH_CACHE_PATH, "w", encoding="utf-8") as cache_file: json.dump(current_cache, cache_file, indent=2) get_path_cache.cache_clear() @cache_single def get_path_cache(): """Return the contents of the path cache file; this dict is cached, so do not modify it.""" return read_path_cache() def read_path_cache(): """Read the contents of the path cache file, and does not cache it.""" with open(GAME_PATH_CACHE_PATH, encoding="utf-8") as cache_file: try: return json.load(cache_file) except json.JSONDecodeError: return {} def remove_from_path_cache(game): logger.debug("Removing %s from path cache", game) current_cache = read_path_cache() if game.id not in current_cache: logger.warning("Game %s (id=%s) not in cache path", game, game.id) return del current_cache[game.id] with open(GAME_PATH_CACHE_PATH, "w", encoding="utf-8") as cache_file: json.dump(current_cache, cache_file, indent=2) get_path_cache.cache_clear() class MissingGames: """This class is a singleton that holds a set of game-ids for games whose directories are missing. It is updated on a background thread, but there's a NotificationSource ('updated') that fires when that thread has made changes and exited, so that the UI cab update then.""" def __init__(self): self.updated = NotificationSource() self.missing_game_ids = set() self._update_running = None @property def is_initialized(self): """True if the missing games have ever been updated.""" return self._update_running is not None def update_all_missing(self) -> None: """This starts the check for all games; the actual list of game-ids will be obtained on the worker thread, and this method will start it.""" if not self._update_running: self._update_running = True AsyncCall(self._update_missing_games, self._update_missing_games_cb) def _update_missing_games(self): """This is the method that runs on the worker thread; it checks each game given and returns True if any changes to missing_game_ids was made.""" logger.debug("Checking for missing games") changed = False for game_id, path in get_path_cache().items(): if path: old_status = game_id in self.missing_game_ids new_status = not os.path.exists(path) if old_status != new_status: if new_status: self.missing_game_ids.add(game_id) else: self.missing_game_ids.discard(game_id) changed = True return changed def _update_missing_games_cb(self, changed, error): self._update_running = False if error: logger.exception("Unable to detect missing games: %s", error) elif changed: self.updated.fire() MISSING_GAMES = MissingGames() lutris-0.5.19/lutris/util/amazon/0000775000175000017500000000000014756670027015710 5ustar hibbyhibbylutris-0.5.19/lutris/util/amazon/__init__.py0000664000175000017500000000000014756670027020007 0ustar hibbyhibbylutris-0.5.19/lutris/util/amazon/sds_proto2.py0000664000175000017500000000506614756670027020367 0ustar hibbyhibbyfrom lutris.util.amazon.protobuf_decoder import ( Message, type_bool, type_bytes, type_enum, type_int64, type_string, type_uint32, ) class CompressionAlgorithm: none = 0 lzma = 1 class HashAlgorithm: sha256 = 0 shake128 = 1 @staticmethod def get_name(algorithm): if algorithm == HashAlgorithm.sha256: return "SHA256" if algorithm == HashAlgorithm.shake128: return "SHAKE128" return None class SignatureAlgorithm: sha256_with_rsa = 0 class CompressionSettings(Message): algorithm = CompressionAlgorithm.none def __init__(self): self.__lookup__ = [("optional", type_enum, "algorithm", 1)] class Dir(Message): path = None mode = None def __init__(self): self.__lookup__ = [("optional", type_string, "path", 1), ("optional", type_uint32, "mode", 2)] class File(Message): path = None mode = None size = None created = None hash = None hidden = None system = None def __init__(self): self.__lookup__ = [ ("optional", type_string, "path", 1), ("optional", type_uint32, "mode", 2), ("optional", type_int64, "size", 3), ("optional", type_string, "created", 4), ("optional", Hash, "hash", 5), ("optional", type_bool, "hidden", 6), ("optional", type_bool, "system", 7), ] class Hash(Message): algorithm = HashAlgorithm.sha256 value = None def __init__(self): self.__lookup__ = [("optional", type_enum, "algorithm", 1), ("optional", type_bytes, "value", 2)] class Manifest(Message): packages = None def __init__(self): self.__lookup__ = [("repeated", Package, "packages", 1)] class ManifestHeader(Message): compression = None hash = None signature = None def __init__(self): self.__lookup__ = [ ("optional", CompressionSettings, "compression", 1), ("optional", Hash, "hash", 2), ("optional", Signature, "signature", 3), ] class Package(Message): name = None files = None dirs = None def __init__(self): self.__lookup__ = [ ("optional", type_string, "name", 1), ("repeated", File, "files", 2), ("repeated", Dir, "dirs", 3), ] class Signature(Message): algorithm = SignatureAlgorithm.sha256_with_rsa value = None def __init__(self): self.__lookup__ = [("optional", type_enum, "algorithm", 1), ("optional", type_bytes, "value", 2)] lutris-0.5.19/lutris/util/amazon/protobuf_decoder.py0000664000175000017500000001426314756670027021615 0ustar hibbyhibbyimport struct from io import BytesIO # for the record: # - int32 = signed varint # - int64 = signed varint # - enum = signed varint # - uint32 = unsigned varint # - uint64 = unsigned varint # - sint32 = zigzag signed varint # - sint64 = zigzag signed varint # https://developers.google.com/protocol-buffers/docs/encoding # 0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum # 1 64-bit fixed64, sfixed64, double # 2 Length-delimited string, bytes, embedded messages, packed repeated fields # 3 Start group groups (deprecated) # 4 End group groups (deprecated) # 5 32-bit fixed32, sfixed32, float class PrimativeType: @staticmethod def decode(data): raise NotImplementedError() class type_double(PrimativeType): wire_type = 1 @staticmethod def decode(data): # data = 64-bit (val,) = struct.unpack(" 0x7FFFFFFFFFFFFFFF: x -= 1 << 64 x |= ~((1 << bits) - 1) else: x &= (1 << bits) - 1 return x # zigzag conversion from google # https://github.com/google/protobuf/blob/master/python/google/protobuf/internal/wire_format.py @staticmethod def zigzag_to_long(x): if not x & 0x1: return x >> 1 return (x >> 1) ^ (~0) @staticmethod def read_tag(stream): var = Message.read_varint(stream) field_number = var >> 3 wire_type = var & 7 if wire_type == 0: data = Message.read_varint(stream) elif wire_type == 1: data = stream.read(8) elif wire_type == 2: length = Message.read_varint(stream) data = stream.read(length) elif wire_type in (3, 4): raise NotImplementedError("groups are deprecated") elif wire_type == 5: data = stream.read(4) else: raise TypeError("unknown wire type (%d)" % wire_type) return (field_number, wire_type, data) def lookup_id(self, _id): for _, i in enumerate(self.__lookup__): if i[3] == _id: return i def decode(self, s: bytes): f = BytesIO(s) while f.tell() < len(s): field_number, _, data = self.read_tag(f) field = self.lookup_id(field_number) if not field: continue field_multiplicity, field_type, field_name, _ = field if issubclass(field_type, PrimativeType): value = field_type.decode(data) elif issubclass(field_type, Message): value = field_type() value.decode(data) else: raise TypeError("field type must be a subclass of PrimativeType or Message") if field_multiplicity == "repeated": if getattr(self, field_name) is None: # if not isinstance(getattr(self, field_name), list): # ? what if the attribute was already filled with data ? setattr(self, field_name, []) getattr(self, field_name).append(value) else: setattr(self, field_name, value) def __lookup__(self): return lutris-0.5.19/lutris/util/magic.py0000664000175000017500000003414314756670027016062 0ustar hibbyhibby""" magic is a wrapper around the libmagic file identification library. See https://github.com/ahupp/python-magic for more information. Usage: >>> import magic >>> magic.from_file("testdata/test.pdf") 'PDF document, version 1.2' >>> magic.from_file("testdata/test.pdf", mime=True) 'application/pdf' >>> magic.from_buffer(open("testdata/test.pdf").read(1024)) 'PDF document, version 1.2' >>> """ import ctypes import ctypes.util import glob import sys import threading from ctypes import POINTER, byref, c_char_p, c_int, c_size_t, c_void_p # avoid shadowing the real open with the version from compat.py _real_open = open class MagicException(Exception): def __init__(self, message): super().__init__(message) self.message = message class Magic: """ Magic is a wrapper around the libmagic C library. """ def __init__( self, mime=False, magic_file=None, mime_encoding=False, # pylint: disable=redefined-outer-name keep_going=False, uncompress=False, raw=False, extension=False, ): """ Create a new libmagic wrapper. mime - if True, mimetypes are returned instead of textual descriptions mime_encoding - if True, codec is returned magic_file - use a mime database other than the system default keep_going - don't stop at the first match, keep going uncompress - Try to look inside compressed files. raw - Do not try to decode "non-printable" chars. extension - Print a slash-separated list of valid extensions for the file type found. """ self.cookie = None self.flags = MAGIC_NONE if mime: self.flags |= MAGIC_MIME_TYPE if mime_encoding: self.flags |= MAGIC_MIME_ENCODING if keep_going: self.flags |= MAGIC_CONTINUE if uncompress: self.flags |= MAGIC_COMPRESS if raw: self.flags |= MAGIC_RAW if extension: self.flags |= MAGIC_EXTENSION self.cookie = magic_open(self.flags) self.lock = threading.Lock() magic_load(self.cookie, magic_file) # MAGIC_EXTENSION was added in 523 or 524, so bail if # it doesn't appear to be available if extension and (not _has_version or version() < 524): raise NotImplementedError("MAGIC_EXTENSION is not supported in this version of libmagic") # For https://github.com/ahupp/python-magic/issues/190 # libmagic has fixed internal limits that some files exceed, causing # an error. We can avoid this (at least for the sample file given) # by bumping the limit up. It's not clear if this is a general solution # or whether other internal limits should be increased, but given # the lack of other reports I'll assume this is rare. if _has_param: try: self.setparam(MAGIC_PARAM_NAME_MAX, 64) except MagicException: # some versions of libmagic fail this call, # so rather than fail hard just use default behavior pass def from_buffer(self, buf): """ Identify the contents of `buf` """ with self.lock: try: # if we're on python3, convert buf to bytes # otherwise this string is passed as wchar* # which is not what libmagic expects if isinstance(buf, str) and str != bytes: buf = buf.encode("utf-8", errors="replace") return maybe_decode(magic_buffer(self.cookie, buf)) except MagicException as e: return self._handle509Bug(e) def from_file(self, filename): # raise FileNotFoundException or IOError if the file does not exist with _real_open(filename): pass with self.lock: try: return maybe_decode(magic_file(self.cookie, filename)) except MagicException as e: return self._handle509Bug(e) def from_descriptor(self, fd): with self.lock: try: return maybe_decode(magic_descriptor(self.cookie, fd)) except MagicException as e: return self._handle509Bug(e) def _handle509Bug(self, e): # libmagic 5.09 has a bug where it might fail to identify the # mimetype of a file and returns null from magic_file (and # likely _buffer), but also does not return an error message. if e.message is None and (self.flags & MAGIC_MIME_TYPE): return "application/octet-stream" raise e def setparam(self, param, val): return magic_setparam(self.cookie, param, val) def getparam(self, param): return magic_getparam(self.cookie, param) def __del__(self): # no _thread_check here because there can be no other # references to this object at this point. # during shutdown magic_close may have been cleared already so # make sure it exists before using it. # the self.cookie check should be unnecessary and was an # incorrect fix for a threading problem, however I'm leaving # it in because it's harmless and I'm slightly afraid to # remove it. if self.cookie and magic_close: magic_close(self.cookie) self.cookie = None _instances = {} def _get_magic_type(mime): i = _instances.get(mime) if i is None: i = _instances[mime] = Magic(mime=mime) return i def from_file(filename, mime=False): """ " Accepts a filename and returns the detected filetype. Return value is the mimetype if mime=True, otherwise a human readable name. >>> magic.from_file("testdata/test.pdf", mime=True) 'application/pdf' """ m = _get_magic_type(mime) return m.from_file(filename) def from_buffer(buffer, mime=False): """ Accepts a binary string and returns the detected filetype. Return value is the mimetype if mime=True, otherwise a human readable name. >>> magic.from_buffer(open("testdata/test.pdf").read(1024)) 'PDF document, version 1.2' """ m = _get_magic_type(mime) return m.from_buffer(buffer) def from_descriptor(fd, mime=False): """ Accepts a file descriptor and returns the detected filetype. Return value is the mimetype if mime=True, otherwise a human readable name. >>> f = open("testdata/test.pdf") >>> magic.from_descriptor(f.fileno()) 'PDF document, version 1.2' """ m = _get_magic_type(mime) return m.from_descriptor(fd) libmagic = None # Let's try to find magic or magic1 dll = ( ctypes.util.find_library("magic") or ctypes.util.find_library("magic1") or ctypes.util.find_library("cygmagic-1") or ctypes.util.find_library("libmagic-1") or ctypes.util.find_library("msys-magic-1") ) # for MSYS2 # necessary because find_library returns None if it doesn't find the library if dll: libmagic = ctypes.CDLL(dll) if not libmagic or not libmagic._name: windows_dlls = ["magic1.dll", "cygmagic-1.dll", "libmagic-1.dll", "msys-magic-1.dll"] platform_to_lib = { "darwin": ["/opt/local/lib/libmagic.dylib", "/usr/local/lib/libmagic.dylib"] # Assumes there will only be one version installed + glob.glob("/usr/local/Cellar/libmagic/*/lib/libmagic.dylib"), # flake8:noqa "win32": windows_dlls, "cygwin": windows_dlls, "linux": ["libmagic.so.1"], # fallback for some Linuxes (e.g. Alpine) where library search does not work # flake8:noqa } platform = "linux" if sys.platform.startswith("linux") else sys.platform for dll in platform_to_lib.get(platform, []): try: libmagic = ctypes.CDLL(dll) break except OSError: pass if not libmagic or not libmagic._name: # It is better to raise an ImportError since we are importing magic module raise ImportError("failed to find libmagic. Check your installation") magic_t = ctypes.c_void_p def errorcheck_null(result, func, args): if result is None: err = magic_error(args[0]) raise MagicException(err) return result def errorcheck_negative_one(result, func, args): if result == -1: err = magic_error(args[0]) raise MagicException(err) return result # return str on python3. Don't want to unconditionally # decode because that results in unicode on python2 def maybe_decode(s): if str == bytes: return s # backslashreplace here because sometimes libmagic will return metadata in the charset # of the file, which is unknown to us (e.g the title of a Word doc) return s.decode("utf-8", "backslashreplace") def coerce_filename(filename): if filename is None: return None # ctypes will implicitly convert unicode strings to bytes with # .encode('ascii'). If you use the filesystem encoding # then you'll get inconsistent behavior (crashes) depending on the user's # LANG environment variable if isinstance(filename, str): return filename.encode("utf-8", "surrogateescape") return filename magic_open = libmagic.magic_open magic_open.restype = magic_t magic_open.argtypes = [c_int] magic_close = libmagic.magic_close magic_close.restype = None magic_close.argtypes = [magic_t] magic_error = libmagic.magic_error magic_error.restype = c_char_p magic_error.argtypes = [magic_t] magic_errno = libmagic.magic_errno magic_errno.restype = c_int magic_errno.argtypes = [magic_t] # mypy does not like assigning functions to properties, and thinks # you are supplying a method, which must have the correct type for # its first 'self' argument. We suppress these errors _magic_file = libmagic.magic_file _magic_file.restype = c_char_p _magic_file.argtypes = [magic_t, c_char_p] _magic_file.errcheck = errorcheck_null # type: ignore def magic_file(cookie, filename): return _magic_file(cookie, coerce_filename(filename)) _magic_buffer = libmagic.magic_buffer _magic_buffer.restype = c_char_p _magic_buffer.argtypes = [magic_t, c_void_p, c_size_t] _magic_buffer.errcheck = errorcheck_null # type: ignore def magic_buffer(cookie, buf): return _magic_buffer(cookie, buf, len(buf)) _magic_descriptor = libmagic.magic_descriptor _magic_descriptor.restype = c_char_p _magic_descriptor.argtypes = [magic_t, c_int] _magic_descriptor.errcheck = errorcheck_null # type: ignore def magic_descriptor(cookie, fd): return _magic_descriptor(cookie, fd) _magic_load = libmagic.magic_load _magic_load.restype = c_int _magic_load.argtypes = [magic_t, c_char_p] _magic_load.errcheck = errorcheck_negative_one # type: ignore def magic_load(cookie, filename): return _magic_load(cookie, coerce_filename(filename)) magic_setflags = libmagic.magic_setflags magic_setflags.restype = c_int magic_setflags.argtypes = [magic_t, c_int] magic_check = libmagic.magic_check magic_check.restype = c_int magic_check.argtypes = [magic_t, c_char_p] magic_compile = libmagic.magic_compile magic_compile.restype = c_int magic_compile.argtypes = [magic_t, c_char_p] _has_param = False if hasattr(libmagic, "magic_setparam") and hasattr(libmagic, "magic_getparam"): _has_param = True _magic_setparam = libmagic.magic_setparam _magic_setparam.restype = c_int _magic_setparam.argtypes = [magic_t, c_int, POINTER(c_size_t)] _magic_setparam.errcheck = errorcheck_negative_one # type: ignore _magic_getparam = libmagic.magic_getparam _magic_getparam.restype = c_int _magic_getparam.argtypes = [magic_t, c_int, POINTER(c_size_t)] _magic_getparam.errcheck = errorcheck_negative_one # type: ignore def magic_setparam(cookie, param, val): if not _has_param: raise NotImplementedError("magic_setparam not implemented") v = c_size_t(val) return _magic_setparam(cookie, param, byref(v)) def magic_getparam(cookie, param): if not _has_param: raise NotImplementedError("magic_getparam not implemented") val = c_size_t() _magic_getparam(cookie, param, byref(val)) return val.value _has_version = False if hasattr(libmagic, "magic_version"): _has_version = True magic_version = libmagic.magic_version magic_version.restype = c_int magic_version.argtypes = [] def version(): if not _has_version: raise NotImplementedError("magic_version not implemented") return magic_version() MAGIC_NONE = 0x000000 # No flags MAGIC_DEBUG = 0x000001 # Turn on debugging MAGIC_SYMLINK = 0x000002 # Follow symlinks MAGIC_COMPRESS = 0x000004 # Check inside compressed files MAGIC_DEVICES = 0x000008 # Look at the contents of devices MAGIC_MIME_TYPE = 0x000010 # Return a mime string MAGIC_MIME_ENCODING = 0x000400 # Return the MIME encoding # TODO: should be # MAGIC_MIME = MAGIC_MIME_TYPE | MAGIC_MIME_ENCODING MAGIC_MIME = 0x000010 # Return a mime string MAGIC_EXTENSION = 0x1000000 # Return a /-separated list of extensions MAGIC_CONTINUE = 0x000020 # Return all matches MAGIC_CHECK = 0x000040 # Print warnings to stderr MAGIC_PRESERVE_ATIME = 0x000080 # Restore access time on exit MAGIC_RAW = 0x000100 # Don't translate unprintable chars MAGIC_ERROR = 0x000200 # Handle ENOENT etc as real errors MAGIC_NO_CHECK_COMPRESS = 0x001000 # Don't check for compressed files MAGIC_NO_CHECK_TAR = 0x002000 # Don't check for tar files MAGIC_NO_CHECK_SOFT = 0x004000 # Don't check magic entries MAGIC_NO_CHECK_APPTYPE = 0x008000 # Don't check application type MAGIC_NO_CHECK_ELF = 0x010000 # Don't check for elf details MAGIC_NO_CHECK_ASCII = 0x020000 # Don't check for ascii files MAGIC_NO_CHECK_TROFF = 0x040000 # Don't check ascii/troff MAGIC_NO_CHECK_FORTRAN = 0x080000 # Don't check ascii/fortran MAGIC_NO_CHECK_TOKENS = 0x100000 # Don't check ascii/tokens MAGIC_PARAM_INDIR_MAX = 0 # Recursion limit for indirect magic MAGIC_PARAM_NAME_MAX = 1 # Use count limit for name/use magic MAGIC_PARAM_ELF_PHNUM_MAX = 2 # Max ELF notes processed MAGIC_PARAM_ELF_SHNUM_MAX = 3 # Max ELF program sections processed MAGIC_PARAM_ELF_NOTES_MAX = 4 # # Max ELF sections processed MAGIC_PARAM_REGEX_MAX = 5 # Length limit for regex searches MAGIC_PARAM_BYTES_MAX = 6 # Max number of bytes to read from file lutris-0.5.19/lutris/util/downloader.py0000664000175000017500000002031614756670027017135 0ustar hibbyhibbyimport bisect import os import threading import time from typing import Any import requests from lutris import __version__ from lutris.util import jobs from lutris.util.log import logger # `time.time` can skip ahead or even go backwards if the current # system time is changed between invocations. Use `time.monotonic` # so we won't have screenshots making fun of us for showing negative # download speeds. get_time = time.monotonic class Downloader: """Non-blocking downloader. Do start() then check_progress() at regular intervals. Download is done when check_progress() returns 1.0. Stop with cancel(). """ (INIT, DOWNLOADING, CANCELLED, ERROR, COMPLETED) = list(range(5)) def __init__(self, url: str, dest: str, overwrite: bool = False, referer: str = None, cookies: Any = None) -> None: self.url: str = url self.dest: str = dest self.cookies = cookies self.overwrite: bool = overwrite self.referer = referer self.stop_request = None self.thread = None # Read these after a check_progress() self.state = self.INIT self.error = None self.downloaded_size: int = 0 # Bytes self.full_size: int = 0 # Bytes self.progress_fraction: float = 0 self.progress_percentage: float = 0 self.average_speed = 0 self.time_left: str = "00:00:00" # Based on average speed self.last_size: int = 0 self.last_check_time: float = 0.0 self.last_speeds = [] self.speed_check_time = 0 self.time_left_check_time = 0 self.file_pointer = None self.progress_event = threading.Event() def __repr__(self): return "downloader for %s" % self.url def start(self): """Start download job.""" logger.debug("⬇ %s", self.url) self.state = self.DOWNLOADING self.last_check_time = get_time() if self.overwrite and os.path.isfile(self.dest): os.remove(self.dest) self.file_pointer = open(self.dest, "wb") # pylint: disable=consider-using-with self.thread = jobs.AsyncCall(self.async_download, None) self.stop_request = self.thread.stop_request def reset(self): """Reset the state of the downloader""" self.state = self.INIT self.error = None self.downloaded_size = 0 # Bytes self.full_size = 0 # Bytes self.progress_fraction = 0 self.progress_percentage = 0 self.average_speed = 0 self.time_left = "00:00:00" # Based on average speed self.last_size = 0 self.last_check_time = 0.0 self.last_speeds = [] self.speed_check_time = 0 self.time_left_check_time = 0 self.file_pointer = None def check_progress(self, blocking=False): """Append last downloaded chunk to dest file and store stats. blocking: if true and still downloading, block until some progress is made. :return: progress (between 0.0 and 1.0)""" if blocking and self.state in [self.INIT, self.DOWNLOADING] and self.progress_fraction < 1.0: self.progress_event.wait() self.progress_event.clear() if self.state not in [self.CANCELLED, self.ERROR]: self.get_stats() return self.progress_fraction def join(self, progress_callback=None): """Blocks waiting for the download to complete. 'progress_callback' is invoked repeatedly as the download proceeds, if given, and is passed the downloader itself. Returns True on success, False if cancelled.""" while self.state == self.DOWNLOADING: self.check_progress(blocking=True) if progress_callback: progress_callback(self) if self.error: raise self.error return self.state == self.COMPLETED def cancel(self): """Request download stop and remove destination file.""" logger.debug("❌ %s", self.url) self.state = self.CANCELLED if self.stop_request: self.stop_request.set() if self.file_pointer: self.file_pointer.close() self.file_pointer = None if os.path.isfile(self.dest): os.remove(self.dest) def async_download(self): try: headers = requests.utils.default_headers() headers["User-Agent"] = "Lutris/%s" % __version__ if self.referer: headers["Referer"] = self.referer response = requests.get(self.url, headers=headers, stream=True, timeout=30, cookies=self.cookies) if response.status_code != 200: logger.info("%s returned a %s error", self.url, response.status_code) response.raise_for_status() self.full_size = int(response.headers.get("Content-Length", "").strip() or 0) self.progress_event.set() for chunk in response.iter_content(chunk_size=8192): if not self.file_pointer: break if chunk: self.downloaded_size += len(chunk) self.file_pointer.write(chunk) self.progress_event.set() self.on_download_completed() except Exception as ex: logger.exception("Download failed: %s", ex) self.on_download_failed(ex) def on_download_failed(self, error: Exception): # Cancelling closes the file, which can result in an # error. If so, we just remain cancelled. if self.state != self.CANCELLED: self.state = self.ERROR self.error = error if self.file_pointer: self.file_pointer.close() self.file_pointer = None def on_download_completed(self): if self.state == self.CANCELLED: return logger.debug("Finished downloading %s", self.url) if not self.downloaded_size: logger.warning("Downloaded file is empty") if not self.full_size: self.progress_fraction = 1.0 self.progress_percentage = 100 self.state = self.COMPLETED self.file_pointer.close() self.file_pointer = None def get_stats(self): """Calculate and store download stats.""" self.average_speed = self.get_speed() self.time_left = self.get_average_time_left() self.last_check_time = get_time() self.last_size = self.downloaded_size if self.full_size: self.progress_fraction = float(self.downloaded_size) / float(self.full_size) self.progress_percentage = self.progress_fraction * 100 def get_speed(self): """Return the average speed of the download so far.""" elapsed_time = get_time() - self.last_check_time if elapsed_time > 0: chunk_size = self.downloaded_size - self.last_size speed = chunk_size / elapsed_time or 1 # insert in sorted order, so we can omit the least and # greatest value later bisect.insort(self.last_speeds, speed) # Until we get the first sample, just return our default if not self.last_speeds: return self.average_speed if get_time() - self.speed_check_time < 1: # Minimum delay return self.average_speed while len(self.last_speeds) > 20: self.last_speeds.pop(0) if len(self.last_speeds) > 7: # Skip extreme values samples = self.last_speeds[1:-1] else: samples = self.last_speeds[:] average_speed = sum(samples) / len(samples) self.speed_check_time = get_time() return average_speed def get_average_time_left(self) -> str: """Return average download time left as string.""" if not self.full_size: return "???" elapsed_time = get_time() - self.time_left_check_time if elapsed_time < 1: # Minimum delay return self.time_left average_time_left = (self.full_size - self.downloaded_size) / self.average_speed minutes, seconds = divmod(average_time_left, 60) hours, minutes = divmod(minutes, 60) self.time_left_check_time = get_time() return "%d:%02d:%02d" % (hours, minutes, seconds) lutris-0.5.19/lutris/util/libretro.py0000664000175000017500000000516114756670027016622 0ustar hibbyhibbyimport os from lutris.util.log import logger class RetroConfig: value_map = {"true": True, "false": False, "": None} def __init__(self, config_path): if not config_path: raise ValueError("Config path is mandatory") self.config_path = config_path self._config = [] @property def config(self): """Lazy loading of the RetroArch config""" if self._config: return self._config try: self.load_config() return self._config except UnicodeDecodeError: logger.error( "The Retroarch config in %s could not " "be read because of character encoding issues", self.config_path ) return [] def load_config(self): """Load the configuration from file""" self._config = [] if not os.path.isfile(self.config_path): raise OSError("Specified config file {} does not exist".format(self.config_path)) with open(self.config_path, "r", encoding="utf-8") as config_file: for line in config_file.readlines(): if not line: continue line = line.strip() if line == "" or line.startswith("#"): continue if "=" in line: key, value = line.split("=", 1) key = key.strip() value = value.strip().strip('"') if not key or not value: continue self._config.append((key, value)) def save(self): with open(self.config_path, "w", encoding="utf-8") as config_file: for key, value in self.config: config_file.write('{} = "{}"\n'.format(key, value)) def serialize_value(self, value): for k, v in self.value_map.items(): if value is v: return k return value def deserialize_value(self, value): for k, v in self.value_map.items(): if value == k: return v return value def __getitem__(self, key): for k, value in self.config: if key == k: return self.deserialize_value(value) def __setitem__(self, key, value): for index, conf in enumerate(self.config): if key == conf[0]: # self.config is read-only self._config[index] = (key, self.serialize_value(value)) return self._config.append((key, self.serialize_value(value))) def keys(self): return [key for (key, _value) in self.config] lutris-0.5.19/lutris/util/graphics/0000775000175000017500000000000014756670027016223 5ustar hibbyhibbylutris-0.5.19/lutris/util/graphics/__init__.py0000664000175000017500000000000014756670027020322 0ustar hibbyhibbylutris-0.5.19/lutris/util/graphics/xrandr.py0000664000175000017500000001551514756670027020102 0ustar hibbyhibby"""XrandR based display management""" import re import subprocess from collections import namedtuple from lutris.settings import DEFAULT_RESOLUTION_HEIGHT, DEFAULT_RESOLUTION_WIDTH from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.system import read_process_output Output = namedtuple("Output", ("name", "mode", "position", "rotation", "primary", "rate")) def _get_vidmodes(): """Return video modes from XrandR""" xrandr_output = read_process_output([LINUX_SYSTEM.get("xrandr")]).split("\n") logger.debug("Retrieving %s video modes from XrandR", len(xrandr_output)) return xrandr_output def get_outputs(): # pylint: disable=too-many-locals """Return list of namedtuples containing output 'name', 'geometry', 'rotation' and whether it is the 'primary' display.""" outputs = [] logger.debug("Retrieving display outputs") vid_modes = _get_vidmodes() position = None rotate = None primary = None name = None if not vid_modes: logger.error("xrandr didn't return anything") return [] for line in vid_modes: fields = line.split() if "connected" in fields[1:] and len(fields) >= 4: try: connected_index = fields.index("connected", 1) name_fields = fields[:connected_index] name = " ".join(name_fields) data_fields = fields[connected_index + 1 :] if data_fields[0] == "primary": data_fields = data_fields[1:] geometry, rotate, *_ = data_fields if geometry.startswith("("): # Screen turned off, no geometry continue if rotate.startswith("("): # Screen not rotated, no need to include rotate = "normal" _, x_pos, y_pos = geometry.split("+") position = "{x_pos}x{y_pos}".format(x_pos=x_pos, y_pos=y_pos) except ValueError as ex: logger.error( "Unhandled xrandr line %s, error: %s. " "Please send your xrandr output to the dev team", line, ex ) continue elif "*" in line: mode, *framerates = fields for number in framerates: if "*" in number: hertz = number[:-2] outputs.append( Output( name=name, mode=mode, position=position, rotation=rotate, primary=primary, rate=hertz, ) ) break return outputs def turn_off_except(display): """Use XrandR to turn off displays except the one referenced by `display`""" if not display: logger.error("No active display given, no turning off every display") return for output in get_outputs(): if output.name != display: logger.info("Turning off %s", output[0]) with subprocess.Popen([LINUX_SYSTEM.get("xrandr"), "--output", output.name, "--off"]) as xrandr: xrandr.communicate() def get_resolutions(): """Return the list of supported screen resolutions.""" resolution_list = [] logger.debug("Retrieving resolution list") for line in _get_vidmodes(): if line.startswith(" "): resolution_match = re.match(r".*?(\d+x\d+).*", line) if resolution_match: resolution_list.append(resolution_match.groups()[0]) if not resolution_list: logger.error("Unable to generate resolution list from xrandr output") return ["%sx%s" % (DEFAULT_RESOLUTION_WIDTH, DEFAULT_RESOLUTION_HEIGHT)] return sorted(set(resolution_list), key=lambda x: int(x.split("x")[0]), reverse=True) def change_resolution(resolution): """Change display resolution. Takes a string for single monitors or a list of displays as returned by get_outputs(). """ if not resolution: logger.warning("No resolution provided") return if isinstance(resolution, str): logger.debug("Switching resolution to %s", resolution) if resolution not in get_resolutions(): logger.warning("Resolution %s doesn't exist.", resolution) else: output_name = get_outputs()[0].name logger.info("Changing resolution on %s to %s", output_name, resolution) args = [LINUX_SYSTEM.get("xrandr"), "--output", output_name, "--mode", resolution] with subprocess.Popen(args) as xrandr: xrandr.communicate() else: for display in resolution: logger.debug("Switching to %s on %s", display.mode, display.name) if display.rotation is not None and display.rotation in ( "normal", "left", "right", "inverted", ): rotation = display.rotation else: rotation = "normal" logger.info("Switching resolution of %s to %s", display.name, display.mode) with subprocess.Popen( [ LINUX_SYSTEM.get("xrandr"), "--output", display.name, "--mode", display.mode, "--pos", display.position, "--rotate", rotation, "--rate", display.rate, ] ) as xrandr: xrandr.communicate() class LegacyDisplayManager: # pylint: disable=too-few-public-methods """Legacy XrandR based display manager. Does not work on Wayland. """ @staticmethod def get_display_names(): """Return output names from XrandR""" return [output.name for output in get_outputs()] @staticmethod def get_resolutions(): """Return available resolutions""" return get_resolutions() @staticmethod def get_current_resolution(): """Return the current resolution for the desktop""" for line in _get_vidmodes(): if line.startswith(" ") and "*" in line: resolution_match = re.match(r".*?(\d+x\d+).*", line) if resolution_match: return resolution_match.groups()[0].split("x") logger.error("Unable to find the current resolution from xrandr output") return str(DEFAULT_RESOLUTION_WIDTH), str(DEFAULT_RESOLUTION_HEIGHT) @staticmethod def set_resolution(resolution): """Change the current resolution""" change_resolution(resolution) @staticmethod def get_config(): """Return the current display configuration""" return get_outputs() lutris-0.5.19/lutris/util/graphics/drivers.py0000664000175000017500000001732414756670027020262 0ustar hibbyhibby# pylint: disable=no-member """Hardware driver related utilities Everything in this module should rely on /proc or /sys only, no executable calls """ import os import re from typing import Dict, Iterable, List from lutris.util import cache_single from lutris.util.graphics.glxinfo import GlxInfo from lutris.util.log import logger from lutris.util.system import read_process_output MIN_RECOMMENDED_NVIDIA_DRIVER = 515 @cache_single def get_nvidia_driver_info() -> Dict[str, Dict[str, str]]: """Return information about Nvidia drivers""" version_file_path = "/proc/driver/nvidia/version" def read_from_proc() -> Dict[str, Dict[str, str]]: try: if not os.path.exists(version_file_path): return {} with open(version_file_path, encoding="utf-8") as version_file: content = version_file.readlines() except PermissionError: # MAC systems (selinux, apparmor) may block access to files in /proc. # If this happens, we may still be able to retrieve the info by # other means, but need additional validation. logger.info("Could not access %s. Falling back to glxinfo.", version_file_path) return {} except OSError as e: logger.warning( "Unexpected error when accessing %s. Falling back to glxinfo.", version_file_path, exc_info=e, ) return {} try: nvrm_version = content[0].split(": ")[1].strip().split() if "Open" in nvrm_version: return { "vendor": nvrm_version[0], "platform": nvrm_version[1], "arch": nvrm_version[6], "version": nvrm_version[7], } return { "vendor": nvrm_version[0], "platform": nvrm_version[1], "arch": nvrm_version[2], "version": nvrm_version[5], "date": " ".join(nvrm_version[6:]), } except IndexError as ex: logger.warning("Unable to parse %s. Falling back to glxinfo: %s", version_file_path, ex) return {} def invoke_glxinfo() -> Dict[str, Dict[str, str]]: glx_info = GlxInfo() platform = read_process_output(["uname", "-s"]) arch = read_process_output(["uname", "-m"]) vendor = glx_info.opengl_vendor # type: ignore[attr-defined] if "nvidia" not in vendor.lower(): raise RuntimeError("Expected NVIDIA vendor information, received %s." % vendor) return { "vendor": vendor, "platform": platform, "arch": arch, "version": glx_info.opengl_version.rsplit(maxsplit=1)[-1], # type: ignore[attr-defined] } try: from_proc = read_from_proc() if from_proc: return from_proc except Exception as ex: logger.exception("Unable to read from '%s': %s", version_file_path, ex) return invoke_glxinfo() def get_nvidia_gpu_ids() -> List[str]: """Return the list of Nvidia GPUs""" gpus_dir = "/proc/driver/nvidia/gpus" try: return os.listdir(gpus_dir) except PermissionError: logger.info("Permission denied to %s. Using lspci instead.", gpus_dir) except OSError as e: logger.warning("Unexpected error accessing %s. Using lspci instead.", gpus_dir, exc_info=e) # 10de is NVIDIA's vendor ID, 0300 gets you video controllers. values = read_process_output(["lspci", "-D", "-n", "-d", "10de::0300"]).splitlines() return [line.split(maxsplit=1)[0] for line in values] def get_nvidia_gpu_info(gpu_id: str) -> Dict[str, str]: """Return details about a GPU""" gpu_info_file = f"/proc/driver/nvidia/gpus/{gpu_id}/information" try: with open(gpu_info_file, encoding="utf-8") as info_file: content = info_file.readlines() except PermissionError: logger.info("Permission denied to %s. Detecting with lspci.", gpu_info_file) except OSError as e: logger.warning( "Unexpected error accessing %s. Detecting with lspci", gpu_info_file, exc_info=e, ) else: info = {} for line in content: key, value = line.split(":", 1) info[key] = value.strip() return info return get_lspci_nvidia_gpu_info(gpu_id) def get_lspci_nvidia_gpu_info(gpu_id: str) -> Dict[str, str]: lspci_data = read_process_output(["lspci", "-v", "-s", gpu_id]) model_info = re.search(r"NVIDIA Corporation \w+ \[(.+?)\]", lspci_data) if model_info: model = model_info.group(1) else: logger.error("Could not detect NVIDIA GPU model.") model = "Unknown" irq_info = re.search("IRQ ([0-9]+)", lspci_data) if irq_info: irq = irq_info.group(1) else: logger.error("Could not detect GPU IRQ information.") irq = None info = { "Model": f"NVIDIA {model}", "IRQ": str(irq), "Bus Location": str(gpu_id), } for line in lspci_data.splitlines(): if ":" not in line: continue key, value = line.split(":", 1) info[key.strip()] = value.strip() return info def is_nvidia() -> bool: """Return true if the Nvidia drivers are currently in use. Note: This function may not detect use of the nouveau drivers. """ try: return os.path.exists("/proc/driver/nvidia") except OSError: logger.info("Could not determine whether /proc/driver/nvidia exists. " "Falling back to alternative method") try: with open("/proc/modules", encoding="utf-8") as f: modules = f.read() return bool(re.search(r"^nvidia ", modules, flags=re.MULTILINE)) except OSError: logger.error("Could not access /proc/modules to find the Nvidia drivers. " "Nvidia card may not be detected.") glx_info = GlxInfo() return "NVIDIA" in glx_info.opengl_vendor # type: ignore[attr-defined] def get_gpu_cards() -> Iterable[str]: """Return GPUs connected to the system""" if not os.path.exists("/sys/class/drm"): logger.error("No GPU available on this system!") return try: cardlist = os.listdir("/sys/class/drm/") except PermissionError: logger.error("Your system does not allow reading from /sys/class/drm, no GPU detected.") return for cardname in cardlist: if re.match(r"^card\d$", cardname): yield cardname def get_gpu_info(card: str) -> Dict[str, str]: """Return information about a GPU""" infos = {"DRIVER": "", "PCI_ID": "", "PCI_SUBSYS_ID": ""} try: with open(f"/sys/class/drm/{card}/device/uevent", encoding="utf-8") as card_uevent: content = card_uevent.readlines() except FileNotFoundError: logger.error("Unable to read driver information for card %s", card) return infos for line in content: key, value = line.split("=", 1) infos[key] = value.strip() return infos def is_amd() -> bool: """Return true if the system uses the AMD driver""" for card in get_gpu_cards(): if get_gpu_info(card)["DRIVER"] == "amdgpu": return True return False def is_outdated() -> bool: if not is_nvidia(): return False driver_info = get_nvidia_driver_info() driver_version = driver_info["version"] if not driver_version: logger.error("Failed to get Nvidia version") return True try: major_version = int(driver_version.split(".")[0]) except (IndexError, ValueError) as ex: logger.exception("Failed to parse Nvidia version: %s", ex) return True return major_version < MIN_RECOMMENDED_NVIDIA_DRIVER lutris-0.5.19/lutris/util/graphics/glxinfo.py0000664000175000017500000000430714756670027020247 0ustar hibbyhibby"""Parser for the glxinfo utility""" from lutris.util.log import logger from lutris.util.system import read_process_output class Container: # pylint: disable=too-few-public-methods """A dummy container for data""" class GlxInfo: """Give access to the glxinfo information""" def __init__(self, output=None): """Creates a new GlxInfo object Params: output (str): If provided, use this as the glxinfo output instead of running the program, useful for testing. """ self._output = output or self.get_glxinfo_output() self._section = None self._attrs = set() # Keep a reference of the created attributes self.parse() @staticmethod def get_glxinfo_output(): """Return the glxinfo -B output""" return read_process_output(["glxinfo", "-B"]) def as_dict(self): """Return the attributes as a dict""" return {attr: getattr(self, attr) for attr in self._attrs} def parse(self): """Converts the glxinfo output to class attributes""" if not self._output: logger.error("No available glxinfo output") return # Fix glxinfo output (Great, you saved one line by # combining display and screen) output = self._output.replace(" screen", "\nscreen") for line in output.split("\n"): # Skip blank lines, and error lines that may contain no ':' if ":" not in line: continue key, value = line.split(":", 1) key = key.replace(" string", "").replace(" ", "_") value = value.strip() if not value and key.startswith(("Extended_renderer_info", "Memory_info")): self._section = key[key.index("(") + 1 : -1] setattr(self, self._section, Container()) continue if self._section: if not key.startswith("____"): self._section = None else: setattr(getattr(self, self._section), key.strip("_").lower(), value) continue self._attrs.add(key.lower()) setattr(self, key.lower(), value) lutris-0.5.19/lutris/util/graphics/xephyr.py0000664000175000017500000000106314756670027020114 0ustar hibbyhibby"""Xephyr utilities""" def get_xephyr_command(display, config): """Return a configured Xephyr command""" xephyr_depth = "8" if config.get("xephyr") == "8bpp" else "16" xephyr_resolution = config.get("xephyr_resolution") or "640x480" xephyr_command = [ "Xephyr", display, "-ac", "-screen", xephyr_resolution + "x" + xephyr_depth, "-glamor", "-reset", "-terminate", ] if config.get("xephyr_fullscreen"): xephyr_command.append("-fullscreen") return xephyr_command lutris-0.5.19/lutris/util/graphics/gpu.py0000664000175000017500000001620014756670027017367 0ustar hibbyhibbyimport glob import os import re import shutil import subprocess from typing import Dict, Optional from lutris.util import system from lutris.util.graphics import drivers from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger VULKANINFO_AVAILABLE = shutil.which("vulkaninfo") VULKAN_DATA_DIRS = [ "/usr/local/etc", # standard site-local location "/usr/local/share", # standard site-local location "/etc", # standard location "/usr/share", # standard location "/usr/lib/x86_64-linux-gnu/GL", # Flatpak GL extension "/usr/lib/i386-linux-gnu/GL", # Flatpak GL32 extension "/opt/amdgpu-pro/etc", # AMD GPU Pro - TkG ] GPUS = {} def get_gpus_info(): """Return the information related to each GPU on the system""" return {card: drivers.get_gpu_info(card) for card in drivers.get_gpu_cards()} def display_gpu_info(gpu_id, gpu_info): """Log GPU information""" try: gpu_string = f"GPU: {gpu_info['PCI_ID']} {gpu_info['PCI_SUBSYS_ID']} ({gpu_info['DRIVER']} drivers)" logger.info(gpu_string) except KeyError: logger.error("Unable to get GPU information from '%s'", gpu_id) def add_icd_search_path(paths): icd_paths = [] if paths: # unixy env vars with multiple paths are : delimited for path in paths.split(":"): path = os.path.join(path, "vulkan") if os.path.exists(path) and path not in icd_paths: icd_paths.append(path) return icd_paths def get_vk_icd_files(): """Returns available vulkan ICD files in the same search order as vulkan-loader, but in a single list""" icd_search_paths = [] for path in VULKAN_DATA_DIRS: icd_search_paths += add_icd_search_path(path) all_icd_files = [] for data_dir in icd_search_paths: path = os.path.join(data_dir, "icd.d", "*.json") # sort here as directory enumeration order is not guaranteed in linux # so it's consistent every time icd_files = sorted(glob.glob(path)) if icd_files: all_icd_files += icd_files return all_icd_files class GPU: def __init__(self, card): self.card = card self.gpu_info = self.get_gpu_info() self.driver = self.gpu_info["DRIVER"] self.pci_id = self.gpu_info["PCI_ID"].lower() self.pci_subsys_id = self.gpu_info["PCI_SUBSYS_ID"].lower() self.pci_slot = self.gpu_info["PCI_SLOT_NAME"] self.icd_files = self.get_icd_files() if VULKANINFO_AVAILABLE: try: self.name = self.get_vulkaninfo_name() or self.get_lspci_name() except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): # already logged this, so we'll just fall back to lspci. self.name = self.get_lspci_name() else: self.name = self.get_lspci_name() def __str__(self): if self.pci_id: return f"{self.short_name} ({self.pci_id} {self.pci_subsys_id} {self.driver})" return f"{self.short_name} ({self.driver})" def get_driver_info(self): driver_info = {} if self.driver == "nvidia": driver_info = drivers.get_nvidia_driver_info() elif LINUX_SYSTEM.glxinfo: if hasattr(LINUX_SYSTEM.glxinfo, "GLX_MESA_query_renderer"): driver_info = { "vendor": LINUX_SYSTEM.glxinfo.opengl_vendor, "version": LINUX_SYSTEM.glxinfo.GLX_MESA_query_renderer.version, "device": LINUX_SYSTEM.glxinfo.GLX_MESA_query_renderer.device, } return driver_info def get_gpu_info(self) -> Dict[str, str]: """Return information about a GPU""" infos = {"DRIVER": "", "PCI_ID": "", "PCI_SUBSYS_ID": "", "PCI_SLOT_NAME": ""} try: with open(f"/sys/class/drm/{self.card}/device/uevent", encoding="utf-8") as card_uevent: content = card_uevent.readlines() except FileNotFoundError: logger.error("Unable to read driver information for card %s", self.card) raise for line in content: key, value = line.split("=", 1) infos[key] = value.strip() return infos def get_vulkaninfo(self) -> Dict[str, Dict[str, str]]: """Runs vulkaninfo to find the GPU name""" subprocess_env = dict(os.environ) subprocess_env["VK_DRIVER_FILES"] = self.icd_files # Currently supported subprocess_env["VK_ICD_FILENAMES"] = self.icd_files # Deprecated vulkaninfo_output = system.read_process_output( ["vulkaninfo", "--summary"], env=subprocess_env, error_result=None ).split("\n") result = {} devices_seen = False for line in vulkaninfo_output: line = line.strip() if not line or line.startswith("==="): continue if line == "Devices:": devices_seen = True continue if not devices_seen: continue if line.startswith("GPU"): current_gpu = line result[current_gpu] = {} else: key, value = line.split("= ", maxsplit=1) result[current_gpu][key.strip()] = value.strip() if "Failed to detect any valid GPUs" in result or "ERROR: [Loader Message]" in result: logger.warning("Vulkan failed to detect any GPUs: %s", result) return {} return result def get_vulkaninfo_name(self) -> Optional[str]: vulkaninfo = self.get_vulkaninfo() for gpu_index in vulkaninfo: pci_id = "%s:%s" % ( vulkaninfo[gpu_index]["vendorID"].replace("0x", ""), vulkaninfo[gpu_index]["deviceID"].replace("0x", ""), ) if pci_id == self.pci_id: return vulkaninfo[gpu_index]["deviceName"] return None def get_lspci_name(self): lspci_results = [line.split(maxsplit=1) for line in system.execute(["lspci"], timeout=3).split("\n")] lspci_results = [parts for parts in lspci_results if len(parts) == 2 and ": " in parts[1]] devices = [(pci_id, device_desc.split(": ")[1]) for pci_id, device_desc in lspci_results] for device in devices: if f"0000:{device[0]}" == self.pci_slot: return device[1] return "No GPU" def get_icd_files(self): loader = self.driver loader_map = { "amdgpu": "radeon", "vc4-drm": "broadcom", "v3d": "broadcom", "virtio-pci": "lvp", "i915": "intel", } if self.driver in loader_map: loader = loader_map[self.driver] icd_files = [] for icd_file in get_vk_icd_files(): if loader in icd_file: icd_files.append(icd_file) return ":".join(icd_files) @property def short_name(self): """Shorten result to just the friendly name of the GPU vulkaninfo returns Vendor Friendly Name (Chip Developer Name) AMD Radeon Pro W6800 (RADV NAVI21) -> AMD Radeon Pro W6800""" return re.sub(r"\s*\(.*?\)", "", self.name) lutris-0.5.19/lutris/util/graphics/vkquery.py0000664000175000017500000003452214756670027020311 0ustar hibbyhibby# pylint: disable=wildcard-import, unused-wildcard-import, invalid-name # Shit python (?) module that will likely crash the Lutris client. # We should not be doing that we live a life of shame knowing we are shipping this code # to you. We are terribly sorry and know we should do better but right now, you get what you get. # If you want a life without suffering, you can run Lutris with LUTRIS_NO_VKQUERY=1 # Vulkan detection by Patryk Obara (@dreamer) """Query Vulkan capabilities""" from collections import namedtuple # Standard Library from ctypes import ( CDLL, POINTER, Structure, byref, c_char, c_char_p, c_float, c_int32, c_size_t, c_uint8, c_uint32, c_uint64, c_void_p, pointer, ) from lutris.util import cache_single VkResult = c_int32 # enum (size == 4) VK_SUCCESS = 0 VK_ERROR_INITIALIZATION_FAILED = -3 VkStructureType = c_int32 # enum (size == 4) VkBool32 = c_uint32 VK_STRUCTURE_TYPE_APPLICATION_INFO = 0 VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO = 1 VK_MAX_PHYSICAL_DEVICE_NAME_SIZE = 256 VK_UUID_SIZE = 16 VkInstanceCreateFlags = c_int32 # enum (size == 4) VkPhysicalDeviceType = c_int32 # enum (size == 4) VK_PHYSICAL_DEVICE_TYPE_CPU = 4 VkSampleCountFlags = c_int32 # enum (size == 4) VkInstance = c_void_p # handle (struct ptr) VkPhysicalDevice = c_void_p # handle (struct ptr) VkDeviceSize = c_uint64 DeviceInfo = namedtuple("DeviceInfo", "name api_version") def vk_make_version(major, minor, patch): """ VK_MAKE_VERSION macro logic for Python https://www.khronos.org/registry/vulkan/specs/1.1-extensions/html/vkspec.html#fundamentals-versionnum """ return (major << 22) | (minor << 12) | patch def vk_api_version_major(version): return (version >> 22) & 0x7F def vk_api_version_minor(version): return (version >> 12) & 0x3FF def vk_api_version_patch(version): return version & 0xFFF class VkApplicationInfo(Structure): """Python shim for struct VkApplicationInfo https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkApplicationInfo.html """ # pylint: disable=too-few-public-methods _fields_ = [ ("sType", VkStructureType), ("pNext", c_void_p), ("pApplicationName", c_char_p), ("applicationVersion", c_uint32), ("pEngineName", c_char_p), ("engineVersion", c_uint32), ("apiVersion", c_uint32), ] def __init__(self, name, version): super().__init__() self.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO self.pApplicationName = name.encode() self.applicationVersion = c_uint32(vk_make_version(*version)) self.apiVersion = c_uint32(vk_make_version(1, 0, 0)) class VkInstanceCreateInfo(Structure): """Python shim for struct VkInstanceCreateInfo https://www.khronos.org/registry/vulkan/specs/1.1-extensions/man/html/VkInstanceCreateInfo.html """ # pylint: disable=too-few-public-methods _fields_ = [ ("sType", VkStructureType), ("pNext", c_void_p), ("flags", VkInstanceCreateFlags), ("pApplicationInfo", POINTER(VkApplicationInfo)), ("enabledLayerCount", c_uint32), ("ppEnabledLayerNames", c_char_p), ("enabledExtensionCount", c_uint32), ("ppEnabledExtensionNames", c_char_p), ] def __init__(self, app_info): super().__init__() self.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO self.pApplicationInfo = pointer(app_info) class VkPhysicalDeviceLimits(Structure): _fields_ = [ ("maxImageDimension1D", c_uint32), ("maxImageDimension2D", c_uint32), ("maxImageDimension3D", c_uint32), ("maxImageDimensionCube", c_uint32), ("maxImageArrayLayers", c_uint32), ("maxTexelBufferElements", c_uint32), ("maxUniformBufferRange", c_uint32), ("maxStorageBufferRange", c_uint32), ("maxPushConstantsSize", c_uint32), ("maxMemoryAllocationCount", c_uint32), ("maxSamplerAllocationCount", c_uint32), ("bufferImageGranularity", VkDeviceSize), ("sparseAddressSpaceSize", VkDeviceSize), ("maxBoundDescriptorSets", c_uint32), ("maxPerStageDescriptorSamplers", c_uint32), ("maxPerStageDescriptorUniformBuffers", c_uint32), ("maxPerStageDescriptorStorageBuffers", c_uint32), ("maxPerStageDescriptorSampledImages", c_uint32), ("maxPerStageDescriptorStorageImages", c_uint32), ("maxPerStageDescriptorInputAttachments", c_uint32), ("maxPerStageResources", c_uint32), ("maxDescriptorSetSamplers", c_uint32), ("maxDescriptorSetUniformBuffers", c_uint32), ("maxDescriptorSetUniformBuffersDynamic", c_uint32), ("maxDescriptorSetStorageBuffers", c_uint32), ("maxDescriptorSetStorageBuffersDynamic", c_uint32), ("maxDescriptorSetSampledImages", c_uint32), ("maxDescriptorSetStorageImages", c_uint32), ("maxDescriptorSetInputAttachments", c_uint32), ("maxVertexInputAttributes", c_uint32), ("maxVertexInputBindings", c_uint32), ("maxVertexInputAttributeOffset", c_uint32), ("maxVertexInputBindingStride", c_uint32), ("maxVertexOutputComponents", c_uint32), ("maxTessellationGenerationLevel", c_uint32), ("maxTessellationPatchSize", c_uint32), ("maxTessellationControlPerVertexInputComponents", c_uint32), ("maxTessellationControlPerVertexOutputComponents", c_uint32), ("maxTessellationControlPerPatchOutputComponents", c_uint32), ("maxTessellationControlTotalOutputComponents", c_uint32), ("maxTessellationEvaluationInputComponents", c_uint32), ("maxTessellationEvaluationOutputComponents", c_uint32), ("maxGeometryShaderInvocations", c_uint32), ("maxGeometryInputComponents", c_uint32), ("maxGeometryOutputComponents", c_uint32), ("maxGeometryOutputVertices", c_uint32), ("maxGeometryTotalOutputComponents", c_uint32), ("maxFragmentInputComponents", c_uint32), ("maxFragmentOutputAttachments", c_uint32), ("maxFragmentDualSrcAttachments", c_uint32), ("maxFragmentCombinedOutputResources", c_uint32), ("maxComputeSharedMemorySize", c_uint32), ("maxComputeWorkGroupCount", c_uint32 * 3), ("maxComputeWorkGroupInvocations", c_uint32), ("maxComputeWorkGroupSize", c_uint32 * 3), ("subPixelPrecisionBits", c_uint32), ("subTexelPrecisionBits", c_uint32), ("mipmapPrecisionBits", c_uint32), ("maxDrawIndexedIndexValue", c_uint32), ("maxDrawIndirectCount", c_uint32), ("maxSamplerLodBias", c_float), ("maxSamplerAnisotropy", c_float), ("maxViewports", c_uint32), ("maxViewportDimensions", c_uint32 * 2), ("viewportBoundsRange", c_float * 2), ("viewportSubPixelBits", c_uint32), ("minMemoryMapAlignment", c_size_t), ("minTexelBufferOffsetAlignment", VkDeviceSize), ("minUniformBufferOffsetAlignment", VkDeviceSize), ("minStorageBufferOffsetAlignment", VkDeviceSize), ("minTexelOffset", c_int32), ("maxTexelOffset", c_uint32), ("minTexelGatherOffset", c_int32), ("maxTexelGatherOffset", c_uint32), ("minInterpolationOffset", c_float), ("maxInterpolationOffset", c_float), ("subPixelInterpolationOffsetBits", c_uint32), ("maxFrameBufferWidth", c_uint32), ("maxFrameBufferHeight", c_uint32), ("maxFrameBufferLayers", c_uint32), ("frameBufferColorSampleCounts", VkSampleCountFlags), ("frameBufferDepthSampleCounts", VkSampleCountFlags), ("frameBufferStencilSampleCounts", VkSampleCountFlags), ("frameBufferNoAttachmentsSampleCounts", VkSampleCountFlags), ("maxColorAttachments", c_uint32), ("sampledImageColorSampleCounts", VkSampleCountFlags), ("sampledImageIntegerSampleCounts", VkSampleCountFlags), ("sampledImageDepthSampleCounts", VkSampleCountFlags), ("sampledImageStencilSampleCounts", VkSampleCountFlags), ("storageImageSampleCounts", VkSampleCountFlags), ("maxSampleMaskWords", c_uint32), ("timestampComputeAndGraphics", VkBool32), ("timestampPeriod", c_float), ("maxClipDistances", c_uint32), ("maxCullDistances", c_uint32), ("maxCombinedClipAndCullDistances", c_uint32), ("discreteQueuePriorities", c_uint32), ("pointSizeRange", c_float * 2), ("lineWidthRange", c_float * 2), ("pointSizeGranularity", c_float), ("lineWidthGranularity", c_float), ("strictLines", VkBool32), ("standardSampleLocations", VkBool32), ("optimalBufferCopyOffsetAlignment", VkDeviceSize), ("optimalBufferCopyRowPitchAlignment", VkDeviceSize), ("nonCoherentAtomSize", VkDeviceSize), ] class VkPhysicalDeviceSparseProperties(Structure): _fields_ = [ ("residencyStandard2DBlockShape", VkBool32), ("residencyStandard2DMultisampleBlockShape", VkBool32), ("residencyStandard3DBlockShape", VkBool32), ("residencyAlignedMipSize", VkBool32), ("residencyNonResidentStrict", VkBool32), ] class VkPhysicalDeviceProperties(Structure): _fields_ = [ ("apiVersion", c_uint32), ("driverVersion", c_uint32), ("vendorID", c_uint32), ("deviceID", c_uint32), ("deviceType", VkPhysicalDeviceType), ("deviceName", c_char * VK_MAX_PHYSICAL_DEVICE_NAME_SIZE), ("pipelineCacheUUID", c_uint8 * VK_UUID_SIZE), ("limits", VkPhysicalDeviceLimits), ("sparseProperties", VkPhysicalDeviceSparseProperties), ] @cache_single def is_vulkan_supported() -> bool: """ Returns True iff vulkan library can be loaded, initialized, and reports at least one physical device available. """ try: vulkan = _get_vulkan() except OSError: return False instance = _get_vk_instance() if not instance: return False dev_count = c_uint32(0) result = vulkan.vkEnumeratePhysicalDevices(instance, byref(dev_count), None) return result == VK_SUCCESS and dev_count.value > 0 @cache_single def get_vulkan_api_version(): """ Queries libvulkan to get the API version; if this library is missing it returns None. Returns an encoded Vulkan version integer; use vk_api_version_major() and like methods to parse it. """ try: vulkan = _get_vulkan() except OSError: return None try: enumerate_instance_version = vulkan.vkEnumerateInstanceVersion except AttributeError: # Vulkan 1.0 did not have vkEnumerateInstanceVersion at all! return vk_make_version(1, 0, 0) version = c_uint32(0) result = enumerate_instance_version(byref(version)) return version.value if result == VK_SUCCESS else None def get_device_info(): """ Returns a list of the physical devices known to Vulkan, represented as (name, api_version) named-tuples and the api_version numbers are encoded, so use vk_api_version_major() and friends to parse them. They are sorted so the highest version device is first, and software rendering devices are omitted. """ try: vulkan = _get_vulkan() except OSError: return [] instance = _get_vk_instance() if not instance: return [] dev_count = c_uint32(0) result = vulkan.vkEnumeratePhysicalDevices(instance, byref(dev_count), None) if result != VK_SUCCESS or dev_count.value <= 0: return [] devices = (VkPhysicalDevice * dev_count.value)() result = vulkan.vkEnumeratePhysicalDevices(instance, byref(dev_count), devices) if result != VK_SUCCESS: return [] device_info = [] for physical_device in devices: dev_props = VkPhysicalDeviceProperties() vulkan.vkGetPhysicalDeviceProperties(physical_device, byref(dev_props)) if dev_props.deviceType != VK_PHYSICAL_DEVICE_TYPE_CPU: name = dev_props.deviceName.decode("utf-8") device_info.append(DeviceInfo(name, dev_props.apiVersion)) return sorted(device_info, key=lambda t: t.api_version, reverse=True) @cache_single def get_expected_api_version(): """Returns the version tuple of the API version we expect to have; it is the least of the Vulkan library API version, and the best device's API version.""" api_version = get_vulkan_api_version() if not api_version: return None devices = get_device_info() if devices: return min(api_version, devices[0].api_version) return api_version def format_version(version): if version: major = vk_api_version_major(version) minor = vk_api_version_minor(version) patch = vk_api_version_patch(version) return "%s.%s.%s" % (major, minor, patch) return "(none)" @cache_single def _get_vk_instance(): """Returns our VKInstance, or None if it can't be obtained. We've had user see crashes when destroying this, so we don't- we allocate it on demand and keep it for the lifetime of Lutris.""" try: vulkan = _get_vulkan() except OSError: return [] app_info = VkApplicationInfo("vkinfo", version=(0, 1, 0)) create_info = VkInstanceCreateInfo(app_info) instance = VkInstance() result = vulkan.vkCreateInstance(byref(create_info), 0, byref(instance)) if result != VK_SUCCESS: return None return instance def _get_vulkan(): vulkan = CDLL("libvulkan.so.1") # Provide function signatures; this is required on platforms where var-args are # passed in some other way than normal args, and is safer in general. vkCreateInstance = vulkan.vkCreateInstance vkCreateInstance.restype = VkResult vkCreateInstance.argtypes = [POINTER(VkInstanceCreateInfo), c_void_p, POINTER(VkInstance)] vkEnumeratePhysicalDevices = vulkan.vkEnumeratePhysicalDevices vkEnumeratePhysicalDevices.restype = VkResult vkEnumeratePhysicalDevices.argtypes = [VkInstance, POINTER(c_uint32), POINTER(VkPhysicalDevice)] vkGetPhysicalDeviceProperties = vulkan.vkGetPhysicalDeviceProperties vkGetPhysicalDeviceProperties.restype = None vkGetPhysicalDeviceProperties.argtypes = [VkPhysicalDevice, POINTER(VkPhysicalDeviceProperties)] vkEnumerateInstanceVersion = vulkan.vkEnumerateInstanceVersion vkEnumerateInstanceVersion.restype = VkResult vkEnumerateInstanceVersion.argtypes = [POINTER(c_uint32)] return vulkan lutris-0.5.19/lutris/util/graphics/displayconfig.py0000664000175000017500000005534114756670027021440 0ustar hibbyhibby"""DBus backed display management for Mutter""" from collections import namedtuple import dbus from lutris.settings import DEFAULT_RESOLUTION_HEIGHT, DEFAULT_RESOLUTION_WIDTH from lutris.util.log import logger DisplayConfig = namedtuple("DisplayConfig", ("monitors", "name", "position", "transform", "primary", "scale")) class Output: """Representation of a physical display output""" def __init__(self, output_info): self._output = output_info def __repr__(self): return "" % (self.vendor, self.product, self.display_name) @property def output_id(self): """ID of the output""" return self._output[0] @property def winsys_id(self): """The low-level ID of this output (XID or KMS handle)""" return self._output[1] @property def current_crtc(self): """The CRTC that is currently driving this output, or -1 if the output is disabled """ return self._output[2] @property def crtcs(self): """All CRTCs that can control this output""" return self._output[3] @property def name(self): """The name of the connector to which the output is attached (like VGA1 or HDMI)""" return self._output[4] @property def modes(self): """Valid modes for this output""" return [int(mode_id) for mode_id in self._output[5]] @property def clones(self): """Valid clones for this output, ie other outputs that can be assigned the same CRTC as this one; if you want to mirror two outputs that don't have each other in the clone list, you must configure two different CRTCs for the same geometry. """ return self._output[6] @property def properties(self): """Other high-level properties that affect this output; they are not necessarily reflected in the hardware. """ return self._output[7] @property def vendor(self): """Vendor name of the output""" return str(self._output[7]["vendor"]) @property def product(self): """Product name of the output""" return str(self._output[7]["product"]) @property def display_name(self): """A human readable name of this output, to be shown in the UI""" return str(self._output[7]["display-name"]) @property def is_primary(self): """True if the output is the primary one""" return bool(self._output[7]["primary"]) class DisplayMode: """Representation of a screen mode (resolution, refresh rate)""" def __init__(self, mode_info): self.mode_info = mode_info def __str__(self): return "%sx%s@%s" % (self.width, self.height, self.frequency) def __repr__(self): return "" % (self.width, self.height, self.frequency) @property def id(self): # pylint: disable=invalid-name """ID of the mode""" return str(self.mode_info[0]) @property def winsys_id(self): """the low-level ID of this mode""" return str(self.mode_info[1]) @property def width(self): """width in physical pixels""" return int(self.mode_info[2]) @property def height(self): """height in physical pixels""" return int(self.mode_info[3]) @property def frequency(self): """refresh rate""" return str(self.mode_info[4]) @property def flags(self): """mode flags as defined in xf86drmMode.h and randr.h""" return self.mode_info[5] class CRTC: """A CRTC (CRT controller) is a logical monitor, ie a portion of the compositor coordinate space. It might correspond to multiple monitors, when in clone mode, but not that it is possible to implement clone mode also by setting different CRTCs to the same coordinates. """ def __init__(self, crtc_info): self.crtc_info = crtc_info def __repr__(self): return "%s %s %s" % (self.id, self.geometry_str, self.current_mode) @property def id(self): # pylint: disable=invalid-name """The ID in the API of this CRTC""" return str(self.crtc_info[0]) @property def winsys_id(self): """the low-level ID of this CRTC (which might be a XID, a KMS handle or something entirely different)""" return self.crtc_info[1] @property def geometry_str(self): """Return a human readable representation of the geometry""" return "%dx%d%s%d%s%d" % ( self.geometry[0], self.geometry[1], "" if self.geometry[2] < 0 else "+", self.geometry[2], "" if self.geometry[3] < 0 else "+", self.geometry[3], ) @property def geometry(self): """The geometry of this CRTC (might be invalid if the CRTC is not in use) """ return (int(self.crtc_info[2]), int(self.crtc_info[3]), int(self.crtc_info[4]), int(self.crtc_info[5])) @property def current_mode(self): """The current mode of the CRTC, or -1 if this CRTC is not used Note: the size of the mode will always correspond to the width and height of the CRTC""" return int(self.crtc_info[6]) @property def current_transform(self): """The current transform (espressed according to the wayland protocol)""" return str(self.crtc_info[7]) @property def transforms(self): """All possible transforms""" return str(self.crtc_info[8]) @property def properties(self): """Other high-level properties that affect this CRTC; they are not necessarily reflected in the hardware. No property is specified in this version of the API. """ return str(self.crtc_info[9]) class MonitorMode(DisplayMode): """Represents a mode given by a Monitor instance In addition to DisplayMode objects, this gives acces to the current scaling used and some additional properties like is_current. """ @property def width(self): """width in physical pixels""" return int(self.mode_info[1]) @property def height(self): """height in physical pixels""" return int(self.mode_info[2]) @property def frequency(self): """refresh rate""" return str(self.mode_info[3]) @property def scale(self): """scale preferred as per calculations""" return float(self.mode_info[4]) @property def supported_scale(self): """scales supported by this mode""" return self.mode_info[5] @property def properties(self): """Additional properties""" return self.mode_info[6] @property def is_current(self): """Return True if the mode is the current one""" return "is-current" in self.properties class Monitor: """A physical monitor""" def __init__(self, monitor): self._monitor = monitor def get_current_mode(self): """Return the current mode""" for mode in self.get_modes(): if mode.is_current: return mode return def get_modes(self): """Return available modes""" return [MonitorMode(mode) for mode in self._monitor[1]] def get_mode_for_resolution(self, resolution): """Return an appropriate mode for a given resolution""" width, height = [int(i) for i in resolution.split("x")] for mode in self.get_modes(): if mode.width == width and mode.height == height: return mode return @property def name(self): """Name of the connector""" return str(self._monitor[0][0]) @property def vendor(self): """Manufacturer of the monitor""" return str(self._monitor[0][1]) @property def model(self): """Model name of the monitor""" return str(self._monitor[0][2]) @property def serial_number(self): """Serial number""" return str(self._monitor[0][3]) @property def is_underscanning(self): """Return true if the monitor is underscanning""" return bool(self._monitor[2]["is-underscanning"]) @property def is_builtin(self): """Return true if the display is builtin the machine (a laptop or a tablet)""" return bool(self._monitor[2]["is-builtin"]) @property def display_name(self): """Human readable name of the display""" return str(self._monitor[2]["display-name"]) class LogicalMonitor: """A logical monitor. Similar to CRTCs but logical monitors also contain scaling information. """ def __init__(self, lm_info, monitors): self._lm = lm_info self._monitors = monitors @property def position(self): """Return the position of the monitor""" return int(self._lm[0]), int(self._lm[1]) @property def scale(self): """Scale""" return self._lm[2] @property def transform(self): """Transforms Possible transform values: 0: normal 1: 90° 2: 180° 3: 270° 4: flipped 5: 90° flipped 6: 180° flipped 7: 270° flipped """ return self._lm[3] @property def primary(self): """True if this is the primary logical monitor""" return bool(self._lm[4]) def _get_monitor_for_connector(self, connector): """Return a Monitor instance from its connector name""" for monitor in self._monitors: if monitor.name == str(connector): return monitor return @property def monitors(self): """Monitors displaying that logical monitor""" return [self._get_monitor_for_connector(m[0]) for m in self._lm[5]] @property def properties(self): """Possibly other properties""" return self._lm[6] def get_config(self): """Export the current configuration so it can be stored then reapplied later""" monitors = [(monitor.name, monitor.get_current_mode().id) for monitor in self.monitors] return DisplayConfig(monitors, self.monitors[0].name, self.position, self.transform, self.primary, self.scale) class DisplayState: """Snapshot of a display configuration at a given time""" def __init__(self, interface): self.interface = interface self._state = self.load_state() def load_state(self): """Return current state from dbus interface""" return self.interface.GetCurrentState() @property def serial(self): """Configuration serial""" return self._state[0] @property def monitors(self): """Available monitors""" return [Monitor(monitor) for monitor in self._state[1]] @property def logical_monitors(self): """Current logical monitor configuration""" return [LogicalMonitor(l_m, self.monitors) for l_m in self._state[2]] @property def properties(self): """Display configuration properties""" return self._state[3] def get_primary_logical_monitor(self): """Return the primary logical monitor""" for lm in self.logical_monitors: if lm.primary: return lm return None def get_primary_monitor(self): """Returns the first physical monitor on the primary logical monitor.""" lm = self.get_primary_logical_monitor() if lm: return lm.monitors[0] return self.monitors[0] def get_current_mode(self): """Return the current mode""" return self.get_primary_monitor().get_current_mode() class MutterDisplayConfig: """Class to interact with the Mutter.DisplayConfig service""" namespace = "org.gnome.Mutter.DisplayConfig" dbus_path = "/org/gnome/Mutter/DisplayConfig" # Methods used in ApplyMonitorConfig VERIFY_METHOD = 0 TEMPORARY_METHOD = 1 PERSISTENT_METHOD = 2 def __init__(self): session_bus = dbus.SessionBus() proxy_obj = session_bus.get_object(self.namespace, self.dbus_path) self.interface = dbus.Interface(proxy_obj, dbus_interface=self.namespace) self.resources = self.interface.GetResources() self.current_state = DisplayState(self.interface) @property def serial(self): """ @serial is an unique identifier representing the current state of the screen. It must be passed back to ApplyConfiguration() and will be increased for every configuration change (so that mutter can detect that the new configuration is based on old state) """ return self.resources[0] @property def crtcs(self): """ A CRTC (CRT controller) is a logical monitor, ie a portion of the compositor coordinate space. It might correspond to multiple monitors, when in clone mode, but not that it is possible to implement clone mode also by setting different CRTCs to the same coordinates. The number of CRTCs represent the maximum number of monitors that can be set to expand and it is a HW constraint; if more monitors are connected, then necessarily some will clone. This is complementary to the concept of the encoder (not exposed in the API), which groups outputs that necessarily will show the same image (again a HW constraint). A CRTC is represented by a DBus structure with the following layout: * u ID: the ID in the API of this CRTC * x winsys_id: the low-level ID of this CRTC (which might be a XID, a KMS handle or something entirely different) * i x, y, width, height: the geometry of this CRTC (might be invalid if the CRTC is not in use) * i current_mode: the current mode of the CRTC, or -1 if this CRTC is not used Note: the size of the mode will always correspond to the width and height of the CRTC * u current_transform: the current transform (espressed according to the wayland protocol) * au transforms: all possible transforms * a{sv} properties: other high-level properties that affect this CRTC; they are not necessarily reflected in the hardware. No property is specified in this version of the API. Note: all geometry information refers to the untransformed display. """ return [CRTC(crtc) for crtc in self.resources[1]] @property def outputs(self): """ An output represents a physical screen, connected somewhere to the computer. Floating connectors are not exposed in the API. An output is a DBus struct with the following fields: * u ID: the ID in the API * x winsys_id: the low-level ID of this output (XID or KMS handle) * i current_crtc: the CRTC that is currently driving this output, or -1 if the output is disabled * au possible_crtcs: all CRTCs that can control this output * s name: the name of the connector to which the output is attached (like VGA1 or HDMI) * au modes: valid modes for this output * au clones: valid clones for this output, ie other outputs that can be assigned the same CRTC as this one; if you want to mirror two outputs that don't have each other in the clone list, you must configure two different CRTCs for the same geometry * a{sv} properties: other high-level properties that affect this output; they are not necessarily reflected in the hardware. Known properties: - "vendor" (s): (readonly) the human readable name of the manufacturer - "product" (s): (readonly) the human readable name of the display model - "serial" (s): (readonly) the serial number of this particular hardware part - "display-name" (s): (readonly) a human readable name of this output, to be shown in the UI - "backlight" (i): (readonly, use the specific interface) the backlight value as a percentage (-1 if not supported) - "primary" (b): whether this output is primary or not - "presentation" (b): whether this output is for presentation only Note: properties might be ignored if not consistenly applied to all outputs in the same clone group. In general, it's expected that presentation or primary outputs will not be cloned. """ return [Output(output) for output in self.resources[2]] @property def modes(self): """ A mode represents a set of parameters that are applied to each output, such as resolution and refresh rate. It is a separate object so that it can be referenced by CRTCs and outputs. Multiple outputs in the same CRTCs must all have the same mode. A mode is exposed as: * u ID: the ID in the API * x winsys_id: the low-level ID of this mode * u width, height: the resolution * d frequency: refresh rate * u flags: mode flags as defined in xf86drmMode.h and randr.h Output and modes are read-only objects (except for output properties), they can change only in accordance to HW changes (such as hotplugging a monitor), while CRTCs can be changed with ApplyConfiguration(). XXX: actually, if you insist enough, you can add new modes through xrandr command line or the KMS API, overriding what the kernel driver and the EDID say. Usually, it only matters with old cards with broken drivers, or old monitors with broken EDIDs, but it happens more often with projectors (if for example the kernel driver doesn't add the 640x480 - 800x600 - 1024x768 default modes). Probably something that we need to handle in mutter anyway. """ return [DisplayMode(mode) for mode in self.resources[3]] @property def max_screen_width(self): """Maximum width supported""" return self.resources[4] @property def max_screen_height(self): """Maximum height supported""" return self.resources[5] def get_mode_for_resolution(self, resolution): """Return an appropriate mode for a given resolution""" width, height = [int(i) for i in resolution.split("x")] for mode in self.modes: if mode.width == width and mode.height == height: return mode return def get_primary_logical_monitor(self): """Return the primary logical monitor""" return self.current_state.get_primary_logical_monitor() def get_primary_monitor(self): """Returns the first physical monitor on the primary logical monitor.""" return self.current_state.get_primary_monitor() def apply_monitors_config(self, display_configs): """Set the selected display to the desired resolution""" # Reload resources if not display_configs: logger.error("No display config given, not applying anything") return self.resources = self.interface.GetResources() self.current_state = DisplayState(self.interface) monitors_config = [ [ config.position[0], config.position[1], dbus.Double(config.scale), dbus.UInt32(config.transform), config.primary, [ [dbus.String(str(display_name)), dbus.String(str(mode)), {}] for display_name, mode in config.monitors ], ] for config in display_configs ] self.interface.ApplyMonitorsConfig(self.current_state.serial, self.TEMPORARY_METHOD, monitors_config, {}) class MutterDisplayManager: """Manage displays using the DBus Mutter interface""" def __init__(self): self.display_config = MutterDisplayConfig() def get_config(self): """Return the current configuration for each logical monitor""" return [logical_monitor.get_config() for logical_monitor in self.display_config.current_state.logical_monitors] def get_display_names(self): """Return display names of connected displays""" return [output.display_name for output in self.display_config.outputs] def get_resolutions(self): """Return available resolutions""" resolutions = ["%sx%s" % (mode.width, mode.height) for mode in self.display_config.modes] if not resolutions: logger.error("Could not generate resolution list") return ["%sx%s" % (DEFAULT_RESOLUTION_WIDTH, DEFAULT_RESOLUTION_HEIGHT)] return sorted(set(resolutions), key=lambda x: int(x.split("x")[0]), reverse=True) def get_current_resolution(self): """Return the current resolution for the primary display""" logger.debug("Retrieving current resolution") current_mode = self.display_config.current_state.get_current_mode() if not current_mode: logger.error("Could not retrieve the current display mode") return str(DEFAULT_RESOLUTION_WIDTH), str(DEFAULT_RESOLUTION_HEIGHT) return str(current_mode.width), str(current_mode.height) def set_resolution(self, resolution): """Change the current resolution""" if isinstance(resolution, str): monitor = self.display_config.get_primary_monitor() mode = monitor.get_mode_for_resolution(resolution) if not mode: logger.error("Could not find valid mode for %s", resolution) return config = [DisplayConfig([(monitor.name, mode.id)], monitor.name, (0, 0), 0, True, 1.0)] self.display_config.apply_monitors_config(config) elif resolution: self.display_config.apply_monitors_config(resolution) else: return # Load a fresh config since the current one has changed self.display_config = MutterDisplayConfig() lutris-0.5.19/lutris/util/game_finder.py0000664000175000017500000000623514756670027017243 0ustar hibbyhibby"""Automatically detects game executables in a folder""" import os from lutris.util import magic, system from lutris.util.log import logger def is_excluded_elf(filename): excluded = ("xdg-open", "uninstall") _fn = filename.lower() return any(exclude in _fn for exclude in excluded) def find_linux_game_executable(path, make_executable=False): """Looks for a binary or shell script that launches the game in a directory""" for base, _dirs, files in os.walk(path): candidates = {} for _file in files: if is_excluded_elf(_file): continue abspath = os.path.join(base, _file) file_type = magic.from_file(abspath) if "ASCII text executable" in file_type: candidates["shell"] = abspath if "Bourne-Again shell script" in file_type: candidates["bash"] = abspath if "POSIX shell script executable" in file_type: candidates["posix"] = abspath if "64-bit LSB executable" in file_type: candidates["64bit"] = abspath if "32-bit LSB executable" in file_type: candidates["32bit"] = abspath if candidates: if make_executable: for candidate in candidates.values(): system.make_executable(candidate) return ( candidates.get("shell") or candidates.get("bash") or candidates.get("posix") or candidates.get("64bit") or candidates.get("32bit") ) logger.error("Couldn't find a Linux executable in %s", path) return "" def is_excluded_dir(path): excluded = ( "Internet Explorer", "Windows NT", "Common Files", "Windows Media Player", "windows", "ProgramData", "users", "GameSpy Arcade", ) return any(dir_name in excluded for dir_name in path.split("/")) def is_excluded_exe(filename): excluded = ( "unins000", "uninstal", "update", "setup", "config.exe", "gsarcade.exe", "dosbox.exe", ) _fn = filename.lower() return any(exclude in _fn for exclude in excluded) def find_windows_game_executable(path): for base, _dirs, files in os.walk(path): candidates = {} if is_excluded_dir(base): continue for _file in files: if is_excluded_exe(_file): continue abspath = os.path.join(base, _file) if os.path.islink(abspath): continue file_type = magic.from_file(abspath) if "MS Windows shortcut" in file_type: candidates["link"] = abspath elif "PE32+ executable (GUI) x86-64" in file_type: candidates["64bit"] = abspath elif "PE32 executable (GUI) Intel 80386" in file_type: candidates["32bit"] = abspath if candidates: return candidates.get("link") or candidates.get("64bit") or candidates.get("32bit") logger.error("Couldn't find a Windows executable in %s", path) return "" lutris-0.5.19/lutris/util/discord/0000775000175000017500000000000014756670027016052 5ustar hibbyhibbylutris-0.5.19/lutris/util/discord/__init__.py0000664000175000017500000000025014756670027020160 0ustar hibbyhibby"""THIS MODULE IS UNMAINTAINTED AND WILL BE MARKED FOR DEPRECATION UNLESS SOMEONE VOLUNTEERS TO PROPERLY MAINTAIN IT.""" __all__ = ["client"] from .rpc import client lutris-0.5.19/lutris/util/discord/client.py0000664000175000017500000000212114756670027017676 0ustar hibbyhibby"""THIS MODULE IS UNMAINTAINTED AND WILL BE MARKED FOR DEPRECATION UNLESS SOMEONE VOLUNTEERS TO PROPERLY MAINTAIN IT.""" from pypresence import DiscordNotFound, Presence from lutris.util.discord.base import DiscordRichPresenceBase class DiscordRichPresenceClient(DiscordRichPresenceBase): rpc = None # Presence Object def __init__(self): self.playing = None self.rpc = None def update(self, discord_id): if self.rpc is not None: # Clear the old RPC before creating a new one self.clear() # Create a new Presence object with the desired app id self.rpc = Presence(discord_id) try: self.rpc.connect() except DiscordNotFound: return self.rpc.update() def clear(self): if self.rpc is None: # Skip already deleted rpc return # Clear and Close Presence connection self.rpc.clear() self.rpc.close() # Clear Presence Object self.rpc = None # Clear Internal Reference self.playing = None lutris-0.5.19/lutris/util/discord/rpc.py0000664000175000017500000000122014756670027017203 0ustar hibbyhibby""" Discord Rich Presence Loader This will enable DiscordRichPresenceClient if pypresence is installed. Otherwise, it will provide a dummy client that does nothing THIS MODULE IS UNMAINTAINTED AND WILL BE MARKED FOR DEPRECATION UNLESS SOMEONE VOLUNTEERS TO PROPERLY MAINTAIN IT. """ from lutris.util.discord.base import DiscordRPCNull try: from lutris.util.discord.client import DiscordRichPresenceClient except ImportError: # If PyPresence is not present, and ImportError will raise, so we provide dummy client client = DiscordRPCNull() else: # PyPresence is present, so we provide the client client = DiscordRichPresenceClient() lutris-0.5.19/lutris/util/discord/base.py0000664000175000017500000000121114756670027017331 0ustar hibbyhibby""" Discord Rich Presence Base Objects THIS MODULE IS UNMAINTAINTED AND WILL BE MARKED FOR DEPRECATION UNLESS SOMEONE VOLUNTEERS TO PROPERLY MAINTAIN IT. """ from abc import ABCMeta class DiscordRichPresenceBase(metaclass=ABCMeta): """ Discord Rich Presence Interface """ def update(self, discord_id: str) -> None: raise NotImplementedError() def clear(self) -> None: raise NotImplementedError() class DiscordRPCNull(DiscordRichPresenceBase): """ Null client for disabled Discord RPC """ def update(self, discord_id: str) -> None: pass def clear(self) -> None: pass lutris-0.5.19/lutris/util/savesync.py0000664000175000017500000002740514756670027016640 0ustar hibbyhibbyimport json import os import platform from datetime import datetime try: from webdav4.client import Client WEBDAV_AVAILABLE = True except ImportError: WEBDAV_AVAILABLE = False from lutris import settings from lutris.game import Game from lutris.util.log import logger from lutris.util.strings import human_size from lutris.util.wine.prefix import find_prefix DIR_CREATE_CACHE = [] SAVE_TYPES = ["saves", "logs", "config", "screenshots"] SYNC_TYPES = ["saves", "config"] class SaveInfo: save_types = ["saves", "logs", "configs", "screenshots"] default_synced_types = ["saves", "configs"] def __init__(self, game: Game): self.game = game self.save_config = self.game.config.game_level["game"].get("save_config") if not self.save_config: raise ValueError("%s has no save configuration" % self.game) self.basedir = self.get_basedir() def get_basedir(self) -> str: save_config = self.game.config.game_level["game"]["save_config"] basedir = save_config.get("basedir") or self.game.directory if not basedir: raise ValueError("No save directory provided") prefix_path = os.path.dirname(self.game.config.game_config.get("exe")) if self.game.runner_name == "wine": prefix_path = self.game.config.game_config.get("prefix", "") if prefix_path and not prefix_path.startswith("/"): prefix_path = os.path.join(self.game.directory, prefix_path) exe = self.game.config.game_config.get("exe", "") if exe and not exe.startswith("/"): exe = os.path.join(self.game.directory, exe) if not prefix_path: prefix_path = find_prefix(exe) if "$GAMEDIR" in basedir: basedir = basedir.replace("$GAMEDIR", self.game.directory or prefix_path) if "$USERDIR" in basedir: username = os.getenv("USER") or "steamuser" basedir = basedir.replace("$USERDIR", os.path.join(prefix_path, "drive_c/users/%s" % username)) else: if "$GAMEDIR" in basedir: basedir = basedir.replace("$GAMEDIR", self.game.directory) if "$USERDIR" in basedir: basedir = os.path.expanduser(basedir.replace("$USERDIR", "~")) return basedir @staticmethod def get_dir_info(path: str) -> dict: path_files = {} path = path.rstrip("/") if os.path.isfile(path): path_files[os.path.basename(path)] = os.stat(path) return path_files for root, _dirs, files in os.walk(path): basedir = root[len(path) + 1 :] for path_file in files: path_files[os.path.join(basedir, path_file)] = os.stat(os.path.join(root, path_file)) return path_files def format_dir_info(self, path: str) -> list: path_files = self.get_dir_info(path) output = [] for path_file in sorted(path_files, key=lambda k: path_files[k].st_mtime, reverse=True): fstats = path_files[path_file] output.append( { "file": path_file, "size": fstats.st_size, "modified": fstats.st_mtime, } ) return output def get_save_files(self) -> dict: """Return list of files with their details for each type along with metadata related to the save info""" results = {"basedir": self.basedir, "config": self.save_config} for section in self.save_types: if section in self.save_config: results[section] = {} path = os.path.join(self.basedir, self.save_config[section]) results[section]["path"] = path results[section]["files"] = self.format_dir_info(path) return results def print_dir_details(self, title: str, path: str): save_files = self.get_dir_info(path) if not save_files: return print(title) print("=" * len(title)) total_size = 0 for save_file in sorted(save_files, key=lambda k: save_files[k].st_mtime, reverse=True): fstats = save_files[save_file] total_size += fstats.st_size print( "%s (%s)\t\t%s" % (save_file, human_size(fstats.st_size), datetime.fromtimestamp(fstats.st_mtime).strftime("%c")) ) print("Total size: %s" % human_size(total_size)) def show_save_stats(game, output_format="text"): try: save_info = SaveInfo(game) except ValueError: logger.error("Save information for %s unavailable", game) return if output_format == "json": print(json.dumps(save_info.get_save_files()), indent=2) else: for section in save_info.save_types: if section in save_info.save_config: save_info.print_dir_details( section.capitalize(), os.path.join(save_info.basedir, save_info.save_config[section]) ) def create_dirs(client, path): parts = path.split("/") for i in range(len(parts)): relpath = os.path.join(*parts[: i + 1]) if relpath in DIR_CREATE_CACHE: continue if not client.exists(relpath): logger.debug("Creating Webdav folder %s", relpath) client.mkdir(relpath) DIR_CREATE_CACHE.append(relpath) def get_webdav_client(): if not WEBDAV_AVAILABLE: logger.error("Python package 'webdav4' not installed.") return webdav_host = settings.read_setting("webdav_host") if not webdav_host: logger.error("No remote host set (webdav_host)") return webdav_user = settings.read_setting("webdav_user") if not webdav_user: logger.error("No remote username set (webdav_user)") return webdav_pass = settings.read_setting("webdav_pass") if not webdav_pass: logger.error("No remote password set (webdav_pass)") return return Client(webdav_host, auth=(webdav_user, webdav_pass), timeout=50) def get_existing_saves(client, game_base_dir): if not client.exists(game_base_dir): return [] base_dir_content = client.ls(game_base_dir) saves = [] for save_folder in base_dir_content: if save_folder["type"] != "directory": continue local_save_info = os.path.join(settings.CACHE_DIR, "%s.json" % os.path.basename(save_folder["name"])) client.download_file(os.path.join(save_folder["name"], "saveinfo.json"), local_save_info) saves.append(local_save_info) return saves def upload_save(game, sections=None): if not sections: sections = SYNC_TYPES try: save_info = SaveInfo(game) except ValueError: logger.error("Can't get save info for %s", game) return print("Uploading save for %s" % game) webdav_saves_path = settings.read_setting("webdav_saves_path") if not webdav_saves_path: logger.error("No save path for the remote host (webdav_saves_path setting)") return client = get_webdav_client() if not client: return # game_base_dir = os.path.join(webdav_saves_path, game.slug) # existing_saves = get_existing_saves(client, game_base_dir) # for save in existing_saves: # print(save) # os.remove(save) save_files = save_info.get_save_files() max_time = 0 for section in sections: for save_file in save_files[section].get("files", []): if int(save_file["modified"]) > max_time: max_time = int(save_file["modified"]) save_id = f"{platform.node()}-{max_time}" save_dest_dir = os.path.join(webdav_saves_path, game.slug, save_id) create_dirs(client, save_dest_dir) save_info_name = "saveinfo.json" save_info_path = os.path.join(settings.CACHE_DIR, save_info_name) with open(save_info_path, "w", encoding="utf-8") as save_info_file: json.dump(save_files, save_info_file, indent=2) client.upload_file(save_info_path, os.path.join(save_dest_dir, save_info_name)) basepath = save_files["saves"]["path"] if os.path.isfile(basepath): basepath = os.path.dirname(basepath) relpath = basepath[len(save_files["basedir"]) + 1 :] create_dirs(client, os.path.join(save_dest_dir, relpath)) for save_file in save_files["saves"]["files"]: upload_file_source = os.path.join(basepath, save_file["file"]) upload_file_dest = os.path.join(save_dest_dir, relpath, save_file["file"]) print(upload_file_source, ">", upload_file_dest) client.upload_file(upload_file_source, upload_file_dest) def load_save_info(save_info_path): with open(save_info_path, "r", encoding="utf-8") as save_info_file: save_info = json.load(save_info_file) return save_info def parse_save_info(save_info_path: str): """Parse the filename of a save info file and extract its information""" save_info_name, _ext = os.path.splitext(os.path.basename(save_info_path)) hostname, ts = save_info_name.rsplit("-", maxsplit=1) return {"hostname": hostname, "datetime": datetime.fromtimestamp(int(ts))} def save_check(game): try: save_info = SaveInfo(game) except ValueError: logger.error("%s has no save configuration", game) return print("Checking sync of save for %s" % game) webdav_saves_path = settings.read_setting("webdav_saves_path") if not webdav_saves_path: logger.error("No save path for the remote host (webdav_saves_path setting)") return client = get_webdav_client() if not client: return remote_basedir = os.path.join(webdav_saves_path, game.slug) existing_saves = get_existing_saves(client, remote_basedir) current_save_files = save_info.get_save_files() for save_path in existing_saves: save_info_meta = parse_save_info(save_path) host = save_info_meta["hostname"] print("Host: %s (%s)" % (host, save_info_meta["datetime"].strftime("%c"))) remote_save_info = load_save_info(save_path) unsynced = {} for section in SAVE_TYPES: if section not in current_save_files: continue files = {file_info["file"]: file_info for file_info in remote_save_info[section].get("files", [])} local_files = {file_info["file"]: file_info for file_info in current_save_files[section].get("files", [])} unsynced[section] = {"unsynced": [], "newer": [], "older": [], "missing": []} for filename, file_info in local_files.items(): remote_file = files.get(filename) if not remote_file: unsynced[section]["unsynced"].append(filename) files[filename]["seen"] = True if file_info["modified"] > files[filename]["modified"]: print("Local file %s is newer" % filename) unsynced[section]["newer"].append(filename) if file_info["modified"] < files[filename]["modified"]: print("Local file %s is older" % filename) unsynced[section]["older"].append(filename) for filename, file_info in files.items(): if file_info.get("seen"): continue unsynced[section]["missing"].append(filename) out_of_sync = False for section in SAVE_TYPES: if section not in unsynced: continue for _key, value in unsynced[section].items(): if value: out_of_sync = True if not out_of_sync: print("🟢 Save %s synced with local game" % os.path.basename(save_path)) else: print("🟠 Save %s out of sync with local game" % os.path.basename(save_path)) print(unsynced) os.remove(save_path) lutris-0.5.19/lutris/util/display.py0000664000175000017500000003544114756670027016451 0ustar hibbyhibby"""Module to deal with various aspects of displays""" # isort:skip_file import enum import os import subprocess from typing import Any, Dict import gi try: gi.require_version("GnomeDesktop", "3.0") from gi.repository import GnomeDesktop LIB_GNOME_DESKTOP_AVAILABLE = True except ValueError: LIB_GNOME_DESKTOP_AVAILABLE = False GnomeDesktop = None try: from dbus.exceptions import DBusException DBUS_AVAILABLE = True except ImportError: DBUS_AVAILABLE = False from gi.repository import Gdk, GLib, Gio, Gtk from lutris.util import cache_single from lutris.settings import DEFAULT_RESOLUTION_HEIGHT, DEFAULT_RESOLUTION_WIDTH from lutris.util.graphics.displayconfig import MutterDisplayManager from lutris.util.graphics.xrandr import LegacyDisplayManager, change_resolution, get_outputs from lutris.util.log import logger def get_default_dpi(): """Computes the DPI to use for the primary monitor which we pass to WINE.""" display = Gdk.Display.get_default() if display: monitor = display.get_primary_monitor() if monitor: scale = monitor.get_scale_factor() dpi = 96 * scale return int(dpi) return 96 @cache_single def is_display_x11(): """True if""" display = Gdk.Display.get_default() return "x11" in type(display).__name__.casefold() class DisplayManager: """Get display and resolution using GnomeDesktop""" def __init__(self, screen: Gdk.Screen): self.rr_screen = GnomeDesktop.RRScreen.new(screen) self.rr_config = GnomeDesktop.RRConfig.new_current(self.rr_screen) self.rr_config.load_current() def get_display_names(self): """Return names of connected displays""" return [output_info.get_display_name() for output_info in self.rr_config.get_outputs()] def get_resolutions(self): """Return available resolutions""" resolutions = ["%sx%s" % (mode.get_width(), mode.get_height()) for mode in self.rr_screen.list_modes()] if not resolutions: logger.error("Failed to generate resolution list from default GdkScreen") return ["%sx%s" % (DEFAULT_RESOLUTION_WIDTH, DEFAULT_RESOLUTION_HEIGHT)] return sorted(set(resolutions), key=lambda x: int(x.split("x")[0]), reverse=True) def _get_primary_output(self): """Return the RROutput used as a primary display""" for output in self.rr_screen.list_outputs(): if output.get_is_primary(): return output return def get_current_resolution(self): """Return the current resolution for the primary display""" output = self._get_primary_output() if not output: logger.error("Failed to get a default output") return str(DEFAULT_RESOLUTION_WIDTH), str(DEFAULT_RESOLUTION_HEIGHT) current_mode = output.get_current_mode() return str(current_mode.get_width()), str(current_mode.get_height()) @staticmethod def set_resolution(resolution): """Set the resolution of one or more displays. The resolution can either be a string, which will be applied to the primary display or a list of configurations as returned by `get_config`. This method uses XrandR and will not work on Wayland. """ return change_resolution(resolution) @staticmethod def get_config(): """Return the current display resolution This method uses XrandR and will not work on wayland The output can be fed in `set_resolution` """ return get_outputs() def get_display_manager(): """Return the appropriate display manager instance. Defaults to Mutter if available. This is the only one to support Wayland. """ if DBUS_AVAILABLE: try: return MutterDisplayManager() except DBusException as ex: logger.debug("Mutter DBus service not reachable: %s", ex) except Exception as ex: # pylint: disable=broad-except logger.exception("Failed to instantiate MutterDisplayConfig. Please report with exception: %s", ex) else: logger.error("DBus is not available, Lutris was not properly installed.") if LIB_GNOME_DESKTOP_AVAILABLE: try: screen = Gdk.Screen.get_default() if screen: return DisplayManager(screen) except GLib.Error: pass return LegacyDisplayManager() DISPLAY_MANAGER = get_display_manager() class DesktopEnvironment(enum.Enum): """Enum of desktop environments.""" PLASMA = 0 MATE = 1 XFCE = 2 DEEPIN = 3 UNKNOWN = 999 # These desktop environment use a compositor that can be detected with a specific # command, and which provide a definite answer; the DE can be asked to start and stop it.. _compositor_commands_by_de = { DesktopEnvironment.MATE: { "check": ["gsettings", "get", "org.mate.Marco.general", "compositing-manager"], "active_result": b"true\n", "stop_compositor": ["gsettings", "set", "org.mate.Marco.general", "compositing-manager", "false"], "start_compositor": ["gsettings", "set", "org.mate.Marco.general", "compositing-manager", "true"], }, DesktopEnvironment.XFCE: { "check": ["xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing"], "active_result": b"true\n", "stop_compositor": ["xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing", "--set=false"], "start_compositor": ["xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing", "--set=true"], }, DesktopEnvironment.DEEPIN: { "check": [ "dbus-send", "--session", "--dest=com.deepin.WMSwitcher", "--type=method_call", "--print-reply=literal", "/com/deepin/WMSwitcher", "com.deepin.WMSwitcher.CurrentWM", ], "active_result": b"deepin wm\n", "stop_compositor": [ "dbus-send", "--session", "--dest=com.deepin.WMSwitcher", "--type=method_call", "/com/deepin/WMSwitcher", "com.deepin.WMSwitcher.RequestSwitchWM", ], "start_compositor": [ "dbus-send", "--session", "--dest=com.deepin.WMSwitcher", "--type=method_call", "/com/deepin/WMSwitcher", "com.deepin.WMSwitcher.RequestSwitchWM", ], }, } # These additional compositors can be detected by looking for their process, # and must be started more directly. _non_de_compositor_commands = [ { "check": ["pgrep", "picom"], "stop_compositor": ["pkill", "picom"], "start_compositor": ["picom", ""], "run_in_background": True, }, { "check": ["pgrep", "compton"], "stop_compositor": ["pkill", "compton"], "start_compositor": ["compton", ""], "run_in_background": True, }, ] def get_desktop_environment(): """Converts the value of the DESKTOP_SESSION environment variable to one of the constants in the DesktopEnvironment class. Returns None if DESKTOP_SESSION is empty or unset. """ desktop_session = os.environ.get("DESKTOP_SESSION", "").lower() if not desktop_session: return None if desktop_session.endswith("mate"): return DesktopEnvironment.MATE if desktop_session.endswith("xfce"): return DesktopEnvironment.XFCE if desktop_session.endswith("deepin"): return DesktopEnvironment.DEEPIN if "plasma" in desktop_session: return DesktopEnvironment.PLASMA return DesktopEnvironment.UNKNOWN def _get_command_output(command): """Some rogue function that gives no shit about residing in the correct module""" try: return subprocess.Popen( # pylint: disable=consider-using-with command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, close_fds=True ).communicate()[0] except FileNotFoundError: logger.error("Unable to run command, %s not found", command[0]) def is_compositing_enabled() -> bool: """Checks whether compositing is currently disabled or enabled. Returns True for enabled, False for disabled or if we didn't recognize the compositor. """ desktop_environment = get_desktop_environment() if desktop_environment in _compositor_commands_by_de: command_set = _compositor_commands_by_de[desktop_environment] return _check_compositor_active(command_set) for command_set in _non_de_compositor_commands: if _check_compositor_active(command_set): return True # No compositor detected return False def _check_compositor_active(command_set: Dict[str, Any]) -> bool: """Applies the 'check' command; and returns whether the result was the desired 'active_result'; if that is omitted, we check for any result at all.""" command = command_set["check"] result = _get_command_output(command) if "active_result" in command_set: return result == command_set["active_result"] return result != b"" # One element is appended to this for every invocation of disable_compositing: # True if compositing has been disabled, False if not. enable_compositing # removes the last element, and only re-enables compositing if that element # was True. _COMPOSITING_DISABLED_STACK = [] @cache_single def _get_compositor_commands(): """Returns the commands to enable/disable compositing on the current desktop environment as a 3-tuple: start command, stop-command and a flag to indicate if we need to run the commands in the background. """ desktop_environment = get_desktop_environment() command_set = _compositor_commands_by_de.get(desktop_environment) if not command_set: for c in _non_de_compositor_commands: if _check_compositor_active(c): command_set = c break if command_set: start_compositor = command_set["start_compositor"] stop_compositor = command_set["stop_compositor"] run_in_background = bool(command_set.get("run_in_background")) return start_compositor, stop_compositor, run_in_background return None, None, False def _run_command(*command, run_in_background=False): """Random _run_command lost in the middle of the project, are you lost little _run_command? """ try: if run_in_background: command = " ".join(command) return subprocess.Popen( # pylint: disable=consider-using-with command, stdin=subprocess.DEVNULL, close_fds=True, shell=run_in_background, start_new_session=run_in_background, ) except FileNotFoundError: errorMessage = "FileNotFoundError when running command:", command logger.error(errorMessage) def disable_compositing(): """Disable compositing if not already disabled.""" compositing_enabled = is_compositing_enabled() if any(_COMPOSITING_DISABLED_STACK): compositing_enabled = False _COMPOSITING_DISABLED_STACK.append(compositing_enabled) if not compositing_enabled: return _, stop_compositor, background = _get_compositor_commands() if stop_compositor: _run_command(*stop_compositor, run_in_background=background) def enable_compositing(): """Re-enable compositing if the corresponding call to disable_compositing disabled it.""" compositing_disabled = _COMPOSITING_DISABLED_STACK.pop() if not compositing_disabled: return start_compositor, _, background = _get_compositor_commands() if start_compositor: _run_command(*start_compositor, run_in_background=background) class DBusScreenSaverInhibitor: """Inhibit and uninhibit the screen saver using DBus. It will use the Gtk.Application's inhibit and uninhibit methods to inhibit the screen saver. For enviroments which don't support either org.freedesktop.ScreenSaver or org.gnome.ScreenSaver interfaces one can declare a DBus interface which requires the Inhibit() and UnInhibit() methods to be exposed.""" def __init__(self): self.proxy = None def set_dbus_iface(self, name, path, interface, bus_type=Gio.BusType.SESSION): """Sets a dbus proxy to be used instead of Gtk.Application methods, this method can raise an exception.""" self.proxy = Gio.DBusProxy.new_for_bus_sync( bus_type, Gio.DBusProxyFlags.NONE, None, name, path, interface, None ) def inhibit(self, game_name): """Inhibit the screen saver. Returns a cookie that must be passed to the corresponding uninhibit() call. If an error occurs, None is returned instead.""" reason = "Running game: %s" % game_name if self.proxy: try: return self.proxy.Inhibit("(ss)", "Lutris", reason) except Exception: return None else: app = Gio.Application.get_default() window = app.window flags = Gtk.ApplicationInhibitFlags.SUSPEND | Gtk.ApplicationInhibitFlags.IDLE cookie = app.inhibit(window, flags, reason) # Gtk.Application.inhibit returns 0 if there was an error. if cookie == 0: return None return cookie def uninhibit(self, cookie): """Uninhibit the screen saver. Takes a cookie as returned by inhibit. If cookie is None, no action is taken.""" if not cookie: return if self.proxy: self.proxy.UnInhibit("(u)", cookie) else: app = Gio.Application.get_default() app.uninhibit(cookie) def _get_screen_saver_inhibitor(): """Return the appropriate screen saver inhibitor instance. If the required interface isn't available, it will default to GTK's implementation.""" desktop_environment = get_desktop_environment() name = None inhibitor = DBusScreenSaverInhibitor() if desktop_environment is DesktopEnvironment.MATE: name = "org.mate.ScreenSaver" path = "/" interface = "org.mate.ScreenSaver" elif desktop_environment is DesktopEnvironment.XFCE: # According to # https://github.com/xfce-mirror/xfce4-session/blob/master/xfce4-session/xfce-screensaver.c#L240 # The XFCE enviroment does support the org.freedesktop.ScreenSaver interface # but this might be not present in older releases. name = "org.xfce.ScreenSaver" path = "/" interface = "org.xfce.ScreenSaver" if name: try: inhibitor.set_dbus_iface(name, path, interface) except GLib.Error as err: logger.warning( "Failed to set up a DBus proxy for name %s, path %s, " "interface %s: %s", name, path, interface, err ) return inhibitor SCREEN_SAVER_INHIBITOR = _get_screen_saver_inhibitor() lutris-0.5.19/lutris/util/portals.py0000664000175000017500000000654114756670027016467 0ustar hibbyhibbyimport os from gettext import gettext as _ from typing import Callable, Iterable from gi.repository import Gio, GLib, GObject from lutris.util.jobs import schedule_at_idle from lutris.util.log import logger PORTAL_BUS_NAME = "org.freedesktop.portal.Desktop" PORTAL_OBJECT_PATH = "/org/freedesktop/portal/desktop" class TrashPortal(GObject.Object): portal_interface = "org.freedesktop.portal.Trash" _dbus_proxy = None CompletionFunction = Callable[[], None] ErrorFunction = Callable[[Exception], None] def __init__( self, file_paths: Iterable[str], completion_function: CompletionFunction = None, error_function: ErrorFunction = None, ): super().__init__() self.file_paths = list(file_paths) self.completion_function = completion_function self.error_function = error_function Gio.DBusProxy.new_for_bus( Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, PORTAL_BUS_NAME, PORTAL_OBJECT_PATH, self.portal_interface, None, self._new_for_bus_cb, ) def _new_for_bus_cb(self, obj, result): proxy = obj.new_for_bus_finish(result) if proxy: self._dbus_proxy = proxy self.trash_file() def trash_file(self): try: fds_in = Gio.UnixFDList.new() for file_path in self.file_paths: flags = os.O_RDONLY | os.O_PATH | os.O_CLOEXEC # You'd think you could use O_NOFOLLOW for any file, but # I find TrashFile fails. We don't want to trash the link target # in any case. if os.path.islink(file_path): flags |= os.O_NOFOLLOW file_handle = os.open(file_path, flags) fds_in.append(file_handle) self._dbus_proxy.call_with_unix_fd_list( "TrashFile", GLib.Variant.new_tuple( GLib.Variant.new_handle(0), ), Gio.DBusCallFlags.NONE, GObject.G_MAXINT, fds_in, None, self._call_cb, ) except Exception as ex: self.report_error(ex) def _call_cb(self, obj, result): values = obj.call_finish(result) if values: result = values[0] if result == 0: if len(self.file_paths) == 1: message = ( _("'%s' could not be moved to the trash. You will need to delete it yourself.") % self.file_paths[0] ) else: message = _( "The items could not be moved to the trash. You will need to delete them yourself:\n%s" ) % "\n".join(self.file_paths) self.report_error(RuntimeError(message)) return self.report_completion() def report_error(self, error: Exception) -> None: if self.error_function: schedule_at_idle(self.error_function, error) else: logger.exception("Failed to trash %s: %s", ", ".join(self.file_paths), error) def report_completion(self): if self.completion_function: schedule_at_idle(self.completion_function) lutris-0.5.19/lutris/util/system.py0000664000175000017500000005371314756670027016332 0ustar hibbyhibby"""System utilities""" import hashlib import os import re import shutil import signal import stat import string import subprocess import zipfile from gettext import gettext as _ from pathlib import Path from typing import Dict, List, Optional, Tuple from gi.repository import Gio, GLib from lutris import settings from lutris.exceptions import MissingExecutableError from lutris.util.log import logger from lutris.util.portals import TrashPortal # Home folders that should never get deleted. PROTECTED_HOME_FOLDERS = ( _("Documents"), _("Downloads"), _("Desktop"), _("Pictures"), _("Videos"), _("Pictures"), _("Projects"), _("Games"), ) def get_environment(): """Return a safe to use copy of the system's environment. Values starting with BASH_FUNC can cause issues when written in a text file.""" return {key: value for key, value in os.environ.items() if not key.startswith("BASH_FUNC")} def execute( command: List[str], env: Dict[str, str] = None, cwd: str = None, quiet: bool = False, shell: bool = False, timeout: float = None, ) -> str: """ Execute a system command and return its standard output; standard error is discarded. Params: command (list): A list containing an executable and its parameters env (dict): Dict of values to add to the current environment cwd (str): Working directory quiet (bool): Do not display log messages timeout (int): Number of seconds the program is allowed to run, disabled by default Returns: str: stdout output """ stdout, _stderr = _execute(command, env=env, cwd=cwd, quiet=quiet, shell=shell, timeout=timeout) return stdout def execute_with_error( command: List[str], env: Dict[str, str] = None, cwd: str = None, quiet: bool = False, shell: bool = False, timeout: float = None, ) -> Tuple[str, str]: """ Execute a system command and return its standard output and; standard error in a tuple. Params: command (list): A list containing an executable and its parameters env (dict): Dict of values to add to the current environment cwd (str): Working directory quiet (bool): Do not display log messages timeout (int): Number of seconds the program is allowed to run, disabled by default Returns: str, str: stdout output and stderr output """ return _execute(command, env=env, cwd=cwd, capture_stderr=True, quiet=quiet, shell=shell, timeout=timeout) def _execute( command: List[str], env: Dict[str, str] = None, cwd: str = None, capture_stderr: bool = False, quiet: bool = False, shell: bool = False, timeout: float = None, ) -> Tuple[str, str]: # Check if the executable exists if not command: logger.error("No executable provided!") return "", "" if os.path.isabs(command[0]) and not path_exists(command[0]): logger.error("No executable found in %s", command) return "", "" if not quiet: logger.debug("Executing %s", " ".join([str(i) for i in command])) # Set up environment existing_env = get_environment() if env: if not quiet: logger.debug(" ".join("{}={}".format(k, v) for k, v in env.items())) env = {k: v for k, v in env.items() if v is not None} existing_env.update(env) # Piping stderr can cause slowness in the programs, use carefully # (especially when using regedit with wine) try: with subprocess.Popen( command, shell=shell, stdout=subprocess.PIPE, stderr=subprocess.PIPE if capture_stderr else subprocess.DEVNULL, env=existing_env, cwd=cwd, errors="replace", ) as command_process: stdout, stderr = command_process.communicate(timeout=timeout) except (OSError, TypeError) as ex: logger.error("Could not run command %s (env: %s): %s", command, env, ex) return "", "" except subprocess.TimeoutExpired: logger.error("Command %s after %s seconds", command, timeout) return "", "" return stdout.strip(), (stderr or "").strip() def spawn(command, env=None, cwd=None, quiet=False, shell=False): """ Execute a system command but discard its results and do not wait for it to complete. Params: command (list): A list containing an executable and its parameters env (dict): Dict of values to add to the current environment cwd (str): Working directory quiet (bool): Do not display log messages """ # Check if the executable exists if not command: logger.error("No executable provided!") return if os.path.isabs(command[0]) and not path_exists(command[0]): logger.error("No executable found in %s", command) return if not quiet: logger.debug("Spawning %s", " ".join([str(i) for i in command])) # Set up environment existing_env = get_environment() if env: if not quiet: logger.debug(" ".join("{}={}".format(k, v) for k, v in env.items())) env = {k: v for k, v in env.items() if v is not None} existing_env.update(env) # Piping stderr can cause slowness in the programs, use carefully # (especially when using regedit with wine) try: subprocess.Popen( # pylint: disable=consider-using-with command, shell=shell, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=existing_env, cwd=cwd ) except (OSError, TypeError) as ex: logger.error("Could not run command %s (env: %s): %s", command, env, ex) def read_process_output(command, timeout=5, env=None, error_result=""): """Return the output of a command as a string; if 'error_result' is not None, returns that on errors. If it is, raises an exception instead.""" try: return subprocess.check_output(command, timeout=timeout, env=env, encoding="utf-8", errors="ignore").strip() except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as ex: logger.error("%s command failed: %s", command, ex) if error_result is None: raise return error_result def get_md5_in_zip(filename): """Return the md5 hash of a file in a zip""" with zipfile.ZipFile(filename, "r") as archive: files = archive.namelist() if len(files) > 1: logger.warning("More than 1 file in archive %s, reading 1st one: %s", filename, files[0]) with archive.open(files[0]) as file_in_zip: _hash = read_file_md5(file_in_zip) return _hash def get_md5_hash(filename): """Return the md5 hash of a file.""" try: with open(filename, "rb") as _file: _hash = read_file_md5(_file) except IOError: logger.warning("Error reading %s", filename) return False return _hash def read_file_md5(filedesc): md5 = hashlib.md5() for chunk in iter(lambda: filedesc.read(8192), b""): md5.update(chunk) return md5.hexdigest() def get_file_checksum(filename, hash_type): """Return the checksum of type `hash_type` for a given filename""" hasher = hashlib.new(hash_type) with open(filename, "rb") as input_file: for chunk in iter(lambda: input_file.read(4096), b""): hasher.update(chunk) return hasher.hexdigest() def is_executable(exec_path): """Return whether exec_path is an executable""" return os.access(exec_path, os.X_OK) def make_executable(exec_path): file_stats = os.stat(exec_path) os.chmod(exec_path, file_stats.st_mode | stat.S_IEXEC) def can_find_executable(exec_name: str) -> bool: """Checks if an executable can be located; if false, find_executable will return None, and find_required_executable will raise an exception.""" return bool(find_executable(exec_name)) def find_executable(exec_name: str) -> Optional[str]: """Return the absolute path of an executable, or None if it could not be found.""" return shutil.which(exec_name) if exec_name else None def find_required_executable(exec_name: str) -> str: """Return the absolute path of an executable, but raises a MissingExecutableError if it could not be found.""" exe = find_executable(exec_name) if not exe: raise MissingExecutableError(_("The executable '%s' could not be found.") % exec_name) return exe def get_pid(program, multiple=False): """Return pid of process. :param str program: Name of the process. :param bool multiple: If True and multiple instances of the program exist, return all of them; if False only return the first one. """ pids = execute(["pgrep", program]) if not pids.strip(): return pids = pids.split() if multiple: return pids return pids[0] def kill_pid(pid): """Terminate a process referenced by its PID""" try: pid = int(pid) except ValueError: logger.error("Invalid pid %s") return logger.info("Killing PID %s", pid) try: os.kill(pid, signal.SIGKILL) except OSError: logger.error("Could not kill process %s", pid) def python_identifier(unsafe_string): """Converts a string to something that can be used as a python variable""" if not isinstance(unsafe_string, str): logger.error("Cannot convert %s to a python identifier", type(unsafe_string)) return def _dashrepl(matchobj): return matchobj.group(0).replace("-", "_") return re.sub(r"(\${)([\w-]*)(})", _dashrepl, unsafe_string) def substitute(string_template, variables): """Expand variables on a string template Args: string_template (str): template with variables preceded by $ variables (dict): mapping of variable identifier > value Return: str: String with substituted values """ string_template = python_identifier(str(string_template)) identifiers = variables.keys() # We support dashes in identifiers but they are not valid in python # identifiers, which is a requirement for the templating engine we use # Replace the dashes with underscores in the mapping and template variables = dict((k.replace("-", "_"), v) for k, v in variables.items()) for identifier in identifiers: string_template = string_template.replace("${}".format(identifier), "${}".format(identifier.replace("-", "_"))) template = string.Template(string_template) if string_template in list(variables.keys()): return variables[string_template] return template.safe_substitute(variables) def merge_folders(source, destination): """Merges the content of source to destination""" logger.debug("Merging %s into %s", source, destination) # We do not use shutil.copytree() here because that would copy # the file permissions, and we do not want them. source = os.path.abspath(source) for dirpath, dirnames, filenames in os.walk(source): source_relpath = dirpath[len(source) :].strip("/") dst_abspath = os.path.join(destination, source_relpath) for dirname in dirnames: new_dir = os.path.join(dst_abspath, dirname) logger.debug("creating dir: %s", new_dir) try: os.mkdir(new_dir) except OSError: pass for filename in filenames: # logger.debug("Copying %s", filename) if not os.path.exists(dst_abspath): os.makedirs(dst_abspath) shutil.copy(os.path.join(dirpath, filename), os.path.join(dst_abspath, filename), follow_symlinks=False) def remove_folder( path: str, completion_function: TrashPortal.CompletionFunction = None, error_function: TrashPortal.ErrorFunction = None, ) -> None: """Trashes a folder specified by path, asynchronously. The folder likely exists after this returns, since it's using DBus to ask for the entrashification. """ if not os.path.exists(path): logger.warning("Non existent path: %s", path) if completion_function: completion_function() return if os.path.samefile(os.path.expanduser("~"), path): if error_function: error_function(RuntimeError("Lutris tried to trash home directory!")) return logger.debug("Trashing folder %s", path) TrashPortal([path], completion_function=completion_function, error_function=error_function) def delete_folder(path): """Delete a folder specified by path immediately. The folder will not be recoverable, so consider remove_folder() instead. Returns true if the folder was successfully deleted. """ if not os.path.exists(path): logger.warning("Non existent path: %s", path) return False if os.path.samefile(os.path.expanduser("~"), path): raise RuntimeError("Lutris tried to erase home directory!") logger.debug("Deleting folder %s", path) try: shutil.rmtree(path) except OSError as ex: logger.error("Failed to delete folder %s: %s (Error code %s)", path, ex.strerror, ex.errno) return False return True def create_folder(path): """Creates a folder specified by path""" if not path: return path = os.path.expanduser(path) os.makedirs(path, exist_ok=True) return path def list_unique_folders(folders): """Deduplicate directories with the same Device.Inode""" unique_dirs = {} for folder in folders: folder_stat = os.stat(folder) identifier = "%s.%s" % (folder_stat.st_dev, folder_stat.st_ino) if identifier not in unique_dirs: unique_dirs[identifier] = folder return unique_dirs.values() def is_removeable(path, system_config): """Check if a folder is safe to remove (not system or home, ...). This needs the system config dict so it can check the default game path, too.""" if not path_exists(path): return False parts = path.strip("/").split("/") if not parts: return False if parts[0] == "var": # Fedora Silverblue puts mount points under /var since they are mutable # so we'll special case /var/mnt//*. if len(parts) > 3 and parts[1] in ("mnt", "media"): return True return False if parts[0] in ("usr", "lib", "etc", "boot", "sbin", "bin"): # Path is part of the system folders return False if parts[0] == "home": if len(parts) <= 2: return False if len(parts) == 3 and parts[2] in PROTECTED_HOME_FOLDERS: return False if system_config: default_game_path = system_config.get("game_path") if path_contains(path, default_game_path, resolve_symlinks=False): return False return True def fix_path_case(path): """Do a case-insensitive check, return the real path with correct case. If the path is not for a real file, this corrects as many components as do exist.""" if not path or os.path.exists(path) or not path.startswith("/"): # If a path isn't provided, or it exists as is, or is a relative path, just return it. return path parts = path.strip("/").split("/") current_path = "/" for part in parts: parent_path = current_path current_path = os.path.join(current_path, part) if not os.path.exists(current_path) and os.path.isdir(parent_path): try: path_contents = os.listdir(parent_path) except OSError: logger.error("Can't read contents of %s", parent_path) path_contents = [] for filename in path_contents: if filename.lower() == part.lower(): current_path = os.path.join(parent_path, filename) break # Only return the path if we got the same number of elements if len(parts) == len(current_path.strip("/").split("/")): return current_path # otherwise return original path return path def get_pids_using_file(path): """Return a set of pids using file `path`.""" if not os.path.exists(path): logger.error("Can't return PIDs using non existing file: %s", path) return set() fuser_path = find_executable("fuser") if not fuser_path: logger.warning("fuser not available, please install psmisc") return set() fuser_output = execute([fuser_path, path], quiet=True) return set(fuser_output.split()) def reverse_expanduser(path): """Replace '/home/username' with '~' in given path.""" if not path: return path user_path = os.path.expanduser("~") if path.startswith(user_path): path = path[len(user_path) :].strip("/") return "~/" + path return path def path_contains(parent, child, resolve_symlinks=False) -> bool: """Tests if a child path is actually within a parent directory or a subdirectory of it. Resolves relative paths, and ~, and optionally symlinks.""" if parent is None or child is None: return False resolved_parent = Path(os.path.abspath(os.path.expanduser(parent))) resolved_child = Path(os.path.abspath(os.path.expanduser(child))) if resolve_symlinks: resolved_parent = resolved_parent.resolve() resolved_child = resolved_child.resolve() return resolved_child == resolved_parent or resolved_parent in resolved_child.parents def path_exists(path: str, check_symlinks: bool = False, exclude_empty: bool = False) -> bool: """Wrapper around system.path_exists that doesn't crash with empty values Params: path (str): File to the file to check check_symlinks (bool): If the path is a broken symlink, return False exclude_empty (bool): If true, consider 0 bytes files as non existing """ if not path: return False if path.startswith("~"): path = os.path.expanduser(path) if os.path.exists(path): if exclude_empty: return os.stat(path).st_size > 0 return True if os.path.islink(path): logger.warning("%s is a broken link", path) return not check_symlinks return False def create_symlink(source: str, destination: str): """Create a symlink from source to destination. If there is already a symlink at the destination and it is broken, it will be deleted.""" is_directory = os.path.isdir(source) if os.path.islink(destination) and not os.path.exists(destination): logger.warning("Deleting broken symlink %s", destination) os.remove(destination) try: os.symlink(source, destination, target_is_directory=is_directory) except OSError: logger.error("Failed linking %s to %s", source, destination) def reset_library_preloads(): """Remove library preloads from environment""" for key in ("LD_LIBRARY_PATH", "LD_PRELOAD"): if os.environ.get(key): try: del os.environ[key] except OSError: logger.error("Failed to delete environment variable %s", key) def get_existing_parent(path): """Return the 1st existing parent for a folder (or itself if the path exists and is a directory). returns None, when none of the parents exists. """ if path == "": return None if os.path.exists(path) and not os.path.isfile(path): return path return get_existing_parent(os.path.dirname(path)) def update_desktop_icons(): """Update Icon for GTK+ desktop manager Other desktop manager icon cache commands must be added here if needed """ if can_find_executable("gtk-update-icon-cache"): execute(["gtk-update-icon-cache", "-tf", os.path.join(GLib.get_user_data_dir(), "icons/hicolor")], quiet=True) execute(["gtk-update-icon-cache", "-tf", os.path.join(settings.RUNTIME_DIR, "icons/hicolor")], quiet=True) def get_disk_size(path: str) -> int: """Return the disk size in bytes of a file or folder""" def get_file_size(file_path): return os.stat(file_path).st_size if os.path.isfile(path): return get_file_size(path) total_size = 0 for base, _dirs, files in os.walk(path): paths = [os.path.join(base, f) for f in files] total_size += sum(get_file_size(p) for p in paths if os.path.isfile(p) and not os.path.islink(p)) return total_size def get_locale_list(): """Return list of available locales""" try: with subprocess.Popen(["locale", "-a"], stdout=subprocess.PIPE) as locale_getter: output = locale_getter.communicate() locales = output[0].decode("ASCII").split() # locale names use only ascii characters except FileNotFoundError: lang = os.environ.get("LANG", "") if lang: locales = [lang] else: locales = [] return locales def get_running_pid_list(): """Return the list of PIDs from processes currently running""" return [int(p) for p in os.listdir("/proc") if p[0].isdigit()] def get_mounted_discs(): """Return a list of mounted discs and ISOs :rtype: list of Gio.Mount """ volumes = Gio.VolumeMonitor.get() drives = [] for mount in volumes.get_mounts(): if mount.get_volume(): device = mount.get_volume().get_identifier("unix-device") if not device: logger.debug("No device for mount %s", mount.get_name()) continue # Device is a disk drive or ISO image if "/dev/sr" in device or "/dev/loop" in device: drives.append(mount.get_root().get_path()) return drives def find_mount_point(path): """Return the mount point a file is located on""" path = os.path.abspath(path) while not os.path.ismount(path): path = os.path.dirname(path) return path def set_keyboard_layout(layout): setxkbmap_command = ["setxkbmap", "-model", "pc101", layout, "-print"] xkbcomp_command = ["xkbcomp", "-", os.environ.get("DISPLAY", ":0")] with subprocess.Popen(xkbcomp_command, stdin=subprocess.PIPE) as xkbcomp: with subprocess.Popen(setxkbmap_command, env=os.environ, stdout=xkbcomp.stdin) as setxkbmap: setxkbmap.communicate() xkbcomp.communicate() lutris-0.5.19/lutris/util/process_watcher.py0000664000175000017500000000471514756670027020177 0ustar hibbyhibby"""Process management""" import os import shlex import sys from lutris.util.process import Process # Processes that are considered sufficiently self-managing by the # monitoring system. These are not considered game processes for # the purpose of determining if a game is still running and Lutris # will never attempt to send signals to these processes. # This is mostly a minor UX improvement where wine games will exit # faster if we let the wine processes tear themselves down. SYSTEM_PROCESSES = { "wineserver", "services.exe", "winedevice.exe", "plugplay.exe", "explorer.exe", "wineconsole", "svchost.exe", "rpcss.exe", "rundll32.exe", "mscorsvw.exe", "iexplore.exe", "winedbg.exe", "tabtip.exe", "conhost.exe", } class ProcessWatcher: """Keeps track of child processes of the client""" def __init__(self, include_processes, exclude_processes): """Create a process watcher. Params: exclude_processes (str or list): list of processes that shouldn't be monitored include_processes (str or list): list of process that should be forced to be monitored """ self.unmonitored_processes = ( self.parse_process_list(exclude_processes) | SYSTEM_PROCESSES ) - self.parse_process_list(include_processes) @staticmethod def parse_process_list(process_list): """Parse a process list that may be given as a string""" if not process_list: return set() if isinstance(process_list, str): process_list = shlex.split(process_list) # process names from /proc only contain 15 characters return {p[0:15] for p in process_list} @staticmethod def iterate_children(): """Iterates through all children process of the lutris client. This is not accurate since not all processes are started by lutris but are started by Systemd instead. """ return Process(os.getpid()).iter_children() def iterate_processes(self): for child in self.iterate_children(): if child.state == "Z": continue if child.name and child.name not in self.unmonitored_processes: yield child def is_alive(self, message=None): """Returns whether at least one watched process exists""" if message: sys.stdout.write("%s\n" % message) return next(self.iterate_processes(), None) is not None lutris-0.5.19/lutris/util/http.py0000664000175000017500000001411514756670027015756 0ustar hibbyhibby"""HTTP utilities""" import json import os import socket import ssl import urllib.error import urllib.parse import urllib.request from ssl import CertificateError import certifi from lutris.settings import PROJECT, SITE_URL, VERSION, read_setting from lutris.util import system from lutris.util.log import logger DEFAULT_TIMEOUT = read_setting("default_http_timeout") or 30 ssl._create_default_https_context = lambda: ssl.create_default_context(cafile=certifi.where()) class HTTPError(Exception): """Exception raised on request failures""" def __init__(self, message, code=None): super().__init__(message) self.code = code class UnauthorizedAccessError(Exception): """Exception raised for 401 HTTP errors""" class Request: def __init__( self, url, timeout=DEFAULT_TIMEOUT, stop_request=None, headers=None, cookies=None, ): self.url = self._clean_url(url) self.status_code = None self.content = b"" self.timeout = timeout self.stop_request = stop_request self.buffer_size = 1024 * 1024 # Bytes self.total_size = None self.downloaded_size = 0 self.headers = {"User-Agent": self.user_agent} self.response_headers = None self.info = None if headers is None: headers = {} if not isinstance(headers, dict): raise TypeError("HTTP headers needs to be a dict ({})".format(headers)) self.headers.update(headers) if cookies: cookie_processor = urllib.request.HTTPCookieProcessor(cookies) self.opener = urllib.request.build_opener(cookie_processor) else: self.opener = None @staticmethod def _clean_url(url): """Checks that a given URL is valid and return a usable version""" if not url: raise ValueError("An URL is required!") if url == "None": raise ValueError("You'd better stop that right now.") if url.startswith("//"): url = "https:" + url if url.startswith("/"): logger.error("Stop using relative URLs!: %s", url) url = SITE_URL + url # That's for a single URL in EGS... not sure if we need more escaping # The url received should already be receiving an escaped string url = url.replace(" ", "%20") return url @property def user_agent(self): return "{} {}".format(PROJECT, VERSION) def _request(self, method, data=None): logger.debug("%s %s", method, self.url) try: req = urllib.request.Request(url=self.url, data=data, headers=self.headers, method=method) except ValueError as ex: raise HTTPError("Failed to create HTTP request to %s: %s" % (self.url, ex)) from ex try: if self.opener: request = self.opener.open(req, timeout=self.timeout) else: request = urllib.request.urlopen(req, timeout=self.timeout) # pylint: disable=consider-using-with except urllib.error.HTTPError as error: if error.code == 401: raise UnauthorizedAccessError("Access to %s denied" % self.url) from error raise HTTPError("%s" % error, code=error.code) from error except CertificateError as error: raise HTTPError("%s" % error, code=0) from error except (socket.timeout, urllib.error.URLError) as error: raise HTTPError("Unable to connect to server %s: %s" % (self.url, error)) from error self.response_headers = request.getheaders() self.status_code = request.getcode() if self.status_code > 299: logger.warning("Request responded with code %s", self.status_code) try: self.total_size = int(request.info().get("Content-Length").strip()) except AttributeError: self.total_size = 0 self.content = b"".join(self._iter_chunks(request)) self.info = request.info() request.close() return self def _iter_chunks(self, request): while 1: if self.stop_request and self.stop_request.is_set(): self.content = b"" return self try: chunk = request.read(self.buffer_size) except (socket.timeout, ConnectionResetError) as err: raise HTTPError("Request timed out") from err self.downloaded_size += len(chunk) if not chunk: return yield chunk def get(self, data=None): return self._request("GET", data) def post(self, data=None): return self._request("POST", data) def delete(self, data=None): return self._request("DELETE", data) def write_to_file(self, path): content = self.content logger.debug("Writing to %s", path) if not content: logger.warning("No content to write") return dirname = os.path.dirname(path) if not system.path_exists(dirname): os.makedirs(dirname) with open(path, "wb") as dest_file: dest_file.write(content) @property def json(self): _raw_json = self.text if _raw_json: try: return json.loads(_raw_json) except json.decoder.JSONDecodeError as err: raise ValueError(f"JSON response from {self.url} could not be decoded: '{_raw_json[:80]}'") from err return {} @property def text(self): if self.content: return self.content.decode() return "" def download_file(url, dest, overwrite=False, raise_errors=False): """Save a remote resource locally""" if system.path_exists(dest): if overwrite: os.remove(dest) else: return dest if not url: return None try: request = Request(url).get() except HTTPError as ex: if raise_errors: raise logger.error("Failed to get url %s: %s", url, ex) return None request.write_to_file(dest) return dest lutris-0.5.19/lutris/util/busy.py0000664000175000017500000000226614756670027015765 0ustar hibbyhibbyfrom lutris.gui.widgets import NotificationSource from lutris.util.jobs import AsyncCall BUSY_STARTED = NotificationSource() BUSY_STOPPED = NotificationSource() _busy_count = 0 def start_busy(): """Put Lutris into the 'busy' state, which causes BUSY_STARTED to fire; LutrisWindow will display a 'progress' cursor.""" global _busy_count _busy_count += 1 if _busy_count == 1: BUSY_STARTED.fire() def stop_busy(): """Takes Lutris out of the 'busy' state', which causes BUSY_STOPPED to fire. Note that busy states can be nested or overlapped; business must be stopped as many times as it is started.""" global _busy_count _busy_count -= 1 if _busy_count == 0: BUSY_STOPPED.fire() class BusyAsyncCall(AsyncCall): """This is a version of AsyncCall that calls start_busy() and stop_busy(), which will cause the LutrisWindow to show a progress cursor while the task runs.""" def __init__(self, func, callback, *args, **kwargs): def on_completion(*a, **kw): stop_busy() if callback: callback(*a, **kw) super().__init__(func, on_completion, *args, **kwargs) start_busy() lutris-0.5.19/lutris/util/process.py0000664000175000017500000001142214756670027016453 0ustar hibbyhibby"""Class to manipulate a process""" import os from lutris.util.log import logger IGNORED_PROCESSES = ( "tracker-store", "tracker-extract", "kworker", ) class Process: """Python abstraction a Linux process""" def __init__(self, pid): try: self.pid = int(pid) except ValueError as err: raise ValueError("'%s' is not a valid pid" % pid) from err def __repr__(self): return "Process {}".format(self.pid) def __str__(self): return "{} ({}:{})".format(self.name, self.pid, self.state) def _read_content(self, file_path): """Return the contents from a file in /proc""" try: with open(file_path, encoding="utf-8", errors="replace") as proc_file: content = proc_file.read() except PermissionError: return "" except (ProcessLookupError, FileNotFoundError) as ex: logger.debug(ex) return "" return content.strip("\x00") def get_stat(self, parsed=True): stat_filename = "/proc/{}/stat".format(self.pid) try: with open(stat_filename, encoding="utf-8", errors="replace") as stat_file: _stat = stat_file.readline() except (ProcessLookupError, FileNotFoundError): return None if parsed: return _stat[_stat.rfind(")") + 1 :].split() return _stat def get_thread_ids(self): """Return a list of thread ids opened by process.""" basedir = "/proc/{}/task/".format(self.pid) if os.path.isdir(basedir): try: return os.listdir(basedir) except FileNotFoundError: return [] else: return [] def get_children_pids_of_thread(self, tid): """Return pids of child processes opened by thread `tid` of process.""" children_path = "/proc/{}/task/{}/children".format(self.pid, tid) try: with open(children_path, encoding="utf-8", errors="replace") as children_file: children_content = children_file.read() except (FileNotFoundError, ProcessLookupError, PermissionError): children_content = "" return children_content.strip().split() @property def name(self): """Filename of the executable.""" _stat = self.get_stat(parsed=False) if _stat: return _stat[_stat.find("(") + 1 : _stat.rfind(")")] return None @property def state(self): """One character from the string "RSDZTW" where R is running, S is sleeping in an interruptible wait, D is waiting in uninterruptible disk sleep, Z is zombie, T is traced or stopped (on a signal), and W is paging. """ _stat = self.get_stat() if _stat: return _stat[0] return None @property def cmdline(self): """Return command line used to run the process `pid`.""" cmdline_path = "/proc/{}/cmdline".format(self.pid) _cmdline_content = self._read_content(cmdline_path) if _cmdline_content: return _cmdline_content.replace("\x00", " ").replace("\\", "/") @property def cwd(self): """Return current working dir of process""" cwd_path = "/proc/%d/cwd" % int(self.pid) return os.readlink(cwd_path) @property def environ(self): """Return the process' environment variables""" environ_path = "/proc/{}/environ".format(self.pid) _environ_text = self._read_content(environ_path) if not _environ_text or "=" not in _environ_text: return {} env_vars = [] for line in _environ_text.split("\x00"): if "=" not in line: continue env_vars.append(line.split("=", 1)) return dict(env_vars) @property def children(self): """Return the child processes of this process""" _children = [] for tid in self.get_thread_ids(): for child_pid in self.get_children_pids_of_thread(tid): _children.append(Process(child_pid)) return _children def iter_children(self): """Iterator that yields all the children of a process""" for child in self.children: yield child yield from child.iter_children() def wait_for_finish(self): """Waits until the process finishes This only works if self.pid is a child process of Lutris """ try: pid, ret_status = os.waitpid(int(self.pid) * -1, 0) except OSError as ex: logger.error("Failed to get exit status for PID %s", self.pid) logger.error(ex) return -1 logger.info("PID %s exited with code %s", pid, ret_status) return ret_status lutris-0.5.19/lutris/util/test_config.py0000664000175000017500000000050114756670027017275 0ustar hibbyhibbyimport os import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from lutris import startup from lutris.database import schema def setup_test_environment(): """Sets up a system to be able to run tests""" os.environ["LUTRIS_SKIP_INIT"] = "1" schema.syncdb() startup.init_lutris() lutris-0.5.19/lutris/util/jobs.py0000664000175000017500000001146314756670027015737 0ustar hibbyhibbyimport sys import threading import traceback from typing import Callable from gi.repository import GLib from lutris.util.log import logger class AsyncCall(threading.Thread): def __init__(self, func, callback, *args, **kwargs): """Execute `function` in a new thread then schedule `callback` for execution in the main loop. """ self.callback_task = None self.stop_request = threading.Event() super().__init__(target=self.target, args=args, kwargs=kwargs) self.function = func self.callback = callback if callback else lambda r, e: None self.daemon = kwargs.pop("daemon", True) self.start() def target(self, *a, **kw): result = None error = None try: result = self.function(*a, **kw) except Exception as ex: # pylint: disable=broad-except logger.error("Error while completing task %s: %s %s", self.function, type(ex), ex) error = ex _ex_type, _ex_value, trace = sys.exc_info() traceback.print_tb(trace) self.callback_task = schedule_at_idle(self.callback, result, error) class IdleTask: """This class provides a safe interface for cancelling idle tasks and timeouts; this will simply do nothing after being used once, and once the task completes, it will also do nothing. These objects are returned by the schedule methods below, which disconnect them when appropriate.""" def __init__(self) -> None: """Initializes a task with no connection to a source, but also not completed; this can be connected to a source via the connect() method, unless it is completed first.""" self.source_id = None self._is_completed = False def unschedule(self) -> None: """Call this to prevent the idle task from running, if it has not already run.""" if self.is_connected(): GLib.source_remove(self.source_id) self.disconnect() def is_connected(self) -> bool: """True if the idle task can still be unscheduled. If false, unschedule() will do nothing.""" return self.source_id is not None def is_completed(self) -> bool: """True if the idle task has completed; that is, if mark_completed() was called on it.""" return self._is_completed def connect(self, source_id) -> None: """Connects this task to a source to be unscheduled; but if the task is already completed, this does nothing.""" if not self._is_completed: self.source_id = source_id def disconnect(self) -> None: """Break the link to the idle task, so it can't be unscheduled.""" self.source_id = None def mark_completed(self) -> None: """Marks the task as completed, and also disconnect it.""" self._is_completed = True self.disconnect() # A task that is always completed and disconnected and does nothing. COMPLETED_IDLE_TASK = IdleTask() COMPLETED_IDLE_TASK.mark_completed() def schedule_at_idle(func: Callable[..., None], *args, delay_seconds: float = 0.0) -> IdleTask: """Schedules a function to run at idle time, once. You can specify a delay in seconds before it runs. Returns an object to prevent it running.""" task = IdleTask() def wrapper(*a, **kw) -> bool: try: func(*a, **kw) return False finally: task.disconnect() handler_object = func.__self__ if hasattr(func, "__self__") else None if handler_object: wrapper.__self__ = handler_object # type: ignore[attr-defined] if delay_seconds >= 0.0: milliseconds = int(delay_seconds * 1000) source_id = GLib.timeout_add(milliseconds, wrapper, *args) else: source_id = GLib.idle_add(wrapper, *args) task.connect(source_id) return task def schedule_repeating_at_idle( func: Callable[..., bool], *args, interval_seconds: float = 0.0, ) -> IdleTask: """Schedules a function to run at idle time, over and over until it returns False. It can be repeated at an interval in seconds, which will also delay it's first invocation. Returns an object to stop it running.""" task = IdleTask() def wrapper(*a, **kw) -> bool: repeat = False try: repeat = func(*a, **kw) return repeat finally: if not repeat: task.disconnect() handler_object = func.__self__ if hasattr(func, "__self__") else None if handler_object: wrapper.__self__ = handler_object # type: ignore[attr-defined] if interval_seconds >= 0.0: milliseconds = int(interval_seconds * 1000) source_id = GLib.timeout_add(milliseconds, wrapper, *args) else: source_id = GLib.idle_add(wrapper, *args) task.connect(source_id) return task lutris-0.5.19/lutris/util/dolphin.py0000664000175000017500000000243114756670027016432 0ustar hibbyhibby# Standard Library from mmap import mmap def scan_to_00(mm, start): """Read bytes from the mm mmap, beggining at the start offset and ending at the first 0x00. Return: bytes """ buff = b"" achar = None number = start while achar != 0: achar = mm[number] if achar != 0: buff += bytes((achar,)) number += 1 return buff def bytes_to_str(byte): """transform bytes to string with the default codec""" return str(byte)[2:-1] def rom_read_data(location): """extract data from the rom location at location. return a dict with "data" and "config", to be applied to a game in Lutris""" # TODO: extract the image of the rom data = {} with open(location, "r+", encoding="utf-8") as rom: mm = mmap(rom.fileno(), 0) # the most of the scan of the game if mm[0:4] == b"WBFS": # wii WBFS file data["name"] = bytes_to_str(scan_to_00(mm, 0x220)) data["slug"] = "wii-" + bytes_to_str(scan_to_00(mm, 0x200)) elif mm[0x18:0x1C] == b"\x5d\x1c\x9e\xa3": # wii iso file data["name"] = bytes_to_str(scan_to_00(mm, 0x20)) data["slug"] = "wii-" + bytes_to_str(scan_to_00(mm, 0x0)) else: return False return data lutris-0.5.19/lutris/util/wine/0000775000175000017500000000000014756670027015365 5ustar hibbyhibbylutris-0.5.19/lutris/util/wine/registry.py0000664000175000017500000002755714756670027017627 0ustar hibbyhibby"""Manipulate Wine registry files""" import os import re from collections import OrderedDict from datetime import datetime from lutris.util import system from lutris.util.log import logger from lutris.util.wine.wine import WINE_DEFAULT_ARCH ( REG_NONE, REG_SZ, REG_EXPAND_SZ, REG_BINARY, REG_DWORD, REG_DWORD_BIG_ENDIAN, REG_LINK, REG_MULTI_SZ, ) = range(8) DATA_TYPES = { '"': REG_SZ, 'str:"': REG_SZ, 'str(2):"': REG_EXPAND_SZ, 'str(7):"': REG_MULTI_SZ, "hex": REG_BINARY, "dword": REG_DWORD, } class WindowsFileTime: """Utility class to deal with Windows FILETIME structures. See: https://msdn.microsoft.com/en-us/library/ms724284(v=vs.85).aspx """ ticks_per_seconds = 10000000 # 1 tick every 100 nanoseconds epoch_delta = 11644473600 # 3600 * 24 * ((1970 - 1601) * 365 + 89) def __init__(self, timestamp=None): self.timestamp = timestamp def __repr__(self): return "<{}>: {}".format(self.__class__.__name__, self.timestamp) @classmethod def from_hex(cls, hexvalue): timestamp = int(hexvalue, 16) return WindowsFileTime(timestamp) def to_hex(self): return "{:x}".format(self.timestamp) @classmethod def from_unix_timestamp(cls, timestamp): timestamp = timestamp + cls.epoch_delta timestamp = int(timestamp * cls.ticks_per_seconds) return WindowsFileTime(timestamp) def to_unix_timestamp(self): if not self.timestamp: raise ValueError("No timestamp set") unix_ts = self.timestamp / self.ticks_per_seconds unix_ts = unix_ts - self.epoch_delta return unix_ts def to_date_time(self): return datetime.fromtimestamp(self.to_unix_timestamp()) class WineRegistry: version_header = "WINE REGISTRY Version " relative_to_header = ";; All keys relative to " def __init__(self, reg_filename=None): self.arch = WINE_DEFAULT_ARCH self.version = 2 self.relative_to = "\\\\User\\\\S-1-5-21-0-0-0-1000" self.keys = OrderedDict() self.reg_filename = reg_filename if reg_filename: if not system.path_exists(reg_filename): logger.error("No registry file at %s", reg_filename) self.parse_reg_file(reg_filename) def __str__(self): return "Windows Registry @ %s" % self.reg_filename @property def prefix_path(self): """Return the Wine prefix path (where the .reg files are located)""" if self.reg_filename: return os.path.dirname(self.reg_filename) return None @staticmethod def get_raw_registry(reg_filename): """Return an array of the unprocessed contents of a registry file""" if not system.path_exists(reg_filename): return [] with open(reg_filename, "r", encoding="utf-8") as reg_file: try: registry_content = reg_file.readlines() except Exception: # pylint: disable=broad-except logger.exception("Failed to registry read %s", reg_filename) registry_content = [] return registry_content def parse_reg_file(self, reg_filename): registry_lines = self.get_raw_registry(reg_filename) current_key = None add_next_to_value = False additional_values = [] for line in registry_lines: line = line.rstrip("\n") if line.startswith("["): current_key = WineRegistryKey(key_def=line) self.keys[current_key.name] = current_key elif current_key: if add_next_to_value: additional_values.append(line) elif not add_next_to_value: if additional_values: additional_values = "\n".join(additional_values) current_key.add_to_last(additional_values) additional_values = [] current_key.parse(line) add_next_to_value = line.endswith("\\") elif line.startswith(self.version_header): self.version = int(line[len(self.version_header) :]) elif line.startswith(self.relative_to_header): self.relative_to = line[len(self.relative_to_header) :] elif line.startswith("#arch"): self.arch = line.split("=")[1] def render(self): content = "{}{}\n".format(self.version_header, self.version) content += "{}{}\n\n".format(self.relative_to_header, self.relative_to) content += "#arch={}\n".format(self.arch) for key in self.keys: content += "\n" content += self.keys[key].render() return content def save(self, path=None): """Write the registry to a file""" if not path: path = self.reg_filename if not path: raise OSError("No filename provided") prefix_path = os.path.dirname(path) if not os.path.isdir(prefix_path): raise OSError( "Invalid Wine prefix path %s, make sure to " "create the prefix before saving to a registry" % prefix_path ) with open(path, "w", encoding="utf-8") as registry_file: registry_file.write(self.render()) def query(self, path, subkey): key = self.keys.get(path) if key: return key.get_subkey(subkey) return def set_value(self, path, subkey, value): key = self.keys.get(path) if not key: key = WineRegistryKey(path=path) self.keys[key.name] = key key.set_subkey(subkey, value) def clear_key(self, path): """Removes all subkeys from a key""" key = self.keys.get(path) if not key: return key.subkeys.clear() def clear_subkeys(self, path, keys): """Remove some subkeys from a key""" key = self.keys.get(path) if not key: return for subkey in list(key.subkeys.keys()): if subkey not in keys: continue key.subkeys.pop(subkey) def get_unix_path(self, windows_path): windows_path = windows_path.replace("\\", "/") if not self.prefix_path: return drives_path = os.path.join(self.prefix_path, "dosdevices") if not system.path_exists(drives_path): return letter, relpath = windows_path.split(":", 1) relpath = relpath.strip("/") drive_link = os.path.join(drives_path, letter.lower() + ":") try: drive_path = os.readlink(drive_link) except FileNotFoundError: logger.error("Unable to read link for %s", drive_link) return if not os.path.isabs(drive_path): drive_path = os.path.join(drives_path, drive_path) return os.path.join(drive_path, relpath) class WineRegistryKey: def __init__(self, key_def=None, path=None): self.subkeys = OrderedDict() self.metas = OrderedDict() if path: # Key is created by path, it's a new key timestamp = datetime.now().timestamp() self.name = path self.raw_name = "[{}]".format(path.replace("/", "\\\\")) self.raw_timestamp = " ".join(str(timestamp).split(".")) windows_timestamp = WindowsFileTime.from_unix_timestamp(timestamp) self.metas["time"] = windows_timestamp.to_hex() else: # Existing key loaded from file self.raw_name, self.raw_timestamp = re.split(re.compile(r"(?<=[^\\]\]) "), key_def, maxsplit=1) self.name = self.raw_name.replace("\\\\", "/").strip("[]") # Parse timestamp either as int or float ts_parts = self.raw_timestamp.strip().split() if len(ts_parts) == 1: self.timestamp = int(ts_parts[0]) else: self.timestamp = float("{}.{}".format(ts_parts[0], ts_parts[1])) def __str__(self): return "{0} {1}".format(self.raw_name, self.raw_timestamp) def parse(self, line): """Parse a registry line, populating meta and subkeys""" if len(line) < 4: # Line is too short, nothing to parse return if line.startswith("#"): self.add_meta(line) elif line.startswith('"'): try: key, value = re.split(re.compile(r"(? bool: """True if the version indicated specifies a Proton version of Wine; these require special handling.""" return version in get_proton_versions() def is_umu_path(path: str) -> bool: """True if the path given actually runs Umu; this will run Proton-Wine in turn, but can be directed to particular Proton implementation by setting the env-var PROTONPATH, but if this is omitted it will default to the latest Proton it downloads.""" return bool(path and (path.endswith("/umu_run.py") or path.endswith("/umu-run"))) def is_proton_path(wine_path: str) -> bool: """True if the path given actually runs Umu; this will run Proton-Wine in turn, but can be directed to particular Proton implementation by setting the env-var PROTONPATH, but if this is omitted it will default to the latest Proton it downloads. This function may be given the wine root directory or a file within such as the wine executable and will return true for either.""" for candidate_wine_path in get_proton_versions().values(): if system.path_contains(candidate_wine_path, wine_path): return True return False @cache_single def get_umu_path() -> str: """Returns the path to the Umu launch script, which can be run to execute a Proton version. It can supply a default Proton, but if the env-var PROTONPATH is set this will direct it to a specific Proton installation. The path that this returns will be considered an Umu path by is_umu_path(). If this script can't be found this will raise MissingExecutableError.""" # 'umu-run' is normally the entry point, and is a zipapp full of Python code. But # We used to ship a directory of loose files, and the entry point then is 'umu_run.py' entry_points = ["umu-run", "umu_run.py"] custom_path = settings.read_setting("umu_path") if custom_path: for entry_point in entry_points: entry_path = os.path.join(custom_path, entry_point) if system.path_exists(entry_path): return entry_path # We only use 'umu-run' when searching the path since that's the command # line entry point. if system.can_find_executable("umu-run"): return system.find_required_executable("umu-run") path_candidates = ( "/app/share", # prioritize flatpak due to non-rolling release distros "/usr/local/share", "/usr/share", "/opt", settings.RUNTIME_DIR, ) for path_candidate in path_candidates: for entry_point in entry_points: entry_path = os.path.join(path_candidate, "umu", entry_point) if system.path_exists(entry_path): return entry_path raise MissingExecutableError("Install umu to use Proton") def get_proton_wine_path(version: str) -> str: """Get the wine path for the specified proton version""" wine_path = get_proton_versions().get(version) if wine_path: wine_path_dist = os.path.join(wine_path, "dist/bin/wine") if os.path.exists(wine_path_dist): return wine_path_dist wine_path_files = os.path.join(wine_path, "files/bin/wine") if os.path.exists(wine_path_files): return wine_path_files raise MissingExecutableError(_("Proton version '%s' is missing its wine executable and can't be used.") % version) def get_proton_path_by_path(wine_path: str) -> str: # Split the path to get the directory containing the file directory_path = os.path.dirname(wine_path) # Navigate up two levels to reach the version directory version_directory = os.path.dirname(os.path.dirname(directory_path)) return version_directory def list_proton_versions() -> List[str]: """Return the list of Proton versions installed in Steam, in sorted order.""" return sorted(get_proton_versions().keys(), key=get_natural_sort_key, reverse=True) @cache_single def get_proton_versions() -> Dict[str, str]: """Return the dict of Proton versions installed in Steam, which is cached. The keys are the versions, and the values are the paths to those versions, which are their wine-paths.""" try: # We can only use a Proton install via the Umu launcher script. _ = get_umu_path() except MissingExecutableError: return {} versions = dict() for proton_path in _iter_proton_locations(): if os.path.isdir(proton_path): for version in os.listdir(proton_path): wine_path = os.path.join(proton_path, version) if os.path.isfile(os.path.join(wine_path, "proton")): versions[version] = wine_path return versions def _iter_proton_locations() -> Generator[str, None, None]: """Iterate through all potential Proton locations""" yield PROTON_DIR try: steamapp_dirs = get_steamapps_dirs() except: return # in case of corrupt or unreadable Steam configuration files! for path in [os.path.join(p, "common") for p in steamapp_dirs]: yield path for path in [os.path.join(p, "") for p in steamapp_dirs]: yield path def update_proton_env(wine_path: str, env: Dict[str, str], game_id: str = DEFAULT_GAMEID, umu_log: str = None) -> None: """Add various env-vars to an 'env' dict for use by Proton and Umu; this won't replace env-vars, so they can still be pre-set before we get here. This sets the PROTONPATH so the Umu launcher will know what Proton to use, and the WINEARCH to win64, which is what we expect Proton to always be. GAMEID is required, but we'll use a default GAMEID if you don't pass one in. This also propagates LC_ALL to HOST_LC_ALL, if LC_ALL is set.""" if "PROTONPATH" not in env: env["PROTONPATH"] = get_proton_path_by_path(wine_path) if "GAMEID" not in env: env["GAMEID"] = game_id if "UMU_LOG" not in env and umu_log: env["UMU_LOG"] = umu_log if "WINEARCH" not in env: env["WINEARCH"] = "win64" if "PROTON_VERB" not in env: # Proton fixes are only applied with waitforexitandrun, so we want to use that # but only if we're the first process start - the next concurrent process should # use run so it does not wait. prefix = env.get("WINEPREFIX") if prefix and prefix in (c.env.get("WINEPREFIX") for c in RUNNING_COMMANDS): env["PROTON_VERB"] = "runinprefix" # do *not* re-initialize a running prefix! else: env["PROTON_VERB"] = "waitforexitandrun" # does full initialization with proton-fixes locale = env.get("LC_ALL") host_locale = env.get("HOST_LC_ALL") if locale and not host_locale: env["HOST_LC_ALL"] = locale def get_game_id(game, env) -> str: if not game: return DEFAULT_GAMEID game_id = env.get("UMU_ID") if game_id: return game_id games_path = os.path.join(settings.RUNTIME_DIR, "umu-games/umu-games.json") if not os.path.exists(games_path): return DEFAULT_GAMEID with open(games_path, "r", encoding="utf-8") as games_file: umu_games = json.load(games_file) for umu_game in umu_games: if ( umu_game["store"] and ( umu_game["store"] == game.service or (umu_game["store"] == "humble" and game.service == "humblebundle") ) and umu_game["appid"] == game.appid ): return umu_game["umu_id"] return DEFAULT_GAMEID lutris-0.5.19/lutris/util/wine/dll_manager.py0000664000175000017500000003265614756670027020220 0ustar hibbyhibby"""Injects sets of DLLs into a prefix""" import json import os import shutil from gettext import gettext as _ from lutris import settings from lutris.api import get_runtime_versions from lutris.util import system from lutris.util.extract import extract_archive from lutris.util.http import download_file from lutris.util.log import logger from lutris.util.strings import parse_version from lutris.util.wine.prefix import WinePrefixManager class DLLManager: """Utility class to install dlls to a Wine prefix""" name = NotImplemented human_name = NotImplemented managed_dlls = NotImplemented managed_appdata_files = [] # most managers have none releases_url = NotImplemented archs = {32: "x32", 64: "x64"} proton_compatible = False # Proton manages its own DLLs def __init__(self, prefix=None, arch="win64", version=None): self.prefix = prefix self._versions = [] self._version = version self.wine_arch = arch @property def base_dir(self): return os.path.join(settings.RUNTIME_DIR, self.name) @property def versions_path(self): return os.path.join(self.base_dir, f"{self.name}_versions.json") @property def versions(self): """Return available versions""" self._versions = self.load_versions() if system.path_exists(self.base_dir): for local_version in os.listdir(self.base_dir): if os.path.isdir(os.path.join(self.base_dir, local_version)) and local_version not in self._versions: self._versions.append(local_version) return self._versions @property def version(self): """Return version (latest known version if not provided)""" if self._version: return self._version versions = self.versions if versions: def get_preference_key(v): return not self.is_compatible_version(v), not self.is_recommended_version(v) # Put the compatible versions first, and the recommended ones before unrecommended ones. sorted_versions = sorted(versions, key=get_preference_key) return sorted_versions[0] def is_recommended_version(self, version): """True if the version given should be usable as the default; false if it should not be the default, but may be selected by the user. If only non-recommended versions exist, we'll still default to one of them, however.""" return True def is_compatible_version(self, version): """True if the version of the component is compatible with this Lutris. We can tell only once it is downloaded; if not this is always True. This checks the file 'lutris.json', which may contain the lowest version of Lutris the component version will work with. If this setting is absent, it is assumed compatible.""" dir_settings = settings.get_lutris_directory_settings(self.base_dir) try: if "min_lutris_version" in dir_settings: min_lutris_version = parse_version(dir_settings["min_lutris_version"]) current_lutris_version = parse_version(settings.VERSION) if current_lutris_version < min_lutris_version: return False except TypeError as ex: logger.exception("Invalid lutris.json: %s", ex) return False return True @property def path(self): """Path to local folder containing DLLs""" version = self.version if not version: raise RuntimeError( "No path can be generated for %s because no version information is available." % self.human_name ) return os.path.join(self.base_dir, version) @property def version_choices(self): _choices = [ (_("Manual"), "manual"), ] for version in self.versions: _choices.append((version, version)) return _choices def load_versions(self) -> list: if not system.path_exists(self.versions_path): return [] with open(self.versions_path, "r", encoding="utf-8") as dll_version_file: try: dll_versions = [v["tag_name"] for v in json.load(dll_version_file)] except (KeyError, json.decoder.JSONDecodeError): logger.warning( "Invalid versions file %s, deleting so it is downloaded on next start.", self.versions_path ) os.remove(self.versions_path) return [] # Ensure the versions.json specified version is present and # is the default by moving it to the top. versions = get_runtime_versions() runtimes = versions.get("runtimes") if runtimes: runtime = runtimes.get(self.name) if runtime and runtime.get("versioned"): default_version = runtime.get("version") if default_version: if default_version in dll_versions: dll_versions.remove(default_version) dll_versions.insert(0, default_version) return dll_versions @staticmethod def is_managed_dll(dll_path): """Check if a given DLL path is provided by the component""" return False def is_available(self): """Return whether component is cached locally""" return self.version and system.path_exists(self.path) def dll_exists(self, dll_name): """Check if the dll is provided by the component The DLL might not be available for all architectures so only check if one exists for the supported ones """ return any(system.path_exists(os.path.join(self.path, arch, dll_name + ".dll")) for arch in self.archs.values()) def get_download_url(self): """Fetch the download URL from the JSON version file""" with open(self.versions_path, "r", encoding="utf-8") as version_file: releases = json.load(version_file) for release in releases: if release["tag_name"] != self.version: continue return release["assets"][0]["browser_download_url"] def download(self): """Download component to the local cache; returns True if successful but False if the component could not be downloaded.""" if self.is_available(): logger.warning("%s already available at %s", self.human_name, self.path) if not system.path_exists(self.versions_path): self.fetch_versions() url = self.get_download_url() if not url: logger.warning("Could not find a release for %s %s", self.human_name, self.version) return False archive_path = os.path.join(self.base_dir, os.path.basename(url)) logger.info("Downloading %s to %s", url, archive_path) download_file(url, archive_path, overwrite=True) if not system.path_exists(archive_path) or not os.stat(archive_path).st_size: logger.error("Failed to download %s %s", self.human_name, self.version) return False logger.info("Extracting %s to %s", archive_path, self.path) extract_archive(archive_path, self.path, merge_single=True) os.remove(archive_path) return True def enable_dll(self, system_dir, arch, dll_path): """Copies dlls to the appropriate destination""" dll = os.path.basename(dll_path) if system.path_exists(dll_path): wine_dll_path = os.path.join(system_dir, dll) if system.path_exists(wine_dll_path): if not self.is_managed_dll(wine_dll_path) and not os.path.islink(wine_dll_path): # Backing up original version (may not be needed) shutil.move(wine_dll_path, wine_dll_path + ".orig") else: os.remove(wine_dll_path) system.create_symlink(dll_path, wine_dll_path) else: self.disable_dll(system_dir, arch, dll) def disable_dll(self, system_dir, _arch, dll): # pylint: disable=unused-argument """Remove DLL from Wine prefix""" wine_dll_path = os.path.join(system_dir, "%s.dll" % dll) if system.path_exists(wine_dll_path + ".orig"): if system.path_exists(wine_dll_path): os.remove(wine_dll_path) shutil.move(wine_dll_path + ".orig", wine_dll_path) def enable_user_file(self, appdata_dir, file_path, source_path): if system.path_exists(source_path): wine_file_path = os.path.join(appdata_dir, file_path) wine_file_dir = os.path.dirname(wine_file_path) if system.path_exists(wine_file_path): if not os.path.islink(wine_file_path): # Backing up original version (may not be needed) shutil.move(wine_file_path, wine_file_path + ".orig") else: os.remove(wine_file_path) if not os.path.isdir(wine_file_dir): os.makedirs(wine_file_dir) system.create_symlink(source_path, wine_file_path) else: self.disable_user_file(appdata_dir, file_path) def disable_user_file(self, appdata_dir, file_path): wine_file_path = os.path.join(appdata_dir, file_path) # We only create a symlink; if it is a real file, it mus tbe user data. if system.path_exists(wine_file_path) and os.path.islink(wine_file_path): os.remove(wine_file_path) if system.path_exists(wine_file_path + ".orig"): shutil.move(wine_file_path + ".orig", wine_file_path) def _iter_dlls(self): windows_path = os.path.join(self.prefix, "drive_c/windows") if self.wine_arch == "win64": system_dirs = { self.archs[64]: os.path.join(windows_path, "system32"), self.archs[32]: os.path.join(windows_path, "syswow64"), } elif self.wine_arch == "win32": system_dirs = {self.archs[32]: os.path.join(windows_path, "system32")} for arch, system_dir in system_dirs.items(): for dll in self.managed_dlls: yield system_dir, arch, dll def _iter_appdata_files(self): if self.managed_appdata_files: prefix_manager = WinePrefixManager(self.prefix) appdata_dir = prefix_manager.appdata_dir for file in self.managed_appdata_files: filename = os.path.basename(file) yield appdata_dir, file, filename def setup(self, enable): """Enable or disable DLLs""" # manual version only sets the dlls to native (in get_enabling_dll_overrides()) manager_version = self.version if not manager_version or manager_version.lower() != "manual": if enable: self.enable() else: self.disable() def get_enabling_dll_overrides(self): """Returns aa dll-override dict for the dlls in this manager; these options will enable the manager's dll, so call this only for enabled managers.""" overrides = {} for dll in self.managed_dlls: # We have to make sure that the dll exists before setting it to native if self.dll_exists(dll): overrides[dll] = "n" return overrides def can_enable(self): return True def enable(self): """Enable Dlls for the current prefix""" if not self.is_available(): if not self.download(): logger.error( "%s %s could not be enabled because it is not available locally", self.human_name, self.version ) return for system_dir, arch, dll in self._iter_dlls(): dll_path = os.path.join(self.path, arch, "%s.dll" % dll) self.enable_dll(system_dir, arch, dll_path) for appdata_dir, file, filename in self._iter_appdata_files(): source_path = os.path.join(self.path, filename) self.enable_user_file(appdata_dir, file, source_path) def disable(self): """Disable DLLs for the current prefix""" for system_dir, arch, dll in self._iter_dlls(): self.disable_dll(system_dir, arch, dll) for appdata_dir, file, _filename in self._iter_appdata_files(): self.disable_user_file(appdata_dir, file) def fetch_versions(self): """Get releases from GitHub""" if not os.path.isdir(self.base_dir): os.mkdir(self.base_dir) download_file(self.releases_url, self.versions_path, overwrite=True) def upgrade(self): if not self.is_available(): versions = self.load_versions() if not versions: logger.warning("Unable to download %s because version information was not available.", self.human_name) # We prefer recommended versions, so download those first. versions.sort(key=self.is_recommended_version, reverse=True) for version in versions: logger.info("Downloading %s %s...", self.human_name, version) self.download() if self.is_compatible_version(version): return # got a compatible version, that'll do. logger.warning( "Version %s of %s is not compatible with this version of Lutris.", version, self.human_name ) # We found nothing compatible, and downloaded everything, we just give up. lutris-0.5.19/lutris/util/wine/wine.py0000664000175000017500000002541714756670027016712 0ustar hibbyhibby"""Utilities for manipulating Wine""" import os from collections import OrderedDict from gettext import gettext as _ from typing import Dict, List, Optional, Tuple from lutris import settings from lutris.api import get_default_wine_runner_version_info from lutris.exceptions import MisconfigurationError, UnavailableRunnerError, UnspecifiedVersionError from lutris.util import cache_single, linux, system from lutris.util.log import logger from lutris.util.strings import get_natural_sort_key, parse_version from lutris.util.wine import fsync, proton WINE_DIR: str = os.path.join(settings.RUNNER_DIR, "wine") WINE_DEFAULT_ARCH: str = "win64" if linux.LINUX_SYSTEM.is_64_bit else "win32" WINE_PATHS: Dict[str, str] = { "winehq-devel": "/opt/wine-devel/bin/wine", "winehq-staging": "/opt/wine-staging/bin/wine", "wine-development": "/usr/lib/wine-development/wine", "system": "wine", } # Insert additional system-wide Wine installations. try: if system.path_exists("/usr/lib"): for _candidate in os.listdir("/usr/lib/"): if _candidate.startswith("wine-"): _wine_path = os.path.join("/usr/lib/", _candidate, "bin/wine") if os.path.isfile(_wine_path): WINE_PATHS["System " + _candidate] = _wine_path _candidate = None _wine_path = None except Exception as ex: logger.exception("Unable to enumerate system Wine versions: %s", ex) def detect_arch(prefix_path: str = None, wine_path: str = None) -> str: """Given a Wine prefix path, return its architecture""" if wine_path: if proton.is_proton_path(wine_path) or system.path_exists(wine_path + "64"): return "win64" if prefix_path and is_prefix_directory(prefix_path): return detect_prefix_arch(prefix_path) return "win32" def is_prefix_directory(prefix_path: str) -> bool: """Detects if a path is ther oot of a Wine prefix; to be one, it must contain a 'system.reg' file.""" if not prefix_path: return False prefix_path = os.path.expanduser(prefix_path) registry_path = os.path.join(prefix_path, "system.reg") return os.path.isdir(prefix_path) and os.path.isfile(registry_path) def detect_prefix_arch(prefix_path: str) -> str: """Return the architecture of the prefix found in `prefix_path`""" if not is_prefix_directory(prefix_path): raise RuntimeError("Prefix not found: %s" % prefix_path) prefix_path = os.path.expanduser(prefix_path) registry_path = os.path.join(prefix_path, "system.reg") with open(registry_path, "r", encoding="utf-8") as registry: for _line_no in range(5): line = registry.readline() if "win64" in line: return "win64" if "win32" in line: return "win32" logger.error("Failed to detect Wine prefix architecture in %s; defaulting to 32-bit.", prefix_path) return "win32" def set_drive_path(prefix: str, letter: str, path: str) -> None: """Changes the path to a Wine drive""" dosdevices_path = os.path.join(prefix, "dosdevices") if not system.path_exists(dosdevices_path): raise OSError("Invalid prefix path %s" % prefix) drive_path = os.path.join(dosdevices_path, letter + ":") if system.path_exists(drive_path): os.remove(drive_path) logger.debug("Linking %s to %s", drive_path, path) system.create_symlink(path, drive_path) def is_gstreamer_build(wine_path: str) -> bool: """Returns whether a wine build ships with gstreamer libraries. This allows to set GST_PLUGIN_SYSTEM_PATH_1_0 for the builds that support it. """ base_path = os.path.dirname(os.path.dirname(wine_path)) return system.path_exists(os.path.join(base_path, "lib64/gstreamer-1.0")) def is_installed_systemwide() -> bool: """Return whether Wine is installed outside of Lutris""" for build in WINE_PATHS.values(): if system.can_find_executable(build): return True return False def list_system_wine_versions() -> List[str]: """Return the list of wine versions installed on the system""" versions = [name for name, path in WINE_PATHS.items() if get_system_wine_version(path)] return sorted(versions, key=get_natural_sort_key, reverse=True) def list_lutris_wine_versions() -> List[str]: """Return the list of wine versions installed by lutris""" if not system.path_exists(WINE_DIR): return [] versions = [] for dirname in version_sort(os.listdir(WINE_DIR), reverse=True): try: wine_path = get_wine_path_for_version(version=dirname) if wine_path and os.path.isfile(wine_path): versions.append(dirname) except MisconfigurationError: pass # if it's not properly installed, skip it return sorted(versions, key=get_natural_sort_key, reverse=True) @cache_single def get_installed_wine_versions() -> List[str]: """Return the list of Wine versions installed""" return list_system_wine_versions() + proton.list_proton_versions() + list_lutris_wine_versions() def clear_wine_version_cache() -> None: get_installed_wine_versions.cache_clear() proton.get_proton_versions.cache_clear() proton.get_umu_path.cache_clear() def get_runner_files_dir_for_version(version: str) -> Optional[str]: """This returns the path to the root of the Wine files for a specific version. The 'bin' directory for that version is there, and we can place more directories there. If we shouldn't do that, this will return None.""" if version in WINE_PATHS: return None elif proton.is_proton_version(version): return os.path.join(proton.PROTON_DIR, version, "files") else: return os.path.join(WINE_DIR, version) def get_wine_path_for_version(version: str, config: dict = None) -> str: """Return the absolute path of a wine executable for a given version, or the configured version if you don't ask for a version.""" if not version and config: version = config["version"] if not version: raise UnspecifiedVersionError(_("The Wine version must be specified.")) if version in WINE_PATHS: return system.find_required_executable(WINE_PATHS[version]) if proton.is_proton_version(version): return proton.get_proton_wine_path(version) if version == "custom": if config is None: raise RuntimeError("Custom wine paths are only supported when a configuration is available.") wine_path = config.get("custom_wine_path") if not wine_path: raise RuntimeError("The 'custom' Wine version can be used only if the custom wine path is set.") return wine_path return os.path.join(WINE_DIR, version, "bin/wine") def parse_wine_version(version: str) -> Tuple[List[int], str, str]: """This is a specialized parse_version() that adjusts some odd Wine versions for correct parsing.""" version = version.replace("Proton7-", "Proton-7.") version = version.replace("Proton8-", "Proton-8.") version = version.replace("Proton9-", "Proton-9.") return parse_version(version) def version_sort(versions: List[str], reverse: bool = False) -> List[str]: def version_key(version): version_list, prefix, suffix = parse_wine_version(version) # Normalize the length of sub-versions sort_key = version_list + [0] * (10 - len(version_list)) sort_key.append(prefix) sort_key.append(suffix) return sort_key return sorted(versions, key=version_key, reverse=reverse) def is_esync_limit_set() -> bool: """Checks if the number of files open is acceptable for esync usage.""" return linux.LINUX_SYSTEM.has_enough_file_descriptors() def is_fsync_supported() -> bool: """Checks if the running kernel has Valve's futex patch applied.""" return fsync.get_fsync_support() def get_default_wine_version() -> str: """Return the default version of wine.""" installed_versions = get_installed_wine_versions() if installed_versions: default_version = get_default_wine_runner_version_info() if default_version and "version" in default_version and "architecture" in default_version: version = default_version["version"] + "-" + default_version["architecture"] if version in installed_versions: return version return installed_versions[0] raise UnavailableRunnerError(_("No versions of Wine are installed.")) def get_system_wine_version(wine_path: str = "wine") -> str: """Return the version of Wine installed on the system.""" if wine_path != "wine" and not system.path_exists(wine_path): return "" if wine_path == "wine" and not system.can_find_executable("wine"): return "" version = system.read_process_output([wine_path, "--version"]) if not version: logger.error("Error reading wine version for %s", wine_path) return "" if version.startswith("wine-"): version = version[5:] return version def get_real_executable(windows_executable: str, working_dir: str) -> Tuple[str, List[str], str]: """Given a Windows executable, return the real program capable of launching it along with necessary arguments.""" exec_name = windows_executable.lower() if exec_name.endswith(".msi"): return ("msiexec", ["/i", windows_executable], working_dir) if exec_name.endswith(".bat"): if not working_dir or os.path.dirname(windows_executable) == working_dir: working_dir = os.path.dirname(windows_executable) windows_executable = os.path.basename(windows_executable) return ("cmd", ["/C", windows_executable], working_dir) if exec_name.endswith(".lnk"): return ("start", ["/unix", windows_executable], working_dir) return (windows_executable, [], working_dir) def get_overrides_env(overrides: Dict[str, str]) -> str: """ Output a string of dll overrides usable with WINEDLLOVERRIDES See: https://wiki.winehq.org/Wine_User%27s_Guide#WINEDLLOVERRIDES.3DDLL_Overrides """ default_overrides = {"winemenubuilder": ""} overrides.update(default_overrides) override_buckets = OrderedDict([("n,b", []), ("b,n", []), ("b", []), ("n", []), ("d", []), ("", [])]) for dll, value in overrides.items(): if not value: value = "" value = value.replace(" ", "") value = value.replace("builtin", "b") value = value.replace("native", "n") value = value.replace("disabled", "") try: override_buckets[value].append(dll) except KeyError: logger.error("Invalid override value %s", value) continue override_strings = [] for value, dlls in override_buckets.items(): if not dlls: continue override_strings.append("{}={}".format(",".join(sorted(dlls)), value)) return ";".join(override_strings) lutris-0.5.19/lutris/util/wine/dxvk_nvapi.py0000664000175000017500000000331114756670027020106 0ustar hibbyhibbyimport os from lutris.util import system from lutris.util.linux import LINUX_SYSTEM from lutris.util.nvidia import get_nvidia_dll_path from lutris.util.wine.dll_manager import DLLManager class DXVKNVAPIManager(DLLManager): name = "dxvk-nvapi" human_name = "DXVK-NVAPI" # apparently, nvofapi.dll (the 32 bit version) is not being included here - # see https://github.com/jp7677/dxvk-nvapi/pull/213 managed_dlls = ("nvapi", "nvapi64", "nvml", "nvofapi64") releases_url = "https://api.github.com/repos/lutris/dxvk-nvapi/releases" dlss_dlls = ("nvngx", "_nvngx") def can_enable(self): return LINUX_SYSTEM.is_vulkan_supported() def disable_dll(self, system_dir, _arch, dll): # pylint: disable=unused-argument """Remove DLL from Wine prefix""" wine_dll_path = os.path.join(system_dir, "%s.dll" % dll) if system.path_exists(wine_dll_path): os.remove(wine_dll_path) def enable(self): """Enable Dlls for the current prefix""" super().enable() dlss_dll_dir = get_nvidia_dll_path() if not dlss_dll_dir: return windows_path = os.path.join(self.prefix, "drive_c/windows") system_dir = os.path.join(windows_path, "system32") for dll in self.dlss_dlls: dll_path = os.path.join(dlss_dll_dir, "%s.dll" % dll) self.enable_dll(system_dir, "x64", dll_path) def disable(self): """Disable DLLs for the current prefix""" super().disable() windows_path = os.path.join(self.prefix, "drive_c/windows") system_dir = os.path.join(windows_path, "system32") for dll in self.dlss_dlls: self.disable_dll(system_dir, "x64", dll) lutris-0.5.19/lutris/util/wine/dxvk.py0000664000175000017500000000341614756670027016717 0ustar hibbyhibby"""DXVK helper module""" import os from lutris.util.graphics import vkquery from lutris.util.linux import LINUX_SYSTEM from lutris.util.wine.dll_manager import DLLManager REQUIRED_VULKAN_API_VERSION = vkquery.vk_make_version(1, 3, 0) class DXVKManager(DLLManager): name = "dxvk" human_name = "DXVK" managed_dlls = ("dxgi", "d3d11", "d3d10core", "d3d9", "d3d8") releases_url = "https://api.github.com/repos/lutris/dxvk/releases" def can_enable(self): if os.environ.get("LUTRIS_NO_VKQUERY"): return True return LINUX_SYSTEM.is_vulkan_supported() def is_recommended_version(self, version): # DXVK 2.x and later require Vulkan 1.3, so if that is lacking # we default to 1.x. if os.environ.get("LUTRIS_NO_VKQUERY"): return True vulkan_api_version = vkquery.get_expected_api_version() if vulkan_api_version and vulkan_api_version < REQUIRED_VULKAN_API_VERSION: return version.startswith("v1.") return super().is_recommended_version(version) @staticmethod def is_managed_dll(dll_path): """Check if a given DLL path is provided by the component Very basic check to see if a dll contains the string "dxvk". """ try: with open(dll_path, "rb") as file: prev_block_end = b"" while True: block = file.read(2 * 1024 * 1024) # 2 MiB if not block: break if b"dxvk" in prev_block_end + block[:4]: return True if b"dxvk" in block: return True prev_block_end = block[-4:] except OSError: pass return False lutris-0.5.19/lutris/util/wine/prefix.py0000664000175000017500000003460514756670027017244 0ustar hibbyhibby"""Wine prefix management""" import os from lutris.settings import get_lutris_directory_settings, set_lutris_directory_settings from lutris.util import joypad, system from lutris.util.display import DISPLAY_MANAGER from lutris.util.log import logger from lutris.util.wine.registry import WineRegistry from lutris.util.xdgshortcuts import get_xdg_entry DESKTOP_KEYS = ["Desktop", "Personal", "My Music", "My Videos", "My Pictures"] DEFAULT_DESKTOP_FOLDERS = ["Desktop", "Documents", "Music", "Videos", "Pictures"] DESKTOP_XDG = ["DESKTOP", "DOCUMENTS", "MUSIC", "VIDEOS", "PICTURES"] DEFAULT_DLL_OVERRIDES = { "winemenubuilder": "", } def is_prefix(path): """Return True if the path is prefix""" return os.path.isdir(os.path.join(path, "drive_c")) and os.path.exists(os.path.join(path, "user.reg")) def find_prefix(path): """Given an executable path, try to find a Wine prefix associated with it.""" dir_path = path if not dir_path: logger.info("No path given, unable to guess prefix location") return dir_path = os.path.expanduser(dir_path) while dir_path != "/" and dir_path: dir_path = os.path.dirname(dir_path) if is_prefix(dir_path): return dir_path for prefix_dir in ("prefix", "pfx"): prefix_path = os.path.join(dir_path, prefix_dir) if is_prefix(prefix_path): return prefix_path class WinePrefixManager: """Class to allow modification of Wine prefixes without the use of Wine""" hkcu_prefix = "HKEY_CURRENT_USER" hklm_prefix = "HKEY_LOCAL_MACHINE" def __init__(self, path): if not path: logger.warning("No path specified for Wine prefix") # expanduser() just in case- it should already be expanded. self.path = os.path.expanduser(path) def get_user_dir(self, default_user=None): user = default_user or os.getenv("USER") or "lutrisuser" return os.path.join(self.path, "drive_c/users/", user) @property def user_dir(self): """Returns the directory that contains the current user's profile in the WINE prefix.""" return self.get_user_dir() @property def appdata_dir(self): """Returns the app-data directory for the user; this depends on a registry key.""" user_dir = self.get_user_dir() folder = self.get_registry_key( self.hkcu_prefix + "/Software/Microsoft/Windows/CurrentVersion/Explorer/Shell Folders", "AppData", ) if folder is None: logger.warning("Get Registry Key function returned NoneType to variable folder.") else: # Don't try to resolve the Windows path we get- there's # just two options, the Vista-and later option and the # XP-and-earlier option. if folder.lower().endswith("\\application data"): return os.path.join(user_dir, "Application Data") # Windows XP return os.path.join(user_dir, "AppData/Roaming") # Vista def setup_defaults(self): """Sets the defaults for newly created prefixes""" for dll, value in DEFAULT_DLL_OVERRIDES.items(): self.override_dll(dll, value) def create_user_symlinks(self): """Link together user profiles created by Wine and Proton""" wine_user_dir = self.get_user_dir() proton_user_dir = self.get_user_dir(default_user="steamuser") if system.path_exists(wine_user_dir) and not system.path_exists(proton_user_dir, check_symlinks=True): system.create_symlink(wine_user_dir, proton_user_dir) elif system.path_exists(proton_user_dir) and not system.path_exists(wine_user_dir, check_symlinks=True): system.create_symlink(proton_user_dir, wine_user_dir) def get_registry_path(self, key): """Matches registry keys to a registry file Currently, only HKEY_CURRENT_USER keys are supported. """ if key.startswith(self.hkcu_prefix): return os.path.join(self.path, "user.reg") if key.startswith(self.hklm_prefix): return os.path.join(self.path, "system.reg") raise ValueError("Unsupported key '{}'".format(key)) def get_key_path(self, key): for prefix in (self.hkcu_prefix, self.hklm_prefix): if key.startswith(prefix): return key[len(prefix) + 1 :] raise ValueError("The key {} is currently not supported by WinePrefixManager".format(key)) def get_registry_key(self, key, subkey): registry = WineRegistry(self.get_registry_path(key)) return registry.query(self.get_key_path(key), subkey) def set_registry_key(self, key, subkey, value): registry = WineRegistry(self.get_registry_path(key)) registry.set_value(self.get_key_path(key), subkey, value) registry.save() def clear_registry_key(self, key): registry = WineRegistry(self.get_registry_path(key)) registry.clear_key(self.get_key_path(key)) registry.save() def clear_registry_subkeys(self, key, subkeys): registry = WineRegistry(self.get_registry_path(key)) registry.clear_subkeys(self.get_key_path(key), subkeys) registry.save() def override_dll(self, dll, mode): key = self.hkcu_prefix + "/Software/Wine/DllOverrides" if mode.startswith("dis"): mode = "" if mode not in ("builtin", "native", "builtin,native", "native,builtin", ""): logger.error("DLL override '%s' mode is not valid", mode) return self.set_registry_key(key, dll, mode) def get_desktop_folders(self): """Return the list of desktop folder names loaded from the Windows registry""" desktop_folders = [] for key in DESKTOP_KEYS: folder = self.get_registry_key( self.hkcu_prefix + "/Software/Microsoft/Windows/CurrentVersion/Explorer/Shell Folders", key, ) if not folder: logger.warning("Couldn't load shell folder name for %s", key) continue desktop_folders.append(folder[folder.rfind("\\") + 1 :]) return desktop_folders or DEFAULT_DESKTOP_FOLDERS def install_desktop_integration(self): """Replace WINE's desktop folders with links to the corresponding folders in your home directory.""" user_dir = self.user_dir home_dir = os.path.expanduser("~") current_dir = self._get_desktop_integration_assignment() or user_dir if system.path_exists(user_dir, check_symlinks=True) and current_dir != home_dir: desktop_folders = self.get_desktop_folders() for i, item in enumerate(desktop_folders): path = os.path.join(user_dir, item) safe_path = path + ".winecfg" self._remove_desktop_folder(path, safe_path) # if we want to create a symlink and one is already there, just # skip to the next item. this also makes sure we don't # find a dir (isdir only looks at the target of the symlink). src_path = get_xdg_entry(DESKTOP_XDG[i]) if not src_path: logger.error("No XDG entry found for %s, launcher not created", DESKTOP_XDG[i]) else: system.create_symlink(src_path, path) self._set_desktop_integration_assignment(home_dir) def remove_desktop_integration(self): """Replace the desktop integration links with proper folders.""" user_dir = self.user_dir current_dir = self._get_desktop_integration_assignment() or user_dir if system.path_exists(user_dir) and current_dir != user_dir: desktop_folders = self.get_desktop_folders() for item in desktop_folders: path = os.path.join(user_dir, item) safe_path = path + ".winecfg" # Disintegration means the desktop folders in WINE are # actual directories not links, and that's all we want. if not os.path.islink(path): continue self._remove_desktop_folder(path, safe_path) # We prefer to restore the previously saved directory. if os.path.isdir(safe_path): os.rename(safe_path, path) else: os.makedirs(path, exist_ok=True) self._set_desktop_integration_assignment(user_dir) def _remove_desktop_folder(self, path, safe_path): """Removes the link or directory at 'path'; if it is a non-empty directory this will rename it to 'safe_path' instead of removing it entirely.""" if os.path.islink(path): os.unlink(path) elif os.path.isdir(path): try: os.rmdir(path) except OSError: # We can't delete nonempty dir, so we rename as wine do. os.rename(path, safe_path) def _get_desktop_integration_assignment(self): try: # If the old tracking file is found, we'll read it, unlink it, and # save the setting in the new form. obsolete_path = os.path.join(self.path, ".lutris_destkop_integration") if os.path.isfile(obsolete_path): with open(obsolete_path, "r", encoding="utf-8") as f: desktop_dir = f.read() self._set_desktop_integration_assignment(desktop_dir) os.unlink(obsolete_path) except Exception as ex: logger.exception("Unable to read Lutris desktop integration setting: %s", ex) settings = get_lutris_directory_settings(self.path) return settings.get("desktop_integration_directory", "") def _set_desktop_integration_assignment(self, desktop_dir): set_lutris_directory_settings(self.path, {"desktop_integration_directory": desktop_dir or ""}) def set_crash_dialogs(self, enabled): """Enable or diable Wine crash dialogs""" self.set_registry_key( self.hkcu_prefix + "/Software/Wine/WineDbg", "ShowCrashDialog", 1 if enabled else 0, ) def set_virtual_desktop(self, enabled): """Enable or disable wine virtual desktop. The Lutris virtual desktop is refered to as 'WineDesktop', in Wine the virtual desktop name is 'default'. """ path = self.hkcu_prefix + "/Software/Wine/Explorer" if enabled: self.set_registry_key(path, "Desktop", "WineDesktop") default_resolution = "x".join(DISPLAY_MANAGER.get_current_resolution()) logger.debug( "Enabling wine virtual desktop with default resolution of %s", default_resolution, ) self.set_registry_key( self.hkcu_prefix + "/Software/Wine/Explorer/Desktops", "WineDesktop", default_resolution, ) else: self.clear_registry_key(path) def set_desktop_size(self, desktop_size): """Sets the desktop size if one is given but do not reset the key if one isn't. """ path = self.hkcu_prefix + "/Software/Wine/Explorer/Desktops" if desktop_size: self.set_registry_key(path, "WineDesktop", desktop_size) def set_dpi(self, dpi): """Sets the DPI for WINE to use. None to remove the Lutris setting, and leave WINE in control.""" # Convert the old hidden file into a 'lutris.json' settings file obsolete_path = os.path.join(self.path, ".lutris_dpi_assignment") try: if os.path.isfile(obsolete_path): with open(obsolete_path, "r", encoding="utf-8") as f: dpi_assigned = int(f.read()) set_lutris_directory_settings(self.path, int(dpi_assigned)) os.unlink(obsolete_path) except Exception as ex: logger.exception("Unable to read Lutris assigned DPI: %s", ex) settings = get_lutris_directory_settings(self.path) key_paths = [self.hkcu_prefix + "/Software/Wine/Fonts", self.hkcu_prefix + "/Control Panel/Desktop"] def assign_dpi(dpi): for key_path in key_paths: self.set_registry_key(key_path, "LogPixels", dpi) def is_lutris_dpi_assigned(): """Check if Lutris assigned the DPI presently found in the registry.""" try: dpi_assigned = settings.get("dpi_assigned") if dpi_assigned: dpi_assigned = int(dpi_assigned) else: return False except Exception as ex: logger.exception("Unable to read Lutris assigned DPI: %s", ex) return False for key_path in key_paths: if dpi_assigned != self.get_registry_key(key_path, "LogPixels"): return False return True if dpi: assign_dpi(dpi) set_lutris_directory_settings(self.path, {"dpi_assigned": dpi}) elif settings.get("dpi_assigned"): if is_lutris_dpi_assigned(): assign_dpi(96) # reset previous DPI set_lutris_directory_settings(self.path, {"dpi_assigned": ""}) def configure_joypads(self): """Disables some joypad devices""" key = self.hkcu_prefix + "/Software/Wine/DirectInput/Joysticks" self.clear_registry_key(key) for _device, joypad_name in joypad.get_joypads(): # Attempt at disabling mice that register as joysticks. # Although, those devices aren't returned by `get_joypads` # A better way would be to read /dev/input files directly. if "HARPOON RGB" in joypad_name: self.set_registry_key(key, "{} (js)".format(joypad_name), "disabled") self.set_registry_key(key, "{} (event)".format(joypad_name), "disabled") # This part of the code below avoids having 2 joystick interfaces # showing up simulatenously. It is not sure if it's still needed # so it is disabled for now. Street Fighter IV now runs in Proton # without this sort of hack. # # for device, joypad_name in joypads: # if "event" in device: # disabled_joypad = "{} (js)".format(joypad_name) # else: # disabled_joypad = "{} (event)".format(joypad_name) # self.set_registry_key(key, disabled_joypad, "disabled") lutris-0.5.19/lutris/util/wine/extract_icon.py0000664000175000017500000001152514756670027020425 0ustar hibbyhibby# pylint: disable=no-member import struct from io import BytesIO try: import pefile PEFILE_AVAILABLE = True except ImportError: pefile = None PEFILE_AVAILABLE = False from PIL import Image # From https://github.com/firodj/extract-icon-py class ExtractIcon(object): GRPICONDIRENTRY_format = ( "GRPICONDIRENTRY", ("B,Width", "B,Height", "B,ColorCount", "B,Reserved", "H,Planes", "H,BitCount", "I,BytesInRes", "H,ID"), ) GRPICONDIR_format = ("GRPICONDIR", ("H,Reserved", "H,Type", "H,Count")) RES_ICON = 1 RES_CURSOR = 2 def __init__(self, filepath): self.pe = pefile.PE(filepath) def find_resource_base(self, res_type): if hasattr(self.pe, "DIRECTORY_ENTRY_RESOURCE"): try: rt_base_idx = [entry.id for entry in self.pe.DIRECTORY_ENTRY_RESOURCE.entries].index( pefile.RESOURCE_TYPE[res_type] ) if rt_base_idx is not None: return self.pe.DIRECTORY_ENTRY_RESOURCE.entries[rt_base_idx] except (ValueError, IndexError): pass # if the resource is not found or the index is bogus return None def find_resource(self, res_type, res_index): rt_base_dir = self.find_resource_base(res_type) if not rt_base_dir: return None if res_index < 0: try: idx = [entry.id for entry in rt_base_dir.directory.entries].index(-res_index) except: return None else: idx = res_index if res_index < len(rt_base_dir.directory.entries) else None if idx is None: return None test_res_dir = rt_base_dir.directory.entries[idx] res_dir = test_res_dir if test_res_dir.struct.DataIsDirectory: # another Directory # probably language take the first one res_dir = test_res_dir.directory.entries[0] if res_dir.struct.DataIsDirectory: # Ooooooooooiconoo no !! another Directory !!! return None return res_dir def get_group_icons(self): rt_base_dir = self.find_resource_base("RT_GROUP_ICON") if not rt_base_dir: return [] groups = [] for res_index in range(0, len(rt_base_dir.directory.entries)): grp_icon_dir_entry = self.find_resource("RT_GROUP_ICON", res_index) if not grp_icon_dir_entry: continue data_rva = grp_icon_dir_entry.data.struct.OffsetToData size = grp_icon_dir_entry.data.struct.Size data = self.pe.get_memory_mapped_image()[data_rva : data_rva + size] file_offset = self.pe.get_offset_from_rva(data_rva) grp_icon_dir = pefile.Structure(self.GRPICONDIR_format, file_offset=file_offset) grp_icon_dir.__unpack__(data) if grp_icon_dir.Reserved != 0 or grp_icon_dir.Type != self.RES_ICON: continue offset = grp_icon_dir.sizeof() entries = [] for _idx in range(0, grp_icon_dir.Count): grp_icon = pefile.Structure(self.GRPICONDIRENTRY_format, file_offset=file_offset + offset) grp_icon.__unpack__(data[offset:]) offset += grp_icon.sizeof() entries.append(grp_icon) groups.append(entries) return groups def get_icon(self, index): icon_entry = self.find_resource("RT_ICON", -index) if not icon_entry: return None data_rva = icon_entry.data.struct.OffsetToData size = icon_entry.data.struct.Size data = self.pe.get_memory_mapped_image()[data_rva : data_rva + size] return data def export_raw(self, entries, index=None): if index is not None: entries = entries[index : index + 1] ico = struct.pack("\n" "__NR_" + syscall_name + "\n") except FileNotFoundError as ex: raise RuntimeError( "failed to determine " + syscall_name + " syscall number: " "cpp not installed or not in PATH" ) from ex if popen.returncode: raise RuntimeError( "failed to determine " + syscall_name + " syscall number: " "cpp returned nonzero exit code", stderr ) if not stdout: raise RuntimeError("failed to determine " + syscall_name + " syscall number: " "no output from cpp") last_line = stdout.splitlines()[-1] if last_line == "__NR_futex": raise RuntimeError( "failed to determine " + syscall_name + " syscall number: " "__NR_" + syscall_name + " not expanded" ) try: return int(last_line) except ValueError as ex: raise RuntimeError( "failed to determine " + syscall_name + " syscall number: " "__NR_" + syscall_name + " not a valid number: " + last_line ) from ex assert False # Hardcode some of the most commonly used architectures's # futex syscall numbers. _NR_FUTEX_PER_ARCH = { ("i386", 32): 240, ("i686", 32): 240, ("x86_64", 32): 240, ("x86_64", 64): 202, ("aarch64", 64): 240, ("aarch64_be", 64): 240, ("armv8b", 32): 240, ("armv8l", 32): 240, } def _get_futex_syscall_nr(): """Get the syscall number of the Linux futex() syscall. Returns: The futex() syscall number. Raises: RuntimeError: When the syscall number could not be determined. """ bits = ctypes.sizeof(ctypes.c_void_p) * 8 try: return _NR_FUTEX_PER_ARCH[(os.uname()[4], bits)] except KeyError: pass return _get_syscall_nr_from_headers("futex") def _is_ctypes_obj(obj): return hasattr(obj, "_b_base_") and hasattr(obj, "_b_needsfree_") and hasattr(obj, "_objects") def _is_ctypes_obj_pointer(obj): return hasattr(obj, "_type_") and hasattr(obj, "contents") def _coerce_to_pointer(obj): if obj is None: return None if _is_ctypes_obj(obj): if _is_ctypes_obj_pointer(obj): return obj return ctypes.pointer(obj) obj = tuple(obj) return (obj[0].__class__ * len(obj))(*obj) def _get_futex_syscall(): """Create a function that can be used to execute the Linux futex() syscall. Returns: A proxy function for the Linux futex() syscall. Raises: AttributeError: When the libc has no syscall() function. RuntimeError: When the syscall number could not be determined. """ futex_syscall = ctypes.CDLL(None, use_errno=True).syscall futex_syscall.argtypes = ( ctypes.c_long, ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.POINTER(timespec), ctypes.c_void_p, ctypes.c_int, ) futex_syscall.restype = ctypes.c_int futex_syscall_nr = _get_futex_syscall_nr() # pylint: disable=too-many-arguments def _futex_syscall(uaddr, futex_op, val, timeout, uaddr2, val3): """Invoke the Linux futex() syscall with the provided arguments. Args: See the description of the futex() syscall for the parameter meanings. `uaddr` and `uaddr2` are automatically converted to pointers. If timeout is None, a zero timeout is passed. Returns: A tuple of the return value of the syscall and the error code in case an error occurred. Raises: AttributeError: When the libc has no syscall() function. RuntimeError: When the syscall number could not be determined. TypeError: If `uaddr` or `uaddr2` is not a pointer and can't be converted into one. """ error = futex_syscall( futex_syscall_nr, _coerce_to_pointer(uaddr), futex_op, val, _coerce_to_pointer(timeout or timespec()), _coerce_to_pointer(uaddr2), val3, ) return error, (ctypes.get_errno() if error == -1 else 0) return _futex_syscall def _get_futex_wait_multiple_op(futex_syscall): """Detects which (if any) futex opcode is used for the FUTEX_WAIT_MULTIPLE operation on this kernel. Returns: The opcode number, or None if the operation is not supported. """ ret = futex_syscall(None, 31, 0, None, None, 0) if ret[1] != errno.ENOSYS: return 31 ret = futex_syscall(None, 13, 0, None, None, 0) if ret[1] != errno.ENOSYS: return 13 return None @cache_single def is_futex_wait_multiple_supported(): """Checks whether the Linux futex FUTEX_WAIT_MULTIPLE operation is supported on this kernel. Returns: Whether this kernel supports the FUTEX_WAIT_MULTIPLE operation. """ try: return _get_futex_wait_multiple_op(_get_futex_syscall()) is not None except (AttributeError, RuntimeError): return False @cache_single def is_futex2_supported(): """Checks whether the Linux futex2 syscall is supported on this kernel. Returns: Whether this kernel supports the futex2 syscall. """ try: for filename in ("wait", "waitv", "wake"): with open("/sys/kernel/futex2/" + filename, "rb") as file: if not file.readline().strip().isdigit(): return False except OSError: return False return True # Hardcode some of the most commonly used architectures's # futex_waitv syscall numbers. _NR_FUTEX_WAITV_PER_ARCH = { ("i386", 32): 449, ("i686", 32): 449, ("x86_64", 32): 449, ("x86_64", 64): 449, ("aarch64", 64): 449, ("aarch64_be", 64): 449, ("armv8b", 32): 449, ("armv8l", 32): 449, } def _get_futex_waitv_syscall_nr(): """Get the syscall number of the Linux futex_waitv() syscall. Returns: The futex_waitv() syscall number. Raises: RuntimeError: When the syscall number could not be determined. """ bits = ctypes.sizeof(ctypes.c_void_p) * 8 try: return _NR_FUTEX_WAITV_PER_ARCH[(os.uname()[4], bits)] except KeyError: pass return _get_syscall_nr_from_headers("futex_waitv") # pylint: disable=invalid-name,too-few-public-methods class futex_waitv(ctypes.Structure): """Linux kernel compatible futex_waitv type. Fields: val: The expected value. uaddr: The address to wait for. flags: The type and size of the futex. """ __slots__ = () _fields_ = [ ("val", ctypes.c_uint64), ("uaddr", ctypes.c_void_p), ("flags", ctypes.c_uint), ] def _get_futex_waitv_syscall(): """Create a function that can be used to execute the Linux futex_waitv() syscall. Returns: A proxy function for the Linux futex_waitv() syscall. Raises: AttributeError: When the libc has no syscall() function. RuntimeError: When the syscall number could not be determined. """ futex_waitv_syscall = ctypes.CDLL(None, use_errno=True).syscall futex_waitv_syscall.argtypes = ( ctypes.c_long, ctypes.POINTER(futex_waitv), ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(timespec), ) futex_waitv_syscall.restype = ctypes.c_long futex_waitv_syscall_nr = _get_futex_waitv_syscall_nr() # pylint: disable=too-many-arguments def _futex_waitv_syscall(waiters, nr_futexes, flags, timeout): """Invoke the Linux futex_waitv() syscall with the provided arguments. Args: See the description of the futex_waitv() syscall for the parameter meanings. `waiters` is automatically converted to a pointer. If timeout is None, a zero timeout is passed. Returns: A tuple of the return value of the syscall and the error code in case an error occurred. Raises: AttributeError: When the libc has no syscall() function. RuntimeError: When the syscall number could not be determined. TypeError: If `waiters` is not a pointer and can't be converted into one. """ error = futex_waitv_syscall( futex_waitv_syscall_nr, _coerce_to_pointer(waiters), nr_futexes, flags, _coerce_to_pointer(timeout) ) return error, (ctypes.get_errno() if error == -1 else 0) return _futex_waitv_syscall @cache_single def is_futex_waitv_supported(): """Checks whether the Linux 5.16 futex_waitv syscall is supported on this kernel. Returns: Whether this kernel supports the futex_waitv syscall. """ try: ret = _get_futex_waitv_syscall()(None, 0, 0, None) return ret[1] != errno.ENOSYS except (AttributeError, RuntimeError): return False @cache_single def get_fsync_support(): """Checks whether the FUTEX_WAIT_MULTIPLE operation, the futex2 syscalls, or the futex_waitv syscall is supported on this kernel. Returns: The result of the check. """ if is_futex_waitv_supported(): return True if is_futex2_supported(): return True if is_futex_wait_multiple_supported(): return True return False lutris-0.5.19/lutris/util/wine/d3d_extras.py0000664000175000017500000000237514756670027020006 0ustar hibbyhibbyfrom lutris.util.wine.dll_manager import DLLManager class D3DExtrasManager(DLLManager): name = "d3d_extras" human_name = "D3D Extras" managed_dlls = ( "d3dx10_33", "d3dx10_34", "d3dx10_35", "d3dx10_36", "d3dx10_37", "d3dx10_38", "d3dx10_39", "d3dx10_40", "d3dx10_41", "d3dx10_42", "d3dx10_43", "d3dx10", "d3dx11_42", "d3dx11_43", "d3dx9_24", "d3dx9_25", "d3dx9_26", "d3dx9_27", "d3dx9_28", "d3dx9_29", "d3dx9_30", "d3dx9_31", "d3dx9_32", "d3dx9_33", "d3dx9_34", "d3dx9_35", "d3dx9_36", "d3dx9_37", "d3dx9_38", "d3dx9_39", "d3dx9_40", "d3dx9_41", "d3dx9_42", "d3dx9_43", "d3dcompiler_33", "d3dcompiler_34", "d3dcompiler_35", "d3dcompiler_36", "d3dcompiler_37", "d3dcompiler_38", "d3dcompiler_39", "d3dcompiler_40", "d3dcompiler_41", "d3dcompiler_42", "d3dcompiler_43", "d3dcompiler_46", "d3dcompiler_47", ) releases_url = "https://api.github.com/repos/lutris/d3d_extras/releases" lutris-0.5.19/lutris/util/fileio.py0000664000175000017500000000467714756670027016262 0ustar hibbyhibby# Standard Library import re from collections import OrderedDict from configparser import RawConfigParser class EvilConfigParser(RawConfigParser): # pylint: disable=too-many-ancestors """ConfigParser with support for evil INIs using duplicate keys.""" _SECT_TMPL = r""" \[ # [ (?P
[^]]+) # very permissive! \] # ] """ _OPT_TMPL = r""" (?P