pax_global_header00006660000000000000000000000064145143515470014523gustar00rootroot0000000000000052 comment=ba9c0aea5a9db54dca2bc39dc7e9bad5c6a566b5 lutris-0.5.14/000077500000000000000000000000001451435154700131345ustar00rootroot00000000000000lutris-0.5.14/.agignore000066400000000000000000000000341451435154700147250ustar00rootroot00000000000000share/lutris/bin/winetricks lutris-0.5.14/.editorconfig000066400000000000000000000005741451435154700156170ustar00rootroot00000000000000# EditorConfig is awesome: http://EditorConfig.org # top-most EditorConfig file root = true [*] end_of_line = lf insert_final_newline = true indent_style = space indent_size = 4 tab_width = 4 charset = utf-8 trim_trailing_whitespace = true max_line_length = 120 [*.rst] tab_width = 4 [*.{yaml,yml}] tab_width = 2 indent_size = 2 [Makefile] indent_style = tab indent_size = 4 lutris-0.5.14/.flake8000066400000000000000000000005161451435154700143110ustar00rootroot00000000000000[flake8] ignore = # , # description # do not use bare except' (done by pylint) E722, # line break before binary operator W503, # module level import not at top of file (gtk stuff) E402, max-line-length = 120 exclude = .venv,venv,.env,env,lutris/game.py max-complexity = 15 accept-encodings = utf-8 lutris-0.5.14/.github/000077500000000000000000000000001451435154700144745ustar00rootroot00000000000000lutris-0.5.14/.github/FUNDING.yml000066400000000000000000000001041451435154700163040ustar00rootroot00000000000000patreon: lutris liberapay: Lutris custom: https://lutris.net/donate lutris-0.5.14/.github/ISSUE_TEMPLATE/000077500000000000000000000000001451435154700166575ustar00rootroot00000000000000lutris-0.5.14/.github/ISSUE_TEMPLATE/bug_report_form.yml000066400000000000000000000103541451435154700226000ustar00rootroot00000000000000name: 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.** *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*** - type: checkboxes id: terms attributes: label: "Checklist:" description: "By submitting this issue, you confirm that (this is purely performative, we know you're going to ignore the stuff below):" options: - label: "I'm not asking for support with a game or the wine runner." required: true - label: "I have followed the above mentioned guides and have all the graphics and wine dependencies installed." required: true - label: "I have checked for existing issues that describe my problem prior to opening this one." required: true - label: "I understand that improperly formatted bug reports may be closed without explanation." required: true lutris-0.5.14/.github/scripts/000077500000000000000000000000001451435154700161635ustar00rootroot00000000000000lutris-0.5.14/.github/scripts/build-ubuntu-22.04.sh000077500000000000000000000026121451435154700216050ustar00rootroot00000000000000#!/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.14/.github/scripts/build-ubuntu-generic.sh000077500000000000000000000047331451435154700225620ustar00rootroot00000000000000#!/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.14/.github/scripts/build-ubuntu.sh000077500000000000000000000137261451435154700211520ustar00rootroot00000000000000#!/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.14/.github/scripts/install-ubuntu-generic.sh000077500000000000000000000004001451435154700231140ustar00rootroot00000000000000#!/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.14/.github/scripts/install-ubuntu.sh000077500000000000000000000032541451435154700215140ustar00rootroot00000000000000#!/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.14/.github/workflows/000077500000000000000000000000001451435154700165315ustar00rootroot00000000000000lutris-0.5.14/.github/workflows/publish-lutris-ppa.yml000066400000000000000000000033161451435154700230230ustar00rootroot00000000000000# 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.14/.github/workflows/publish-ppa.yml000066400000000000000000000045271451435154700215100ustar00rootroot00000000000000# 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. "kinetic lunar" JAMMY_BUILDS: "kinetic lunar" 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.14/.gitignore000066400000000000000000000005141451435154700151240ustar00rootroot00000000000000nbproject build .project .pydevproject .settings .ropeproject .idea tags *.pyc *.pyo *.ui~ PYSMELLTAGS lutris.e4p .coverage pga.db tests/coverage/* /dist /lutris.egg-info # meson builddirs builddir # i18n files po/lutris.pot po/*.mo transl-builddir # virtual environment folders venv .venv env .env # glade recovery files *.ui~ lutris-0.5.14/.isort.cfg000066400000000000000000000003651451435154700150370ustar00rootroot00000000000000[settings] line_length=120 multi_line_output=6 skip= application.py, ;known_deps = ;known_third_party = known_first_party = lutris sections = FUTURE, STDLIB, THIRDPARTY, FIRSTPARTY, LOCALFOLDER default_section=THIRDPARTY lutris-0.5.14/.mypy_baseline000066400000000000000000000000011451435154700157640ustar00rootroot00000000000000 lutris-0.5.14/.pylintrc000066400000000000000000000313351451435154700150060ustar00rootroot00000000000000[MASTER] # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code extension-pkg-whitelist=lxml.etree # Add files or directories to the blacklist. They should be base names, not # paths. ignore= # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. ignore-patterns= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). init-hook="import gi; gi.require_version('Gdk', '3.0'); gi.require_version('Gtk', '3.0'); gi.require_version('GnomeDesktop', '3.0')" # Use multiple processes to speed up Pylint. jobs=4 # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins= # Pickle collected data for later comparisons. persistent=yes # Specify a configuration file. #rcfile= # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED confidence= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once).You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" disable= broad-except, fixme, global-statement, invalid-name, missing-docstring, too-few-public-methods, unexpected-keyword-arg, ungrouped-imports, useless-object-inheritance, inconsistent-return-statements, unsubscriptable-object, not-an-iterable, unused-argument, bare-except, too-many-statements, too-many-locals, too-many-branches, too-many-public-methods, arguments-differ, signature-differs, unsupported-membership-test, protected-access, wrong-import-position, import-outside-toplevel, duplicate-code, consider-using-f-string # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable= [REPORTS] # Python expression which should return a note less than 10 (10 is the highest # note). You have access to the variables errors warning, statement which # respectively contain the number of errors / warnings messages and the total # number of statements analyzed. This is used by the global evaluation report # (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details #msg-template= # Set the output format. Available formats are text, parseable, colorized, json # and msvs (visual studio).You can also give a reporter class, eg # mypackage.mymodule.MyReporterClass. output-format=text # Tells whether to display a full report or only the messages reports=no # Activate the evaluation score. score=yes [REFACTORING] # Maximum number of nested blocks for function / method body max-nested-blocks=5 [LOGGING] # Logging modules to check that the string format arguments are in logging # function parameter format logging-modules=logging [SPELLING] # Spelling dictionary name. Available dictionaries: none. To make it working # install python-enchant package. spelling-dict= # List of comma separated words that should not be checked. spelling-ignore-words= # A path to a file that contains private dictionary; one word per line. spelling-private-dict-file= # Tells whether to store unknown words to indicated private dictionary in # --spelling-private-dict-file option instead of raising a message. spelling-store-unknown-words=no [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME,TODO [SIMILARITIES] # Ignore comments when computing similarities. ignore-comments=yes # Ignore docstrings when computing similarities. ignore-docstrings=yes # Ignore imports when computing similarities. ignore-imports=yes # Minimum lines number of a similarity. min-similarity-lines=4 [TYPECHECK] # List of decorators that produce context managers, such as # contextlib.contextmanager. Add to this list to register other decorators that # produce valid context managers. contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. generated-members= # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members=yes # This flag controls whether pylint should warn about no-member and similar # checks whenever an opaque object is returned when inferring. The inference # can return multiple potential results while evaluating a Python object, but # some branches might not be evaluated, which results in partial inference. In # that case, it might be useful to still emit no-member and other checks for # the rest of the inferred objects. ignore-on-opaque-inference=yes # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of # qualified names. ignored-classes=optparse.Values,thread._local,_thread._local # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. ignored-modules= # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. missing-member-hint=yes # The minimum edit distance a name should have in order to be considered a # similar match for a missing member name. missing-member-hint-distance=1 # The total number of similar names that should be taken in consideration when # showing a hint for a missing member. missing-member-max-choices=1 [VARIABLES] # List of additional names supposed to be defined in builtins. Remember that # you should avoid to define new builtins when possible. additional-builtins= # Tells whether unused global variables should be treated as a violation. allow-global-unused-variables=yes # List of strings which can identify a callback function by name. A callback # name must start or end with one of those strings. callbacks=cb_,_cb # A regular expression matching the name of dummy variables (i.e. expectedly # not used). dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ # Argument names that match this expression will be ignored. Default to name # with leading underscore ignored-argument-names=_.*|^ignored_|^unused_ # Tells whether we should check for unused import in __init__ files. init-import=no # List of qualified module names which can have objects that can redefine # builtins. redefining-builtins-modules=six.moves,future.builtins [FORMAT] # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' # Maximum number of characters on a single line. max-line-length=120 # Maximum number of lines in a module max-module-lines=1000 # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=no [BASIC] # Regular expression matching correct argument names argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Regular expression matching correct attribute names attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Bad variable names which should always be refused, separated by a comma bad-names=foo,bar,baz,toto,tutu,tata # Regular expression matching correct class attribute names class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ # Regular expression matching correct class names class-rgx=[A-Z_][a-zA-Z0-9]+$ # Regular expression matching correct constant names const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length=-1 # Regular expression matching correct function names function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Good variable names which should always be accepted, separated by a comma good-names=i,j,k,ex,Run,_ # Include a hint for the correct naming format with invalid-name include-naming-hint=no # Regular expression matching correct inline iteration names inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ # Regular expression matching correct method names method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Regular expression matching correct module names module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=^_ # List of decorators that produce properties, such as abc.abstractproperty. Add # to this list to register other decorators that produce valid properties. property-classes=abc.abstractproperty # Regular expression matching correct variable names variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ [DESIGN] # Maximum number of arguments for function / method max-args=9 # Maximum number of attributes for a class (see R0902). max-attributes=35 # Maximum number of boolean expressions in a if statement max-bool-expr=5 # Maximum number of branch for function / method body max-branches=12 # Maximum number of locals for function / method body max-locals=15 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of public methods for a class (see R0904). max-public-methods=20 # Maximum number of return / yield for function / method body max-returns=6 # Maximum number of statements in function / method body max-statements=50 # Minimum number of public methods for a class (see R0903). min-public-methods=2 [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__,__new__,setUp # List of member names, which should be excluded from the protected access # warning. exclude-protected=_asdict,_fields,_replace,_source,_make # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=mcs [IMPORTS] # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all=no # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists # only in one or another interpreter, leading to false positives when analysed. analyse-fallback-blocks=no # Deprecated modules which should not be used, separated by a comma deprecated-modules=regsub,TERMIOS,Bastion,rexec # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled) ext-import-graph= # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled) import-graph= # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled) int-import-graph= # Force import order to recognize a module as part of the standard # compatibility libraries. known-standard-library= # Force import order to recognize a module as part of a third party library. known-third-party= lutris-0.5.14/.travis.yml000066400000000000000000000021511451435154700152440ustar00rootroot00000000000000dist: bionic language: python python: - "3.7" - "3.8" - "3.9" env: - LUTRIS_SKIP_INIT=1 virtualenv: system_site_packages: false services: - xvfb addons: apt: update: true packages: - xvfb - libdbus-1-dev - python3-yaml - python3-gi - python3-gi-cairo - python3-pil - python3-setproctitle - python3-distro - python3-magic - python3-lxml - gir1.2-gtk-3.0 - psmisc - gir1.2-glib-2.0 - libgirepository1.0-dev - gir1.2-gnomedesktop-3.0 - gir1.2-webkit2-4.0 - gir1.2-notify-0.7 - at-spi2-core before_install: - "export DISPLAY=:99.0" - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & - "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16" - sleep 3 # Give xvfb some time to start install: - pip install --upgrade pip pypresence~=3.3.2 poetry - poetry install --no-root script: - make isort-check - make flake8 - make pylint - nosetests # Cache the pip dependencies cache: pip lutris-0.5.14/.yapf000066400000000000000000000007661451435154700141050ustar00rootroot00000000000000[style] based_on_style = pep8 column_limit = 120 align_closing_bracket_with_visual_indent = true blank_line_before_class_docstring = true blank_line_before_module_docstring = true blank_line_before_nested_class_or_def = true coalesce_brackets = false dedent_closing_brackets = true spaces_around_power_operator = false spaces_before_comment = 2 split_before_first_argument = false split_before_logical_operator = true split_before_arithmetic_operator = true split_penalty_after_opening_bracket = 1000 lutris-0.5.14/AUTHORS000066400000000000000000000032721451435154700142100ustar00rootroot00000000000000Copyright (C) 2009 Mathieu Comandon Contributors: Mathieu Comandon Pascal Reinhard (Xodetaetl) Daniel J (@djazz) Tom Todd Rob Loach cxf (@AccountOneOff) Alexandr Oleynikov Patrick Griffis Aaron Opfer (@AaronOpfer) Rebecca Wallander Frederik “Freso” S. Olesen telanus Leandro Stanger Travis Nickles Medath Manuel Vögele Xenega sigmaSd Arne Sellmann LeandroStanger duhow MrTimscampi Nbiba Bedis soredake Alexander Ravenheart Rémi Verschelde tcarrio Tammas Loughran Max le Fou mandruis 999gary Christoffer Anselm bebop350 Ivan Julien Machiels Julio Campagnolo Kukuh Syafaat TotalCaesar659 mikeyd nastys v-vansteen Christian Dannie Storgaard Clonewayx LEARAX Roxie Gibson Taeyeon Mori BunnyApocalypse glitchbunny luthub malt1 matthewkovacs boombatower Alan Pearce Alexander Bessman AsciiWolf Benjamin Weis FlyingWombat Francesco Turco Jan Havran Jeff Corcoran Joshua Strobl Kevin Turner Lucki Marcin Mikołajczak Mehdi Lahlou Nathaniel Case Nico Linder Steven Pledger Tom Willemse Tomas Tomecek Wybe Westra Édouard Lopez Ludovic Soulié Yunusemre Şentürk Yurii Kolesnykov Patryk Obara (@dreamer) hazelnot danieljohnson2 lutris-0.5.14/CONTRIBUTING.md000066400000000000000000000165651451435154700154020ustar00rootroot00000000000000Contributing to Lutris ====================== IMPORTANT! If you contribute to Lutris on a somewhat regular basis, be sure to add yourself to the AUTHORS file! 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%22need+help%22) that can't be reproduced on the developers setup. Other issues, tagged [need help](https://github.com/lutris/lutris/issues?q=is%3Aissue+is%3Aopen+label%3A%22need+help%22) might be a bit more technical to resolve but you can always have a look and see if they fit your area of expertise. 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 option 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 plans for any rewrite in another language or switching to another toolkit. 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 and it is not possible for pip to install them or use them from a virtualenv. 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 dependencies installed run: `make dev` This will install all necessary python packages to get you up and running. 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 pep8 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 When writing docstrings, you should follow the Google style (See: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) You should always provide docstrings, otherwise your code wouldn't pass a Pylint check. 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. At the very least, run the test suite and check that nothing broke. You can run the test suite by typing `make test` in the source directory. In order to run the test, you'll need to install nose2 and flake8: pip3 install nose2 flake8 QAing your changes ------------------ It is very important that any of your changes be tested manually, especially if you didn't add unit tests for the patch. 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, or `next` if you want to target a future release. 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 #nnn)` or `(Fixes #nnn)` to your PR title or message, where `nnn` is the ticket number you're fixing. If you have been fixing your PR with several commits, please consider squashing those commits into one with `git rebase -i`. 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) * [Fakegir GObject code completion](https://github.com/strycore/fakegir) 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.14/INSTALL.rst000066400000000000000000000066741451435154700150110ustar00rootroot00000000000000Installing Lutris ================= Requirements ------------ Lutris should work on any up to date Linux system. It is based on Python and Gtk but will run on any desktop environment. If you installed Lutris from our PPA or some other repository, it should already come with all of its essential dependencies. However, if you need to install Lutris manually, it requires the following components: * Python >= 3.7 * PyGObject * PyGObject bindings for: Gtk, Gdk, Cairo, GnomeDesktop, Webkit2, Notify * python3-requests * python3-pillow * python3-yaml * python3-setproctitle * python3-distro * python3-evdev (optional, for controller detection) These dependencies are only for running the Lutris client. To install and run games themselves we recommend you install the following packages: * psmisc (or the package providing 'fuser') * p7zip (or the package providing '7z') * curl * fluid-soundfont-gs (or other soundfonts for MIDI support) * cabextract (if needed, to install Windows games) * x11-xserver-utils (or the package providing 'xrandr', if you are running Xorg, if you are not, you will depend on the GnomeDesktop bindings to fetch screen resolutions on Wayland, the GnomeDesktop library is not directly related to the Gnome desktop and is only used as a xrandr replacement.) * The 32bit OpenGL and Vulkan drivers for your graphics card * Wine (not actually needed, but installing it is the easiest way to get all the libraries missing from our runtime). To install all those dependencies (except for Wine and graphics drivers) on Ubuntu based systems, you can run:: sudo apt install python3-yaml python3-requests python3-pil python3-gi python3-gi-cairo \ gir1.2-gtk-3.0 gir1.2-gnomedesktop-3.0 gir1.2-webkit2-4.0 \ gir1.2-notify-0.7 psmisc cabextract unzip p7zip curl fluid-soundfont-gs \ x11-xserver-utils python3-evdev libgirepository1.0-dev \ python3-setproctitle python3-distro Note : If you use OpenSUSE, some dependencies are missing. You need to install python3-gobject-Gdk and typelib-1_0-Gtk-3_0 ``sudo apt install python3-gobject-Gdk typelib-1_0-Gtk-3_0`` Installation ------------ To install Lutris, please follow instructions listed on our `Downloads Page `_. Getting Lutris from a PPA or a repository is the preferred way of installing it and we *strongly advise* to use this method if you can. However, if the instructions on our Downloads page don't apply to your Linux distribution or there's some other reason you can't get it from a package, you can run it directly from the source directory:: git clone https://github.com/lutris/lutris cd lutris ./bin/lutris Alternatively you can install Lutris manually with the help of **virtualenv**. First, install ``python-virtualenv`` from your distribution's repositories, along with dependencies listed in Requirements_. Then, create and activate virtual environment for Lutris:: virtualenv --system-site-packages ~/lutris source ~/lutris/bin/activate While in the virtual environment, run the installation script:: python3 setup.py install Run Lutris ----------- If you installed Lutris using a package, you can launch the program by typing ``lutris`` at the command line (same applies to virtualenv method, but you need to activate the virtual environment first). And if you want to run Lutris without installing it, start ``./bin/lutris`` from within the source directory. lutris-0.5.14/LICENSE000066400000000000000000001043741451435154700141520ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . lutris-0.5.14/MANIFEST.in000066400000000000000000000002211451435154700146650ustar00rootroot00000000000000recursive-include lutris *.py include bin/lutris include LICENSE include AUTHORS include MANIFEST.in include README.rst graft debian prune tests lutris-0.5.14/Makefile000066400000000000000000000064411451435154700146010ustar00rootroot00000000000000VERSION=`grep "__version__" lutris/__init__.py | cut -d" " -f 3 | sed 's|"\(.*\)"|\1|'` GITBRANCH ?= master # Default GPG key ID to use for package signing. PPA_GPG_KEY_ID ?= 82D96E430A1F1C0F0502747E37B90EDD4E3EFAE4 PYTHON:=$(shell which python3) PIP:=$(PYTHON) -m pip all: export GITBRANCH=master debuild debclean unsigned: export GITBRANCH=master debuild -i -us -uc -b debclean # Build process for GitHub runners. # Requires two environment variables related to package signing. # PPA_GPG_KEY_ID # Key ID used to sign the .deb package files. # PPA_GPG_PASSPHRASE # Decrypts the private key associated with GPG_KEY_ID. # # When running from a GitHub workflow. The above environment variables # are passed in from .github/scripts/build-ubuntu.sh and that script # receives those variables from the .github/workflows/publish-lutris-ppa.yml # which receives them from the repository secrets. github-ppa: export GITBRANCH=master # Automating builds for different Ubuntu codenames manipulates the # version string, and so that lintian check is suppressed. Also note # that all parameters after "--lintian-opts" are passed to lintian # so that _must_ be the last parameter. echo "y" | debuild -S \ -k"${PPA_GPG_KEY_ID}" \ -p"gpg --batch --passphrase ${PPA_GPG_PASSPHRASE} --pinentry-mode loopback" \ --lintian-opts --suppress-tags malformed-debian-changelog-version build-deps-ubuntu: sudo apt install devscripts debhelper dh-python meson build: gbp buildpackage --git-debian-branch=${GITBRANCH} clean: debclean build-source: clean gbp buildpackage -S --git-debian-branch=${GITBRANCH} mkdir build mv ../lutris_${VERSION}* build release: build-source upload upload-ppa test: rm tests/fixtures/pga.db -f nose2 cover: rm tests/fixtures/pga.db -f rm tests/coverage/ -rf nose2 --with-coverage --cover-package=lutris --cover-html --cover-html-dir=tests/coverage pgp-renew: osc signkey --extend home:strycore osc rebuildpac home:strycore --all changelog-add: EDITOR=vim dch -i changelog-edit: EDITOR=vim dch -e upload: scp build/lutris_${VERSION}.tar.xz anaheim:~/volumes/releases/ upload-ppa: dput ppa:lutris-team/lutris build/lutris_${VERSION}*_source.changes upload-staging: dput --force ppa:lutris-team/lutris-staging build/lutris_${VERSION}*_source.changes snap: snapcraft clean lutris -s pull snapcraft dev: pip3 install isort flake8 pylint autopep8 pytest mypy mypy-baseline # ============ # Style checks # ============ style: isort autopep8 ## Format code isort: isort lutris autopep8: autopep8 --in-place --recursive --ignore E402 setup.py lutris # =============== # Static analysis # =============== check: isort-check flake8 pylint mypy isort-check: isort lutris -c flake8: flake8 . --count --max-complexity=25 --max-line-length=120 --show-source --statistics pylint: pylint lutris --rcfile=.pylintrc --output-format=colorized bandit: bandit . --recursive --skip B101,B105,B107,B108,B303,B310,B311,B314,B320,B404,B405,B410,B602,B603,B607,B608 black: black . --check mypy: mypy . --install-types --non-interactive 2>&1 | mypy-baseline filter mypy-reset-baseline: # Add new typing errors to mypy. Use sparingly. mypy . --install-types --non-interactive 2>&1 | mypy-baseline sync # ============= # Abbreviations # ============= sc: style check styles: style checks: check lutris-0.5.14/README.rst000066400000000000000000000132421451435154700146250ustar00rootroot00000000000000****** Lutris ****** |LiberaPayBadge|_ |PatreonBadge|_ Lutris helps you install and play video games from all eras and from most gaming systems. By leveraging and combining existing emulators, engine re-implementations and compatibility layers, it gives you a central interface to launch all your games. The client can connect with existing services like Humble Bundle, GOG and Steam to make your game libraries easily available. Game downloads and installations are automated and can be modified through user made scripts. Running Lutris ============== If you have not installed Lutris through your package manager and are using the source package, it is recommended that you install lutris at least once, even an older version to have all dependencies available. Once all dependencies are satisfied, you can run lutris directly from the source directory with `./bin/lutris` If you need to run lutris through gdb to troubleshoot segmentation faults, you can use the following command: `gdb -ex r --args "/usr/bin/python3" "./bin/lutris"` Installer scripts ================= Lutris installations are fully automated through scripts, which can be written in either JSON or YAML. The scripting syntax is described in ``docs/installers.rst``, and is also available online at `lutris.net `_. Game library ============ Optional accounts can be created at `lutris.net `_ and linked with Lutris clients. This enables your client to automatically sync fetch library from the website. **It is currently not possible to sync from the client to the cloud.** Via the website, it is also possible to sync your Steam library to your Lutris library. The Lutris client only stores a token when connected with the website, and your login credentials are never saved. This token is stored in ``~/.cache/lutris/auth-token``. Configuration files =================== * ``~/.config/lutris``: The client, runners, and game configuration files There is no need to manually edit these files as everything should be done from the client. * ``lutris.conf``: Preferences for the client's UI * ``system.yml``: Default game configuration, which applies to every game * ``runners/*.yml``: Runner-specific configurations * ``games/*.yml``: Game-specific configurations Game-specific configurations overwrite runner-specific configurations, which in turn overwrite the system configuration. Runners and the game database ============================= ``~/.local/share/lutris``: All data necessary to manage Lutris' library and games, including: * ``pga.db``: An SQLite database tracking the game library, game installation status, various file locations, and some additional metadata * ``runners/*``: Runners downloaded from `lutris.net ` * ``banners/*.jpg``: Game banners ``~/.local/share/icons/hicolor/128x128/apps/lutris_*.png``: Game icons Command line options ==================== The following command line arguments are available:: -v, --version Print the version of Lutris and exit -d, --debug Show debug messages -i, --install Install a game from a yml file -b, --output-script Generate a bash script to run a game without the client -e, --exec Execute a program with the lutris runtime -l, --list-games List all games in database -o, --installed Only list installed games -s, --list-steam-games List available Steam games --list-steam-folders List all known Steam library folders --list-runners List all known runners --list-wine-versions List all known Wine runners -a, --list-all-service-games List all games for all services in database --list-service-games List all games for provided service in database -r, --install-runner Install a Runner -u, --uninstall-runner Uninstall a Runner -j, --json Display the list of games in JSON format --reinstall Reinstall game --display=DISPLAY X display to use --export Exports specified game (requires --dest) --import Specifies Export/Import destination folder Additionally, you can pass a ``lutris:`` protocol link followed by a game identifier on the command line such as:: lutris lutris:quake This will install the game if it is not already installed, otherwise it will launch the game. The game will always be installed if the ``--reinstall`` flag is passed. Support the project =================== Lutris is 100% community supported, to ensure a continuous development on the project, please consider donating to the project. Our main platform for supporting Lutris is Patreon: https://www.patreon.com/lutris but there are also other options available at https://lutris.net/donate Come with us! ============= Want to make Lutris better? Help implement features, fix bugs, test pre-releases, or simply chat with the developers? You can always reach us on: * Discord: https://discordapp.com/invite/Pnt5CuY * IRC: ircs://irc.libera.chat:6697/lutris * Github: https://github.com/lutris * Twitter: https://twitter.com/LutrisGaming .. |LiberaPayBadge| image:: http://img.shields.io/liberapay/receives/Lutris.svg?logo=liberapay .. _LiberaPayBadge: https://liberapay.com/Lutris/ .. |PatreonBadge| image:: https://img.shields.io/badge/dynamic/json?color=%23ff424d&label=Patreon&query=data.attributes.patron_count&suffix=%20Patreons&url=https%3A%2F%2Fwww.patreon.com%2Fapi%2Fcampaigns%2F556103&style=flat&logo=patreon .. _PatreonBadge: https://www.patreon.com/lutris lutris-0.5.14/bin/000077500000000000000000000000001451435154700137045ustar00rootroot00000000000000lutris-0.5.14/bin/lutris000077500000000000000000000041601451435154700151550ustar00rootroot00000000000000#!/usr/bin/env python3 # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU General Public License version 3, as published # by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranties of # MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR # PURPOSE. See the GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program. If not, see . """Main entry point for Lutris""" import gettext import locale import os import sys from os.path import dirname, normpath, realpath LAUNCH_PATH = dirname(realpath(__file__)) # Prevent loading Python modules from home folder # They can interfere with Lutris and prevent it # from working. sys.path = [path for path in sys.path if not path.startswith("/home") and not path.startswith("/var/home")] if os.path.isdir(os.path.join(LAUNCH_PATH, "../lutris")): sys.dont_write_bytecode = True SOURCE_PATH = normpath(os.path.join(LAUNCH_PATH, '..')) sys.path.insert(0, SOURCE_PATH) else: sys.path.insert(0, os.path.normpath(os.path.join(LAUNCH_PATH, "../lib/lutris"))) try: locale.setlocale(locale.LC_ALL, "") except locale.Error as ex: sys.stderr.write("Unsupported locale setting: %s\n" % ex) try: # optional_settings does not exist if you don't use the meson build system from lutris import optional_settings try: locale.bindtextdomain("lutris", optional_settings.LOCALE_DIR) gettext.bindtextdomain("lutris", optional_settings.LOCALE_DIR) locale.textdomain("lutris") gettext.textdomain("lutris") except: sys.stderr.write( "Couldn't bind gettext domain, translations won't work.\n" "LOCALE_DIR: %s\n" % optional_settings.LOCALE_DIR ) except ImportError: pass from lutris.gui.application import Application # pylint: disable=no-name-in-module app = Application() # pylint: disable=invalid-name sys.exit(app.run(sys.argv)) lutris-0.5.14/debian/000077500000000000000000000000001451435154700143565ustar00rootroot00000000000000lutris-0.5.14/debian/changelog000066400000000000000000001467451451435154700162510ustar00rootroot00000000000000lutris (0.5.14) jammy; urgency=medium * Add Steam account switcher to handle multiple Steam accounts on the same device. * Add user defined tags / categories * Group every API calls for runtime updates in a single one * Download appropriate DXVK and VKD3D versions based on the available GPU PCI IDs * EA App integration. Your Origin games and saves can be manually imported from your Origin prefix. * Add integration with ScummVM local library * Download Wine-GE updates when Lutris starts * Group GOG and Amazon download in a single progress bar * Fix blank login window on online services such as GOG or EGS * Add a sort name field * Yuzu and xemu now use an AppImage * Experimental support for Flatpak provided runners * Header-bar search for configuration options * Support for Gamescope 3.12 * Missing games show an additional badge * Add missing dependency on python3-gi-cairo for Debian packages -- Mathieu Comandon Mon, 16 Oct 2023 01:40:33 -0700 lutris (0.5.13) jammy; urgency=medium * Add support for Proton * Add drag and drop on the main window. Dropped files will be matched No-Intro, Redump and TOSEC checksums. * Add support for ModDB links in installers (moddb python module required) * Added "Missing" sidebar option for games whose directory is missing * Re-style the configuration, preferences, installer and add-games windows * Group configuration options into sections * Added checkbox to stop asking for the launch config for a game * Added checkbox to sort installed games first * Support for launch-configs in shortcuts and the command line * Show platform badges on banners and cover-art * Installing games from setup files can now use different presets (Win98, 3DFX, ...) * Add filter field to runner list * Show game count in search bar * Workaround Humble Bundle authentication issues by allowing importing cookies from Firefox * Add Itch.io integration * Add Battle.net integration (protobuf dependency required) * Improve detection of DOSBox games on GOG * Added "Unspecified" Vulkan ICD option * Removed ResidualVM (now merged into ScummVM) * Detect obsolete Vulkan drivers and default to DXVK 1.x for them * Improved High-DPI support for custom media * Performance improvements -- Mathieu Comandon Fri, 16 May 2023 13:15:40 -0800 lutris (0.5.12) jammy; urgency=medium * Add support for Xbox games with the xemu runner * Fix authentication issue with Origin * Fix authentication issue with EGS * Fix authentication issue with Ubisoft Connect when 2FA is enabled * Fix integration issue with GOG * Add Discord Rich Presence integration * Add ability to extract icons from Windows executables * Allow setting custom cover art * Re-style configuration dialogs -- Mathieu Comandon Tue, 18 Oct 2022 16:18:45 -0700 lutris (0.5.11) jammy; urgency=medium * Fix for some installers commands exiting with return code 256 * Change shortcut for show/hide installed games to Ctrl + i * Show/hide hidden games is assigned to Ctrl + h * Install game launcher before login for services that use one. * Add Amazon Games integration * Added SheepShaver, BasiliskII and Mini vMac runners * Don't perform runtime updates when a game is launched via a shortcut * Support variables in script URLs * Fix crash when Lutris is unable to read the screen resolution * Enable Gamescope on Nvidia >= 515 * Fixes for Steam shortcuts * Add Gnome Console and Deepin Terminal to supported terminal emulators * Fix crash when Mangohud is used alongside Gamescope * Translation updates -- Mathieu Comandon Thu, 04 Aug 2022 15:29:12 -0700 lutris (0.5.10.1) impish; urgency=medium * Check for Steam executable in home folder for Flatpak * Adjust the Steam, application and desktop shortcuts to launch with Flatpak when necessary * Disable local (XDG) service in Flatpak * Simplify MangoHUD option (now an On/Off toggle) * Remove ability for Lutris to quit Steam * Don't default to fsync on older kernels * Default to a base resolution when Lutris is unable to read the current configuration * Fix issue with .NET 4.8 installation which affects the FF XIV launcher -- Mathieu Comandon Mon, 18 Apr 2022 19:07:08 -0700 lutris (0.5.10) impish; urgency=medium * Add new window to add games to Lutris, with searches from the website, scanning a folder for previously installed games, installing a Windows game from a setup file, installing from a YAML script or configuring a single game manually. * Move the search for Lutris installers from a tab in the Lutris service to the window for adding games. * Add option to add a Lutris game to Steam * Add a coverart format * Add integration with EA Origin * Add integration with Ubisoft Connect * Download missing media on startup * Remove Winesteam runner (install Steam for Windows in Lutris instead) * PC (Linux and Windows) games have their own dedicated Nvidia shader cache * Add dgvoodoo2 option * Add option to enable BattleEye anti-cheat support * Default to Retroarch cores in ~/.config/retroarch/cores if available * Add support for downloading patches and DLC for GOG games * Add --export and --import command line flags to export a game a lutris game and re-import it (requires --dest for the destination path, feature still experimental) * Add command line flags to manage runners: --install-runner, --uninstall-runners, --list-runners, --list-wine-versions * Change behavior of the "Stop" button, remove "Kill all Wine processes" action * Gamescope option is now disabled on Nvidia GPUs * Enable F-Sync by default -- Mathieu Comandon Fri, 04 Feb 2022 19:14:41 -0800 lutris (0.5.9.1) hirsute; urgency=medium * Fix possible escaping error for gamescope option * Remove walrus operator to restore compatibility with Python 3.7 / Ubuntu 18.04 * Remove log file being written in the home folder * Fix install button for community installer * Fix markup error on gamescope option * Update URL for Ryujinx build * Fix Steam sync creating duplicate games -- Mathieu Comandon Sat, 16 Oct 2021 18:08:26 -0700 lutris (0.5.9) hirsute; urgency=medium * Add initial support for Epic Games Store * Add support for Steam for Windows as a game source * Add support for DXVK-NVAPI and DLSS * Add FidelityFX Super Resolution (FSR) option for compatible Wine versions * Add workaround for locale issues when Lutris is launched from Steam * Add gamescope option * Lutris games can now be launched from Steam * 3rd party services can be enabled or disabled in the preferences * The main preferences window has now tabs on the left side * Runner configuration is now available from the main preferences window * VKD3D is a separate option from DXVK * Esync is enabled by default * Dolphin is available as a game source (reads games from the emulator's local database of games) * Scan for installed games when using Steam source * Improved automatic installers for GOG, detection of DOSBOX and ScummVM games. * DRM free services (Humble, GOG) can locate existing installations of games * Use 7zip as the default extractor when not given an archive type * Improve process monitoring, allowing for monitoring of Steam games * Disable AMD switchable graphics layer by default (breaks games) * Removed support for Gallium 9 * Removed support for X360CE * Removed legacy WineD3D options -- Mathieu Comandon Mon, 11 Oct 2021 12:33:39 -0700 lutris (0.5.8.3) groovy; urgency=medium * Really fix popovers not showing on Wayland without making them non-modal * Prevent GStreamer based configuration from being applied in incompatible wine builds. * Fix crash when wine runner accesses DXVK versions before they are uploaded. * Prevent init dialog from being closed while it downloads the runtime. -- Mathieu Comandon Fri, 22 Jan 2021 16:24:51 -0800 lutris (0.5.8.2) groovy; urgency=medium * Fix popover menus not appearing on Wayland * Fix game bar getting unselected on Wayland (Forces the last game to stay selected) * Update Chinese, Dutch, German and Russian translations * Download DXVK when Lutris starts * Add fsync2 feature detection * Limit simultaneous downloads to 3 * Add support for deb file extraction * Add support for Adobe Air games from Humble Bundle (Installation only, Air runtime will come at a later stage) * Add support for GStreamer enabled Wine builds. This will provide better compatibility for games using Media Foundation -- Mathieu Comandon Mon, 04 Jan 2021 23:54:29 -0800 lutris (0.5.8.1) groovy; urgency=medium * Remove Proton from available Wine versions * Display a dialog until Lutris finishes initializing * Allow to keep game files when uninstalling a game * Remove custom sidebar CSS * Fix popup menu not showing in list view * Fix script loading for local files * Fix installed at column setting name for list view * Fix lutris not launching games with rungameid * Fix installed Steam game for fresh lutris installs -- Mathieu Comandon Fri, 27 Nov 2020 14:23:48 -0800 lutris (0.5.8) groovy; urgency=medium * 3rd party services are now available from the main window * The "Import games" window has been removed. The concept of importing games from other services into Lutris has been removed. Syncing games from other services on start has been removed. * Integration with the lutris website such as login and showing your library has been delegated to the 'lutris' service in the sidebar. * The lutris service gives the option of searching your library or the whole lutris.net library. * Games from 3rd party services no longer depend on an install script to be present on the website. Lutris will automatically install games with an auto-generated script. Scripts from the website take precedence if available. * Steam games are directly loaded from the Steam API and it is no longer needed to sync your Steam library on the lutris website to see all your Steam games. * Game banners and icons are downloaded from the services themselves. This allows for customized media size in the UI based on what's available from the service. * Added option to hide the text under the icons * The installer game cache configuration has been moved to the installer window. * Installers now offer the choice between downloaded files, custom user provided files or cached files (when available). * Bonus content for GOG games such as manuals or soundtracks can now be downloaded as part of the install process. Selected content are downloaded in a 'extras' folder in the game folder. Those files will likely be in compressed format. * The right side bar has been moved to the bottom of the window to optimize space and to declutter the overall design. Game actions are now shown in a popover menu displayed next to the play button. Runner actions, if available (for example, wine), will show up in a popover menu next to the runner icon. * Running games have been moved from the right side bar to a row on the left side bar. * Added favorites section and allow to add/remove games from favorites * When removing a game, Lutris now displays the size of the folder to be deleted. * Game logs are no longer erased when switching to another game in the window. * Game logs can be saved to a file * Lutris runners can now be written in JSON instead of Python code. This handles only simple cases but it's enough to handle a vast number of emulators or game engines. Some existing runners have been migrated to JSON such as dgen, ppsspp, citra, ags, virtualjaguar... as well as new ones like melonds, tic80, pcem... Check out the `share/lutris/json` folder for those runners. If you plan to submit new JSON based runner be sure to provide a valid 'download_url' otherwise the lutris client won't have a runner to download. * Lutris will not delete any game folder that is used by another game or any folder that is in some predefined locations. Note that protection of folders such as 'Documents' or 'Downloads' only works on English locales for the moment. * Added a Mangohud option with special modes for OpenGL and 32bit games. * Added a wine menu entry to launch a bash shell in the game's environment with WINEPREFIX set and the correct Wine build aliased to `wine`. * Added a command line option to generate a bash script that will run a lutris game without the client. ex: `lutris quake --output-script quake.sh`. This will create a 'quake.sh' script to launch the game. * Removed all platform and runner icons from the code base to eliminate any issue regarding their licenses (This is done to help get the lutris package into debian). * DOSBox and PCSX2 display an error if needed libraries are missing. * The old versions of gamemode are no longer supported. Make sure you have the one that ships with a `gamemoderun` executable. * The runtime now supports downloading individual files. New icons can be submitted by sending a PR to github.com/lutris/lutris-runtime. * Refactor of several core components. New python packages `lutris.database` and `lutris.gui.installer` -- Mathieu Comandon Sat, 14 Nov 2020 15:03:28 -0700 lutris (0.5.7.1) focal; urgency=medium * Provide D3D12.DLL, based on vkd3d-proton project (https://github.com/HansKristian-Work/vkd3d-proton), as part of our DXVK runtime. This will help push updates faster and provide better compatibility for Direct3D 12 titles such as World of Warcraft. -- Mathieu Comandon Sat, 18 Jul 2020 14:35:23 -0700 lutris (0.5.7) focal; urgency=medium * Use Meson and Ninja to build translation files * Improve Debian package compliance with standards * Add translation strings for the code base * Set a default directory to manually added games, allowing to remove them * Deprecate MESS runner * Migrate all MESS games to MAME * Get full supported system list from the XML given by MAME * Allow to run MAME games by ID if the ROM path is set * Add a no-GUI option to RPCS3 * Fix GalliumNine conflicts with DXVK * Improve performance of DirectX 12 games running on AMD GPU by setting RADV_DEBUG=zerovram * Code style fixes. Pylint is now used in the Travis checks. -- Mathieu Comandon Fri, 26 Jun 2020 18:06:18 -0700 lutris (0.5.6) eoan; urgency=medium * Add some wine core processes to be excluded from monitor (Fixes Battle.net and Origin installation issues) * Convert play time from string to float in the database. Do not downgrade back to older versions or you'll experience issues. * Fix for the wine sandbox on non English systems * Allow Citra and MAME to be launched as standalone programs * Avoid a crash if ldconfig -p returns corrupt data * Allow custom messages to be displayed at the end of install scripts * Add option to provide alternate config file for PCSX2 games * Fix issue with usernames containing accented characters * Fix "Restrict to display" option on Wayland/Mutter * Fix blurry icons on KDE * Remove broken translation files (until internationalization is done properly) * Switch source of DXVK builds to Lutris' own (allows Lutris to delay broken DXVK releases and ship custom ones) -- Mathieu Comandon Sun, 12 Apr 2020 19:04:15 -0700 lutris (0.5.5) eoan; urgency=medium * Initial support for Humble Bundle * Add resolution switching support for Wayland (Mutter only) * Add option to enable ACO shader compiler on Mesa >= 19.3 * DXVK is enabled by default * Add initial support for VKD3D * Migrate D9VK configs to use DXVK * Remove d3d10 and d3d10_1 from dlls handled by DXVK * Fix an API breakage occuring with a Gtk update * Add a System info tab in Preferences * Better handle authentication failure for GOG * Fix case issue with key lookup in Steam VDF files * Add Yuzu runner * Add bsnes-hd beta and smsplus libretro cores * Add sound device option for Mednafen * Remove bundled winetricks * Remove xboxdrv integration -- Mathieu Comandon Thu, 26 Mar 2020 22:21:28 -0700 lutris (0.5.4) eoan; urgency=medium * Added support for Python 3.8. * Added config validation. * Added support for Nvidia PRIME off-load. * Added a popup after a successful game import. * Added alacritty as a terminal option. * Newly installed games that don't specify wine version will now default to the version used during installation. * Provide a fallback for when Lutris can't create a working directory. * Update libretro runners list. * Removed runners that have no binary builds. * Esync can now be enabled for Wine Staging >= 4.6. * Default scaling option for Mednafen is now nn4x. * steamwebhelper.exe is no longer disabled to avoid issues with the new Steam UI. * Ignore special symbols when generating identifiers for games. * Wine processes are now killed if installation is cancelled. * Fixed installation issues for users whose username begin with "x". * Fixed a bug with side panels hidden by default on first start. * Fixed an issue that would not allow user to unselect a game in right panel by clicking on an empty space in the library if that game was no longer installed. * Fixed an issue that allowed user to change the configuration of a game that was already removed. * Fixed an issue that made games imported from native Steam to appear as uninstalled. * Fixed a bug that opened Wine Console instead of Wine Registry. * Fixed warnings that occurred when Gamemode was enabled. * Fixed various locale issues. * Fixed a bug preventing Lutris to find Gallium Nine libraries. * Fixed issues with positioning of the Lutris window. * Fixed game panel updates on game quit. * Fixed game loading error in cases when libstrangle is missing but was previously enabled. * Fixed a bug that made Lutris download Linux version of a GOG game even when the runner was set to Wine. * Fixed installation of the local install scripts. * Fixed installation issues for wine installers that don't have a "files" section. * Further fixed issues with wine sandboxing on non-english systems. -- Mathieu Comandon Wed, 20 Nov 2019 17:51:16 -0800 lutris (0.5.3) disco; urgency=medium * Added D9VK option. * Added options to hide right and left panels. * Added support for Discord Rich Presence (option is only available if you have python-pypresence installed). * Added option to launch Wine console. * Added option to hide Lutris on game launch. * Added lazy loading for some UI components that fetch data from Lutris. * WINE_LARGE_ADDRESS_AWARE is now set to 1 when DXVK or D9VK is enabled (only works with lutris-provided builds) to workaround crashes in 32-bit games. * Lutris should now be minimized when launching games from shortcuts. * An error is now displayed when Lutris fails to install a runner. * Added Ubuntu's AMDVLK path to Vulkan ICD loader search. * State of right panel is now refreshed after adding/removing shortcuts. * Working directory no longer defaults to /tmp * Switched PC-Engine module from pce to pce_fast. * Fixed crash due to invalid GOG credentials. * Fixed UI bug that would sometimes result in “No File Provided” error messages. * Fixed bug that would lead to path warnings when prompted to select files. * Fixed crashes due to unexpected data from xrandr. * Fixed bug that could make antialiasing not function in some games. * Fixed sorting for games that start with a lowercase letter. * Fixed bug that would cause user session to end when launching games on Linux Mint. * Fixed bug with process monitor that could cause games to not launch. * Fixed bug that would not let user execute some options and launch external executables when a game is still running and ESYNC is enabled. * Fixed issues with restoration of original .dll files when disabling DXVK/D9VK. * Fixed crashes due to inability to read GPU driver information. * Fixed crash when working directory isn’t defined. * Fixed stuck game importing due to failure to load icons. * Fixed library loading issues on Gentoo. * Fixed wine sandboxing on non-english systems. * Fixed various issues with locales. * Made various changes and improvements for libretro runner. * Made various changes and improvements for future Flatpak support. * Made minor changes to wording in UI. * Updated Zdoom icon * Updated Lutris logo (improvements by @Scout339) -- Mathieu Comandon Mon, 02 Sep 2019 20:50:01 -0700 lutris (0.5.2.1) cosmic; urgency=high * Handle distributions where ldconfig is not in $PATH, like Debian * Fix a crash when GOG credential have changed * Leave "Show logs" button always enabled * Add BlastEm to libretro cores -- Mathieu Comandon Tue, 09 Apr 2019 19:46:02 -0700 lutris (0.5.2) cosmic; urgency=medium * Avoid a crash if the lutris config file is corrupted * Install Asian fonts by default on Wine prefix creation * Add Vulkan ICD loaders in system options * Add SampleCount option to Wine (allows enabling antialiasing in old games) * Replace joystick panel with Wine config panel (which contains the joypad panel) * Display warning when installing games on NTFS drives * Display warning if Vulkan is not fully installed * Use ldconfig to determine library paths * Disable steamwebhelper in Wine Steam to prevent spamming logs with errors * Various bug fixes -- Mathieu Comandon Thu, 04 Apr 2019 02:47:30 -0700 lutris (0.5.1.3) cosmic; urgency=critical * Fake release to work around Launchpad / OBS publishing issues. -- Mathieu Comandon Mon, 25 Mar 2019 17:38:55 -0700 lutris (0.5.1.2) cosmic; urgency=high * Fix issue with custom Proton detection preventing Wine game from running -- Mathieu Comandon Mon, 25 Mar 2019 14:24:38 -0700 lutris (0.5.1.1) cosmic; urgency=medium * Fixed a crash when trying to open webpages on system without GVFS installed * Fixed GOG login dialog being displayed multiple times during the install * Add mesa-utils as dependency for glxinfo * Add gvfs-backends as dependency to fix the open_uri issue * Add detection of custom proton builds in compatibilitytools.d folder, as documented here: https://github.com/valvesoftware/proton#install-proton-locally (by @GloriousEggroll) -- Mathieu Comandon Sun, 24 Mar 2019 23:39:18 -0700 lutris (0.5.1) cosmic; urgency=medium * Download the default Lutris Wine version when not available * Prevent duplicates when importing games from 3rd party services * Fix some sorting issues in the view * Add issue reporting feature with the --submit-issue flag. The issue can only be saved locally, API integration will be implemented at a later stage. * Add support for CD-ROM images for non CD32/CDTV Amiga models * Remove website search from sidebar and merge it with the main search entry * Display a warning message if the installed Nvidia driver is too old * Fix GOG games not being installable without being connected to GOG * Improve performance of log handling * Remove winecfg if Proton is used * Use discrete graphics by default with compatible systems * Increase game icon size from 32x32 to 128x128 * Various fixes -- Mathieu Comandon Wed, 20 Mar 2019 00:24:27 -0700 lutris (0.5.0.1ubuntu1) cosmic; urgency=medium * Bullshit my way out of Gtk+ fuckery (Closes #1697) * Initialize playtime attribute when invalid playtime found (Closes #1698) * Strip equal signs from envvars (Closes #1699) * If the Fedora shit breaks the SUSE shit, I just delete it, OK? (Closes #1700) * Add application attribute on GenericPanel (Closes #1702) * Avoid crashing on weird GPU configs (Closes #1706) * Remove get_config_id (Closes #1708) -- Mathieu Comandon Sun, 03 Feb 2019 00:21:32 -0800 lutris (0.5.0) cosmic; urgency=medium * Modernize the Gtk UI, thanks to the improvements made by @TingPing * Add GOG support, allowing users to sign-in their account, import games and download game files automatically during install. * Add finer game import options, allowing imports from different 3rd party such as Steam, GOG and locally installed games. * Re-architecture the process monitor. This fixes issues with games exiting prematurely. Many thanks to @AaronOpfer for his patches! * Multiple games can now be launched at the same time without losing control over the first game. * Game information and actions are now displayed in a panel on the right side. Coverart fetching for the panel will be added in a future release, until then cover art files can be placed in ~/.local/share/lutris/coverart/[game-identifier].jpg * Games from lutris.net can be searched and installed from the client itself. * New install_cab_component installer command for Media Foundation based games. * Add a download cache to re-use files between installations. * Print graphics drivers and GPU on startup * Re-design installer selection picker. * Add a button to show installer scripts before installing. * Add a FPS limiter option when libstrangle is available (https://gitlab.com/torkel104/libstrangle) * Re-architecturing of several parts of the application (views, linux feature detection, main game class, ...) -- Mathieu Comandon Fri, 01 Feb 2019 07:26:25 -0800 lutris (0.4.23) bionic; urgency=medium * Prevent monitor from quitting games that open a 2nd process * Run on-demand scripts from game directory * Tell the user what executable is expected after a failed install * Fix a circular import causing issues on some distributions * Add missing dependency for openSUSE Tumbleweed -- Mathieu Comandon Tue, 06 Nov 2018 19:10:19 -0800 lutris (0.4.22) bionic; urgency=medium * Use lspci instead of xrandr to detect video cards * Detect if Vulkan is supported by the system for DXVK games * Add experimental playtime support * Detect Proton and add it to Wine versions * Fix runtime being downloaded when not needed * Add experimental tray icon with last games played * Add support for Feral Gamemode * Prevent process monitor to quit games prematurely * Code cleanup -- Mathieu Comandon Sat, 03 Nov 2018 00:01:19 -0700 lutris (0.4.21.1) bionic; urgency=medium * Fix detection of libvulkan -- Mathieu Comandon Tue, 23 Oct 2018 19:31:14 -0700 lutris (0.4.21) bionic; urgency=medium * Added an Esync toggle for wine builds with esync patches and a check for limits if the toggle was activated. * Added a warning for wine games if wine is not installed on the system (to avoid issues with dependencies). * Added a check for Vulkan loaders when using DXVK (forbids from launching the game if it can't detect them) * Added check for the presence of executable after the installation finished. * Added an option to sort installed games first * Added a discouraging warning if Lutris was launched as root. * Added a "--version" command line option. * Added an error message if requested DXVK version does not exist. * Improved behavior of Lutris' background process. * Improved UI when changing game's identifier. * Wine's own Virtual Desktop configuration is now respected. * Merge command now has a 'copy' alias. * Executable selection how has a text field. * Blacklisted Proton and SteamWorks from showing up as games. * Sidebar now shows number of installed games per runner and platform. * Visual improvements to wine download dialog * Fixed an issue when DXVK versions didn't get updated if dxvk directory wasn't present. * Fixed an issue when the watcher would sync Steam games even if the feature was disabled. * Fixed missing warning for existing prefix during installation process if the path contained "~". * Prevent Steam games from being synced from the AppManifest watcher if Steam sync if off * Games load properly when launching Lutris for the first time * Minor improvements to wording in some menus. -- Mathieu Comandon Sat, 20 Oct 2018 17:39:31 -0700 lutris (0.4.20) bionic; urgency=medium * Fix detection of winetricks path * Improve visual feedback on wine download dialog * Add skill and command-line arguments for Zdoom * Add option to disable joypad auto-configuration * Restore refresh rate on monitor reset -- Mathieu Comandon Mon, 24 Sep 2018 20:46:46 -0700 lutris (0.4.19) bionic; urgency=medium * Prioritize winetricks from the runtime * Populate DXVK versions with github releases * Add support for DirectX 10 with DXVK * Fix detection of xgamma * Add 24BPP option for Xephyr * Restore Alsa option for Wine * Prepend additional system paths to runtime -- Mathieu Comandon Tue, 04 Sep 2018 18:48:52 -0700 lutris (0.4.18) bionic; urgency=medium * Fix 'execute' command arguments * Fix 'write_json' command when no file exists * Avoid crash when wine prefix has broken symlinks * Update DXVK latest version to 0.52 * Update winetricks -- Mathieu Comandon Thu, 24 May 2018 14:06:45 -0700 lutris (0.4.17) bionic; urgency=medium * Add DVXK version option * Fixes in Wine registry handling * Prioritize /usr/lib32 over Lutris runtime * Re-enable Lutris runtime if using a Lutris Wine build * Fix xrandr parsing when DisplayPort are available * Get pids used by wineserver (experimental, likely to be removed) -- Mathieu Comandon Sun, 20 May 2018 14:51:49 -0700 lutris (0.4.16) bionic; urgency=high * Fix crash preventing running or configuring wine games * Fix Steam being shut down regardless of the associated option's setting * Fix some external library folders not being detected * Fix crash on InstallerWindow for GTK < 3.22 * Remove Ctrl+Q shortcut -- Mathieu Comandon Fri, 11 May 2018 22:59:26 -0700 lutris (0.4.15) bionic; urgency=medium * Add RPCS3 runner * Prevent Lutris from killing Steam if it's downloading a game * Add option to run DRM free Steam games without opening Steam * Add `custom-name` directive for install scripts * Set default Wine architecture to 64bit * Add support for DXVK in Wine games * Prioritize libraries in /usr/lib over the Lutris runtime * Disable Lutris runtime on Wine games if Wine is installed globally * Download recent wine version if the system installed one is too old * Record installation date of games * Add option for menu mode key in MESS * Support hard disk images for FS-UAE * Various UI fixes -- Mathieu Comandon Fri, 11 May 2018 14:05:48 -0700 lutris (0.4.14) artful; urgency=medium * Add option to include and exclude processes from monitoring in installers and during gameplay. * Add winekill installer task * Fix lutris eating 100% CPU after game quits * Fix the way wine games quit * Fix Wine Steam being killed on game exit even if the option is disabled * Add support for 64bit dinput8.dll for x360ce * Add support for dumbxinputemu as a x360ce alternative * Add option to enable xinput9_1_0.dll in x360ce * Deprecate koku-xinput option * Add system option to enable DRI_PRIME * Add more platforms to Mednafen * Better controller support for Mednafen -- Mathieu Comandon Tue, 21 Nov 2017 20:48:38 -0800 lutris (0.4.13) zesty; urgency=medium * Add new libretro cores * Stop process monitoring as soon as process stops * Default 'reset_desktop' option to False * Make calling executables more robust * Fix xboxdrv not being monitored properly -- Mathieu Comandon Wed, 26 Jul 2017 19:12:21 -0700 lutris (0.4.12) zesty; urgency=medium * Increase process monitor delay * Increase HTTP requests timeouts * Disable stdout logger for unmonitored processes * Display error when downloaded file doesn't resolve to a filename * Add support for symlinks in tar archives * Fix sqlite query error when syncing games * Fix installation of local scripts * Catch errors while reading Steam VDF files -- Mathieu Comandon Tue, 13 Jun 2017 20:46:18 -0700 lutris (0.4.11.1) zesty; urgency=medium * Fix typo in wineboot check -- Mathieu Comandon Tue, 30 May 2017 15:43:07 -0700 lutris (0.4.11) zesty; urgency=medium * Add system option to disable process monitoring * Finish ScummVM game importing * Fix path resolution for local installer scripts * Fix 'execute' installer command not being monitored * Fix I/O watch hogging a CPU core after game quits * Code cleanup -- Mathieu Comandon Tue, 30 May 2017 13:36:41 -0700 lutris (0.4.10) zesty; urgency=medium * Remove PCSX-R runner * Migrate PCSX-R games to use PCSX Rearmed on RetroArch * Fix game config being overidden if edited while the game is running * Fix Y Axis mapping for the Dual Shock 3 in X360CE * Add dinput8 option for X360CE for games requiring it (Dead Space 2, Darksiders, ...) * Add dialog to optionally sync Steam and XDG desktop shortcuts at startup * (Re)add ScummVM import * Reenable Lutris runtime by default for Dolphin * Update Winetricks (Fixes .NET 3.5 installation) * Avoid a crash if Wine prefix is not created * Update Wine and Steam icons * Add support for lutris:rungame/... and lutris:install/... urls * Always instanciate the client's window even when installing or launching a game * Stop Lutris process monitor instantly when all child processes have quit, speeds up game installs and prevents zombie processes. * Display real time console output in the game log dialog * Display real time console output during game installations * Add option to launch the Steam client instead of the game in Wine Steam -- Mathieu Comandon Mon, 15 May 2017 22:08:13 -0700 lutris (0.4.9) zesty; urgency=medium * Add option to auto-configure x360ce in Wine games based on plugged in controllers * Add support for batch files in Wine * Fix FS-UAE path handling * Fix regedit commands on newer Wine versions * Fix local offline script installation -- Mathieu Comandon Thu, 04 May 2017 00:06:56 -0700 lutris (0.4.8) zesty; urgency=medium * Switch installer scripts to the REST API * Allow users to test installer drafts * Add cabextract as a dependency * Fix for processes crashing when the working directory doesn't exist * Add $VERSION as a variable usable in scripts -- Mathieu Comandon Tue, 18 Apr 2017 16:06:38 -0700 lutris (0.4.7.1) yakkety; urgency=medium * Fix a bug with the platforms accessing the database before it's created -- Mathieu Comandon Sun, 09 Apr 2017 16:38:39 -0700 lutris (0.4.7) yakkety; urgency=medium * Add support for more libretro cores * Revert main view to IconView instead of Flowbox, improving performance * Persist game platforms to the database, improving performance * Fix argument parsing for msi installers * Use gzdoom instead of zdoom * Misc bugfixes -- Mathieu Comandon Sun, 09 Apr 2017 13:40:50 -0700 lutris (0.4.6) yakkety; urgency=medium * Various UI fixes * Add option for SDL2 controller mappings * Fix Wine install in game installers * Disable Lutris Runtime in XDG imported games * Fix Wine Registry parsing for keys ending in a backslash * Prevent games from stopping twice -- Mathieu Comandon Wed, 15 Mar 2017 16:07:39 -0700 lutris (0.4.5) yakkety; urgency=medium * Fix Quit menu item * Fall back to an existing Wine version if selected doesn't exist * Remove Desura * Add --exec command line flag * Fix minor issues when switching between grid and list view * Add "View on Lutris.net" to game context menus * Add 64-bit support to Wine Steam runner * Make Lutris remember window maximized state and size * Sidepanel doesn't resize with the window * Make delete key trigger remove game dialog * Auto-import installed (.desktop) games on the system * Scan for games before loading gui * Show runner human name everywhere * Add Steam Big Picture mode option to the Steam runner config * Make year editable in game info dialog * Remove the force-disable of DirectWrite in Wine Steam * Show last played in game list view * Fix Wine dll overrides * Add game command line argument option to Steam/Wine Steam games * Add small icons option * Fix the runner icons in sidebar * Add filter by platform -- Mathieu Comandon Wed, 08 Mar 2017 08:08:09 +0100 lutris (0.4.4.1) yakkety; urgency=medium * Fix installer command line options -- Mathieu Comandon Tue, 13 Dec 2016 18:47:29 -0800 lutris (0.4.4) yakkety; urgency=medium * Add widget to edit environment variables in system options * Ignore processes launched before the game * Check for presence and checksum of BIOS files in RetroArch * Prevent a crash on empty Wine prefixes * Remove DBus service and replace with Gtk.Application * Make Dolphin runnable by itself * Remove dependencies to python3-xdg and xdg-user-dirs * Fix joystick detection in Mednafen -- Mathieu Comandon Tue, 13 Dec 2016 16:55:59 -0800 lutris (0.4.3) yakkety; urgency=medium * Change labels in dialogs to "Save" * Disable Lutris runtime by default in Dolphin * Fix typo preventing the Steam Store to be displayed in Wine * Fix path handling for fuser * Fix Wine registry parser for keys with square brackets * Fix Mednafen joystick detection * Fix ld_library_path option * Fix Wine not being displayed in the sidebar -- Mathieu Comandon Tue, 29 Nov 2016 13:20:55 -0800 lutris (0.4.2) yakkety; urgency=medium * Add suport for 7zip extractors * Python based Wine registry parser * Allow more complex rules for installer dependencies * Fixes in RetroArch runner * Misc bugfixes -- Mathieu Comandon Mon, 31 Oct 2016 18:05:14 -0700 lutris (0.4.1) xenial; urgency=medium * Switch to new versioning scheme * Improve terminal emulator detection * Initial support for ARM -- Mathieu Comandon Tue, 18 Oct 2016 13:35:36 -0700 lutris (0.4.0ubuntu4) xenial; urgency=medium * Better fixes for old Gtk versions * System wine is detected when installing Wine Steam * Preselect runner when adding a game and the sidebar filter is active * Fix sidebar being displayed on splash screen -- Mathieu Comandon Mon, 17 Oct 2016 17:16:59 -0700 lutris (0.4.0ubuntu3) xenial; urgency=medium * Fallback to list view when running an old version of Gtk -- Mathieu Comandon Mon, 17 Oct 2016 13:59:14 -0700 lutris (0.4.0ubuntu2) xenial; urgency=medium * Fix a nasty bug that would freeze the installer window -- Mathieu Comandon Mon, 17 Oct 2016 13:21:01 -0700 lutris (0.4.0ubuntu1) xenial; urgency=medium * Fix some packaging issues * Fix AGS runner -- Mathieu Comandon Mon, 17 Oct 2016 12:08:34 -0700 lutris (0.4.0) xenial; urgency=medium * Project ported to Python3 * Libretro runner added * New web runner, using Electron by default * Adventure Game Studio runner added * Improvements and fixes in Vice runner * Fixes for Zdoom runner * Main icon view uses Gtk.FlowBox * Optimization for downloading icons and banners * Add system option to switch to US keyboard layout while running a game * Add system option to select monitor in SDL1 games * Allow changing game id * Allow setting custom banners and icons -- Mathieu Comandon Tue, 11 Oct 2016 11:19:17 -0700 lutris (0.3.8) xenial; urgency=medium * Add option to use the dark GTK theme variant * Add Desmume runner * Add option to limit games to a single CPU core * Fix button mappings on mednafen * Improve Reicast installation * Add controller support to Reicast * Disable Wine crash dialogs by default * Sync steam games without depending on the remote library * Use inotify to detect changes in Steam folders * Allow to browse for mounted CD images during installation -- Mathieu Comandon Thu, 04 Aug 2016 00:13:38 -0700 lutris (0.3.7.5) xenial; urgency=medium * Fix a bug where booleans in scripts would be converted to strings * Update Debian package source format -- Mathieu Comandon Mon, 07 Mar 2016 09:57:29 -0800 lutris (0.3.7.4) xenial; urgency=medium * Add support for Xephyr * Detect Wine versions installed from WineHQ * Update koku-xinput-wine to work with the build provided in the runtime * Always install the required runner when a game is installed -- Mathieu Comandon Sun, 06 Mar 2016 14:37:09 -0800 lutris (0.3.7.3) xenial; urgency=medium * Add PCSX2 runner * Add PPSSPP runner * Extended kickstart support for Amiga CD32 * UI improvements * Regedit fixes -- Mathieu Comandon Sun, 21 Feb 2016 21:13:39 -0800 lutris (0.3.7.2) wily; urgency=medium * Add button to eject CD-ROMs during installation of Wine games * Prevent MAME and MESS to save config files in home directory * Monitor installation tasks so installers can respawn processes * Randomize extractions folder names to prevent a bug occuring when extracting several archives concurrently * Allow loading environment variables from system config -- Mathieu Comandon Tue, 05 Jan 2016 08:41:23 -0800 lutris (0.3.7.1) wily; urgency=medium * Improved command line option to list games * Force update of runners * Add support of 64bit wine * Improve MESS runner * Fix Vice runner for non Commodore 64 machines * Fix RPM packaging * Various bugfixes -- Mathieu Comandon Tue, 29 Dec 2015 18:47:05 -0800 lutris (0.3.7) wily; urgency=medium * Global: - Open a single instance of the program - Improved performance and responsiveness of the UI - New sidebar to filter games by runner - New log window to view output of last launched game - Much improved runtime (cross-distro support for games and emulators) - Initial support for the installation of multiple versions of the same game - Cancelling a game installation will clean up downloaded and installed files - Showing wait cursor when loading a game - Improved config dialogs usability & reliability - Improved monitoring of running game process - Tons of bug fixes and minor improvements * Runner specific: - New runner: Dolphin (Wii and GameCube) - New runner: Reicast (Dreamcast) - New runner: ResidualVM (some 3D adventure games) - Gens is replaced by DGen for Sega Genesis games - Fully automate Steam for Windows installation - Installing Steam games now does start the installation in Steam - New option to shut down Steam when quitting a Steam game - Added ability to manage wine versions - Added Winetricks and other config tools for Wine games (in the context menu) - Winetricks now bundled, used as fallback if not installed on the system - New experimental support for Xinput in Wine games - Monitor installation for Steam games -- Mathieu Comandon Sat, 21 Nov 2015 18:02:58 -0800 lutris (0.3.6.3) utopic; urgency=medium * Added "Custom Steam location" option to winesteam runner * Use Windows Steam from ~/.wine if not installed in Lutris' own prefix * Fixed Winetricks used in installers -- Mathieu Comandon Fri, 14 Nov 2014 00:10:00 +0100 lutris (0.3.6.2) utopic; urgency=medium * Add gvfs-backend to fix downloads on non-Gnome environments * Fix winesteam install -- Mathieu Comandon Tue, 11 Nov 2014 15:05:49 +0100 lutris (0.3.6.1) utopic; urgency=medium * Fixed an issue with Steam sync * Fixed an issue with displaying years in listview -- Mathieu Comandon Tue, 04 Nov 2014 22:31:47 +0100 lutris (0.3.6) trusty; urgency=medium * New: - Lutris Runtime, removing the need to install libraries on the system - Synchronization of installation state of (Linux) Steam games - Real uninstallation of Steam games through Steam - Auto-install of Wine Steam - Better detection of Wine Steam install location - Support for Steam's secondary library folders - Wine version 1.7.29 (including fix for Steam's overlay/keyboard crash) - Tooltips on most configuration options - Wine's desktop integration disabled for newly installed Wine games - DOSBox options: scaler and auto-exit - ScummVM options: aspect correction, subtitles - "Remove" context menu action added to uninstalled games - sdlmame and sdlmess runners renamed to mame and mess - "Prefix command" system option - Button to access runners folder in the Manage runners window - Manually re-synchronize from the menu: Lutris > Synchronize library * Fixes: - Fixed inconsistent password field limit to 26 chars, raised to 1024. - Fixed impossibility to use system's Wine when Wine Steam was running. - Fixed Wine games install failing when there is a space in the setup file path - Fixed browser games not launching at all - Fixed PCSX-Reloaded and Vice emulators not launching at all - Fixed Hatari and Mess emulators not launching nor warning when no bios file configured - Fixed Hatari startup fail if there is spaces in bios path - Fixed the "Restore desktop resolution" option and enable it by default - Fixed the Browse Files action on DOSBox games - Fixed Winetricks in installers - Fixed checked by default config options not saving unchecked state - Fixed the "insert disc" part of installers - Fixed renaming games breaking synchronization with the website - Fixed Mupen64Plus fullscreen option not working when unchecked. - More small fixes * Website changes since the last version: - Improved the About page - Added a direct link to your Library in the menu * For contributors: - New mailing list available at lists.lutris.net/cgi-bin/mailman/listinfo/lutris - Fixed the game submission form - Improved feedback on submissions - New write_config installer directive to write into INI files - Documented how to use tasks from any runner in an installer -- Mathieu Comandon Sat, 27 Sep 2014 01:36:10 +0200 lutris (0.3.5) trusty; urgency=medium * All runners now use the version hosted on lutris.net (auto-install!) * Desura and Virtual Jaguar support * Browse installed games' files from the context menu * New "Connected" status indicator in the status bar * New small icons and small banners style (switch in View menu) * Better path management * Consistent configuration loading * UI Fixes * Runner fixes * Even more fixes -- Mathieu Comandon Wed, 10 Sep 2014 16:41:10 +0200 lutris (0.3.4) quantal; urgency=low * Initial SDLMess Support * Fixes for Gens and Hatari runners * Various bugfixes -- Mathieu Comandon Sat, 8 Feb 2014 19:14:18 +0200 lutris (0.3.3) quantal; urgency=low * Improved design of installer dialog and main window * Prevent users from deleting important files when uninstalling games * Show help screen on first start * Support for Amiga CD32 games * Dosbox install scripts can now run DOS executables * Better xrandr support * Give option to restrict display to a single monitor while in-game * Improve contextual menu in client (Install, uninstall, manually add) * Show dialog when trying to install games with no script. -- Mathieu Comandon Sat, 25 Jan 2014 17:58:00 +0200 lutris (0.3.2) quantal; urgency=low * Support for Steam for Linux * Allow switching from Steam for Linux <-> Wine * Option to show only installed games in UI * Ability to automatically migrate local database * Misc bugfixes -- Mathieu Comandon Sun, 15 Dec 2013 17:51:00 +0200 lutris (0.3.1) quantal; urgency=low * Support for Wine installers * Library sync with Lutris.net * Misc bugfixes -- Mathieu Comandon Sat, 20 Jul 2013 21:55:37 +0200 lutris (0.3.0) quantal; urgency=low * Initial release of Lutris 0.3 * Support for game installers * Support for lutris.net authentication * Games are now stored in SQLite database * Basic support for Personnal Game Archives -- Mathieu Comandon Wed, 26 Jun 2013 12:05:22 +0200 lutris (0.2.8) quantal; urgency=low * Bump version to 0.2.8 * Save window size and view type * Let user choose which Web browser to use for Browser runner * Fix search in icon view -- Mathieu Comandon Mon, 04 Feb 2013 15:02:47 +0100 lutris (0.2.7ubuntu0) quantal; urgency=low * Updated to version 0.2.7 -- Mathieu Comandon Sat, 10 Nov 2012 03:46:55 +0100 lutris (0.2.6ubuntu1) natty; urgency=low * Forgot to actually remove cedega stuff ... Silly me -- Mathieu Comandon Thu, 12 May 2011 03:40:27 +0200 lutris (0.2.6) natty; urgency=low * Improved appearence of runners dialog * Removed Cedega runner (Not maintained anymore) * Minor Bugfixes -- Mathieu Comandon Thu, 12 May 2011 03:32:12 +0200 lutris (0.2.5r2) natty; urgency=low * xdg is a build dependency -- Mathieu Comandon Mon, 09 May 2011 13:36:55 +0200 lutris (0.2.5r1) natty; urgency=low * Oops, forgot to bump the python version -- Mathieu Comandon Mon, 09 May 2011 11:25:52 +0200 lutris (0.2.5) natty; urgency=low * Bugfixes and code cleanup * Installer enhancement * Added Play button -- Mathieu Comandon Mon, 09 May 2011 11:03:43 +0200 lutris (0.2.4) maverick; urgency=low * A lot of bug fixes * Better support for the web installers * Initial support for Mupen64+ * New logging system inspired by Gwibber -- Mathieu Comandon Thu, 06 Jan 2011 02:08:35 +0100 lutris (0.2.2ubuntu2) maverick; urgency=low * Fixed a bug in Steam and NullDC runner which prevented to add games. -- Mathieu Comandon Wed, 13 Oct 2010 19:54:17 +0200 lutris (0.2.2ubuntu1) maverick; urgency=low * Fixed file paths -- Mathieu Comandon Tue, 12 Oct 2010 23:33:41 +0200 lutris (0.2.2) maverick; urgency=low * Added support for nullDC, joy2key * New common dialogs * Basic steam installer * Many bugfixes -- Mathieu Comandon Sat, 25 Sep 2010 12:53:43 +0200 lutris (0.2.1) maverick; urgency=low * Cleaned some files to prepare for 0.33 release * First attempt at the Lutris installer * Many bugfixes -- Mathieu Comandon Tue, 31 Aug 2010 02:44:47 +0200 lutris (0.2) lucid; urgency=low * Initial Quickly release. -- Mathieu Comandon Fri, 22 Jan 2010 19:38:42 +0100 lutris (0.1.1) * Resize the covers to 250px * Added fullscreen coverflow * Icons show up in the status bar when joysticks are connected * Rewrite of preferences dialog using GTK and not Glade * Implementation of user_wm and game_wm options * Removed the oss boolean option, set the oss_wrapper to none for no oss lutris (0.1.0) * First public release * Support for uae * ScummVM and Cedega import * Search Google Images for covers -- Mathieu Comandon Sat, 28 Nov 2009 21:57:00 lutris-0.5.14/debian/clean000066400000000000000000000000121451435154700153540ustar00rootroot00000000000000builddir/ lutris-0.5.14/debian/control000066400000000000000000000031301451435154700157560ustar00rootroot00000000000000Source: lutris Section: games Priority: optional Maintainer: Mathieu Comandon Build-Depends: debhelper-compat (= 12), appstream, dh-sequence-python3, meson, Rules-Requires-Root: no Standards-Version: 4.5.0 Homepage: https://lutris.net Vcs-Browser: https://github.com/lutris/lutris Vcs-Git: https://github.com/lutris/lutris.git Package: lutris Architecture: all Depends: ${misc:Depends}, ${python3:Depends}, python3-yaml, python3-lxml, python3-requests, python3-pil, python3-gi, python3-gi-cairo, python3-setproctitle, python3-magic, python3-distro, python3-dbus, gir1.2-gtk-3.0, gir1.2-webkit2-4.0 | gir1.2-webkit2-4.1, gir1.2-notify-0.7, psmisc, cabextract, unzip, p7zip, curl, fluid-soundfont-gs, x11-xserver-utils, mesa-utils, vulkan-tools, xdg-desktop-portal, xdg-desktop-portal-gtk | xdg-desktop-portal-kde, fluidsynth, Recommends: python3-evdev, python3-protobuf, gvfs-backends, libwine-development | libwine, winetricks, Suggests: gamemode, gamescope, Description: video game preservation platform Lutris helps you install and play video games from all eras and from most gaming systems. By leveraging and combining existing emulators, engine re-implementations and compatibility layers, it gives you a central interface to launch all your games. lutris-0.5.14/debian/copyright000066400000000000000000000012351451435154700163120ustar00rootroot00000000000000Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ Upstream-Name: lutris Upstream-Contact: Mathieu Comandon Upstream-Source: https://github.com/lutris Files: * Copyright: 2009 Mathieu Comandon License: GPL-3.0-or-later On Debian systems, the complete text of the General Public License version 3 can be found in "/usr/share/common-licenses/GPL-3". Files: share/metainfo/net.lutris.Lutris.metainfo.xml Copyright: Lutris Team License: CC0-1.0 On Debian systems, the complete text of the CC0-1.0 license can be found in "/usr/share/common-licenses/CC0-1.0". lutris-0.5.14/debian/rules000077500000000000000000000003651451435154700154420ustar00rootroot00000000000000#!/usr/bin/make -f # -*- makefile -*- # Uncomment this to turn on verbose mode. #export DH_VERBOSE=1 %: dh $@ --buildsystem=meson override_dh_auto_configure: dh_auto_configure -- -Dbindir=games override_dh_builddeb: dh_builddeb -- -Zgzip lutris-0.5.14/debian/source/000077500000000000000000000000001451435154700156565ustar00rootroot00000000000000lutris-0.5.14/debian/source/format000066400000000000000000000000151451435154700170650ustar00rootroot000000000000003.0 (native) lutris-0.5.14/docs/000077500000000000000000000000001451435154700140645ustar00rootroot00000000000000lutris-0.5.14/docs/installers.rst000066400000000000000000001022751451435154700170050ustar00rootroot00000000000000================== Writing installers ================== Table of contents ================= * `Basics`_ * `Variable substitution`_ * `Game configuration directives`_ * `Runner configuration directives`_ * `System configuration directives`_ * `Fetching required files`_ * `Installer meta data`_ * `Writing the installation script`_ * `Example scripts`_ Basics ====== Games in Lutris are written in the YAML format in a declarative way. The same document provides information on how to acquire game files, setup the game and store a base configuration. Make sure you have some level of understanding of the YAML format before getting into Lutris scripting. The Ansible documentation provides a short guide on the syntax: https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html At the very least, a Lutris installer should have a ``game`` section. If the installer needs to download or ask the user for some files, those can be added in the `files` section. Installer instructions are stored in the ``installer`` section. This is where the installer files are processed and will results in a runnable game when the installer has finished. The configuration for a game is constructed from its installer. The `files` and `installer` sections are removed from the script, some variables such as $GAMEDIR are substituted and the results is saved in: ~/.config/lutris/games/-.yml. Published installers can be accessed from a command line by using the ``lutris:`` URL prefix followed by the installer slug. For example, calling ``lutris lutris:quake-darkplaces`` will launch the Darkplaces installer for Quake. **Important note:** Installer scripts downloaded to the client are embedded in another document. What is editable on the Lutris section is the ``script`` section of a bigger document. In addition to the script it self, Lutris needs to know the following information: * ``name``: Name of the game, should be surrounded in quotes if containing special characters. * ``game_slug``: Game identifier on the Lutris website * ``version``: Name of the installer * ``slug``: Installer identifier * ``runner``: Runner used for the game. If you intend to write installers locally and not use the website, you should have those keys provided at the root level and everything else indented under a ``script`` section. Local installers can be launched from the CLI with ``lutris -i /path/to/file.yaml``. Variable substitution ===================== You can use variables in your script to customize some aspect of it. Those variables get substituted for their actual value during the install process. Available variables are: * ``$GAMEDIR``: Absolute path where the game is installed. * ``$CACHE``: Temporary cache used to manipulate game files and deleted at the end of the installation. * ``$RESOLUTION``: Full resolution of the user's main display (eg. ``1920x1080``) * ``$RESOLUTION_WIDTH``: Resolution width of the user's main display (eg. ``1920``) * ``$RESOLUTION_HEIGHT``: Resolution height of the user's main display (eg. ``1080``) * ``$WINEBIN``: Absolute path to the Lutris provided Wine binary used to install the game. Additional variables are referenced in a `variables` section in the script. Example:: variables: VERSION: 1.3 files: stk: https://github.com/supertuxkart/stk-code/releases/download/$VERSION/SuperTuxKart-$VERSION-linux-64bit.tar.xz You can also reference files from the ``files`` section by their identifier, they will resolve to the absolute path of the downloaded or user provided file. Referencing game files usually doesn't require preceding the variable name with a dollar sign. Installer meta data =================== Installer meta-data is any directive that is at the root level of the installer used for customizing the installer. Referencing the main file ------------------------- Referencing the main file of a game is possible to do at the root level of the installer but this information is later merged in the ``game`` section. It is recommended to put this information directly in the ``game`` section. If you see an existing installer with keys like ``exe`` or ``main_file`` sitting at the root level, please move them to the ``game`` section. Requiring additional binaries ----------------------------- If the game or the installer needs some system binaries to run, you can specify them in the ``require-binaries`` directive. The value is a comma-separated list of required binaries (acting as AND), if one of several binaries are able to run the program, you can add them as a ``|`` separated list (acting as OR). Example:: # This requires cmake to be installed and either ggc or clang require-binaries: cmake, gcc | clang Mods and add-ons ---------------- Mods and add-ons require that a base game is already installed on the system. You can let the installer know that you want to install an add-on by specifying the ``requires`` directive. The value of ``requires`` must be the canonical slug name of the base game, not one of its aliases. For example, to install the add-on "The reckoning" for Quake 2, you should add: ``requires: quake-2`` You can also add complex requirements following the same syntax as the ``require-binaries`` directive described above. Extensions / patches -------------------- You can write installers that will not create a new game entry in Lutris. Instead they will modify the configuration on an exsiting game. You can use this feature with the ``extends`` directive. It works the same way as the ``requires`` directive and will check for a base game to be available. Example:: # Used in a installer that fixes issues with Mesa extends: unreal-gold Customizing the end of install text ----------------------------------- You can display a custom message when the installation is completed. To do so, use the ``install_complete_text`` key. Game configuration directives ============================= A game configuration file can contain up to 3 sections: `game`, `system` and a section named after the runner used for the game. The `game` section can also contain references to other stores such as Steam or GOG. Some IDs are used to launch the game (Steam, ScummVM) while in other cases, the ID is only used to find games files on a 3rd party platform and download the installer (Humble Bundle, GOG). Lutris supports the following game identifiers: `appid`: For Steam games. Numerical ID found in the URL of the store page. Example: The `appid` for https://store.steampowered.com/app/238960/Path_of_Exile/ is `238960`. This ID is used for installing and running the game. `game_id`: Identifier used for ScummVM games. Can be looked up on the game compatibility list: https://www.scummvm.org/compatibility/ `gogid`: GOG identifier. Can be looked up on https://www.gogdb.org/products Be sure to reference the base game and not one of its package or DLC. Example: The `gogid` for Darksiders III is 1246703238 `humbleid`: Humble Bundle ID. There currently isn't a way to lookup game IDs other than using the order details from the HB API. Lutris will soon provide easier ways to find this ID. `main_file`: For MAME games, the `main_file` can refer to a MAME ID instead of a file path. Common game section entries --------------------------- ``exe``: Main game executable. Used for Linux and Wine games. Example: ``exe: exult`` ``main_file``: Used in most emulator runners to reference the ROM or disk file. Example: ``main_file: game.rom``. Can also be used to pass the URL for web based games: ``main_file: http://www...`` ``args``: Pass additional arguments to the command. Can be used with linux, wine, dosbox, scummvm, pico8 and zdoom runners. Example: ``args: -c $GAMEDIR/exult.cfg`` ``working_dir``: Set the working directory for the game executable. This is useful if the game needs to run from a different directory than the one the executable resides in. This directive can be used for Linux, Wine and Dosbox installers. Example: ``$GAMEDIR/path/to/game`` ``launch_configs``: When you have games with multiple executables (example: a game that comes with a map editor, or that need to be launched with different arguments) you can specify them in this section. In this section, you can have a list of configurations containing ``exe``, ``args`` and ``working_dir`` plus a ``name`` to show in the launcher dialog. Example:: game: exe: main.exe launch_configs: - exe: map_editor.exe name: Map Editor - exe: main.exe args: -missionpack name: Mission Pack Wine and other wine based runners ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``arch``: Sets the architecture of a Wine prefix. By default it is set to ``win64``, the value can be set to ``win32`` to setup the game in a 32-bit prefix. ``prefix``: Path to the Wine prefix. For Wine games, it should be set to ``$GAMEDIR``. DRM free Steam ^^^^^^^^^^^^^^ Lutris has the ability to run Steam games without launching the Steam client. This is only possible with certain games lacking the Steam DRM. ``run_without_steam``: Activate the DRM free mode and no not launch Steam when the game runs. ``steamless_binary``: Used in conjonction with ``run_without_steam``. This allows to provide the path of the game executable if it's able to run without the Steam client. The game must not have the Steam DRM to use this feature. Example: ``steamless_binary: $GAMEDIR/System/GMDX.exe`` ScummVM ^^^^^^^ ``path``: Location of the game files. This should be set to ``$GAMEDIR`` in installer scripts. Runner configuration directives =============================== Runners can be customized in a section named after the runner identifier (``slug`` field in the API). A complete list of all runners is available at https://lutris.net/api/runners. Use the runner's slug as the runner identifier. Please keep the amount of runner customization to a minimum, only adding what is needed to make the game run correctly. A lot of runner options do not have their place in Lutris installers and are reserved for the user's preferences. The following sections will describe runner directives commonly used in installers. wine ---- ``version``: Set the Wine version to a specific build. Only set this if the game has known regressions with the current default build. Abusing this feature slows down the development of the Wine project. Example: ``version: staging-2.21-x86_64`` ``Desktop``: Run the game in a Wine virtual desktop. This should be used if the game has issues with Linux window managers such as crashes on Alt-Tab. Example: ``Desktop: true`` ``WineDesktop``: Set the resolution of the Wine virtual desktop. If not provided, the virtual desktop will take up the whole screen, which is likely the desired behavior. It is unlikely that you would add this directive in an installer but can be useful is a game is picky about the resolution it's running in. Example: ``WineDesktop: 1024x768`` ``dxvk``: Use this to disable DXVK if needed. (``dxvk: false``) ``dxvk_version``: Use this to define a specific DXVK version. (``dxvk_version: 1.10.3``) ``esync``: Use this to enable esync. (``esync: true``) ``overrides``: Overrides for Wine DLLs. List your DLL overrides in a mapping with the following values: ``n,b`` = Try native and fallback to builtin if native doesn't work ``b,n`` = Try builtin and fallback to native if builtin doesn't work ``b`` = Use builtin ``n`` = Use native ``disabled`` = Disable library Example:: overrides: ddraw.dll: n d3d9: disabled winegstreamer: builtin System configuration directives =============================== Those directives are stored in the ``system`` section and allow for customization of system features. As with runner configuration options, system directives should be used carefully, only adding them when absolutely necessary to run a game. ``restore_gamma``: If the game doesn't restore the correct gamma on exit, you can use this option to call xgamma and reset the default values. This option won't work on Wayland. Example: ``restore_gamma: true`` ``terminal``: Run the game in a terminal if the game is a text based one. Do not use this option to get the console output of the game, this will result in a broken installer. **Only use this option for text based games.** ``env``: Sets environment variables before launching a game and during install. Do not **ever** use this directive to enable a framerate counter. Do not use this directive to override Wine DLLs. Variable substitution is available in values. Example:: env: __GL_SHADER_DISK_CACHE: 1 __GL_THREADED_OPTIMIZATIONS: '1' __GL_SHADER_DISK_CACHE_PATH: $GAMEDIR mesa_glthread: 'true' ``single_cpu``: Run the game on a single CPU core. Useful for some old games that handle multicore CPUs poorly. (``single_cpu: true``) ``disable_runtime``: **DO NOT DISABLE THE LUTRIS RUNTIME IN LUTRIS INSTALLERS** ``pulse_latency``: Set PulseAudio latency to 60 msecs. Can reduce audio stuttering. (``pulse_latency: true``) ``use_us_layout``: Change the keyboard layout to a standard US one while the game is running. Useful for games that handle other layouts poorly and don't have key remapping options. (``use_us_layou: true``) ``xephyr``: Run the game in Xephyr. This is useful for games only handling 256 color modes. To enable Xephyr, pass the desired bit per plane value. (``xephyr: 8bpp``) ``xephyr_resolution``: Used with the ``xephyr`` option, this sets the size of the Xephyr window. (``xephyr_resolution: 1024x768``) Fetching required files ======================= The ``files`` section of the installer references every file needed for installing the game. This section's keys are unique identifier used later in the ``installer`` section. The value can either be a string containing a URI pointing at the required file or a dictionary containing the ``filename`` and ``url`` keys. The ``url`` key is equivalent to passing only a string to the installer and the ``filename`` key will be used to give the local copy another name. If you need to set referer use ``referer`` key. If the game contains copyrighted files that cannot be redistributed, the value should begin with ``N/A``. When the installer encounter this value, it will prompt the user for the location of the file. To indicate to the user what file to select, append a message to ``N/A`` like this: ``N/A:Please select the installer for this game`` Examples:: files: - file1: https://example.com/gamesetup.exe - file2: "N/A:Select the game's setup file" - file3: url: https://example.com/url-that-doesnt-resolve-to-a-proper-filename filename: actual_local_filename.zip referer: www.mywebsite.com If the game makes use of Steam data, the value should be ``$STEAM:appid:path/to/data``. This will check that the data is available or install it otherwise. If the game or file is hosted on moddb.com, it is necessary to understand that the platform rotates the actual download links every few hours, making it impractical to set these links as source url in installers. Lutris has routines to overcome this limitation (with blessing from moddb.com). When specifying a file hosted on moddb.com, please use the url of the files details page (the one with the red "Download now" button). Example URLs for ModDB files:: https://www.moddb.com/games/{game-title}/downloads/{file-title} https://www.moddb.com/mods/{mod-title}/downloads/{file-title} Writing the installation script =============================== After every file needed by the game has been acquired, the actual installation can take place. A series of directives will tell the installer how to set up the game correctly. Start the installer section with ``installer:`` then stack the directives by order of execution (top to bottom). Displaying an 'Insert disc' dialog ---------------------------------- The ``insert-disc`` command will display a message box to the user requesting him to insert the game's disc into the optical drive. Ensure a correct disc detection by specifying a file or folder present on the disc with the ``requires`` parameter. The $DISC variable will contain the drive's path for use in subsequent installer tasks. A link to CDEmu's homepage and PPA will also be displayed if the program isn't detected on the machine, otherwise it will be replaced with a button to open gCDEmu. You can override this default text with the ``message`` parameter. Example:: - insert-disc: requires: diablosetup.exe Moving files and directories ---------------------------- Move files or directories by using the ``move`` command. ``move`` requires two parameters: ``src`` (the source file or folder) and ``dst`` (the destination folder). The ``src`` parameter can either be a ``file ID`` or a path relative to game dir. If the parameter value is not found in the list of file ids, then it must be prefixed by either ``$CACHE`` or ``$GAMEDIR`` to move a file or directory from the download cache or the game's install dir, respectively. The ``dst`` parameter should be prefixed by either ``$GAMEDIR`` or ``$HOME`` to move files to path relative to the game dir or the current user's home. If the source is a ``file ID``, it will be updated with the new destination path. It can then be used in following commands to access the moved file. The ``move`` command cannot overwrite files. If the destination directory doesn't exist, it will be created. Be sure to give the full path of the destination (including filename), not just the destination folder. Example:: - move: src: game_file_id dst: $GAMEDIR/location Copying and merging directories ------------------------------- Both merging and copying actions are done with the ``merge`` or the ``copy`` directive. It is not important which of these directives is used because ``copy`` is just an alias for ``merge``. Whether the action does a merge or copy depends on the existence of the destination directory. When merging into an existing directory, original files with the same name as the ones present in the merged directory will be overwritten. Take this into account when writing your script and order your actions accordingly. If the source is a ``file ID``, it will be updated with the new destination path. It can then be used in following commands to access the copied file. Example:: - merge: src: game_file_id dst: $GAMEDIR/location Extracting archives ------------------- Extracting archives is done with the ``extract`` directive, the ``file`` argument is a ``file id`` or a file path with optional wildcards. If the archive(s) should be extracted in some other location than the ``$GAMEDIR``, you can specify a ``dst`` argument. You can optionally specify the archive's type with the ``format`` option. This is useful if the archive's file extension does not match what it should be. Accepted values for ``format`` are: tgz, tar, zip, 7z, rar, txz, bz2, gzip, deb, exe and gog(innoextract), as well as all other formats supported by 7zip. Example:: - extract: file: game_archive dst: $GAMEDIR/datadir/ Making a file executable ------------------------ Marking the file as executable is done with the ``chmodx`` directive. It is often needed for games that ship in a zip file, which does not retain file permissions. Example: ``- chmodx: $GAMEDIR/game_binary`` Executing a file ---------------- Execute files with the ``execute`` directive. Use the ``file`` parameter to reference a ``file id`` or a path, ``args`` to add command arguments, ``working_dir`` to set the directory to execute the command in (defaults to the install path). The command is executed within the Lutris Runtime (resolving most shared library dependencies). The file is made executable if necessary, no need to run chmodx before. You can also use ``env`` (environment variables), ``exclude_processes`` (space-separated list of processes to exclude from being monitored when determining if the execute phase finished), ``include_processes`` (the opposite of ``exclude_processes``, is used to override Lutris' built-in monitoring exclusion list) and ``disable_runtime`` (run a process without the Lutris Runtime, useful for running system binaries). Example:: - execute: args: --arg file: great_id You can use the ``command`` parameter instead of ``file`` and ``args``. This lets you run bash/shell commands easier. ``bash`` is used and is added to ``include_processes`` internally. Example:: - execute: command: 'echo Hello World! | cat' Writing files ------------- Writing text files ^^^^^^^^^^^^^^^^^^ Create or overwrite a file with the ``write_file`` directive. Use the ``file`` (an absolute path or a ``file id``) and ``content`` parameters. You can also use the optional parameter ``mode`` to specify a file write mode. Valid values for ``mode`` include ``w`` (the default, to write to a new file) or ``a`` to append data to an existing file. Refer to the YAML documentation for reference on how to including multiline documents and quotes. Example:: - write_file: file: $GAMEDIR/myfile.txt content: 'This is the contents of the file.' Writing into an INI type config file ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Modify or create a config file with the ``write_config`` directive. A config file is a text file composed of key=value (or key: value) lines grouped under [sections]. Use the ``file`` (an absolute path or a ``file id``), ``section``, ``key`` and ``value`` parameters or the ``data`` parameter. Set ``merge: false`` to first truncate the file. Note that the file is entirely rewritten and comments are left out; Make sure to compare the initial and resulting file to spot any potential parsing issues. Example:: - write_config: file: $GAMEDIR/myfile.ini section: Engine key: Renderer value: OpenGL :: - write_config: file: $GAMEDIR/myfile.ini data: General: iNumHWThreads: 2 bUseThreadedAI: 1 Writing into a JSON type file ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Modify or create a JSON file with the ``write_json`` directive. Use the ``file`` (an absolute path or a ``file id``) and ``data`` parameters. Note that the file is entirely rewritten; Make sure to compare the initial and resulting file to spot any potential parsing issues. You can set the optional parameter ``merge`` to ``false`` if you want to overwrite the JSON file instead of updating it. Example:: - write_json: file: $GAMEDIR/myfile.json data: Sound: Enabled: 'false' This writes (or updates) a file with the following content:: { "Sound": { "Enabled": "false" } } Running a task provided by a runner ----------------------------------- Some actions are specific to some runners, you can call them with the ``task`` command. You must at least provide the ``name`` parameter which is the function that will be called. Other parameters depend on the task being called. It is possible to call functions from other runners by prefixing the task name with the runner's name (e.g., from a dosbox installer you can use the wineexec task with ``wine.wineexec`` as the task's ``name``) If the command you will run in the task doesn't exit with a return code of 0, you can specify an accepted return code like ``return_code: 256`` Currently, the following tasks are implemented: * wine: ``create_prefix`` Creates an empty Wine prefix at the specified path. The other wine directives below include the creation of the prefix, so in most cases you won't need to use the create_prefix command. Parameters are: * ``prefix``: the path * ``arch``: optional architecture of the prefix, default: win64 unless a 32bit build is specified in the runner options. * ``overrides``: optional DLL overrides, format described later * ``install_gecko``: optional variable to stop installing gecko * ``install_mono``: optional variable to stop installing mono Example:: - task: name: create_prefix arch: win64 * wine: ``wineexec`` Runs a windows executable. Parameters are ``executable`` (``file ID`` or path), ``args`` (optional arguments passed to the executable), ``prefix`` (optional WINEPREFIX), ``arch`` (optional WINEARCH value, by default inherited from the `game:` section, which itself defaults to win64. The value can be set to ``win32`` to run the task in a 32-bit prefix.), ``blocking`` (if true, do not run the process in a thread), ``description`` (a message be shown to the user during the execution of the task), ``working_dir`` (optional working directory), ``exclude_processes`` (optional space-separated list of processes to exclude from being monitored when determining if the execute phase finished), ``include_processes`` (the opposite of ``exclude_processes``, is used to override Lutris' built-in monitoring exclusion list), ``env`` (optional environment variables), ``overrides`` (optional DLL overrides). Example:: - task: arch: win64 blocking: true description: Doing something... name: wineexec executable: drive_c/Program Files/Game/Game.exe exclude_processes: process_not_to_monitor.exe "Process Not To Monitor.exe" include_processes: process_from_the_excluded_list.exe working_dir: /absolute/path/ args: --windowed * wine: ``winetricks`` Runs winetricks with the ``app`` argument. ``prefix`` is an optional WINEPREFIX path. You can run many tricks at once by adding more to the ``app`` parameter (space-separated). By default Winetricks will run in silent mode but that can cause issues with some components such as XNA. In such cases, you can provide the option ``silent: false`` Example:: - task: name: winetricks app: nt40 silent: true For a full list of available ``winetricks`` see here: https://github.com/Winetricks/winetricks/tree/master/files/verbs * wine: ``eject_disk`` runs eject_disk in your ``prefix`` argument. Parameters are ``prefix`` (optional wineprefix path). Example:: - task: name: eject_disc * wine: ``set_regedit`` Modifies the Windows registry. Parameters are ``path`` (the registry path, use backslashes), ``key``, ``value``, ``type`` (optional value type, default is REG_SZ (string)), ``prefix`` (optional WINEPREFIX), ``arch`` (optional architecture of the prefix). Example:: - task: name: set_regedit path: HKEY_CURRENT_USER\Software\Valve\Steam key: SuppressAutoRun value: '00000000' type: REG_DWORD * wine: ``delete_registry_key`` Deletes registry key in the Windows registry. Parameters are ``key``, ``prefix`` (optional WINEPREFIX), ``arch`` (optional architecture of the prefix). Example:: - task: name: set_regedit path: HKEY_CURRENT_USER\Software\Valve\Steam key: SuppressAutoRun value: '00000000' type: REG_DWORD * wine: ``set_regedit_file`` Apply a regedit file to the registry, Parameters are ``filename`` (regfile name), ``arch`` (optional architecture of the prefix). Example:: - task: name: set_regedit_file filename: myregfile * wine: ``winekill`` Stops processes running in Wine prefix. Parameters are ``prefix`` (optional WINEPREFIX), ``arch`` (optional architecture of the prefix). Example:: - task: name: winekill * dosbox: ``dosexec`` Runs dosbox. Parameters are ``executable`` (optional ``file ID`` or path to executable), ``config_file`` (optional ``file ID`` or path to .conf file), ``args`` (optional command arguments), ``working_dir`` (optional working directory, defaults to the ``executable``'s dir or the ``config_file``'s dir), ``exit`` (set to ``false`` to prevent DOSBox to exit when the ``executable`` is terminated). Example:: - task: name: dosexec executable: file_id config: $GAMEDIR/game_install.conf args: -scaler normal3x -conf more_conf.conf Displaying a drop-down menu with options ---------------------------------------- Request input from the user by displaying a menu filled with options to choose from with the ``input_menu`` directive. The ``description`` parameter holds the message to the user, ``options`` is an indented list of ``value: label`` lines where "value" is the text that will be stored and "label" is the text displayed, and the optional ``preselect`` parameter is the value to preselect for the user. The result of the last input directive is available with the ``$INPUT`` alias. If need be, you can add an ``id`` parameter to the directive which will make the selected value available with ``$INPUT_`` with "" obviously being the id you specified. The id must contain only numbers, letters and underscores. Example:: - input_menu: description: "Choose the game's language:" id: LANG options: - en: English - fr: French - "value and": "label can be anything, surround them with quotes to avoid issues" preselect: en In this example, English would be preselected. If the option eventually selected is French, the "$INPUT_LANG" alias would be available in following directives and would correspond to "fr". "$INPUT" would work as well, up until the next input directive. Example scripts =============== Those example scripts are intended to be used as standalone files. Only the ``script`` section should be added to the script submission form. Example Linux game:: name: My Game game_slug: my-game version: Installer slug: my-game-installer runner: linux script: game: exe: $GAMEDIR/mygame args: --some-arg working_dir: $GAMEDIR files: - myfile: https://example.com/mygame.zip installer: - chmodx: $GAMEDIR/mygame system: env: SOMEENV: true Example wine game:: name: My Game game_slug: my-game version: Installer slug: my-game-installer runner: wine script: game: exe: $GAMEDIR/mygame args: --some-args prefix: $GAMEDIR/prefix arch: win32 working_dir: $GAMEDIR/prefix files: - installer: "N/A:Select the game's setup file" installer: - task: executable: installer name: wineexec prefix: $GAMEDIR/prefix wine: Desktop: true overrides: ddraw.dll: n system: env: SOMEENV: true Example gog wine game, some installer crash with with /SILENT or /VERYSILENT option (Cuphead and Star Wars: Battlefront II for example), (most options can be found here http://www.jrsoftware.org/ishelp/index.php?topic=setupcmdline, there is undocumented gog option ``/NOGUI``, you need to use it when you use ``/SILENT`` and ``/SUPPRESSMSGBOXES`` parameters):: name: My Game game_slug: my-game version: Installer slug: my-game-installer runner: wine script: game: exe: $GAMEDIR/drive_c/game/bin/Game.exe args: --some-arg prefix: $GAMEDIR working_dir: $GAMEDIR/drive_c/game files: - installer: "N/A:Select the game's setup file" installer: - task: args: /SILENT /LANG=en /SP- /NOCANCEL /SUPPRESSMSGBOXES /NOGUI /DIR="C:/game" executable: installer name: wineexec Example gog wine game, alternative (requires innoextract):: name: My Game game_slug: my-game version: Installer slug: my-game-installer runner: wine script: game: exe: $GAMEDIR/drive_c/Games/YourGame/game.exe args: --some-arg prefix: $GAMEDIR/prefix files: - installer: "N/A:Select the game's setup file" installer: - execute: args: --gog -d "$CACHE" setup description: Extracting game data file: innoextract - move: description: Extracting game data dst: $GAMEDIR/drive_c/Games/YourGame src: $CACHE/app Example gog linux game (mojosetup options found here https://www.reddit.com/r/linux_gaming/comments/42l258/fully_automated_gog_games_install_howto/):: name: My Game game_slug: my-game version: Installer slug: my-game-installer runner: linux script: game: exe: $GAMEDIR/game.sh args: --some-arg working_dir: $GAMEDIR files: - installer: "N/A:Select the game's setup file" installer: - chmodx: installer - execute: file: installer description: Installing game, it will take a while... args: -- --i-agree-to-all-licenses --noreadme --nooptions --noprompt --destination=$GAMEDIR Example gog linux game, alternative:: name: My Game game_slug: my-game version: Installer slug: my-game-installer runner: linux script: files: - goginstaller: N/A:Please select the GOG.com Linux installer game: args: --some-arg exe: start.sh installer: - extract: dst: $CACHE/GOG file: goginstaller format: zip - merge: dst: $GAMEDIR src: $CACHE/GOG/data/noarch/ Example steam Linux game:: name: My Game game_slug: my-game version: Installer slug: my-game-installer runner: steam script: game: appid: 227300 args: --some-args lutris-0.5.14/docs/steam.rst000066400000000000000000000012461451435154700157320ustar00rootroot00000000000000 AppState -------- :: StateInvalid 0 StateUninstalled 1 StateUpdateRequired 2 StateFullyInstalled 4 StateEncrypted 8 StateLocked 16 StateFilesMissing 32 StateAppRunning 64 StateFilesCorrupt 128 StateUpdateRunning 256 StateUpdatePaused 512 StateUpdateStarted 1024 StateUninstalling 2048 StateBackupRunning 4096 StateReconfiguring 65536 StateValidating 131072 StateAddingFiles 262144 StatePreallocating 524288 StateDownloading 1048576 StateStaging 2097152 StateCommitting 4194304 StateUpdateStopping 8388608 lutris-0.5.14/lutris.spec000066400000000000000000000123251451435154700153350ustar00rootroot00000000000000%global appid net.lutris.Lutris Name: lutris Version: 0.5.14 Release: 7%{?dist} Summary: Video game preservation platform License: GPL-3.0+ Group: Amusements/Games/Other URL: http://lutris.net Source0: http://lutris.net/releases/lutris_%{version}.tar.xz BuildArch: noarch BuildRequires: desktop-file-utils BuildRequires: python3-devel BuildRequires: python3-gobject BuildRequires: python-wheel BuildRequires: python-setuptools BuildRequires: fdupes BuildRequires: libappstream-glib BuildRequires: meson BuildRequires: gettext Requires: python3-gobject Requires: python3-PyYAML Requires: python3-requests Requires: python3-dbus Requires: python3-evdev Requires: python3-distro Requires: python3-pillow Requires: cabextract Requires: mesa-vulkan-drivers Requires: vulkan-loader Recommends: wine-core %ifarch x86_64 Requires: mesa-vulkan-drivers(x86-32) Requires: vulkan-loader(x86-32) %endif %if 0%{?fedora} Requires: gtk3, psmisc, xrandr Requires: gnome-desktop3 Requires: mesa-libGL %ifarch x86_64 Requires: mesa-libGL(x86-32) %endif %endif %if 0%{?suse_version} BuildRequires: typelib-1_0-Gtk-3_0 BuildRequires: update-desktop-files BuildRequires: hicolor-icon-theme Requires: typelib-1_0-Gtk-3_0 Requires: typelib-1_0-GnomeDesktop-3_0 Requires: typelib-1_0-WebKit2-4_0 Requires: typelib-1_0-Notify-0_7 %endif %description Lutris helps you install and play video games from all eras and from most gaming systems. By leveraging and combining existing emulators, engine re-implementations and compatibility layers, it gives you a central interface to launch all your games. %prep %autosetup -n %{name}-%{version} -p1 %build %py3_build %meson %meson_build %install %py3_install %meson_install %if 0%{?fedora} || 0%{?suse_version} %fdupes %{buildroot}%{python3_sitelib} %endif #desktop icon %if 0%{?suse_version} %suse_update_desktop_file -r -i %{appid} Game Network %endif %if 0%{?fedora} || 0%{?rhel} || 0%{?centos} desktop-file-install --dir=%{buildroot}%{_datadir}/applications share/applications/%{appid}.desktop desktop-file-validate %{buildroot}%{_datadir}/applications/%{appid}.desktop %endif %if 0%{?suse_version} >= 1140 %post %icon_theme_cache_post %desktop_database_post %endif %if 0%{?suse_version} >= 1140 %postun %icon_theme_cache_postun %desktop_database_postun %endif %files %{_bindir}/%{name} %{_datadir}/%{name}/ %{_datadir}/metainfo/%{appid}.metainfo.xml %{_datadir}/applications/%{appid}.desktop %{_datadir}/icons/hicolor/16x16/apps/lutris.png %{_datadir}/icons/hicolor/22x22/apps/lutris.png %{_datadir}/icons/hicolor/24x24/apps/lutris.png %{_datadir}/icons/hicolor/32x32/apps/lutris.png %{_datadir}/icons/hicolor/48x48/apps/lutris.png %{_datadir}/icons/hicolor/64x64/apps/lutris.png %{_datadir}/icons/hicolor/128x128/apps/lutris.png %{_datadir}/icons/hicolor/scalable/apps/lutris.svg %{_datadir}/man/man1/%{name}.1.gz %{python3_sitelib}/%{name}-*.egg-info %{python3_sitelib}/%{name}/ %{_datadir}/metainfo/ %{_datadir}/locale/ %changelog * Mon Sep 11 2023 Mathieu Comandon 0.5.13 - Update to Meson build system * Wed Feb 06 2019 Andrew Schott - 0.5.0.1-3 - Moved fedora dependency of "gnome-desktop3" to recommends to resolve a snafu with the way it was packaged. - Fixed the .desktop file registration (was using %{name}, needed %{appid}) * Tue Nov 29 2016 Mathieu Comandon - 0.4.3 - Ensure correct Python3 dependencies - Set up Python macros for building (Thanks to Pharaoh_Atem on #opensuse-buildservice) * Sat Oct 15 2016 Mathieu Comandon - 0.4.0 - Update to Python 3 - Bump version to 0.4.0 * Sat Dec 12 2015 Rémi Verschelde - 0.3.7-2 - Remove ownership of system directories - Spec file cleanup * Fri Nov 27 2015 Mathieu Comandon - 0.3.7-1 - Bump to version 0.3.7 * Thu Oct 30 2014 Mathieu Comandon - 0.3.6-1 - Bump to version 0.3.6 - Add OpenSuse compatibility (contribution by @malkavi) * Fri Sep 12 2014 Mathieu Comandon - 0.3.5-1 - Bump version to 0.3.5 * Thu Aug 14 2014 Travis Nickles - 0.3.4-3 - Edited Requires to include pygobject3. * Wed Jun 04 2014 Travis Nickles - 0.3.4-2 - Changed build and install step based on template generated by rpmdev-newspec. - Added Requires. - Ensure package can be built using mock. * Tue Jun 03 2014 Travis Nickles - 0.3.4-1 - Initial version of the package lutris-0.5.14/lutris/000077500000000000000000000000001451435154700144565ustar00rootroot00000000000000lutris-0.5.14/lutris/__init__.py000066400000000000000000000000621451435154700165650ustar00rootroot00000000000000"""Main Lutris package""" __version__ = "0.5.14" lutris-0.5.14/lutris/api.py000066400000000000000000000335471451435154700156150ustar00rootroot00000000000000"""Functions to interact with the Lutris REST API""" import functools import json import os import re import socket import time import urllib.error import urllib.parse import urllib.request from collections import OrderedDict import requests from lutris import settings from lutris.util import http, system from lutris.util.http import HTTPError, Request from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger API_KEY_FILE_PATH = os.path.join(settings.CACHE_DIR, "auth-token") USER_INFO_FILE_PATH = os.path.join(settings.CACHE_DIR, "user.json") def get_time_from_api_date(date_string): """Convert a date string originating from the API and convert it to a datetime object""" return time.strptime(date_string[:date_string.find(".")], "%Y-%m-%dT%H:%M:%S") def load_runtime_versions() -> dict: """Load runtime versions from json file""" if not system.path_exists(settings.RUNTIME_VERSIONS_PATH): return {} with open(settings.RUNTIME_VERSIONS_PATH, mode="r", encoding="utf-8") as runtime_file: return json.load(runtime_file) def read_api_key(): """Read the API token from disk""" if not system.path_exists(API_KEY_FILE_PATH): return None with open(API_KEY_FILE_PATH, "r", encoding='utf-8') as token_file: api_string = token_file.read() try: username, token = api_string.split(":") except ValueError: logger.error("Unable to read Lutris token in %s", API_KEY_FILE_PATH) return None return {"token": token, "username": username} def connect(username, password): """Connect to the Lutris API""" login_url = settings.SITE_URL + "/api/accounts/token" credentials = {"username": username, "password": password} try: response = requests.post(url=login_url, data=credentials, timeout=10) response.raise_for_status() json_dict = response.json() if "token" in json_dict: token = json_dict["token"] with open(API_KEY_FILE_PATH, "w", encoding='utf-8') as token_file: token_file.write("%s:%s" % (username, token)) get_user_info() return token except (requests.RequestException, requests.ConnectionError, requests.HTTPError, requests.TooManyRedirects, requests.Timeout) as ex: logger.error("Unable to connect to server (%s): %s", login_url, ex) return False def disconnect(): """Removes the API token, disconnecting the user""" for file_path in [API_KEY_FILE_PATH, USER_INFO_FILE_PATH]: if system.path_exists(file_path): os.remove(file_path) def get_user_info(): """Retrieves the user info to cache it locally""" credentials = read_api_key() if not credentials: return url = settings.SITE_URL + "/api/users/me" request = http.Request(url, headers={"Authorization": "Token " + credentials["token"]}) response = request.get() account_info = response.json if not account_info: logger.warning("Unable to fetch user info for %s", credentials["username"]) with open(USER_INFO_FILE_PATH, "w", encoding='utf-8') as token_file: json.dump(account_info, token_file, indent=2) def get_runners(runner_name): """Return the available runners for a given runner name""" logger.debug("Retrieving runners") api_url = settings.SITE_URL + "/api/runners/" + runner_name host = settings.SITE_URL.split("//")[1] answers = socket.getaddrinfo(host, 443) (_family, _type, _proto, _canonname, _sockaddr) = answers[0] headers = OrderedDict({ 'Host': host }) session = requests.Session() session.headers = headers response = session.get(api_url, headers=headers) return response.json() def download_runner_versions(runner_name: str) -> list: try: request = Request("{}/api/runners/{}".format(settings.SITE_URL, runner_name)) runner_info = request.get().json if not runner_info: logger.error("Failed to get runner information") except HTTPError as ex: logger.error("Unable to get runner information: %s", ex) runner_info = None if not runner_info: return [] versions = runner_info.get("versions") or [] return versions @functools.lru_cache() def get_default_runner_version(runner_name: str, version: str = "") -> dict: """Get the appropriate version for a runner Params: version: Optional version to lookup, will return this one if found Returns: Dict containing version, architecture and url for the runner, None if the data can't be retrieved. If a pseudo-version is accepted, may be a dict containing only the version itself. """ if not version: runtime_versions = load_runtime_versions() if runtime_versions: try: runner_versions = runtime_versions["runners"][runner_name] except KeyError: runner_versions = [] for runner_version in runner_versions: if runner_version["architecture"] in (LINUX_SYSTEM.arch, "all"): return runner_version logger.info( "Getting runner information for %s%s", runner_name, " (version: %s)" % version if version else "", ) arch = LINUX_SYSTEM.arch versions = download_runner_versions(runner_name) # Please someone clean up the abomination that is the code below. if version: if version.endswith("-i386") or version.endswith("-x86_64"): version, arch = version.rsplit("-", 1) versions = [v for v in versions if v["version"] == version] versions_for_arch = [v for v in versions if v["architecture"] == arch] if len(versions_for_arch) == 1: return versions_for_arch[0] if len(versions_for_arch) > 1: default_version = [v for v in versions_for_arch if v["default"] is True] if default_version: return default_version[0] elif len(versions) > 1 and LINUX_SYSTEM.is_64_bit: default_version = [v for v in versions if v["default"] is True] if default_version: return default_version[0] # If we didn't find a proper version yet, return the first available. if len(versions_for_arch) >= 1: return versions_for_arch[0] return {} def get_http_post_response(url, payload): response = http.Request(url, headers={"Content-Type": "application/json"}) try: response.post(data=payload) except http.HTTPError as ex: logger.error("Unable to get games from API: %s", ex) return None if response.status_code != 200: logger.error("API call failed: %s", response.status_code) return None return response.json def get_game_api_page(game_slugs, page=1): """Read a single page of games from the API and return the response Args: game_ids (list): list of game slugs page (str): Page of results to get """ url = settings.SITE_URL + "/api/games" if int(page) > 1: url += "?page={}".format(page) if not game_slugs: return [] payload = json.dumps({"games": game_slugs, "page": page}).encode("utf-8") return get_http_post_response(url, payload) def get_game_service_api_page(service, appids, page=1): """Get matching Lutris games from a list of appids from a given service""" url = settings.SITE_URL + "/api/games/service/%s" % service if int(page) > 1: url += "?page={}".format(page) if not appids: return [] payload = json.dumps({"appids": appids}).encode("utf-8") return get_http_post_response(url, payload) def get_api_games(game_slugs=None, page=1, service=None): """Return all games from the Lutris API matching the given game slugs""" if service: response_data = get_game_service_api_page(service, game_slugs) else: response_data = get_game_api_page(game_slugs) if not response_data: return [] results = response_data.get("results", []) while response_data.get("next"): page_match = re.search(r"page=(\d+)", response_data["next"]) if page_match: next_page = page_match.group(1) else: logger.error("No page found in %s", response_data["next"]) break if service: response_data = get_game_service_api_page(service, game_slugs, page=next_page) else: response_data = get_game_api_page(game_slugs, page=next_page) if not response_data: logger.warning("Unable to get response for page %s", next_page) break results += response_data.get("results") return results def get_game_installers(game_slug, revision=None): """Get installers for a single game""" if not game_slug: raise ValueError("No game_slug provided. Can't query an installer") if revision: installer_url = settings.INSTALLER_REVISION_URL % (game_slug, revision) else: installer_url = settings.INSTALLER_URL % game_slug logger.debug("Fetching installer %s", installer_url) request = http.Request(installer_url) request.get() response = request.json if response is None: raise RuntimeError("Couldn't get installer at %s" % installer_url) # Revision requests return a single installer if revision: installers = [response] else: installers = response["results"] return [normalize_installer(i) for i in installers] def get_game_details(slug: str) -> dict: url = settings.SITE_URL + "/api/games/%s" % slug request = http.Request(url) try: response = request.get() except http.HTTPError as ex: logger.debug("Unable to load %s: %s", slug, ex) return {} return response.json def normalize_installer(installer: dict) -> dict: """Adjusts an installer dict so it is in the correct form, with values of the expected types.""" def must_be_str(key): if key in installer: installer[key] = str(installer[key]) must_be_str("name") must_be_str("version") must_be_str("os") must_be_str("slug") must_be_str("game_slug") must_be_str("dlcid") must_be_str("runner") return installer def search_games(query) -> dict: if not query: return {} query = query.lower().strip()[:255] url = "/api/games?%s" % urllib.parse.urlencode({"search": query, "with-installers": True}) response = http.Request(settings.SITE_URL + url, headers={"Content-Type": "application/json"}) try: response.get() except http.HTTPError as ex: logger.error("Unable to get games from API: %s", ex) return {} return response.json def download_runtime_versions(pci_ids: list) -> dict: """Queries runtime + runners + current client versions""" url = settings.SITE_URL + "/api/runtimes/versions?pci_ids=" + ",".join(pci_ids) response = http.Request(url, headers={"Content-Type": "application/json"}) try: response.get() except http.HTTPError as ex: logger.error("Unable to get runtimes from API: %s", ex) return {} with open(settings.RUNTIME_VERSIONS_PATH, mode="w", encoding="utf-8") as runtime_file: json.dump(response.json, runtime_file, indent=2) return response.json def parse_installer_url(url): """ Parses `lutris:` urls, extracting any info necessary to install or run a game. """ action = None launch_config_name = None try: parsed_url = urllib.parse.urlparse(url, scheme="lutris") except Exception: # pylint: disable=broad-except logger.warning("Unable to parse url %s", url) return False if parsed_url.scheme != "lutris": return False url_path = parsed_url.path if not url_path: return False # urlparse can't parse if the path only contain numbers # workaround to remove the scheme manually: if url_path.startswith("lutris:"): url_path = url_path[7:] url_parts = [urllib.parse.unquote(part) for part in url_path.split("/")] if len(url_parts) == 3: action = url_parts[0] game_slug = url_parts[1] launch_config_name = url_parts[2] elif len(url_parts) == 2: action = url_parts[0] game_slug = url_parts[1] elif len(url_parts) == 1: game_slug = url_parts[0] else: raise ValueError("Invalid lutris url %s" % url) # To link to service games, format a slug like : if ":" in game_slug: service, appid = game_slug.split(":", maxsplit=1) else: service, appid = "", "" revision = None if parsed_url.query: query = dict(urllib.parse.parse_qsl(parsed_url.query)) revision = query.get("revision") return { "game_slug": game_slug, "revision": revision, "action": action, "service": service, "appid": appid, "launch_config_name": launch_config_name } def format_installer_url(installer_info): """ Generates 'lutris:' urls, given the same dictionary that parse_intaller_url returns. """ game_slug = installer_info.get("game_slug") revision = installer_info.get("revision") action = installer_info.get("action") service = installer_info.get("service") appid = installer_info.get("appid") launch_config_name = installer_info.get("launch_config_name") parts = [] if action: parts.append(action) elif not launch_config_name: raise ValueError("A 'lutris:' URL can contain a launch configuration name only if it has an action.") if game_slug: parts.append(game_slug) else: parts.append(service + ":" + appid) if launch_config_name: parts.append(launch_config_name) parts = [urllib.parse.quote(str(part)) for part in parts] path = "/".join(parts) if revision: query = urllib.parse.urlencode({"revision": str(revision)}) else: query = "" url = urllib.parse.urlunparse(("lutris", "", path, "", query, None)) return url lutris-0.5.14/lutris/cache.py000066400000000000000000000020141451435154700160700ustar00rootroot00000000000000"""Module for handling the PGA cache""" import os import shutil from lutris import settings from lutris.util.log import logger from lutris.util.system import merge_folders def get_cache_path(): """Return the path of the PGA cache""" pga_cache_path = settings.read_setting("pga_cache_path") if pga_cache_path: return os.path.expanduser(pga_cache_path) return None def save_cache_path(path): """Saves the PGA cache path to the settings""" settings.write_setting("pga_cache_path", path) def save_to_cache(source, destination): """Copy a file or folder to the cache""" if not source: raise ValueError("Missing source") if os.path.dirname(source) == destination: logger.info("Skipping caching of %s, already cached in %s", source, destination) return if os.path.isdir(source): # Copy folder recursively merge_folders(source, destination) else: shutil.copy(source, destination) logger.debug("Cached %s to %s", source, destination) lutris-0.5.14/lutris/command.py000066400000000000000000000217601451435154700164540ustar00rootroot00000000000000"""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 gi.repository import GLib from lutris import runtime, 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() 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): """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_executable(self.terminal) if not terminal_path: raise RuntimeError("Couldn't find terminal %s" % 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 = user_env or {} # 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()) return {key: value for key, value in env.items() if "=" not in key} def get_child_environment(self): """Returns the calculated environment for the child process.""" env = os.environ.copy() env.update(self.env) return env def start(self): """Run the thread.""" # 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) 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_handler_stdout(self, line): """Add the line to this command's stdout attribute""" 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""" 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 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 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.14/lutris/config.py000066400000000000000000000227711451435154700163060ustar00rootroot00000000000000"""Handle the game, runner and global system configurations.""" import os import time from shutil import copyfile from lutris import settings, sysoptions from lutris.runners import InvalidRunner, 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): self.game_config_id = game_config_id if runner_slug: self.runner_slug = str(runner_slug) else: self.runner_slug = runner_slug # 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.CONFIG_DIR, "runners/%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.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) 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: defaults[option] = params["default"] return defaults def options_as_dict(self, options_type): """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 None attribute_name = options_type + "_options" try: runner = import_runner(self.runner_slug) except InvalidRunner: 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.14/lutris/database/000077500000000000000000000000001451435154700162225ustar00rootroot00000000000000lutris-0.5.14/lutris/database/__init__.py000066400000000000000000000000001451435154700203210ustar00rootroot00000000000000lutris-0.5.14/lutris/database/categories.py000066400000000000000000000057361451435154700207340ustar00rootroot00000000000000import re from lutris import settings from lutris.database import sql 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 of 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(): """Get the list of every category in database.""" return sql.db_select(settings.PGA_DB, "categories", ) def get_category(name): """Return a category by name""" categories = sql.db_select(settings.PGA_DB, "categories", condition=("name", name)) if categories: return categories[0] def get_game_ids_for_category(category_name): """Get the ids of games in database.""" query = ( "SELECT game_id FROM games_categories " "JOIN categories ON categories.id = games_categories.category_id " "WHERE categories.name=?" ) return [ game["game_id"] for game in sql.db_query(settings.PGA_DB, query, (category_name,)) ] 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.PGA_DB, query, (game_id,)) ] def add_category(category_name): """Add a category to the database""" return sql.db_insert(settings.PGA_DB, "categories", {"name": category_name}) def add_game_to_category(game_id, category_id): """Add a category to a game""" return sql.db_insert(settings.PGA_DB, "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.PGA_DB) 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""" query = ( "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.PGA_DB, query) for category in empty_categories: if category['name'] == 'favorite': continue query = "DELETE FROM categories WHERE categories.id=?" with sql.db_cursor(settings.PGA_DB) as cursor: sql.cursor_execute(cursor, query, (category['id'],)) lutris-0.5.14/lutris/database/games.py000066400000000000000000000200611451435154700176670ustar00rootroot00000000000000import 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.PGA_DB, "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.PGA_DB, 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_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.PGA_DB, "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.PGA_DB, "games", condition=("runner", runner)) def get_games_by_slug(slug): """Return all games using a specific slug""" return sql.db_select(settings.PGA_DB, "games", condition=("slug", slug)) def add_game(**game_data): """Add a game to the PGA 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.PGA_DB, "games", game_data) def add_games_bulk(games): """ Add a list of games to the PGA 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.PGA_DB, "games", game) for game in games] def add_or_update(**params): """Add a game to the PGA 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.PGA_DB, "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.PGA_DB, "games", "id", game_id) def get_used_runners(): """Return a list of the runners in use by installed games.""" with sql.db_cursor(settings.PGA_DB) 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.PGA_DB) 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.PGA_DB, "games", fields=("COUNT(id)",), condition=(param, value)) if res: return res[0]["COUNT(id)"] lutris-0.5.14/lutris/database/schema.py000066400000000000000000000132471451435154700200430ustar00rootroot00000000000000from 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": "hidden", "type": "INTEGER" }, { "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}, ] } def get_schema(tablename): """ Fields: - position - name - type - not null - default - indexed """ tables = [] query = "pragma table_info('%s')" % tablename with sql.db_cursor(settings.PGA_DB) 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("[PGAQuery] %s", query) with sql.db_cursor(settings.PGA_DB) 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.PGA_DB, 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.14/lutris/database/services.py000066400000000000000000000025171451435154700204240ustar00rootroot00000000000000from 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.PGA_DB, "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.PGA_DB, "service_games", filters={"service": service}) @classmethod def get_game(cls, service, appid): """Return a single game referred by its appid""" logger.debug("Getting service game %s for %s", appid, service) if not service: raise ValueError("No service provided") if not appid: raise ValueError("No appid provided") results = sql.filtered_query(settings.PGA_DB, "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.14/lutris/database/sources.py000066400000000000000000000030341451435154700202570ustar00rootroot00000000000000import 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.PGA_DB, "sources", {"uri": uri}) def delete_source(uri): sql.db_delete(settings.PGA_DB, "sources", "uri", uri) def read_sources(): with sql.db_cursor(settings.PGA_DB) 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.PGA_DB, "sources", "uri", uri) for uri in sources: if uri not in db_sources: sql.db_insert(settings.PGA_DB, "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.14/lutris/database/sql.py000066400000000000000000000117661451435154700174060ustar00rootroot00000000000000 import sqlite3 import threading from lutris.util.log import logger # 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=1) # pylint: disable=consider-using-with if not lock: logger.error("Database is busy. Not executing %s", query) return results = cursor.execute(query, params) DB_LOCK.release() return results 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.14/lutris/exceptions.py000066400000000000000000000072751451435154700172240ustar00rootroot00000000000000"""Exception handling module""" from functools import wraps from gettext import gettext as _ from lutris.util.log import logger class LutrisError(Exception): """Base exception for Lutris related errors""" def __init__(self, message): super().__init__(message) self.message = message class GameConfigError(LutrisError): """Throw this error when the game configuration prevents the game from running properly. """ class UnavailableLibrariesError(RuntimeError): def __init__(self, libraries, arch=None): message = _( "The following {arch} libraries are required but are not installed on your system:\n{libs}" ).format( arch=arch if arch else "", libs=", ".join(libraries) ) super().__init__(message) self.libraries = libraries class AuthenticationError(Exception): """Raised when authentication to a service fails""" class UnavailableGameError(Exception): """Raised when a game is unavailable from a service""" class UnavailableRunnerError(Exception): """Raised when a runner is not installed or not installed fully.""" class EsyncLimitError(Exception): """Raised when the ESYNC limit is not set correctly.""" class FsyncUnsupportedError(Exception): """Raised when FSYNC is enabled, but is not supported by the kernel.""" def watch_errors(error_result=None, handler_object=None): """Decorator used to catch exceptions for GUI signal handlers. This catches any exception from the decorated function and calls on_watch_errors(error) on the first argument, which we presume to be self. and then the method will return 'error_result'""" captured_handler_object = handler_object def inner_decorator(function): @wraps(function) def wrapper(*args, **kwargs): myself = captured_handler_object or args[0] try: return function(*args, **kwargs) except Exception as ex: logger.exception(str(ex), exc_info=ex) myself.on_watched_error(ex) return error_result return wrapper return inner_decorator def watch_game_errors(game_stop_result, game=None): """Decorator used to catch exceptions and send events instead of propagating them normally. If 'game_stop_result' is not None, and the decorated function returns that, this will send game-stop and make the game stopped as well. This simplifies handling cancellation. Also, if an error occurs and is emitted, the function returns this value, so callers can tell that the function failed. If you do not provide a game object directly, it is assumed to be in the first argument to the decorated method (which is 'self', typically). """ captured_game = game def inner_decorator(function): @wraps(function) def wrapper(*args, **kwargs): """Catch all exceptions and emit an event.""" game = captured_game if captured_game else args[0] try: result = function(*args, **kwargs) if game_stop_result is not None and result == game_stop_result and game.state != game.STATE_STOPPED: game.state = game.STATE_STOPPED game.emit("game-stop") return result except Exception as ex: logger.exception("%s has encountered an error: %s", game, ex, exc_info=ex) if game.state != game.STATE_STOPPED: game.state = game.STATE_STOPPED game.emit("game-stop") game.signal_error(ex) return game_stop_result return wrapper return inner_decorator lutris-0.5.14/lutris/game.py000066400000000000000000001237201451435154700157460ustar00rootroot00000000000000"""Module that actually runs the games.""" # pylint: disable=too-many-public-methods disable=too-many-lines import json import os import shlex import shutil import signal import subprocess import time from gettext import gettext as _ from gi.repository import GLib, GObject, Gtk from lutris import settings from lutris.command import MonitoredCommand from lutris.config import LutrisConfig from lutris.database import categories as categories_db from lutris.database import games as games_db from lutris.database import sql from lutris.exceptions import GameConfigError, watch_game_errors from lutris.runner_interpreter import export_bash_script, get_launch_parameters from lutris.runners import InvalidRunner, import_runner from lutris.util import audio, discord, extract, jobs, linux, strings, system, xdgshortcuts from lutris.util.display import ( DISPLAY_MANAGER, SCREEN_SAVER_INHIBITOR, disable_compositing, enable_compositing, restore_gamma ) from lutris.util.graphics.xephyr import get_xephyr_command from lutris.util.graphics.xrandr import turn_off_except from lutris.util.log import LOG_BUFFERS, logger from lutris.util.process import Process from lutris.util.savesync import sync_saves from lutris.util.steam.shortcut import remove_shortcut as remove_steam_shortcut from lutris.util.timer import Timer from lutris.util.yaml import write_yaml_to_file HEARTBEAT_DELAY = 2000 class Game(GObject.Object): """This class takes cares of loading the configuration for a game and running it. """ now_playing_path = os.path.join(settings.CACHE_DIR, "now-playing.txt") STATE_STOPPED = "stopped" STATE_LAUNCHING = "launching" STATE_RUNNING = "running" PRIMARY_LAUNCH_CONFIG_NAME = "(primary)" __gsignals__ = { # SIGNAL_RUN_LAST works around bug https://gitlab.gnome.org/GNOME/glib/-/issues/513 # fix merged Dec 2020, but we support older GNOME! "game-error": (GObject.SIGNAL_RUN_LAST, bool, (object,)), "game-unhandled-error": (GObject.SIGNAL_RUN_FIRST, None, (object,)), "game-launch": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-start": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-started": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-stop": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-stopped": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-removed": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-updated": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-install": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-install-update": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-install-dlc": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-installed": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, game_id=None): super().__init__() self._id = game_id # pylint: disable=invalid-name # Load attributes from database game_data = games_db.get_game_by_field(game_id, "id") self.slug = game_data.get("slug") or "" self._runner_name = game_data.get("runner") or "" self.directory = game_data.get("directory") or "" self.name = game_data.get("name") or "" self.sortname = game_data.get("sortname") or "" self.game_config_id = game_data.get("configpath") or "" self.is_installed = bool(game_data.get("installed") and self.game_config_id) self.is_hidden = bool(game_data.get("hidden")) self.platform = game_data.get("platform") or "" self.year = game_data.get("year") or "" self.lastplayed = game_data.get("lastplayed") or 0 self.custom_images = set() if game_data.get("has_custom_banner"): self.custom_images.add("banner") if game_data.get("has_custom_icon"): self.custom_images.add("icon") if game_data.get("has_custom_coverart_big"): self.custom_images.add("coverart_big") self.service = game_data.get("service") self.appid = game_data.get("service_id") self.playtime = float(game_data.get("playtime") or 0.0) self.discord_id = game_data.get('discord_id') # Discord App ID for RPC self._config = None self._runner = None self.game_uuid = None self.game_thread = None self.antimicro_thread = None self.prelaunch_pids = None self.prelaunch_executor = None self.heartbeat = None self.killswitch = None self.state = self.STATE_STOPPED self.game_runtime_config = {} self.resolution_changed = False self.compositor_disabled = False self.original_outputs = None self._log_buffer = None self.timer = Timer() self.screen_saver_inhibitor_cookie = None @staticmethod def create_empty_service_game(db_game, service): """Creates a Game from the database data from ServiceGameCollection, which is not a real game, but which can be used to install. Such a game has no ID, but has an 'appid' and slug.""" game = Game() game.name = db_game["name"] game.slug = db_game["slug"] if "service_id" in db_game: game.appid = db_game["service_id"] elif service: game.appid = db_game["appid"] game.service = service.id if service else None return game def __repr__(self): return self.__str__() def __str__(self): value = self.name or "Game (no name)" if self.runner_name: value += " (%s)" % self.runner_name return value @property def is_cache_managed(self): """Is the DXVK cache receiving updates from lutris?""" if self.runner: env = self.runner.system_config.get("env", {}) return "DXVK_STATE_CACHE_PATH" in env return False @property def id(self): if self._id is None: logger.error("The game '%s' has no ID, it is not stored in the PGA.", self.name) return self._id def get_safe_id(self): """Returns the ID, or None if this Game has not got one; use this rather than 'id' if your code expects to cope with the None.""" return self._id @property def is_db_stored(self): """True if this Game has an ID, which means it is saved in the PGA.""" return self._id is not None @property def is_updatable(self): """Return whether the game can be upgraded""" return self.is_installed and self.service in ["gog", "itchio"] @property def is_favorite(self): """Return whether the game is in the user's favorites""" return "favorite" in self.get_categories() def get_categories(self): """Return the categories the game is in.""" return categories_db.get_categories_in_game(self.id) if self.is_db_stored else [] def update_game_categories(self, added_category_names, removed_category_names): """add to / remove from categories""" for added_category_name in added_category_names: self.add_category(added_category_name, no_signal=True) for removed_category_name in removed_category_names: self.remove_category(removed_category_name, no_signal=True) self.emit("game-updated") def add_category(self, category_name, no_signal=False): """add game to category""" if self.id is None: raise RuntimeError("Games that do not have IDs cannot belong to categories.") category = categories_db.get_category(category_name) if category is None: category_id = categories_db.add_category(category_name) else: category_id = category['id'] categories_db.add_game_to_category(self.id, category_id) if not no_signal: self.emit("game-updated") def remove_category(self, category_name, no_signal=False): """remove game from category""" category = categories_db.get_category(category_name) if category is None: return category_id = category['id'] categories_db.remove_category_from_game(self.id, category_id) if not no_signal: self.emit("game-updated") def add_to_favorites(self): """Add the game to the 'favorite' category""" if self.id is None: raise RuntimeError("Games that do not have IDs cannot be favorites.") favorite = categories_db.get_category("favorite") if not favorite: favorite_id = categories_db.add_category("favorite") else: favorite_id = favorite["id"] categories_db.add_game_to_category(self.id, favorite_id) self.emit("game-updated") def remove_from_favorites(self): """Remove game from favorites""" favorite = categories_db.get_category("favorite") categories_db.remove_category_from_game(self.id, favorite["id"]) self.emit("game-updated") def set_hidden(self, is_hidden): """Do not show this game in the UI""" self.is_hidden = is_hidden self.save() self.emit("game-updated") @property def log_buffer(self): """Access the log buffer object, creating it if necessary""" _log_buffer = LOG_BUFFERS.get(str(self.id)) if _log_buffer: return _log_buffer _log_buffer = Gtk.TextBuffer() _log_buffer.create_tag("warning", foreground="red") if self.game_thread: self.game_thread.set_log_buffer(self._log_buffer) _log_buffer.set_text(self.game_thread.stdout) LOG_BUFFERS[str(self.id)] = _log_buffer return _log_buffer @property def formatted_playtime(self): """Return a human-readable formatted play time""" return strings.get_formatted_playtime(self.playtime) def signal_error(self, error): """Reports an error by firing game-error. If handled, it returns True to indicate it handled it, and that's it. If not, this fires game-unhandled-error, which is actually handled via an emission hook and should not be connected otherwise. This allows special error handling to be set up for a particular Game, but there's always some handling.""" handled = self.emit("game-error", error) if not handled: self.emit("game-unhandled-error", error) @staticmethod def get_config_error(gameplay_info): """Return a GameConfigError based on the runner's output.""" error = gameplay_info["error"] if error == "CUSTOM": message_text = gameplay_info["text"].replace("&", "&") elif error == "RUNNER_NOT_INSTALLED": message_text = _("Error the runner is not installed") elif error == "NO_BIOS": message_text = _("A bios file is required to run this game") elif error == "FILE_NOT_FOUND": filename = gameplay_info["file"] if filename: message_text = _("The file {} could not be found").format(filename.replace("&", "&")) else: message_text = _("This game has no executable set. The install process didn't finish properly.") elif error == "NOT_EXECUTABLE": file = gameplay_info["file"].replace("&", "&") message_text = _("The file %s is not executable") % file elif error == "PATH_NOT_SET": message_text = _("The path '%s' is not set. please set it in the options.") % gameplay_info["path"] else: message_text = _("Unhandled error: %s") % gameplay_info["error"] return GameConfigError(message_text) def get_browse_dir(self): """Return the path to open with the Browse Files action.""" return self.resolve_game_path() def resolve_game_path(self): """Return the game's directory; if it is not known this will try to find it. This can still return an empty string if it can't do that.""" if self.directory: return self.directory if self.runner: return self.runner.resolve_game_path() return "" @property def config(self): if not self.is_installed or not self.game_config_id: return None if not self._config: self._config = LutrisConfig(runner_slug=self.runner_name, game_config_id=self.game_config_id) return self._config @config.setter def config(self, value): self._config = value self._runner = None if value: self.game_config_id = value.game_config_id def reload_config(self): """Triggers the config to reload when next used; this also reloads the runner, so that it will pick up the new configuration.""" self._config = None self._runner = None @property def runner_name(self): return self._runner_name @runner_name.setter def runner_name(self, value): self._runner_name = value if self._runner and self._runner.name != value: self._runner = None @property def runner(self): if not self.runner_name: return None if not self._runner: try: runner_class = import_runner(self.runner_name) self._runner = runner_class(self.config) except InvalidRunner: logger.error("Unable to import runner %s for %s", self.runner_name, self.slug) return self._runner @runner.setter def runner(self, value): self._runner = value if value: self._runner_name = value.name def set_desktop_compositing(self, enable): """Enables or disables compositing""" if enable: if self.compositor_disabled: enable_compositing() self.compositor_disabled = False else: if not self.compositor_disabled: disable_compositing() self.compositor_disabled = True def remove(self, delete_files=False, no_signal=False): """Uninstall a game Params: delete_files (bool): Delete the game files no_signal (bool): Don't emit game-removed signal (if running in a thread) """ sql.db_update(settings.PGA_DB, "games", {"installed": 0, "runner": ""}, {"id": self.id}) if self.config: self.config.remove() xdgshortcuts.remove_launcher(self.slug, self.id, desktop=True, menu=True) remove_steam_shortcut(self) if delete_files and self.runner: # self.directory here, not self.resolve_game_path; no guessing at # directories when we delete them self.runner.remove_game_data(app_id=self.appid, game_path=self.directory) self.is_installed = False self._config = None self._runner = None if str(self.id) in LOG_BUFFERS: # Reset game logs on removal log_buffer = LOG_BUFFERS[str(self.id)] log_buffer.delete(log_buffer.get_start_iter(), log_buffer.get_end_iter()) if not self.playtime: return self.delete(no_signal=no_signal) if no_signal: return self.emit("game-removed") def delete(self, no_signal=False): """Completely remove a game from the library""" if self.is_installed: raise RuntimeError(_("Uninstall the game before deleting")) games_db.delete_game(self.id) if not no_signal: self.emit("game-removed") self._id = None def set_platform_from_runner(self): """Set the game's platform from the runner""" if not self.runner: logger.warning("Game has no runner, can't set platform") return self.platform = self.runner.get_platform() if not self.platform: logger.warning("The %s runner didn't provide a platform for %s", self.runner.human_name, self) def save(self): """ Save the game's config and metadata. """ if self.config: configpath = self.config.game_config_id logger.debug("Saving %s with config ID %s", self, self.config.game_config_id) self.config.save() else: logger.warning("Saving %s with the configuration missing", self) configpath = "" self.set_platform_from_runner() game_data = { "name": self.name, "sortname": self.sortname, "runner": self.runner_name, "slug": self.slug, "platform": self.platform, "directory": self.directory, "installed": self.is_installed, "year": self.year, "lastplayed": self.lastplayed, "configpath": configpath, "id": self.id, "playtime": self.playtime, "hidden": self.is_hidden, "service": self.service, "service_id": self.appid, "discord_id": self.discord_id, "has_custom_banner": "banner" in self.custom_images, "has_custom_icon": "icon" in self.custom_images, "has_custom_coverart_big": "coverart_big" in self.custom_images } self._id = games_db.add_or_update(**game_data) self.emit("game-updated") def save_platform(self): """Save only the platform field- do not restore any other values the user may have changed in another window.""" games_db.update_existing(id=self.id, slug=self.slug, platform=self.platform) self.emit("game-updated") def save_lastplayed(self): """Save only the lastplayed field- do not restore any other values the user may have changed in another window.""" games_db.update_existing( id=self.id, slug=self.slug, lastplayed=self.lastplayed, playtime=self.playtime ) self.emit("game-updated") def check_launchable(self): """Verify that the current game can be launched, and raises exceptions if not.""" if not self.is_installed or not self.is_db_stored: logger.error("%s (%s) not installed", self, self.id) raise GameConfigError(_("Tried to launch a game that isn't installed.")) if not self.runner: raise GameConfigError(_("Invalid game configuration: Missing runner")) return True def restrict_to_display(self, display): outputs = DISPLAY_MANAGER.get_config() if display == "primary": display = None for output in outputs: if output.primary: display = output.name break if not display: logger.warning("No primary display set") else: found = False for output in outputs: if output.name == display: found = True break if not found: logger.warning("Selected display %s not found", display) display = None if display: turn_off_except(display) time.sleep(3) return True return False def start_xephyr(self, display=":2"): """Start a monitored Xephyr instance""" if not system.find_executable("Xephyr"): raise GameConfigError(_("Unable to find Xephyr, install it or disable the Xephyr option")) xephyr_command = get_xephyr_command(display, self.runner.system_config) xephyr_thread = MonitoredCommand(xephyr_command) xephyr_thread.start() time.sleep(3) return display def start_antimicrox(self, antimicro_config): """Start Antimicrox with a given config path""" antimicro_path = system.find_executable("antimicrox") if not antimicro_path: logger.warning("Antimicrox is not installed.") return logger.info("Starting Antic") antimicro_command = [antimicro_path, "--hidden", "--tray", "--profile", antimicro_config] self.antimicro_thread = MonitoredCommand(antimicro_command) self.antimicro_thread.start() def start_prelaunch_command(self, wait_for_completion=False): """Start the prelaunch command specified in the system options""" prelaunch_command = self.runner.system_config.get("prelaunch_command") command_array = shlex.split(prelaunch_command) if not system.path_exists(command_array[0]): logger.warning("Command %s not found", command_array[0]) return env = self.game_runtime_config["env"] if wait_for_completion: logger.info("Prelauch command: %s, waiting for completion", prelaunch_command) # Monitor the prelaunch command and wait until it has finished system.execute(command_array, env=env, cwd=self.resolve_game_path()) else: logger.info("Prelaunch command %s launched in the background", prelaunch_command) self.prelaunch_executor = MonitoredCommand( command_array, include_processes=[os.path.basename(command_array[0])], env=env, cwd=self.resolve_game_path(), ) self.prelaunch_executor.start() def get_terminal(self): """Return the terminal used to run the game into or None if the game is not run from a terminal. Remember that only games using text mode should use the terminal. """ if self.runner.system_config.get("terminal"): terminal = self.runner.system_config.get("terminal_app", linux.get_default_terminal()) if terminal and not system.find_executable(terminal): raise GameConfigError(_("The selected terminal application could not be launched:\n%s") % terminal) return terminal def get_killswitch(self): """Return the path to a file that is monitored during game execution. If the file stops existing, the game is stopped. """ killswitch = self.runner.system_config.get("killswitch") # Prevent setting a killswitch to a file that doesn't exists if killswitch and system.path_exists(self.killswitch): return killswitch def get_gameplay_info(self, launch_ui_delegate): """Return the information provided by a runner's play method. It checks for possible errors and raises exceptions if they occur. This may invoke methods on the delegates to make decisions, and this may show UI. This returns an empty dictionary if the user cancels this UI, in which case the game should not be run. """ if not self.runner: raise GameConfigError(_("Invalid game configuration: Missing runner")) gameplay_info = self.runner.play() if "error" in gameplay_info: raise self.get_config_error(gameplay_info) if "working_dir" not in gameplay_info: gameplay_info["working_dir"] = self.runner.working_dir config = launch_ui_delegate.select_game_launch_config(self) if config is None: return {} # no error here- the user cancelled out if config: # empty dict for primary configuration self.runner.apply_launch_config(gameplay_info, config) return gameplay_info @watch_game_errors(game_stop_result=False) def configure_game(self, launch_ui_delegate): """Get the game ready to start, applying all the options. This method sets the game_runtime_config attribute. """ gameplay_info = self.get_gameplay_info(launch_ui_delegate) if not gameplay_info: # if user cancelled- not an error return False command, env = get_launch_parameters(self.runner, gameplay_info) env["game_name"] = self.name # What is this used for?? self.game_runtime_config = { "args": command, "env": env, "terminal": self.get_terminal(), "include_processes": shlex.split(self.runner.system_config.get("include_processes", "")), "exclude_processes": shlex.split(self.runner.system_config.get("exclude_processes", "")), } if "working_dir" in gameplay_info: self.game_runtime_config["working_dir"] = gameplay_info["working_dir"] # Audio control if self.runner.system_config.get("reset_pulse"): audio.reset_pulse() # Input control if self.runner.system_config.get("use_us_layout"): system.set_keyboard_layout("us") # Display control self.original_outputs = DISPLAY_MANAGER.get_config() if self.runner.system_config.get("disable_compositor"): self.set_desktop_compositing(False) if self.runner.system_config.get("disable_screen_saver"): self.screen_saver_inhibitor_cookie = SCREEN_SAVER_INHIBITOR.inhibit(self.name) if self.runner.system_config.get("display") != "off": self.resolution_changed = self.restrict_to_display(self.runner.system_config.get("display")) resolution = self.runner.system_config.get("resolution") if resolution != "off": DISPLAY_MANAGER.set_resolution(resolution) time.sleep(3) self.resolution_changed = True xephyr = self.runner.system_config.get("xephyr") or "off" if xephyr != "off": env["DISPLAY"] = self.start_xephyr() antimicro_config = self.runner.system_config.get("antimicro_config") if system.path_exists(antimicro_config): self.start_antimicrox(antimicro_config) # Execution control self.killswitch = self.get_killswitch() if self.runner.system_config.get("prelaunch_command"): self.start_prelaunch_command(self.runner.system_config.get("prelaunch_wait")) self.start_game() return True @watch_game_errors(game_stop_result=False) def launch(self, launch_ui_delegate): """Request launching a game. The game may not be installed yet.""" if not self.check_launchable(): logger.error("Game is not launchable") return False if not launch_ui_delegate.check_game_launchable(self): return False self.reload_config() # Reload the config before launching it. saves = self.config.game_level["game"].get("saves") if saves: sync_saves(self) if str(self.id) in LOG_BUFFERS: # Reset game logs on each launch log_buffer = LOG_BUFFERS[str(self.id)] log_buffer.delete(log_buffer.get_start_iter(), log_buffer.get_end_iter()) self.state = self.STATE_LAUNCHING self.prelaunch_pids = system.get_running_pid_list() if not self.prelaunch_pids: logger.error("No prelaunch PIDs could be obtained. Game stop may be ineffective.") self.prelaunch_pids = None self.emit("game-start") @watch_game_errors(game_stop_result=False, game=self) def configure_game(_ignored, error): if error: raise error self.configure_game(launch_ui_delegate) jobs.AsyncCall(self.runner.prelaunch, configure_game) return True def start_game(self): """Run a background command to lauch the game""" self.game_thread = MonitoredCommand( self.game_runtime_config["args"], title=self.name, runner=self.runner, cwd=self.game_runtime_config.get("working_dir"), env=self.game_runtime_config["env"], term=self.game_runtime_config["terminal"], log_buffer=self.log_buffer, include_processes=self.game_runtime_config["include_processes"], exclude_processes=self.game_runtime_config["exclude_processes"], ) if hasattr(self.runner, "stop"): self.game_thread.stop_func = self.runner.stop self.game_uuid = self.game_thread.env["LUTRIS_GAME_UUID"] self.game_thread.start() self.timer.start() self.state = self.STATE_RUNNING self.emit("game-started") # Game is running, let's update discord status if settings.read_setting('discord_rpc') == 'True' and self.discord_id: try: discord.client.update(self.discord_id) except AssertionError: pass self.heartbeat = GLib.timeout_add(HEARTBEAT_DELAY, self.beat) with open(self.now_playing_path, "w", encoding="utf-8") as np_file: np_file.write(self.name) def force_stop(self): # If force_stop_game fails, wait a few seconds and try SIGKILL on any survivors self.runner.force_stop_game(self) if self.get_stop_pids(): self.force_kill_delayed() else: self.stop_game() def force_kill_delayed(self, death_watch_seconds=5, death_watch_interval_seconds=.5): """Forces termination of a running game, but only after a set time has elapsed; Invokes stop_game() when the game is dead.""" def death_watch(): """Wait for the processes to die; returns True if do they all did.""" for _n in range(int(death_watch_seconds / death_watch_interval_seconds)): time.sleep(death_watch_interval_seconds) if not self.get_stop_pids(): return True return False def death_watch_cb(all_died, error): """Called after the death watch to more firmly kill any survivors.""" if error: self.signal_error(error) elif not all_died: self.kill_processes(signal.SIGKILL) # If we still can't kill everything, we'll still say we stopped it. self.stop_game() jobs.AsyncCall(death_watch, death_watch_cb) def kill_processes(self, sig): """Sends a signal to a process list, logging errors.""" pids = self.get_stop_pids() 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) def get_stop_pids(self): """Finds the PIDs of processes that need killin'!""" pids = self.get_game_pids() if self.game_thread and self.game_thread.game_process: pids.add(self.game_thread.game_process.pid) return pids def get_game_pids(self): """Return a list of processes belonging to the Lutris game""" if not self.game_uuid: logger.error("No LUTRIS_GAME_UUID recorded. The game's PIDs cannot be computed.") return set() new_pids = self.get_new_pids() game_pids = [] game_folder = self.resolve_game_path() for pid in new_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: game_pids.append(pid) return set(game_pids + [ pid for pid in new_pids if Process(pid).environ.get("LUTRIS_GAME_UUID") == self.game_uuid ]) def get_new_pids(self): """Return list of PIDs started since the game was launched""" if self.prelaunch_pids: return set(system.get_running_pid_list()) - set(self.prelaunch_pids) logger.error("No prelaunch PIDs recorded. The game's PIDs cannot be computed.") return set() def stop_game(self): """Cleanup after a game as stopped""" duration = self.timer.duration logger.debug("%s has run for %s seconds", self, duration) if duration < 5: logger.warning("The game has run for a very short time, did it crash?") # Inspect why it could have crashed self.state = self.STATE_STOPPED self.emit("game-stop") if os.path.exists(self.now_playing_path): os.unlink(self.now_playing_path) if not self.timer.finished: self.timer.end() self.playtime += self.timer.duration / 3600 logger.debug("Playtime: %s", self.formatted_playtime) @watch_game_errors(game_stop_result=False) def beat(self): """Watch the game's process(es).""" if self.game_thread.error: self.on_game_quit() raise RuntimeError(_("Error lauching the game:\n") + self.game_thread.error) # The killswitch file should be set to a device (ie. /dev/input/js0) # When that device is unplugged, the game is forced to quit. killswitch_engage = self.killswitch and not system.path_exists(self.killswitch) if killswitch_engage: logger.warning("File descriptor no longer present, force quit the game") self.force_stop() return False game_pids = self.get_game_pids() runs_only_prelaunch = False if self.prelaunch_executor and self.prelaunch_executor.is_running: runs_only_prelaunch = game_pids == {self.prelaunch_executor.game_process.pid} if runs_only_prelaunch or (not self.game_thread.is_running and not game_pids): logger.debug("Game thread stopped") self.on_game_quit() return False return True def stop(self): """Stops the game""" if self.state == self.STATE_STOPPED: logger.debug("Game already stopped") return logger.info("Stopping %s", self) if self.game_thread: def stop_cb(result, error): if error: self.signal_error(error) jobs.AsyncCall(self.game_thread.stop, stop_cb) self.stop_game() def on_game_quit(self): """Restore some settings and cleanup after game quit.""" if self.prelaunch_executor and self.prelaunch_executor.is_running: logger.info("Stopping prelaunch script") self.prelaunch_executor.stop() # We need to do some cleanup before we emit game-stop as this can # trigger Lutris shutdown if self.screen_saver_inhibitor_cookie is not None: SCREEN_SAVER_INHIBITOR.uninhibit(self.screen_saver_inhibitor_cookie) self.screen_saver_inhibitor_cookie = None self.heartbeat = None if self.state != self.STATE_STOPPED: logger.warning("Game still running (state: %s)", self.state) self.stop() # Check for post game script postexit_command = self.runner.system_config.get("postexit_command") if postexit_command: command_array = shlex.split(postexit_command) if system.path_exists(command_array[0]): logger.info("Running post-exit command: %s", postexit_command) postexit_thread = MonitoredCommand( command_array, include_processes=[os.path.basename(postexit_command)], env=self.game_runtime_config["env"], cwd=self.resolve_game_path(), ) postexit_thread.start() quit_time = time.strftime("%a, %d %b %Y %H:%M:%S", time.localtime()) logger.debug("%s stopped at %s", self.name, quit_time) self.lastplayed = int(time.time()) self.save_lastplayed() os.chdir(os.path.expanduser("~")) if self.antimicro_thread: self.antimicro_thread.stop() if self.resolution_changed or self.runner.system_config.get("reset_desktop"): DISPLAY_MANAGER.set_resolution(self.original_outputs) if self.compositor_disabled: self.set_desktop_compositing(True) if self.runner.system_config.get("use_us_layout"): with subprocess.Popen(["setxkbmap"], env=os.environ) as setxkbmap: setxkbmap.communicate() if self.runner.system_config.get("restore_gamma"): restore_gamma() # Clear Discord Client Status if settings.read_setting('discord_rpc') == 'True' and self.discord_id: discord.client.clear() self.process_return_codes() def process_return_codes(self): """Do things depending on how the game quitted.""" if self.game_thread.return_code == 127: # Error missing shared lib error = "error while loading shared lib" error_line = strings.lookup_string_in_text(error, self.game_thread.stdout) if error_line: raise RuntimeError(_("Error: Missing shared library.\n\n%s") % error_line) if self.game_thread.return_code == 1: # Error Wine version conflict error = "maybe the wrong wineserver" if strings.lookup_string_in_text(error, self.game_thread.stdout): raise RuntimeError(_("Error: A different Wine version is already using the same Wine prefix.")) def write_script(self, script_path, launch_ui_delegate): """Output the launch argument in a bash script""" gameplay_info = self.get_gameplay_info(launch_ui_delegate) if not gameplay_info: # User cancelled; errors are raised as exceptions instead of this return export_bash_script(self.runner, gameplay_info, script_path) def move(self, new_location): logger.info("Moving %s to %s", self, new_location) new_config = "" old_location = self.directory if os.path.exists(old_location): game_directory = os.path.basename(old_location) target_directory = os.path.join(new_location, game_directory) else: target_directory = new_location self.directory = target_directory self.save() if not old_location: logger.info("Previous location wasn't set. Cannot continue moving") return target_directory with open(self.config.game_config_path, encoding='utf-8') as config_file: for line in config_file.readlines(): if target_directory in line: new_config += line else: new_config += line.replace(old_location, target_directory) with open(self.config.game_config_path, "w", encoding='utf-8') as config_file: config_file.write(new_config) if not system.path_exists(old_location): logger.warning("Location %s doesn't exist, files already moved?", old_location) return target_directory if new_location.startswith(old_location): logger.warning("Can't move %s to one of its children %s", old_location, new_location) return target_directory try: shutil.move(old_location, new_location) except OSError as ex: logger.error( "Failed to move %s to %s, you may have to move files manually (Exception: %s)", old_location, new_location, ex ) return target_directory def export_game(slug, dest_dir): """Export a full game folder along with some lutris metadata""" # List of runner where we know for sure that 1 folder = 1 game. # For runners that handle ROMs, we have to handle this more finely. # There is likely more than one game in a ROM folder but a ROM # might have several files (like a bin/cue, or a multi-disk game) exportable_runners = [ "linux", "wine", "dosbox", "scummvm", ] db_game = games_db.get_game_by_field(slug, "slug") if db_game["runner"] not in exportable_runners: raise RuntimeError("Game %s can't be exported." % db_game["name"]) if not db_game["directory"]: raise RuntimeError("No game directory set. Could we guess it?") game = Game(db_game["id"]) db_game["config"] = game.config.game_level game_path = db_game["directory"] config_path = os.path.join(db_game["directory"], "%s.lutris" % slug) with open(config_path, "w", encoding="utf-8") as config_file: json.dump(db_game, config_file, indent=2) archive_path = os.path.join(dest_dir, "%s.7z" % slug) _7zip_path = os.path.join(settings.RUNTIME_DIR, "p7zip/7z") command = [_7zip_path, "a", archive_path, game_path] return_code = subprocess.call(command) if return_code != 0: print("Creating of archive in %s failed with return code %s" % (archive_path, return_code)) def import_game(file_path, dest_dir): """Import a game in Lutris""" if not os.path.exists(file_path): raise RuntimeError("No file %s" % file_path) if not os.path.isdir(dest_dir): os.makedirs(dest_dir) original_file_list = set(os.listdir(dest_dir)) extract.extract_7zip(file_path, dest_dir) new_file_list = set(os.listdir(dest_dir)) new_dir = list(new_file_list - original_file_list)[0] game_dir = os.path.join(dest_dir, new_dir) game_config = [f for f in os.listdir(game_dir) if f.endswith(".lutris")][0] with open(os.path.join(game_dir, game_config), encoding="utf-8") as config_file: lutris_config = json.load(config_file) old_dir = lutris_config["directory"] with open(os.path.join(game_dir, game_config), 'r', encoding="utf-8") as config_file: config_data = config_file.read() config_data = config_data.replace(old_dir, game_dir) with open(os.path.join(game_dir, game_config), 'w', encoding="utf-8") as config_file: config_file.write(config_data) with open(os.path.join(game_dir, game_config), encoding="utf-8") as config_file: lutris_config = json.load(config_file) config_filename = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % lutris_config["configpath"]) write_yaml_to_file(lutris_config["config"], config_filename) game_id = games_db.add_or_update( name=lutris_config["name"], runner=lutris_config["runner"], slug=lutris_config["slug"], platform=lutris_config["platform"], directory=game_dir, installed=lutris_config["installed"], year=lutris_config["year"], lastplayed=lutris_config["lastplayed"], configpath=lutris_config["configpath"], playtime=lutris_config["playtime"], hidden=lutris_config["hidden"], service=lutris_config["service"], service_id=lutris_config["service_id"], ) print("Added game with ID %s" % game_id) lutris-0.5.14/lutris/game_actions.py000066400000000000000000000346651451435154700174770ustar00rootroot00000000000000"""Handle game specific actions""" # Standard Library # pylint: disable=too-many-public-methods import os from gettext import gettext as _ from gi.repository import Gio, Gtk from lutris.command import MonitoredCommand 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_game import RemoveGameDialog, UninstallGameDialog from lutris.gui.widgets.utils import open_uri from lutris.services.lutris import download_lutris_media from lutris.util import xdgshortcuts 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 from lutris.util.wine.shader_cache import update_shader_cache class GameActions: """Regroup a list of callbacks for a game""" def __init__(self, game, window, application=None): self.application = application or Gio.Application.get_default() self.window = window # also used as a LaunchUIDelegate self.game = game @property def is_game_running(self): return self.game and self.game.is_db_stored and bool(self.application.get_running_game_by_id(self.game.id)) @property def is_game_removable(self): return self.game and (self.game.is_installed or self.game.is_db_stored) def on_game_state_changed(self, game): """Handler called when the game has changed state""" if self.game and game.id == self.game.get_safe_id(): self.game = game def get_game_actions(self): """Return a list of game actions and their callbacks""" 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), ("update-shader-cache", _("Update shader cache"), self.on_update_shader_cache), (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", _("Add installed game"), self.on_add_manually), ("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""" if steam_shortcut.vdf_file_exists(): has_steam_shortcut = steam_shortcut.shortcut_exists(self.game) is_steam_game = steam_shortcut.is_steam_game(self.game) else: has_steam_shortcut = False is_steam_game = False return { "add": not self.game.is_installed, "duplicate": self.game.is_installed, "install": not self.game.is_installed, "play": self.game.is_installed and not self.is_game_running, "update": self.game.is_updatable, "update-shader-cache": self.game.is_cache_managed, "install_dlcs": self.game.is_updatable, "stop": self.is_game_running, "configure": bool(self.game.is_installed), "browse": self.game.is_installed and self.game.runner_name != "browser", "show_logs": self.game.is_installed, "favorite": not self.game.is_favorite and self.game.is_installed, "category": self.game.is_installed, "deletefavorite": self.game.is_favorite, "install_more": not self.game.service and self.game.is_installed, "execute-script": bool( self.game.is_installed and self.game.runner and self.game.runner.system_config.get("manual_command") ), "desktop-shortcut": ( self.game.is_installed and not xdgshortcuts.desktop_launcher_exists(self.game.slug, self.game.id) ), "menu-shortcut": ( self.game.is_installed and not xdgshortcuts.menu_launcher_exists(self.game.slug, self.game.id) ), "steam-shortcut": ( self.game.is_installed and not has_steam_shortcut and not is_steam_game ), "rm-desktop-shortcut": bool( self.game.is_installed and xdgshortcuts.desktop_launcher_exists(self.game.slug, self.game.id) ), "rm-menu-shortcut": bool( self.game.is_installed and xdgshortcuts.menu_launcher_exists(self.game.slug, self.game.id) ), "rm-steam-shortcut": bool( self.game.is_installed and has_steam_shortcut and not is_steam_game ), "remove": self.is_game_removable, "view": True, "hide": self.game.is_installed and not self.game.is_hidden, "unhide": self.game.is_hidden, } def on_game_launch(self, *_args): """Launch a game""" self.game.launch(self.window) def get_running_game(self): if self.game and self.game.is_db_stored: ids = self.application.get_running_game_ids() for game_id in ids: if str(game_id) == str(self.game.id): return self.game logger.warning("Game %s not in %s", self.game.id, ids) return None def on_game_stop(self, *_args): """Stops the game""" game = self.get_running_game() if game: game.force_stop() def on_show_logs(self, _widget): """Display game log""" _buffer = self.game.log_buffer if not _buffer: logger.info("No log for game %s", self.game) return LogWindow( game=self.game, buffer=_buffer, application=self.application ) def on_install_clicked(self, *_args): """Install a game""" # Install the currently selected game in the UI if not self.game.slug: raise RuntimeError("No game to install: %s" % self.game.get_safe_id()) self.game.emit("game-install") def on_update_clicked(self, _widget): self.game.emit("game-install-update") def on_install_dlc_clicked(self, _widget): self.game.emit("game-install-dlc") def on_update_shader_cache(self, _widget): update_shader_cache(self.game) def on_locate_installed_game(self, _button, game): """Show the user a dialog to import an existing install to a DRM free service Params: game (Game): Game instance without a database ID, populated with a fields the service can provides """ AddGameDialog(self.window, game=game) def on_add_manually(self, _widget, *_args): """Callback that presents the Add game dialog""" return AddGameDialog(self.window, game=self.game, runner=self.game.runner_name) def on_game_duplicate(self, _widget): 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(self.game.name), "title": _("Duplicate game?"), } ) 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 = self.game.game_config_id if old_config_id: new_config_id = duplicate_game_config(self.game.slug, old_config_id) else: new_config_id = None duplicate_game_dialog.destroy() db_game = get_game_by_field(self.game.id, "id") db_game["name"] = new_name db_game["slug"] = slugify(new_name) 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 PGA game for a service game. db_game.pop("service", None) db_game.pop("service_id", None) game_id = add_game(**db_game) download_lutris_media(db_game["slug"]) new_game = Game(game_id) new_game.save() def on_edit_game_configuration(self, _widget): """Edit game preferences""" self.application.show_window(EditGameConfigDialog, game=self.game, parent=self.window) def on_add_favorite_game(self, _widget): """Add to favorite Games list""" self.game.add_to_favorites() def on_delete_favorite_game(self, _widget): """delete from favorites""" self.game.remove_from_favorites() def on_edit_game_categories(self, _widget): """Edit game categories""" self.application.show_window(EditGameCategoriesDialog, game=self.game, parent=self.window) def on_hide_game(self, _widget): """Add a game to the list of hidden games""" self.game.set_hidden(True) def on_unhide_game(self, _widget): """Removes a game from the list of hidden games""" self.game.set_hidden(False) def on_execute_script_clicked(self, _widget): """Execute the game's associated script""" manual_command = self.game.runner.system_config.get("manual_command") if path_exists(manual_command): MonitoredCommand( [manual_command], include_processes=[os.path.basename(manual_command)], cwd=self.game.directory, ).start() logger.info("Running %s in the background", manual_command) 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_create_menu_shortcut(self, *_args): """Add the selected game to the system's Games menu.""" launch_config_name = self._select_game_launch_config_name(self.game) if launch_config_name is not None: xdgshortcuts.create_launcher(self.game.slug, self.game.id, self.game.name, menu=True) def on_create_steam_shortcut(self, *_args): """Add the selected game to steam as a nonsteam-game.""" launch_config_name = self._select_game_launch_config_name(self.game) if launch_config_name is not None: steam_shortcut.create_shortcut(self.game, launch_config_name) def on_create_desktop_shortcut(self, *_args): """Create a desktop launcher for the selected game.""" launch_config_name = self._select_game_launch_config_name(self.game) if launch_config_name is not None: xdgshortcuts.create_launcher(self.game.slug, self.game.id, self.game.name, launch_config_name, desktop=True) def on_remove_menu_shortcut(self, *_args): """Remove an XDG menu shortcut""" xdgshortcuts.remove_launcher(self.game.slug, self.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""" xdgshortcuts.remove_launcher(self.game.slug, self.game.id, desktop=True) 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 "" def on_view_game(self, _widget): """Callback to open a game on lutris.net""" open_uri("https://lutris.net/games/%s" % self.game.slug.replace("_", "-")) def on_remove_game(self, *_args): """Callback that present the uninstall dialog to the user""" if self.game.is_installed: UninstallGameDialog(game_id=self.game.id, parent=self.window).run() else: RemoveGameDialog(game_id=self.game.id, parent=self.window).run() lutris-0.5.14/lutris/gui/000077500000000000000000000000001451435154700152425ustar00rootroot00000000000000lutris-0.5.14/lutris/gui/__init__.py000066400000000000000000000000331451435154700173470ustar00rootroot00000000000000""" Lutris GUI package """ lutris-0.5.14/lutris/gui/addgameswindow.py000066400000000000000000000722061451435154700206200ustar00rootroot00000000000000import os from gettext import gettext as _ from gi.repository import Gio, GLib, Gtk from lutris import api, sysoptions from lutris.exceptions import watch_errors from lutris.gui.config.add_game_dialog import AddGameDialog from lutris.gui.dialogs import ErrorDialog, ModelessDialog from lutris.gui.dialogs.game_import import ImportGameDialog from lutris.gui.widgets.common import FileChooserEntry from lutris.gui.widgets.navigation_stack import NavigationStack from lutris.installer import AUTO_WIN32_EXE, get_installers from lutris.scanners.lutris import scan_directory from lutris.util.jobs import AsyncCall from lutris.util.strings import gtk_safe, slugify class AddGamesWindow(ModelessDialog): # pylint: disable=too-many-public-methods """Show a selection of ways to add games to Lutris""" sections = [ ( "system-search-symbolic", "go-next-symbolic", _("Search the Lutris website for installers"), _("Query our website for community installers"), "search_installers", ), ( "folder-new-symbolic", "go-next-symbolic", _("Import previously installed Lutris games"), _("Scan a folder for games installed from a previous Lutris installation"), "scan_folder" ), ( "application-x-executable-symbolic", "go-next-symbolic", _("Install a Windows game from an executable"), _("Launch a Windows executable (.exe) installer"), "install_from_setup" ), ( "x-office-document-symbolic", "go-next-symbolic", _("Install from a local install script"), _("Run a YAML install script"), "install_from_script" ), ( "application-x-firmware-symbolic", "go-next-symbolic", _("Import a ROM"), _("Import a ROM that is known to Lutris"), "import_rom" ), ( "list-add-symbolic", "view-more-horizontal-symbolic", _("Add locally installed game"), _("Manually configure a game available locally"), "add_local_game" ) ] def __init__(self, **kwargs): ModelessDialog.__init__(self, title=_("Add games to Lutris"), use_header_bar=True, **kwargs) self.set_default_size(640, 450) self.search_entry = None self.search_frame = None self.search_explanation_label = None self.search_listbox = None self.search_timer_id = None self.search_spinner = None self.text_query = None self.search_result_label = None content_area = self.get_content_area() self.page_title_label = Gtk.Label(visible=True) content_area.pack_start(self.page_title_label, False, False, 0) self.accelerators = Gtk.AccelGroup() self.add_accel_group(self.accelerators) header_bar = self.get_header_bar() self.back_button = Gtk.Button(_("Back"), no_show_all=True) self.back_button.connect("clicked", self.on_back_clicked) key, mod = Gtk.accelerator_parse("Left") self.back_button.add_accelerator("clicked", self.accelerators, key, mod, Gtk.AccelFlags.VISIBLE) key, mod = Gtk.accelerator_parse("Home") self.accelerators.connect(key, mod, Gtk.AccelFlags.VISIBLE, self.on_navigate_home) header_bar.pack_start(self.back_button) self.continue_button = Gtk.Button(_("_Continue"), no_show_all=True, use_underline=True) header_bar.pack_end(self.continue_button) self.continue_handler = None self.cancel_button = Gtk.Button(_("Cancel"), use_underline=True) self.cancel_button.connect("clicked", self.on_cancel_clicked) key, mod = Gtk.accelerator_parse("Escape") self.accelerators.connect(key, mod, Gtk.AccelFlags.VISIBLE, lambda *_args: self.destroy()) header_bar.pack_start(self.cancel_button) header_bar.set_show_close_button(False) content_area.set_margin_top(18) content_area.set_margin_bottom(18) content_area.set_margin_right(18) content_area.set_margin_left(18) content_area.set_spacing(12) self.stack = NavigationStack(self.back_button, cancel_button=self.cancel_button) content_area.pack_start(self.stack, True, True, 0) # Pre-create some controls so they can be used in signal handlers self.scan_directory_chooser = FileChooserEntry( title=_("Select folder"), action=Gtk.FileChooserAction.SELECT_FOLDER ) self.install_from_setup_game_name_entry = Gtk.Entry() self.install_from_setup_game_slug_checkbox = Gtk.CheckButton(label="Identifier") self.install_from_setup_game_slug_entry = Gtk.Entry(sensitive=False) self.installer_presets = Gtk.ListStore(str, str) self.install_preset_dropdown = Gtk.ComboBox.new_with_model(self.installer_presets) self.installer_locale = Gtk.ListStore(str, str) self.install_locale_dropdown = Gtk.ComboBox.new_with_model(self.installer_locale) self.install_script_file_chooser = FileChooserEntry( title=_("Select script"), action=Gtk.FileChooserAction.OPEN ) self.import_rom_file_chooser = FileChooserEntry( title=_("Select ROM file"), action=Gtk.FileChooserAction.OPEN ) self.stack.add_named_factory("initial", self.create_initial_page) self.stack.add_named_factory("search_installers", self.create_search_installers_page) self.stack.add_named_factory("scan_folder", self.create_scan_folder_page) self.stack.add_named_factory("scanning_folder", self.create_scanning_folder_page) self.stack.add_named_factory("installed_games", self.create_installed_games_page) self.stack.add_named_factory("install_from_setup", self.create_install_from_setup_page) self.stack.add_named_factory("install_from_script", self.create_install_from_script_page) self.stack.add_named_factory("import_rom", self.create_import_rom_page) self.show_all() self.load_initial_page() @watch_errors() def on_back_clicked(self, _widget): self.stack.navigate_back() @watch_errors() def on_navigate_home(self, _accel_group, _window, _keyval, _modifier): self.stack.navigate_home() @watch_errors() def on_cancel_clicked(self, _widget): self.destroy() def on_watched_error(self, error): ErrorDialog(error, parent=self) # Initial Page def load_initial_page(self): self.stack.navigate_to_page(self.present_inital_page) def create_initial_page(self): frame = Gtk.Frame(shadow_type=Gtk.ShadowType.ETCHED_IN) listbox = Gtk.ListBox() listbox.set_activate_on_single_click(True) for icon, next_icon, text, subtext, callback_name in self.sections: row = self._get_listbox_row(icon, text, subtext, next_icon) row.callback_name = callback_name listbox.add(row) listbox.connect("row-activated", self.on_row_activated) frame.add(listbox) return frame def present_inital_page(self): self.set_page_title_markup(None) self.stack.present_page("initial") self.display_cancel_button() @watch_errors() def on_row_activated(self, listbox, row): if row.callback_name: callback = getattr(self, row.callback_name) callback() # Search Installers Page def search_installers(self): """Search installers with the Lutris API""" if self.search_entry: self.search_entry.set_text("") self.search_result_label.set_text("") self.search_result_label.hide() self.search_frame.hide() self.search_explanation_label.show() self.stack.navigate_to_page(self.present_search_installers_page) def create_search_installers_page(self): vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, no_show_all=True, spacing=6, visible=True) hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, visible=True) self.search_entry = Gtk.SearchEntry(visible=True) hbox.pack_start(self.search_entry, True, True, 0) self.search_spinner = Gtk.Spinner(visible=False) hbox.pack_end(self.search_spinner, False, False, 6) vbox.pack_start(hbox, False, False, 0) self.search_result_label = self._get_label("") self.search_result_label.hide() vbox.pack_start(self.search_result_label, False, False, 0) self.search_entry.connect("changed", self._on_search_updated) explanation = _( "Lutris will search Lutris.net for games matching the terms you enter, and any " "that it finds will appear here.\n\n" "When you click on a game that it found, the installer window will appear to " "perform the installation." ) self.search_explanation_label = self._get_explanation_label(explanation) vbox.add(self.search_explanation_label) self.search_frame = Gtk.Frame(shadow_type=Gtk.ShadowType.ETCHED_IN) self.search_listbox = Gtk.ListBox(visible=True) self.search_listbox.connect("row-activated", self._on_game_selected) scroll = Gtk.ScrolledWindow(visible=True) scroll.set_vexpand(True) scroll.add(self.search_listbox) self.search_frame.add(scroll) vbox.pack_start(self.search_frame, True, True, 0) return vbox def present_search_installers_page(self): self.set_page_title_markup(_("Search Lutris.net")) self.stack.present_page("search_installers") self.search_entry.grab_focus() self.display_cancel_button() @watch_errors() def _on_search_updated(self, entry): if self.search_timer_id: GLib.source_remove(self.search_timer_id) self.text_query = entry.get_text().strip() self.search_timer_id = GLib.timeout_add(750, self.update_search_results) @watch_errors() def update_search_results(self): # Don't start a search while another is going; defer it instead. if self.search_spinner.get_visible(): self.search_timer_id = GLib.timeout_add(750, self.update_search_results) return self.search_timer_id = None if self.text_query: self.search_spinner.show() self.search_spinner.start() AsyncCall(api.search_games, self.update_search_results_cb, self.text_query) @watch_errors() def update_search_results_cb(self, api_games, error): if error: raise error self.search_spinner.stop() self.search_spinner.hide() total_count = api_games.get("count", 0) count = len(api_games.get('results', [])) if not count: self.search_result_label.set_markup(_("No results")) elif count == total_count: self.search_result_label.set_markup(_(f"Showing {count} results")) else: self.search_result_label.set_markup(_(f"{total_count} results, only displaying first {count}")) for row in self.search_listbox.get_children(): row.destroy() for game in api_games.get("results", []): platforms = ",".join(gtk_safe(platform["name"]) for platform in game["platforms"]) year = game['year'] or "" if platforms and year: platforms = ", " + platforms row = self._get_listbox_row("", gtk_safe(game['name']), f"{year}{platforms}") row.api_info = game self.search_listbox.add(row) self.search_result_label.show() self.search_frame.show() self.search_explanation_label.hide() @watch_errors() def _on_game_selected(self, listbox, row): game_slug = row.api_info["slug"] installers = get_installers(game_slug=game_slug) application = Gio.Application.get_default() application.show_installer_window(installers) self.destroy() # Scan Folder Page def scan_folder(self): """Scan a folder of already installed games""" self.stack.navigate_to_page(self.present_scan_folder_page) def create_scan_folder_page(self): grid = Gtk.Grid(row_spacing=6, column_spacing=6) label = self._get_label(_("Folder to scan")) grid.attach(label, 0, 0, 1, 1) grid.attach(self.scan_directory_chooser, 1, 0, 1, 1) self.scan_directory_chooser.set_hexpand(True) explanation = _( "This folder will be scanned for games previously installed with Lutris.\n\n" "Folder names have to match their corresponding Lutris ID, each matching ID" "will be queried for existing install script to provide for exe locations.\n\n" "Click 'Continue' to start scanning and import games" ) grid.attach(self._get_explanation_label(explanation), 0, 1, 2, 1) return grid def present_scan_folder_page(self): self.set_page_title_markup("Select folder to scan for games") self.stack.present_page("scan_folder") self.display_continue_button(self.on_continue_scan_folder_clicked) @watch_errors() def on_continue_scan_folder_clicked(self, _widget): path = self.scan_directory_chooser.get_text() if not path: ErrorDialog(_("You must select a folder to scan for games."), parent=self) elif not os.path.isdir(path): ErrorDialog(_("No folder exists at '%s'.") % path, parent=self) else: self.load_scanning_folder_page(path) # Scanning Folder Page def load_scanning_folder_page(self, path): def present_scanning_folder_page(): self.set_page_title_markup("Importing games from a folder") self.stack.present_page("scanning_folder") self.display_no_continue_button() AsyncCall(scan_directory, self._on_folder_scanned, path) self.stack.jump_to_page(present_scanning_folder_page) def create_scanning_folder_page(self): spinner = Gtk.Spinner() spinner.start() return spinner @watch_errors() def _on_folder_scanned(self, result, error): def present_installed_games_page(): if installed or missing: self.set_page_title_markup(_("Games found")) else: self.set_page_title_markup(_("No games found")) page = self.create_installed_games_page(installed, missing) self.stack.present_replacement_page("installed_games", page) self.display_cancel_button(label=_("_Close")) if error: ErrorDialog(error, parent=self) self.stack.navigation_reset() return installed, missing = result self.stack.navigate_to_page(present_installed_games_page) def create_installed_games_page(self, installed, missing): vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) if installed: installed_label = self._get_label("Installed games") vbox.pack_start(installed_label, False, False, 0) installed_listbox = Gtk.ListBox() installed_scroll = Gtk.ScrolledWindow(shadow_type=Gtk.ShadowType.ETCHED_IN) installed_scroll.set_vexpand(True) installed_scroll.add(installed_listbox) vbox.pack_start(installed_scroll, True, True, 0) for folder in installed: installed_listbox.add(self._get_listbox_row("", gtk_safe(folder), "")) if missing: missing_listbox = Gtk.ListBox() missing_scroll = Gtk.ScrolledWindow(shadow_type=Gtk.ShadowType.ETCHED_IN) missing_scroll.set_vexpand(True) missing_scroll.add(missing_listbox) vbox.pack_end(missing_scroll, True, True, 0) for folder in missing: missing_listbox.add(self._get_listbox_row("", gtk_safe(folder), "")) missing_label = self._get_label("No match found") vbox.pack_end(missing_label, False, False, 0) return vbox # Install from Setup Page def install_from_setup(self): """Install from a setup file""" self.stack.navigate_to_page(self.present_install_from_setup_page) def create_install_from_setup_page(self): name_label = self._get_label(_("Game name")) self.install_from_setup_game_name_entry.set_hexpand(True) self.install_from_setup_game_slug_entry.set_hexpand(True) grid = Gtk.Grid(row_spacing=6, column_spacing=6) grid.set_column_homogeneous(False) grid.attach(name_label, 0, 0, 1, 1) grid.attach(self.install_from_setup_game_name_entry, 1, 0, 1, 1) grid.attach(self.install_from_setup_game_slug_checkbox, 0, 1, 1, 1) grid.attach(self.install_from_setup_game_slug_entry, 1, 1, 1, 1) self.install_from_setup_game_name_entry.connect("changed", self.on_install_from_setup_game_name_changed) self.install_from_setup_game_slug_checkbox.connect("toggled", self.on_install_from_setup_game_slug_toggled) explanation = _( "Enter the name of the game you will install.\n\nWhen you click 'Install' below, " "the installer window will appear and guide you through a simple installation.\n\n" "It will prompt you for a setup executable, and will use Wine to install it.\n\n" "If you know the Lutris identifier for the game, you can provide it for improved " "Lutris integration, such as Lutris provided banners." ) grid.attach(self._get_explanation_label(explanation), 0, 2, 2, 1) preset_label = Gtk.Label(_("Installer preset:"), visible=True) grid.attach(preset_label, 0, 3, 1, 1) self.installer_presets.append(["win10", _("Windows 10 64-bit (Default)")]) self.installer_presets.append(["win7", _("Windows 7 64-bit")]) self.installer_presets.append(["winxp", _("Windows XP 32-bit")]) self.installer_presets.append(["winxp-3dfx", _("Windows XP + 3DFX 32-bit")]) self.installer_presets.append(["win98", _("Windows 98 32-bit")]) self.installer_presets.append(["win98-3dfx", _("Windows 98 + 3DFX 32-bit")]) renderer_text = Gtk.CellRendererText() self.install_preset_dropdown.pack_start(renderer_text, True) self.install_preset_dropdown.add_attribute(renderer_text, "text", 1) self.install_preset_dropdown.set_id_column(0) self.install_preset_dropdown.set_active_id('win10') grid.attach(self.install_preset_dropdown, 1, 3, 1, 1) self.install_preset_dropdown.set_halign(Gtk.Align.START) locale_label = Gtk.Label(_("Locale:"), visible=True) locale_label.set_xalign(0) grid.attach(locale_label, 0, 4, 1, 1) locale_list = sysoptions.get_locale_choices() for locale_humanized, locale in locale_list: self.installer_locale.append([locale, _(locale_humanized)]) locale_renderer_text = Gtk.CellRendererText() self.install_locale_dropdown.pack_start(locale_renderer_text, True) self.install_locale_dropdown.add_attribute(locale_renderer_text, "text", 1) self.install_locale_dropdown.set_id_column(0) self.install_locale_dropdown.set_active(0) grid.attach(self.install_locale_dropdown, 1, 4, 1, 1) self.install_locale_dropdown.set_halign(Gtk.Align.START) grid.set_vexpand(True) return grid def present_install_from_setup_page(self): self.set_page_title_markup(_("Select setup file")) self.stack.present_page("install_from_setup") self.display_continue_button(self._on_install_setup_continue, label=_("_Install")) @watch_errors() def on_install_from_setup_game_slug_toggled(self, checkbutton): self.install_from_setup_game_slug_entry.set_sensitive(checkbutton.get_active()) self.on_install_from_setup_game_name_changed() @watch_errors() def on_install_from_setup_game_name_changed(self, *_args): if not self.install_from_setup_game_slug_checkbox.get_active(): name = self.install_from_setup_game_name_entry.get_text() proposed_slug = slugify(name) if name else "" self.install_from_setup_game_slug_entry.set_text(proposed_slug) @watch_errors() def _on_install_setup_continue(self, button): name = self.install_from_setup_game_name_entry.get_text().strip() if not name: ErrorDialog(_("You must provide a name for the game you are installing."), parent=self) return if self.install_from_setup_game_slug_checkbox.get_active(): game_slug = self.install_from_setup_game_slug_entry.get_text() else: game_slug = slugify(name) installer_preset = self.installer_presets[self.install_preset_dropdown.get_active()][0] arch = "win32" if installer_preset.startswith(("win98", "winxp")) else "win64" win_ver = installer_preset.split("-")[0] if win_ver != "win10": win_ver_task = {"task": {"name": "winetricks", "app": win_ver, "arch": arch}} else: win_ver_task = None locale_selected = self.installer_locale[self.install_locale_dropdown.get_active()][0] installer = { "name": name, "version": _("Setup file"), "slug": game_slug + "-setup", "game_slug": game_slug, "runner": "wine", "script": { "game": { "exe": AUTO_WIN32_EXE, "prefix": "$GAMEDIR" }, "files": [ {"setupfile": "N/A:%s" % _("Select the setup file")} ], "installer": [ {"task": {"name": "wineexec", "executable": "setupfile", "arch": arch}} ], "system": { "env": { "LC_ALL": locale_selected } } } } if win_ver_task: installer["script"]["installer"].insert(0, win_ver_task) if installer_preset.endswith("3dfx"): installer["script"]["wine"] = {"dgvoodoo2": True} application = Gio.Application.get_default() application.show_installer_window([installer]) self.destroy() # Install from Script Page def install_from_script(self): """Install from a YAML file""" self.stack.navigate_to_page(self.present_install_from_script_page) def create_install_from_script_page(self): grid = Gtk.Grid(row_spacing=6, column_spacing=6) label = self._get_label(_("Script file")) grid.attach(label, 0, 0, 1, 1) grid.attach(self.install_script_file_chooser, 1, 0, 1, 1) self.install_script_file_chooser.set_hexpand(True) explanation = _( "Lutris install scripts are YAML files that guide Lutris through " "the installation process.\n\n" "They can be obtained on Lutris.net, or written by hand.\n\n" "When you click 'Install' below, the installer window will " "appear and load the script, and it will guide the process from there." ) grid.attach(self._get_explanation_label(explanation), 0, 1, 2, 1) return grid def present_install_from_script_page(self): self.set_page_title_markup("Select a Lutris installer") self.stack.present_page("install_from_script") self.display_continue_button(self.on_continue_install_from_script_clicked, label=_("_Install")) @watch_errors() def on_continue_install_from_script_clicked(self, _widget): path = self.install_script_file_chooser.get_text() if not path: ErrorDialog(_("You must select a script file to install."), parent=self) elif not os.path.isfile(path): ErrorDialog(_("No file exists at '%s'.") % path, parent=self) else: installers = get_installers(installer_file=path) application = Gio.Application.get_default() application.show_installer_window(installers) self.destroy() # Install ROM Page def import_rom(self): """Install from a YAML file""" self.stack.navigate_to_page(self.present_import_rom_page) def create_import_rom_page(self): grid = Gtk.Grid(row_spacing=6, column_spacing=6) label = self._get_label(_("ROM file")) grid.attach(label, 0, 0, 1, 1) grid.attach(self.import_rom_file_chooser, 1, 0, 1, 1) self.import_rom_file_chooser.set_hexpand(True) explanation = _( "Lutris will identify a ROM via its MD5 hash and download game " "information from Lutris.net.\n\n" "The ROM data used for this comes from the TOSEC project.\n\n" "When you click 'Install' below, the process of installing the game will " "begin." ) grid.attach(self._get_explanation_label(explanation), 0, 1, 2, 1) return grid def present_import_rom_page(self): self.set_page_title_markup("Select a ROM file") self.stack.present_page("import_rom") self.display_continue_button(self.on_continue_import_rom_clicked, label=_("_Install")) @watch_errors() def on_continue_import_rom_clicked(self, _widget): path = self.import_rom_file_chooser.get_text() if not path: ErrorDialog(_("You must select a ROM file to install."), parent=self) elif not os.path.isfile(path): ErrorDialog(_("No file exists at '%s'.") % path, parent=self) else: application = Gio.Application.get_default() dialog = ImportGameDialog([path], parent=application.window) dialog.show() self.destroy() # Add Local Game def add_local_game(self): """Manually configure game""" # We use the LutrisWindow as the parent because we would # destroy this window before the AddGameDialog could disconnect. # We've tried to be clever here, but it didn't work reliably. # This does center the AddGameDialog over the main window, which # isn't terrible. application = Gio.Application.get_default() AddGameDialog(parent=application.window) self.destroy() # Subtitle Label def set_page_title_markup(self, markup): """Places some text at the top of the page; set markup to 'None' to remove it.""" if markup: self.page_title_label.set_markup(markup) self.page_title_label.show() else: self.page_title_label.hide() # Continue Button def display_continue_button(self, handler, label=_("_Continue"), suggested_action=True): self.continue_button.set_label(label) style_context = self.continue_button.get_style_context() if suggested_action: style_context.add_class("suggested-action") else: style_context.remove_class("suggested-action") if self.continue_handler: self.continue_button.disconnect(self.continue_handler) self.continue_handler = self.continue_button.connect("clicked", handler) self.continue_button.show() self.cancel_button.set_label(_("Cancel")) self.stack.set_cancel_allowed(True) def display_cancel_button(self, label=_("Cancel")): self.cancel_button.set_label(label) self.stack.set_cancel_allowed(True) self.continue_button.hide() def display_no_continue_button(self): self.continue_button.hide() self.stack.set_cancel_allowed(False) if self.continue_handler: self.continue_button.disconnect(self.continue_handler) self.continue_handler = None # Implementation def _get_icon(self, name, small=False): if small: size = Gtk.IconSize.MENU else: size = Gtk.IconSize.DND icon = Gtk.Image.new_from_icon_name(name, size) icon.show() return icon def _get_label(self, text): label = Gtk.Label(visible=True) label.set_markup(text) label.set_alignment(0, 0.5) return label def _get_explanation_label(self, markup): label = Gtk.Label( visible=True, margin_right=12, margin_left=12, margin_top=12, margin_bottom=12) label.set_markup(markup) label.set_line_wrap(True) return label def _get_listbox_row(self, left_icon_name, text, subtext, right_icon_name=""): row = Gtk.ListBoxRow(visible=True) row.set_selectable(False) row.set_activatable(True) box = Gtk.Box( spacing=12, margin_right=12, margin_left=12, margin_top=12, margin_bottom=12, visible=True) if left_icon_name: icon = self._get_icon(left_icon_name) box.pack_start(icon, False, False, 0) label = self._get_label(f"{text}\n{subtext}") box.pack_start(label, True, True, 0) if left_icon_name: next_icon = self._get_icon(right_icon_name, small=True) box.pack_start(next_icon, False, False, 0) row.add(box) return row lutris-0.5.14/lutris/gui/application.py000066400000000000000000001145061451435154700201260ustar00rootroot00000000000000# pylint: disable=wrong-import-position # pylint: disable=too-many-lines # # Copyright (C) 2009 Mathieu Comandon # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import json import logging import os import signal import sys import tempfile from datetime import datetime, timedelta from gettext import gettext as _ import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import Gio, GLib, Gtk, GObject from lutris.runners import get_runner_names, import_runner, InvalidRunner, RunnerInstallationError from lutris import settings from lutris.api import parse_installer_url, get_runners from lutris.exceptions import watch_errors from lutris.command import exec_command from lutris.database import games as games_db from lutris.game import Game, export_game, import_game from lutris.installer import get_installers from lutris.gui.dialogs import ErrorDialog, InstallOrPlayDialog, NoticeDialog, LutrisInitDialog from lutris.gui.dialogs.issue import IssueReportWindow from lutris.gui.dialogs.delegates import LaunchUIDelegate, InstallUIDelegate, CommandLineUIDelegate from lutris.gui.installerwindow import InstallerWindow, InstallationKind from lutris.gui.widgets.status_icon import LutrisStatusIcon from lutris.migrations import migrate from lutris.startup import init_lutris, run_all_checks from lutris.runtime import RuntimeUpdater from lutris.style_manager import StyleManager from lutris.util import datapath, log, system from lutris.util.http import HTTPError, Request from lutris.util.log import logger from lutris.util.steam.appmanifest import AppManifest, get_appmanifests from lutris.util.steam.config import get_steamapps_dirs from lutris.services import get_enabled_services from lutris.database.services import ServiceGameCollection from .lutriswindow import LutrisWindow class Application(Gtk.Application): def __init__(self): super().__init__( application_id="net.lutris.Lutris", flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, register_session=True, ) GObject.add_emission_hook(Game, "game-launch", self.on_game_launch) GObject.add_emission_hook(Game, "game-start", self.on_game_start) GObject.add_emission_hook(Game, "game-stop", self.on_game_stop) GObject.add_emission_hook(Game, "game-install", self.on_game_install) GObject.add_emission_hook(Game, "game-install-update", self.on_game_install_update) GObject.add_emission_hook(Game, "game-install-dlc", self.on_game_install_dlc) GLib.set_application_name(_("Lutris")) self.force_updates = False self.css_provider = Gtk.CssProvider.new() self.window = None self.launch_ui_delegate = LaunchUIDelegate() self.install_ui_delegate = InstallUIDelegate() self.running_games = Gio.ListStore.new(Game) self.app_windows = {} self.tray = None self.quit_on_game_exit = False self.style_manager = None if os.geteuid() == 0: NoticeDialog(_("Do not run Lutris as root.")) sys.exit(2) try: self.css_provider.load_from_path(os.path.join(datapath.get(), "ui", "lutris.css")) except GLib.Error as e: logger.exception(e) if hasattr(self, "add_main_option"): self.add_arguments() else: ErrorDialog(_("Your Linux distribution is too old. Lutris won't function properly.")) def add_arguments(self): if hasattr(self, "set_option_context_summary"): self.set_option_context_summary(_( "Run a game directly by adding the parameter lutris:rungame/game-identifier.\n" "If several games share the same identifier you can use the numerical ID " "(displayed when running lutris --list-games) and add " "lutris:rungameid/numerical-id.\n" "To install a game, add lutris:install/game-identifier." )) else: logger.warning("GLib.set_option_context_summary missing, was added in GLib 2.56 (Released 2018-03-12)") self.add_main_option( "version", ord("v"), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("Print the version of Lutris and exit"), None, ) self.add_main_option( "debug", ord("d"), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("Show debug messages"), None, ) self.add_main_option( "install", ord("i"), GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Install a game from a yml file"), None, ) self.add_main_option( "force", ord("f"), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("Force updates"), None, ) self.add_main_option( "output-script", ord("b"), GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Generate a bash script to run a game without the client"), None, ) self.add_main_option( "exec", ord("e"), GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Execute a program with the Lutris Runtime"), None, ) self.add_main_option( "list-games", ord("l"), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("List all games in database"), None, ) self.add_main_option( "installed", ord("o"), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("Only list installed games"), None, ) self.add_main_option( "list-steam-games", ord("s"), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("List available Steam games"), None, ) self.add_main_option( "list-steam-folders", 0, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("List all known Steam library folders"), None, ) self.add_main_option( "list-runners", 0, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("List all known runners"), None, ) self.add_main_option( "list-wine-versions", 0, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("List all known Wine versions"), None, ) self.add_main_option( "list-all-service-games", ord('a'), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("List all games for all services in database"), None, ) self.add_main_option( "list-service-games", 0, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("List all games for provided service in database"), None, ) self.add_main_option( "install-runner", ord("r"), GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Install a Runner"), None, ) self.add_main_option( "uninstall-runner", ord("u"), GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Uninstall a Runner"), None, ) self.add_main_option( "export", 0, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Export a game"), None, ) self.add_main_option( "import", 0, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Import a game"), None, ) self.add_main_option( "dest", 0, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Destination path for export"), None, ) self.add_main_option( "json", ord("j"), GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("Display the list of games in JSON format"), None, ) self.add_main_option( "reinstall", 0, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("Reinstall game"), None, ) self.add_main_option("submit-issue", 0, GLib.OptionFlags.NONE, GLib.OptionArg.NONE, _("Submit an issue"), None) self.add_main_option( GLib.OPTION_REMAINING, 0, GLib.OptionFlags.NONE, GLib.OptionArg.STRING_ARRAY, _("URI to open"), "URI", ) def do_startup(self): # pylint: disable=arguments-differ """Sets up the application on first start.""" Gtk.Application.do_startup(self) signal.signal(signal.SIGINT, signal.SIG_DFL) action = Gio.SimpleAction.new("quit") action.connect("activate", lambda *x: self.quit()) self.add_action(action) self.add_accelerator("q", "app.quit") self.style_manager = StyleManager() def do_activate(self): # pylint: disable=arguments-differ if not self.window: self.window = LutrisWindow(application=self) screen = self.window.props.screen # pylint: disable=no-member Gtk.StyleContext.add_provider_for_screen(screen, self.css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) def show_update_runtime_dialog(self, gpu_info): if os.environ.get("LUTRIS_SKIP_INIT"): logger.debug("Skipping initialization") else: pci_ids = [" ".join([gpu["PCI_ID"], gpu["PCI_SUBSYS_ID"]]) for gpu in gpu_info["gpus"].values()] runtime_updater = RuntimeUpdater(pci_ids=pci_ids, force=self.force_updates) if runtime_updater.has_updates: init_dialog = LutrisInitDialog(runtime_updater) init_dialog.run() def get_window_key(self, **kwargs): if kwargs.get("appid"): return kwargs["appid"] if kwargs.get("runner"): return kwargs["runner"].name if kwargs.get("installers"): return kwargs["installers"][0]["game_slug"] if kwargs.get("game"): return str(kwargs["game"].get_safe_id()) return str(kwargs) def show_window(self, window_class, **kwargs): """Instantiate a window keeping 1 instance max Params: window_class (Gtk.Window): class to create the instance from kwargs (dict): Additional arguments to pass to the instanciated window Returns: Gtk.Window: the existing window instance or a newly created one """ window_key = str(window_class.__name__) + self.get_window_key(**kwargs) if self.app_windows.get(window_key): self.app_windows[window_key].present() return self.app_windows[window_key] if issubclass(window_class, Gtk.Dialog): if "parent" in kwargs: window_inst = window_class(**kwargs) else: window_inst = window_class(parent=self.window, **kwargs) window_inst.set_application(self) else: window_inst = window_class(application=self, **kwargs) window_inst.connect("destroy", self.on_app_window_destroyed, self.get_window_key(**kwargs)) self.app_windows[window_key] = window_inst logger.debug("Showing window %s", window_key) window_inst.show() return window_inst def show_installer_window(self, installers, service=None, appid=None, installation_kind=InstallationKind.INSTALL): self.show_window( InstallerWindow, installers=installers, service=service, appid=appid, installation_kind=installation_kind ) def on_app_window_destroyed(self, app_window, window_key): """Remove the reference to the window when it has been destroyed""" window_key = str(app_window.__class__.__name__) + window_key try: del self.app_windows[window_key] logger.debug("Removed window %s", window_key) except KeyError: logger.warning("Failed to remove window %s", window_key) logger.info("Available windows: %s", ", ".join(self.app_windows.keys())) return True @staticmethod def _print(command_line, string): # Workaround broken pygobject bindings command_line.do_print_literal(command_line, string + "\n") def generate_script(self, db_game, script_path): """Output a script to a file. The script is capable of launching a game without the client """ def on_error(game, error): logger.exception("Unable to generate script: %s", error) return True game = Game(db_game["id"]) game.connect("game-error", on_error) game.reload_config() game.write_script(script_path, self.launch_ui_delegate) def do_handle_local_options(self, options): # Text only commands # Print Lutris version and exit if options.contains("version"): executable_name = os.path.basename(sys.argv[0]) print(executable_name + "-" + settings.VERSION) logger.setLevel(logging.NOTSET) return 0 return -1 # continue command line processes def do_command_line(self, command_line): # noqa: C901 # pylint: disable=arguments-differ # pylint: disable=too-many-locals,too-many-return-statements,too-many-branches # pylint: disable=too-many-statements # TODO: split into multiple methods to reduce complexity (35) options = command_line.get_options_dict() # Use stdout to output logs, only if no command line argument is # provided. argc = len(sys.argv) - 1 if "-d" in sys.argv or "--debug" in sys.argv: argc -= 1 if not argc: # Switch back the log output to stderr (the default in Python) # to avoid messing with any output from command line options. log.console_handler.setStream(sys.stderr) # Set up logger if options.contains("debug"): log.console_handler.setFormatter(log.DEBUG_FORMATTER) logger.setLevel(logging.DEBUG) if options.contains("force"): self.force_updates = True logger.info("Starting Lutris %s", settings.VERSION) init_lutris() # Perform migrations early if any command line options # might require it to be done, just in case. We migrate # also during the init dialog, but it should be harmless # to do it twice. # # This way, in typical lutris usage, you get to see the # init dialog when migration is happening. if argc: migrate() gpu_info = run_all_checks() if options.contains("dest"): dest_dir = options.lookup_value("dest").get_string() else: dest_dir = None # List game if options.contains("list-games"): game_list = games_db.get_games(filters=( {"installed": 1} if options.contains("installed") else None) ) if options.contains("json"): self.print_game_json(command_line, game_list) else: self.print_game_list(command_line, game_list) return 0 # List specified service games if options.contains("list-service-games"): service = options.lookup_value("list-service-games").get_string() game_list = games_db.get_games(filters={"installed": 1, "service": service}) service_game_list = ServiceGameCollection.get_for_service(service) for game in service_game_list: game['installed'] = any(('service_id', game['appid']) in item.items() for item in game_list) if options.contains("installed"): service_game_list = [d for d in service_game_list if d['installed']] if options.contains("json"): self.print_service_game_json(command_line, service_game_list) else: self.print_service_game_list(command_line, service_game_list) return 0 # List all service games if options.contains("list-all-service-games"): game_list = games_db.get_games(filters={"installed": 1}) service_game_list = ServiceGameCollection.get_service_games() for game in service_game_list: game['installed'] = any(('service_id', game['appid']) in item.items() for item in game_list if item['service'] == game['service']) if options.contains("installed"): service_game_list = [d for d in service_game_list if d['installed']] if options.contains("json"): self.print_service_game_json(command_line, service_game_list) else: self.print_service_game_list(command_line, service_game_list) return 0 # List Steam games if options.contains("list-steam-games"): self.print_steam_list(command_line) return 0 # List Steam folders if options.contains("list-steam-folders"): self.print_steam_folders(command_line) return 0 # List Runners if options.contains("list-runners"): self.print_runners() return 0 # List Wine Runners if options.contains("list-wine-versions"): self.print_wine_runners() return 0 # install Runner if options.contains("install-runner"): runner = options.lookup_value("install-runner").get_string() self.install_runner(runner) return 0 # Uninstall Runner if options.contains("uninstall-runner"): runner = options.lookup_value("uninstall-runner").get_string() self.uninstall_runner(runner) return 0 if options.contains("export"): slug = options.lookup_value("export").get_string() if not dest_dir: print("No destination dir given") else: export_game(slug, dest_dir) return 0 if options.contains("import"): filepath = options.lookup_value("import").get_string() if not dest_dir: print("No destination dir given") else: import_game(filepath, dest_dir) return 0 # Execute command in Lutris context if options.contains("exec"): command = options.lookup_value("exec").get_string() self.execute_command(command) return 0 if options.contains("submit-issue"): IssueReportWindow(application=self) return 0 url = options.lookup_value(GLib.OPTION_REMAINING) try: installer_info = self.get_lutris_action(url) except ValueError: self._print(command_line, _("%s is not a valid URI") % url.get_strv()) return 1 game_slug = installer_info["game_slug"] action = installer_info["action"] service = installer_info["service"] appid = installer_info["appid"] launch_config_name = installer_info["launch_config_name"] self.launch_ui_delegate = CommandLineUIDelegate(launch_config_name) if options.contains("output-script"): action = "write-script" revision = installer_info["revision"] installer_file = None if options.contains("install"): installer_file = options.lookup_value("install").get_string() if installer_file.startswith(("http:", "https:")): try: request = Request(installer_file).get() except HTTPError: self._print(command_line, _("Failed to download %s") % installer_file) return 1 try: headers = dict(request.response_headers) file_name = headers["Content-Disposition"].split("=", 1)[-1] except (KeyError, IndexError): file_name = os.path.basename(installer_file) file_path = os.path.join(tempfile.gettempdir(), file_name) self._print(command_line, _("download {url} to {file} started").format( url=installer_file, file=file_path)) with open(file_path, 'wb') as dest_file: dest_file.write(request.content) installer_file = file_path action = "install" else: installer_file = os.path.abspath(installer_file) action = "install" if not os.path.isfile(installer_file): self._print(command_line, _("No such file: %s") % installer_file) return 1 db_game = None if game_slug and not service: if action == "rungameid": # Force db_game to use game id self.quit_on_game_exit = True db_game = games_db.get_game_by_field(game_slug, "id") elif action == "rungame": # Force db_game to use game slug self.quit_on_game_exit = True db_game = games_db.get_game_by_field(game_slug, "slug") elif action == "install": # Installers can use game or installer slugs self.quit_on_game_exit = True db_game = games_db.get_game_by_field(game_slug, "slug") \ or games_db.get_game_by_field(game_slug, "installer_slug") else: # Dazed and confused, try anything that might works db_game = ( games_db.get_game_by_field(game_slug, "id") or games_db.get_game_by_field(game_slug, "slug") or games_db.get_game_by_field(game_slug, "installer_slug") ) # If reinstall flag is passed, force the action to install if options.contains("reinstall"): action = "install" if action == "write-script": if not db_game or not db_game["id"]: logger.warning("No game provided to generate the script") return 1 self.generate_script(db_game, options.lookup_value("output-script").get_string()) return 0 # Graphical commands self.set_tray_icon() self.activate() if not action: if db_game and db_game["installed"]: # Game found but no action provided, ask what to do dlg = InstallOrPlayDialog(db_game["name"]) if not dlg.action: action = "cancel" elif dlg.action == "play": action = "rungame" elif dlg.action == "install": action = "install" elif game_slug or installer_file or service: # No game found, default to install if a game_slug or # installer_file is provided action = "install" if service: service_game = ServiceGameCollection.get_game(service, appid) if service_game: service = get_enabled_services()[service]() service.install(service_game) return 0 if action == "cancel": if not self.window.is_visible(): self.quit() return 0 if action == "install": installers = get_installers( game_slug=game_slug, installer_file=installer_file, revision=revision, ) if installers: self.show_installer_window(installers) elif action in ("rungame", "rungameid"): if not db_game or not db_game["id"]: logger.warning("No game found in library") if not self.window.is_visible(): self.quit() return 0 def on_error(game, error): logger.exception("Unable to launch game: %s", error) return True game = Game(db_game["id"]) game.connect("game-error", on_error) game.launch(self.launch_ui_delegate) if game.state == game.STATE_STOPPED and not self.window.is_visible(): self.quit() else: self.show_update_runtime_dialog(gpu_info) # If we're showing the window, it will handle the delegated UI # from here on out, no matter what command line we got. self.launch_ui_delegate = self.window self.install_ui_delegate = self.window self.window.present() # If the Lutris GUI is started by itself, don't quit it when a game stops self.quit_on_game_exit = False return 0 @watch_errors(error_result=True) def on_game_launch(self, game): game.launch(self.launch_ui_delegate) return True # Return True to continue handling the emission hook @watch_errors(error_result=True) def on_game_start(self, game): self.running_games.append(game) if settings.read_setting("hide_client_on_game_start") == "True": self.window.hide() # Hide launcher window return True @watch_errors() def on_game_stop(self, game): """Callback to remove the game from the running games""" ids = self.get_running_game_ids() if str(game.id) in ids: logger.debug("Removing %s from running IDs", game.id) try: self.running_games.remove(ids.index(str(game.id))) except ValueError: pass elif ids: logger.warning("%s not in %s", game.id, ids) else: logger.debug("Game has already been removed from running IDs?") game.emit("game-stopped") if settings.read_setting("hide_client_on_game_start") == "True" and not self.quit_on_game_exit: self.window.show() # Show launcher window elif not self.window.is_visible(): if self.running_games.get_n_items() == 0: if self.quit_on_game_exit or not self.has_tray_icon(): self.quit() return True @watch_errors(error_result=True) def on_game_install(self, game): """Request installation of a game""" if game.service and game.service != "lutris": service = get_enabled_services()[game.service]() db_game = ServiceGameCollection.get_game(service.id, game.appid) if not db_game: logger.error("Can't find %s for %s", game.name, service.name) return True try: game_id = service.install(db_game) except ValueError as e: logger.debug(e) game_id = None if game_id: def on_error(game, error): logger.exception("Unable to install game: %s", error) return True game = Game(game_id) game.connect("game-error", on_error) game.launch(self.launch_ui_delegate) return True if not game.slug: raise ValueError("Invalid game passed: %s" % game) # return True installers = get_installers(game_slug=game.slug) if installers: self.show_installer_window(installers) else: ErrorDialog(_("There is no installer available for %s.") % game.name, parent=self.window) return True @watch_errors(error_result=True) def on_game_install_update(self, game): service = get_enabled_services()[game.service]() db_game = games_db.get_game_by_field(game.id, "id") installers = service.get_update_installers(db_game) if installers: self.show_installer_window(installers, service, game.appid, installation_kind=InstallationKind.UPDATE) else: ErrorDialog(_("No updates found"), parent=self.window) return True @watch_errors(error_result=True) def on_game_install_dlc(self, game): service = get_enabled_services()[game.service]() db_game = games_db.get_game_by_field(game.id, "id") installers = service.get_dlc_installers_runner(db_game, db_game["runner"]) if installers: self.show_installer_window(installers, service, game.appid, installation_kind=InstallationKind.DLC) else: ErrorDialog(_("No DLC found"), parent=self.window) return True def get_running_game_ids(self): ids = [] for i in range(self.running_games.get_n_items()): game = self.running_games.get_item(i) ids.append(str(game.id)) return ids def get_running_game_by_id(self, game_id): for i in range(self.running_games.get_n_items()): game = self.running_games.get_item(i) if str(game.id) == str(game_id): return game return None def on_watched_error(self, error): if self.window: ErrorDialog(error, parent=self.window) @staticmethod def get_lutris_action(url): installer_info = { "game_slug": None, "revision": None, "action": None, "service": None, "appid": None, "launch_config_name": None } if url: url = url.get_strv() if url: url = url[0] installer_info = parse_installer_url(url) if installer_info is False: raise ValueError return installer_info def print_game_list(self, command_line, game_list): for game in game_list: self._print( command_line, "{:4} | {:<40} | {:<40} | {:<15} | {:<64}".format( game["id"], game["name"][:40], game["slug"][:40], game["runner"] or "-", game["directory"] or "-", ), ) def print_game_json(self, command_line, game_list): games = [ { "id": game["id"], "slug": game["slug"], "name": game["name"], "runner": game["runner"], "platform": game["platform"] or None, "year": game["year"] or None, "directory": game["directory"] or None, "hidden": bool(game["hidden"]), "playtime": ( str(timedelta(hours=game["playtime"])) if game["playtime"] else None ), "lastplayed": ( str(datetime.fromtimestamp(game["lastplayed"])) if game["lastplayed"] else None ) } for game in game_list ] self._print(command_line, json.dumps(games, indent=2)) def print_service_game_list(self, command_line, game_list): for game in game_list: self._print( command_line, "{:4} | {:<40} | {:<40} | {:<15} | {:<15}".format( game["id"], game["name"][:40], game["slug"][:40], game["service"][:15], "true" if game["installed"] else "false"[:15], ), ) def print_service_game_json(self, command_line, game_list): games = [ { "id": game["id"], "slug": game["slug"], "name": game["name"], "service": game["service"], "installed": game["installed"], "details": game["details"] } for game in game_list ] self._print(command_line, json.dumps(games, indent=2)) def print_steam_list(self, command_line): steamapps_paths = get_steamapps_dirs() for path in steamapps_paths if steamapps_paths else []: appmanifest_files = get_appmanifests(path) for appmanifest_file in appmanifest_files: appmanifest = AppManifest(os.path.join(path, appmanifest_file)) self._print( command_line, " {:8} | {:<60} | {}".format( appmanifest.steamid, appmanifest.name or "-", ", ".join(appmanifest.states), ), ) @staticmethod def execute_command(command): """Execute an arbitrary command in a Lutris context with the runtime enabled and monitored by a MonitoredCommand """ logger.info("Running command '%s'", command) monitored_command = exec_command(command) try: GLib.MainLoop().run() except KeyboardInterrupt: monitored_command.stop() def print_steam_folders(self, command_line): steamapps_paths = get_steamapps_dirs() if steamapps_paths: for path in steamapps_paths: self._print(command_line, path) def print_runners(self): runner_names = get_runner_names() sorted_names = sorted(runner_names, key=lambda x: x.lower()) for name in sorted_names: print(name) def print_wine_runners(self): runnersName = get_runners("wine") for i in runnersName["versions"]: if i["version"]: print(i) def install_runner(self, runner): if runner.startswith("lutris"): self.install_wine_cli(runner) else: self.install_cli(runner) def uninstall_runner(self, runner): if "wine" in runner: print("Are sure you want to delete Wine and all of the installed runners?[Y/N]") ans = input() if ans.lower() in ("y", "yes"): self.uninstall_runner_cli(runner) else: print("Not Removing Wine") elif runner.startswith("lutris"): self.wine_runner_uninstall(runner) else: self.uninstall_runner_cli(runner) def install_wine_cli(self, version): """ Downloads wine runner using lutris -r """ WINE_DIR = os.path.join(settings.RUNNER_DIR, "wine") runner_path = os.path.join(WINE_DIR, f"{version}{'' if '-x86_64' in version else '-x86_64'}") if os.path.isdir(runner_path): print(f"Wine version '{version}' is already installed.") else: try: runner = import_runner("wine") runner().install(self.install_ui_delegate, version=version) print(f"Wine version '{version}' has been installed.") except (InvalidRunner, RunnerInstallationError) as ex: print(ex.message) def wine_runner_uninstall(self, version): version = f"{version}{'' if '-x86_64' in version else '-x86_64'}" WINE_DIR = os.path.join(settings.RUNNER_DIR, "wine") runner_path = os.path.join(WINE_DIR, version) if os.path.isdir(runner_path): system.remove_folder(runner_path) print(f"Wine version '{version}' has been removed.") else: print(f""" Specified version of Wine is not installed: {version}. Please check if the Wine Runner and specified version are installed (for that use --list-wine-versions). Also, check that the version specified is in the correct format. """) def install_cli(self, runner_name): """ install the runner provided in prepare_runner_cli() """ try: runner = import_runner(runner_name)() if runner.is_installed(): print(f"'{runner_name}' is already installed.") else: runner.install(self.install_ui_delegate, version=None, callback=None) print(f"'{runner_name}' has been installed") except (InvalidRunner, RunnerInstallationError) as ex: print(ex.message) def uninstall_runner_cli(self, runner_name): """ uninstall the runner given in application file located in lutris/gui/application.py provided using lutris -u """ try: runner_class = import_runner(runner_name) runner = runner_class() except InvalidRunner: logger.error("Failed to import Runner: %s", runner_name) return if not runner.is_installed(): print(f"Runner '{runner_name}' is not installed.") return if runner.can_uninstall(): runner.uninstall() print(f"'{runner_name}' has been uninstalled.") else: print(f"Runner '{runner_name}' cannot be uninstalled.") def do_shutdown(self): # pylint: disable=arguments-differ logger.info("Shutting down Lutris") if self.window: selected_category = "%s:%s" % self.window.selected_category settings.write_setting("selected_category", selected_category) self.window.destroy() Gtk.Application.do_shutdown(self) def set_tray_icon(self): """Creates or destroys a tray icon for the application""" active = settings.read_setting("show_tray_icon", default="false").lower() == "true" if active and not self.tray: self.tray = LutrisStatusIcon(application=self) if self.tray: self.tray.set_visible(active) def has_tray_icon(self): return self.tray and self.tray.is_visible() lutris-0.5.14/lutris/gui/config/000077500000000000000000000000001451435154700165075ustar00rootroot00000000000000lutris-0.5.14/lutris/gui/config/__init__.py000066400000000000000000000000471451435154700206210ustar00rootroot00000000000000DIALOG_WIDTH = 845 DIALOG_HEIGHT = 600 lutris-0.5.14/lutris/gui/config/accounts_box.py000066400000000000000000000035331451435154700215540ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris import settings from lutris.gui.config.base_config_box import BaseConfigBox from lutris.util.steam.config import STEAM_ACCOUNT_SETTING, get_steam_users class AccountsBox(BaseConfigBox): def __init__(self): super().__init__() self.add(self.get_section_label(_("Steam accounts"))) self.add(self.get_description_label( _("Select which Steam account is used for Lutris integration and creating Steam shortcuts.") )) frame = Gtk.Frame(visible=True, shadow_type=Gtk.ShadowType.ETCHED_IN) self.pack_start(frame, False, False, 12) vbox = Gtk.VBox(visible=True) frame.add(vbox) main_radio_button = None active_steam_account = settings.read_setting(STEAM_ACCOUNT_SETTING) steam_users = get_steam_users() for account in steam_users: radio_button = Gtk.RadioButton.new_with_label_from_widget( main_radio_button, account["PersonaName"] ) radio_button.set_margin_top(6) radio_button.set_margin_start(12) radio_button.set_margin_bottom(6) radio_button.show() radio_button.set_active(active_steam_account == account["steamid64"]) radio_button.connect("toggled", self.on_steam_account_toggled, account["steamid64"]) vbox.pack_start(radio_button, True, True, 0) if not main_radio_button: main_radio_button = radio_button if not steam_users: vbox.pack_start(Gtk.Label(_("No Steam account found"), visible=True), True, True, 0) def on_steam_account_toggled(self, radio_button, steamid64): """Handler for switching the active Steam account.""" settings.write_setting(STEAM_ACCOUNT_SETTING, steamid64) lutris-0.5.14/lutris/gui/config/add_game_dialog.py000066400000000000000000000014341451435154700221230ustar00rootroot00000000000000from gettext import gettext as _ from lutris.config import LutrisConfig from lutris.gui.config.common import GameDialogCommon class AddGameDialog(GameDialogCommon): """Add game dialog class.""" def __init__(self, parent, game=None, runner=None): super().__init__(_("Add a new game"), parent=parent) self.game = game self.saved = False if game: self.runner_name = game.runner_name self.slug = game.slug else: self.runner_name = runner self.slug = None self.lutris_config = LutrisConfig( runner_slug=self.runner_name, level="game", ) self.build_notebook() self.build_tabs("game") self.name_entry.grab_focus() self.show_all() lutris-0.5.14/lutris/gui/config/base_config_box.py000066400000000000000000000036241451435154700221750ustar00rootroot00000000000000from gi.repository import Gtk from lutris import settings from lutris.gui.widgets.common import VBox class BaseConfigBox(VBox): settings_options = {} settings_accelerators = {} def get_section_label(self, text): label = Gtk.Label(visible=True) label.set_markup("%s" % text) label.set_alignment(0, 0.5) label.set_margin_bottom(8) return label def get_description_label(self, text): label = Gtk.Label(visible=True) label.set_markup("%s" % text) label.set_line_wrap(True) label.set_alignment(0, 0.5) return label def __init__(self): super().__init__(visible=True) self.accelerators = None self.set_margin_top(50) self.set_margin_bottom(50) self.set_margin_right(80) self.set_margin_left(80) def get_setting_box(self, setting_key, label): box = Gtk.Box( spacing=12, margin_top=12, margin_bottom=12, visible=True ) label = Gtk.Label(label, visible=True) label.set_alignment(0, 0.5) box.pack_start(label, True, True, 12) checkbox = Gtk.Switch(visible=True) if settings.read_setting(setting_key).lower() == "true": checkbox.set_active(True) checkbox.connect("state-set", self.on_setting_change, setting_key) if setting_key in self.settings_accelerators: key, mod = Gtk.accelerator_parse(self.settings_accelerators[setting_key]) checkbox.add_accelerator("activate", self.accelerators, key, mod, Gtk.AccelFlags.VISIBLE) box.pack_start(checkbox, False, False, 12) return box def on_setting_change(self, widget, state, setting_key): """Save a setting when an option is toggled""" settings.write_setting(setting_key, state) self.get_toplevel().emit("settings-changed", setting_key) lutris-0.5.14/lutris/gui/config/boxes.py000066400000000000000000001001161451435154700202000ustar00rootroot00000000000000"""Widget generators and their signal handlers""" # Standard Library # pylint: disable=no-member,too-many-public-methods import os from gettext import gettext as _ # Third Party Libraries from gi.repository import Gdk, Gtk # Lutris Modules from lutris import settings, sysoptions from lutris.gui.widgets.common import EditableGrid, FileChooserEntry, Label, VBox from lutris.gui.widgets.searchable_combobox import SearchableCombobox from lutris.runners import InvalidRunner, import_runner from lutris.util.log import logger from lutris.util.strings import gtk_safe class ConfigBox(VBox): """Dynamically generate a vbox built upon on a python dict.""" config_section = NotImplemented def __init__(self, game=None): super().__init__() self.options = [] self.game = game self.config = None self.raw_config = None self.option_widget = None self.wrapper = None self.tooltip_default = None self.files = [] self.files_list_store = None self.reset_buttons = {} self.wrappers = {} self.warning_boxes = {} self.error_boxes = {} self._advanced_visibility = False self._filter = "" @property def advanced_visibility(self): return self._advanced_visibility @advanced_visibility.setter def advanced_visibility(self, value): """Sets the visibility of every 'advanced' option and every section that contains only 'advanced' options.""" self._advanced_visibility = value self.update_option_visibility() @property def filter(self): return self._filter @filter.setter def filter(self, value): """Sets the visibility of the options that have some text in the label or help-text.""" self._filter = value self.update_option_visibility() def update_option_visibility(self): """Recursively searches out all the options and shows or hides them according to the filter and advanced-visibility settings.""" def update_widgets(widgets): filter_text = self.filter.lower() visible_count = 0 for widget in widgets: if isinstance(widget, ConfigBox.SectionFrame): frame_visible_count = update_widgets(widget.vbox.get_children()) visible_count += frame_visible_count widget.set_visible(frame_visible_count > 0) widget.set_frame_visible(frame_visible_count > 1) else: widget_visible = self.advanced_visibility or not widget.get_style_context().has_class("advanced") if widget_visible and filter_text and hasattr(widget, "lutris_option_label"): label = widget.lutris_option_label.lower() helptext = widget.lutris_option_helptext.lower() if filter_text not in label and filter_text not in helptext: widget_visible = False widget.set_visible(widget_visible) widget.set_no_show_all(not widget_visible) if widget_visible: visible_count += 1 widget.show_all() return visible_count update_widgets(self.get_children()) def generate_top_info_box(self, text): """Add a top section with general help text for the current tab""" help_box = Gtk.Box() help_box.set_margin_left(15) help_box.set_margin_right(15) help_box.set_margin_bottom(5) icon = Gtk.Image.new_from_icon_name("dialog-information", Gtk.IconSize.MENU) help_box.pack_start(icon, False, False, 5) title_label = Gtk.Label("%s" % text) title_label.set_line_wrap(True) title_label.set_alignment(0, 0.5) title_label.set_use_markup(True) help_box.pack_start(title_label, False, False, 5) self.pack_start(help_box, False, False, 0) self.pack_start(Gtk.HSeparator(), False, False, 12) help_box.show_all() def generate_widgets(self): # noqa: C901 # pylint: disable=too-many-branches,too-many-statements """Parse the config dict and generates widget accordingly.""" if not self.options: no_options_label = Label(_("No options available"), width_request=-1) no_options_label.set_halign(Gtk.Align.CENTER) no_options_label.set_valign(Gtk.Align.CENTER) self.pack_start(no_options_label, True, True, 0) self.show_all() return # Select config section. if self.config_section == "game": self.config = self.lutris_config.game_config self.raw_config = self.lutris_config.raw_game_config elif self.config_section == "runner": self.config = self.lutris_config.runner_config self.raw_config = self.lutris_config.raw_runner_config elif self.config_section == "system": self.config = self.lutris_config.system_config self.raw_config = self.lutris_config.raw_system_config current_section = None current_vbox = self # Go thru all options. for option in self.options: try: if "scope" in option: if self.config_section not in option["scope"]: continue option_key = option["option"] value = self.config.get(option_key) if callable(option.get("choices")) and option["type"] != "choice_with_search": option["choices"] = option["choices"]() if callable(option.get("condition")): option["condition"] = option["condition"]() if option.get("section") != current_section: current_section = option.get("section") if current_section: frame = ConfigBox.SectionFrame(current_section) current_vbox = frame.vbox self.pack_start(frame, False, False, 0) else: current_vbox = self self.wrapper = Gtk.Box() self.wrapper.set_spacing(12) self.wrapper.set_margin_bottom(6) self.wrappers[option_key] = self.wrapper # Set tooltip's "Default" part default = option.get("default") self.tooltip_default = default if isinstance(default, str) else None # Generate option widget self.option_widget = None self.call_widget_generator(option, option_key, value, default) # Reset button reset_btn = Gtk.Button.new_from_icon_name("edit-undo-symbolic", Gtk.IconSize.MENU) reset_btn.set_valign(Gtk.Align.CENTER) reset_btn.set_margin_bottom(6) reset_btn.set_relief(Gtk.ReliefStyle.NONE) reset_btn.set_tooltip_text(_("Reset option to global or default config")) reset_btn.connect( "clicked", self.on_reset_button_clicked, option, self.option_widget, self.wrapper, ) self.reset_buttons[option_key] = reset_btn placeholder = Gtk.Box() placeholder.set_size_request(32, 32) if option_key not in self.raw_config: reset_btn.set_visible(False) reset_btn.set_no_show_all(True) placeholder.pack_start(reset_btn, False, False, 0) # Tooltip helptext = option.get("help") if isinstance(self.tooltip_default, str): helptext = helptext + "\n\n" if helptext else "" helptext += _("Default: ") + _(self.tooltip_default) if value != default and option_key not in self.raw_config: helptext = helptext + "\n\n" if helptext else "" helptext += _( "(Italic indicates that this option is " "modified in a lower configuration level.)" ) if helptext: self.wrapper.props.has_tooltip = True self.wrapper.connect("query-tooltip", self.on_query_tooltip, helptext) hbox = Gtk.Box(visible=True) option_container = hbox hbox.set_margin_left(18) hbox.pack_end(placeholder, False, False, 5) # Grey out option if condition unmet if "condition" in option and not option["condition"]: hbox.set_sensitive(False) hbox.pack_start(self.wrapper, True, True, 0) if "warning" in option: option_body = option_container option_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True) option_container.pack_start(option_body, False, False, 0) warning = ConfigWarningBox(option["warning"], option_key) warning.update_warning(self.config) self.warning_boxes[option_key] = warning option_container.pack_start(warning, False, False, 0) if "error" in option: option_body = option_container option_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True) option_container.pack_start(option_body, False, False, 0) error = ConfigErrorBox(option["error"], option_key, hbox) error.update_warning(self.config) self.error_boxes[option_key] = error option_container.pack_start(error, False, False, 0) # Hide if advanced if option.get("advanced"): option_container.get_style_context().add_class("advanced") option_container.lutris_option_key = option_key option_container.lutris_option_label = option["label"] option_container.lutris_option_helptext = option.get("help") or "" current_vbox.pack_start(option_container, False, False, 0) except Exception as ex: logger.exception("Failed to generate option widget for '%s': %s", option.get("option"), ex) self.show_all() show_advanced = settings.read_setting("show_advanced_options") == "True" self.advanced_visibility = show_advanced def update_warnings(self): for box in self.warning_boxes.values(): box.update_warning(self.config) for box in self.error_boxes.values(): box.update_warning(self.config) def call_widget_generator(self, option, option_key, value, default): # noqa: C901 """Call the right generation method depending on option type.""" # pylint: disable=too-many-branches option_type = option["type"] option_size = option.get("size", None) if option_key in self.raw_config: self.set_style_property("font-weight", "bold", self.wrapper) elif value != default: self.set_style_property("font-style", "italic", self.wrapper) if option_type == "choice": self.generate_combobox(option_key, option["choices"], option["label"], value, default) elif option_type == "choice_with_entry": self.generate_combobox( option_key, option["choices"], option["label"], value, default, has_entry=True, ) elif option_type == "choice_with_search": self.generate_searchable_combobox( option_key, option["choices"], option["label"], value, default, ) elif option_type == "bool": self.generate_checkbox(option, value) self.tooltip_default = "Enabled" if default else "Disabled" elif option_type == "range": self.generate_range(option_key, option["min"], option["max"], option["label"], value) elif option_type == "string": if "label" not in option: raise ValueError("Option %s has no label" % option) self.generate_entry(option_key, option["label"], value, option_size) elif option_type == "directory_chooser": self.generate_directory_chooser(option, value) elif option_type == "file": self.generate_file_chooser(option, value) elif option_type == "command_line": self.generate_file_chooser(option, value, shell_quoting=True) elif option_type == "multiple": self.generate_multiple_file_chooser(option_key, option["label"], value) elif option_type == "label": self.generate_label(option["label"]) elif option_type == "mapping": self.generate_editable_grid(option_key, label=option["label"], value=value) else: raise ValueError("Unknown widget type %s" % option_type) # Label def generate_label(self, text): """Generate a simple label.""" label = Label(text) label.set_use_markup(True) label.set_halign(Gtk.Align.START) label.set_valign(Gtk.Align.CENTER) self.wrapper.pack_start(label, True, True, 0) # Checkbox def generate_checkbox(self, option, value=None): """Generate a checkbox.""" label = Label(option["label"]) self.wrapper.pack_start(label, False, False, 0) switch = Gtk.Switch() if value is True: switch.set_active(value) switch.connect("notify::active", self.checkbox_toggle, option["option"]) switch.set_valign(Gtk.Align.CENTER) self.wrapper.pack_start(switch, False, False, 0) self.option_widget = switch def checkbox_toggle(self, widget, _gparam, option_name): """Action for the checkbox's toggled signal.""" self.option_changed(widget, option_name, widget.get_active()) # Entry def generate_entry(self, option_name, label, value=None, option_size=None): """Generate an entry box.""" label = Label(label) self.wrapper.pack_start(label, False, False, 0) entry = Gtk.Entry() if value: entry.set_text(value) entry.connect("changed", self.entry_changed, option_name) expand = option_size != "small" self.wrapper.pack_start(entry, expand, expand, 0) self.option_widget = entry def entry_changed(self, entry, option_name): """Action triggered for entry 'changed' signal.""" self.option_changed(entry, option_name, entry.get_text()) def generate_searchable_combobox(self, option_name, choice_func, label, value, default): """Generate a searchable combo box""" combobox = SearchableCombobox(choice_func, value or default) combobox.connect("changed", self.on_searchable_entry_changed, option_name) self.wrapper.pack_start(Label(label), False, False, 0) self.wrapper.pack_start(combobox, True, True, 0) self.option_widget = combobox def on_searchable_entry_changed(self, combobox, value, key): self.option_changed(combobox, key, value) def _populate_combobox_choices(self, liststore, choices, default): for choice in choices: if isinstance(choice, str): choice = (choice, choice) if choice[1] == default: liststore.append((_("%s (default)") % choice[0], choice[1])) self.tooltip_default = choice[0] else: liststore.append(choice) # ComboBox def generate_combobox(self, option_name, choices, label, value=None, default=None, has_entry=False): """Generate a combobox (drop-down menu).""" liststore = Gtk.ListStore(str, str) self._populate_combobox_choices(liststore, choices, default) # With entry ("choice_with_entry" type) if has_entry: combobox = Gtk.ComboBox.new_with_model_and_entry(liststore) combobox.set_entry_text_column(0) if value: combobox.get_child().set_text(value) # No entry ("choice" type) else: combobox = Gtk.ComboBox.new_with_model(liststore) cell = Gtk.CellRendererText() combobox.pack_start(cell, True) combobox.add_attribute(cell, "text", 0) combobox.set_id_column(1) choices = list(v for k, v in choices) if value in choices: combobox.set_active_id(value) else: combobox.set_active_id(default) combobox.connect("changed", self.on_combobox_change, option_name) combobox.connect("scroll-event", self._on_combobox_scroll) label = Label(label) combobox.set_valign(Gtk.Align.CENTER) self.wrapper.pack_start(label, False, False, 0) self.wrapper.pack_start(combobox, True, True, 0) self.option_widget = combobox @staticmethod def _on_combobox_scroll(combobox, _event): """Prevents users from accidentally changing configuration values while scrolling down dialogs. """ combobox.stop_emission_by_name("scroll-event") return False def on_combobox_change(self, combobox, option): """Action triggered on combobox 'changed' signal.""" list_store = combobox.get_model() active = combobox.get_active() option_value = None if active < 0: if combobox.get_has_entry(): option_value = combobox.get_child().get_text() else: option_value = list_store[active][1] self.option_changed(combobox, option, option_value) # Range def generate_range(self, option_name, min_val, max_val, label, value=None): """Generate a ranged spin button.""" adjustment = Gtk.Adjustment(float(min_val), float(min_val), float(max_val), 1, 0, 0) spin_button = Gtk.SpinButton() spin_button.set_adjustment(adjustment) if value: spin_button.set_value(value) spin_button.connect("changed", self.on_spin_button_changed, option_name) label = Label(label) self.wrapper.pack_start(label, False, False, 0) self.wrapper.pack_start(spin_button, True, True, 0) self.option_widget = spin_button def on_spin_button_changed(self, spin_button, option): """Action triggered on spin button 'changed' signal.""" value = spin_button.get_value_as_int() self.option_changed(spin_button, option, value) # File chooser def generate_file_chooser(self, option, text=None, shell_quoting=False): """Generate a file chooser button to select a file.""" option_name = option["option"] label = Label(option["label"]) default_path = option.get("default_path") or (self.runner.default_path if self.runner else "") file_chooser = FileChooserEntry( title=_("Select file"), action=Gtk.FileChooserAction.OPEN, text=text, default_path=default_path, shell_quoting=shell_quoting ) # file_chooser.set_size_request(200, 30) if "default_path" in option: default_path = self.lutris_config.system_config.get(option["default_path"]) if default_path and os.path.exists(default_path): file_chooser.entry.set_text(default_path) if text: # If path is relative, complete with game dir if not os.path.isabs(text): text = os.path.expanduser(text) if not os.path.isabs(text): if self.game and self.game.directory: text = os.path.join(self.game.directory, text) file_chooser.entry.set_text(text) file_chooser.set_valign(Gtk.Align.CENTER) self.wrapper.pack_start(label, False, False, 0) self.wrapper.pack_start(file_chooser, True, True, 0) self.option_widget = file_chooser file_chooser.connect("changed", self._on_chooser_file_set, option_name) # Directory chooser def generate_directory_chooser(self, option, path=None): """Generate a file chooser button to select a directory.""" label = Label(option["label"]) option_name = option["option"] default_path = None if not path and self.game and self.game.runner: default_path = self.game.runner.working_dir directory_chooser = FileChooserEntry( title=_("Select folder"), action=Gtk.FileChooserAction.SELECT_FOLDER, text=path, default_path=default_path ) directory_chooser.connect("changed", self._on_chooser_file_set, option_name) directory_chooser.set_valign(Gtk.Align.CENTER) self.wrapper.pack_start(label, False, False, 0) self.wrapper.pack_start(directory_chooser, True, True, 0) self.option_widget = directory_chooser def _on_chooser_file_set(self, entry, option): """Action triggered when the field's content changes.""" text = entry.get_text() if text != entry.get_text(): entry.set_text(text) self.option_changed(entry, option, text) # Editable grid def generate_editable_grid(self, option_name, label, value=None): """Adds an editable grid widget""" value = value or {} try: value = list(value.items()) except AttributeError: logger.error("Invalid value of type %s passed to grid widget: %s", type(value), value) value = {} label = Label(label) grid = EditableGrid(value, columns=["Key", "Value"]) grid.connect("changed", self._on_grid_changed, option_name) self.wrapper.pack_start(label, False, False, 0) self.wrapper.pack_start(grid, True, True, 0) self.option_widget = grid return grid def _on_grid_changed(self, grid, option): values = dict(grid.get_data()) self.option_changed(grid, option, values) # Multiple file selector def generate_multiple_file_chooser(self, option_name, label, value=None): """Generate a multiple file selector.""" vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) label = Label(label + ":") label.set_halign(Gtk.Align.START) button = Gtk.Button(_("Add files")) button.connect("clicked", self.on_add_files_clicked, option_name, value) button.set_margin_left(10) vbox.pack_start(label, False, False, 5) vbox.pack_end(button, False, False, 0) if value: if isinstance(value, str): self.files = [value] else: self.files = value else: self.files = [] self.files_list_store = Gtk.ListStore(str) for filename in self.files: self.files_list_store.append([filename]) cell_renderer = Gtk.CellRendererText() files_treeview = Gtk.TreeView(self.files_list_store) files_column = Gtk.TreeViewColumn(_("Files"), cell_renderer, text=0) files_treeview.append_column(files_column) files_treeview.connect("key-press-event", self.on_files_treeview_keypress, option_name) treeview_scroll = Gtk.ScrolledWindow() treeview_scroll.set_min_content_height(130) treeview_scroll.set_margin_left(10) treeview_scroll.set_shadow_type(Gtk.ShadowType.ETCHED_IN) treeview_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) treeview_scroll.add(files_treeview) vbox.pack_start(treeview_scroll, True, True, 0) self.wrapper.pack_start(vbox, True, True, 0) self.option_widget = self.files_list_store def on_add_files_clicked(self, _widget, option_name, value): """Create and run multi-file chooser dialog.""" dialog = Gtk.FileChooserNative.new( _("Select files"), None, Gtk.FileChooserAction.OPEN, _("_Add"), _("_Cancel"), ) dialog.set_select_multiple(True) first_file_dir = os.path.dirname(value[0]) if value else None dialog.set_current_folder( first_file_dir or self.game.directory or self.config.get("game_path") or os.path.expanduser("~") ) response = dialog.run() if response == Gtk.ResponseType.ACCEPT: self.add_files_to_treeview(dialog, option_name, self.wrapper) dialog.destroy() def add_files_to_treeview(self, dialog, option, wrapper): """Add several files to the configuration""" filenames = dialog.get_filenames() files = self.config.get(option, []) for filename in filenames: self.files_list_store.append([filename]) if filename not in files: files.append(filename) self.option_changed(wrapper, option, files) def on_files_treeview_keypress(self, treeview, event, option): """Action triggered when a row is deleted from the filechooser.""" key = event.keyval if key == Gdk.KEY_Delete: selection = treeview.get_selection() (model, treepaths) = selection.get_selected_rows() for treepath in treepaths: row_index = int(str(treepath)) treeiter = model.get_iter(treepath) model.remove(treeiter) self.raw_config[option].pop(row_index) @staticmethod def on_query_tooltip(_widget, x, y, keybmode, tooltip, text): # pylint: disable=unused-argument """Prepare a custom tooltip with a fixed width""" label = Label(text) label.set_use_markup(True) label.set_max_width_chars(60) hbox = Gtk.Box() hbox.pack_start(label, False, False, 0) hbox.show_all() tooltip.set_custom(hbox) return True def option_changed(self, widget, option_name, value): """Common actions when value changed on a widget""" self.raw_config[option_name] = value self.config[option_name] = value reset_btn = self.reset_buttons.get(option_name) wrapper = self.wrappers.get(option_name) if reset_btn: reset_btn.set_visible(True) if wrapper: self.set_style_property("font-weight", "bold", wrapper) self.update_warnings() def on_reset_button_clicked(self, btn, option, _widget, wrapper): """Clear option (remove from config, reset option widget).""" option_key = option["option"] current_value = self.config[option_key] btn.set_visible(False) self.set_style_property("font-weight", "normal", wrapper) self.raw_config.pop(option_key) self.lutris_config.update_cascaded_config() reset_value = self.config.get(option_key) if current_value == reset_value: return # Destroy and recreate option widget self.wrapper = wrapper children = wrapper.get_children() for child in children: child.destroy() self.call_widget_generator(option, option_key, reset_value, option.get("default")) self.wrapper.show_all() self.update_warnings() @staticmethod def set_style_property(property_, value, wrapper): """Add custom style.""" style_provider = Gtk.CssProvider() style_provider.load_from_data("GtkHBox {{{}: {};}}".format(property_, value).encode()) style_context = wrapper.get_style_context() style_context.add_provider(style_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) class SectionFrame(Gtk.Frame): """A frame that is styled to have particular margins, and can have its frame hidden. This leaves the content but removes the margins and borders and all that, so it looks like the frame was never there.""" def __init__(self, section): super().__init__(label=section) self.section = section self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.add(self.vbox) self.get_style_context().add_class("section-frame") def set_frame_visible(self, visible): if visible: self.show_frame() else: self.hide_frame() def show_frame(self): self.get_style_context().remove_class("frame-hidden") def hide_frame(self): self.get_style_context().add_class("frame-hidden") class GameBox(ConfigBox): config_section = "game" def __init__(self, lutris_config, game): ConfigBox.__init__(self, game) self.lutris_config = lutris_config self.runner = game.runner if self.runner: self.options = self.runner.game_options else: logger.warning("No runner in game supplied to GameBox") class RunnerBox(ConfigBox): """Configuration box for runner specific options""" config_section = "runner" def __init__(self, lutris_config, game=None): ConfigBox.__init__(self, game) self.lutris_config = lutris_config try: self.runner = import_runner(self.lutris_config.runner_slug)() except InvalidRunner: self.runner = None if self.runner: self.options = self.runner.get_runner_options() if lutris_config.level == "game": self.generate_top_info_box(_( "If modified, these options supersede the same options from " "the base runner configuration." )) class SystemBox(ConfigBox): config_section = "system" def __init__(self, lutris_config): ConfigBox.__init__(self) self.lutris_config = lutris_config self.runner = None runner_slug = self.lutris_config.runner_slug if runner_slug: self.options = sysoptions.with_runner_overrides(runner_slug) else: self.options = sysoptions.system_options if lutris_config.game_config_id and runner_slug: self.generate_top_info_box(_( "If modified, these options supersede the same options from " "the base runner configuration, which themselves supersede " "the global preferences." )) elif runner_slug: self.generate_top_info_box(_( "If modified, these options supersede the same options from " "the global preferences." )) class ConfigMessageBox(Gtk.Box): def __init__(self, warning, option_key, icon_name): self.warning = warning self.option_key = option_key super().__init__(spacing=6, visible=False, no_show_all=True) self.set_margin_left(18) self.set_margin_right(18) self.set_margin_bottom(6) image = Gtk.Image(visible=True) image.set_from_icon_name(icon_name, Gtk.IconSize.DND) self.pack_start(image, False, False, 0) self.label = Gtk.Label(visible=True, xalign=0) self.label.set_line_wrap(True) self.pack_start(self.label, False, False, 0) def update_warning(self, config): try: if callable(self.warning): text = self.warning(config, self.option_key) else: text = self.warning except Exception as err: logger.exception("Unable to generate configuration warning: %s", err) text = gtk_safe(err) if text: self.label.set_markup(str(text)) visible = bool(text) self.set_visible(visible) return visible class ConfigWarningBox(ConfigMessageBox): def __init__(self, warning, option_key): super().__init__(warning, option_key, icon_name="dialog-warning") class ConfigErrorBox(ConfigMessageBox): def __init__(self, error, option_key, option_container): super().__init__(error, option_key, icon_name="dialog-error") self.option_container = option_container def update_warning(self, config): visible = super().update_warning(config) self.option_container.set_sensitive(not visible) return visible lutris-0.5.14/lutris/gui/config/common.py000066400000000000000000000720421451435154700203560ustar00rootroot00000000000000"""Shared config dialog stuff""" # pylint: disable=not-an-iterable import os import shutil from gettext import gettext as _ from gi.repository import GdkPixbuf, Gtk, Pango from lutris import runners, settings from lutris.config import LutrisConfig, make_game_config_id from lutris.exceptions import watch_errors from lutris.game import Game from lutris.gui import dialogs from lutris.gui.config import DIALOG_HEIGHT, DIALOG_WIDTH from lutris.gui.config.boxes import GameBox, RunnerBox, SystemBox from lutris.gui.dialogs import DirectoryDialog, ErrorDialog, QuestionDialog, SavableModelessDialog from lutris.gui.dialogs.delegates import DialogInstallUIDelegate from lutris.gui.widgets.common import Label, NumberEntry, SlugEntry from lutris.gui.widgets.notifications import send_notification from lutris.gui.widgets.scaled_image import ScaledImage from lutris.gui.widgets.utils import get_image_file_format, invalidate_media_caches from lutris.runners import import_runner from lutris.services.lutris import LutrisBanner, LutrisCoverart, LutrisIcon, download_lutris_media from lutris.util.log import logger from lutris.util.strings import slugify # pylint: disable=too-many-instance-attributes, no-member class GameDialogCommon(SavableModelessDialog, DialogInstallUIDelegate): """Base class for config dialogs""" no_runner_label = _("Select a runner in the Game Info tab") def __init__(self, title, parent=None): super().__init__(title, parent=parent, border_width=0) self.set_default_size(DIALOG_WIDTH, DIALOG_HEIGHT) self.vbox.set_border_width(0) self.notebook = None self.name_entry = None self.sortname_entry = None self.runner_box = None self.timer_id = None self.game = None self.saved = None self.slug = None self.slug_entry = None self.directory_entry = None self.year_entry = None self.slug_change_button = None self.runner_dropdown = None self.image_buttons = {} self.option_page_indices = set() self.searchable_page_indices = set() self.advanced_switch_widgets = [] self.header_bar_widgets = [] self.game_box = None self.system_box = None self.runner_name = None self.runner_index = None self.lutris_config = None self.service_medias = {"icon": LutrisIcon(), "banner": LutrisBanner(), "coverart_big": LutrisCoverart()} self.notebook_page_generators = {} self.build_header_bar() @staticmethod def build_scrolled_window(widget): """Return a scrolled window containing config widgets""" scrolled_window = Gtk.ScrolledWindow(visible=True) scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled_window.add(widget) return scrolled_window def build_notebook(self): self.notebook = Gtk.Notebook(visible=True) self.notebook.set_show_border(False) self.notebook.connect("switch-page", self.on_notebook_switch_page) self.vbox.pack_start(self.notebook, True, True, 0) def on_notebook_switch_page(self, notebook, page, index): generator = self.notebook_page_generators.get(index) if generator: generator() del self.notebook_page_generators[index] self.update_advanced_switch_visibility(index) self.update_search_entry_visibility(index) def build_tabs(self, config_level): """Build tabs (for game and runner levels)""" self.timer_id = None if config_level == "game": self._build_info_tab() self._build_game_tab() self._build_runner_tab(config_level) self._build_system_tab(config_level) current_page_index = self.notebook.get_current_page() self.update_advanced_switch_visibility(current_page_index) self.update_search_entry_visibility(current_page_index) def set_header_bar_widgets_visibility(self, value): for widget in self.header_bar_widgets: widget.set_visible(value) def update_advanced_switch_visibility(self, current_page_index): if self.notebook: show_switch = current_page_index in self.option_page_indices for widget in self.advanced_switch_widgets: widget.set_visible(show_switch) def update_search_entry_visibility(self, current_page_index): """Shows or hides the search entry according to what page is currently displayed.""" if self.notebook: show_search = current_page_index in self.searchable_page_indices self.set_search_entry_visibility(show_search) def set_search_entry_visibility(self, show_search, placeholder_text=None): """Explicitly shows or hides the search entry; can also update the placeholder text.""" header_bar = self.get_header_bar() if show_search and self.search_entry: header_bar.set_custom_title(self.search_entry) self.search_entry.set_placeholder_text(placeholder_text or self.get_search_entry_placeholder()) else: header_bar.set_custom_title(None) def get_search_entry_placeholder(self): if self.game and self.game.name: return _("Search %s options") % self.game.name return _("Search options") def _build_info_tab(self): info_box = Gtk.VBox() if self.game: centering_container = Gtk.HBox() banner_box = self._get_banner_box() centering_container.pack_start(banner_box, True, False, 0) info_box.pack_start(centering_container, False, False, 0) # Banner info_box.pack_start(self._get_name_box(), False, False, 6) # Game name info_box.pack_start(self._get_sortname_box(), False, False, 6) # Game sort name self.runner_box = self._get_runner_box() info_box.pack_start(self.runner_box, False, False, 6) # Runner info_box.pack_start(self._get_year_box(), False, False, 6) # Year if self.game: info_box.pack_start(self._get_slug_box(), False, False, 6) info_box.pack_start(self._get_directory_box(), False, False, 6) info_box.pack_start(self._get_launch_config_box(), False, False, 6) info_sw = self.build_scrolled_window(info_box) self._add_notebook_tab(info_sw, _("Game info")) def _get_name_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Label(_("Name")) box.pack_start(label, False, False, 0) self.name_entry = Gtk.Entry() self.name_entry.set_max_length(150) if self.game: self.name_entry.set_text(self.game.name) box.pack_start(self.name_entry, True, True, 0) return box def _get_sortname_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Label(_("Sort name")) box.pack_start(label, False, False, 0) self.sortname_entry = Gtk.Entry() self.sortname_entry.set_max_length(150) if self.game: self.sortname_entry.set_placeholder_text(self.game.name) if self.game.sortname: self.sortname_entry.set_text(self.game.sortname) box.pack_start(self.sortname_entry, True, True, 0) return box def _get_slug_box(self): slug_box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Label(_("Identifier")) slug_box.pack_start(label, False, False, 0) self.slug_entry = SlugEntry() self.slug_entry.set_text(self.game.slug) self.slug_entry.set_sensitive(False) self.slug_entry.connect("activate", self.on_slug_entry_activate) slug_box.pack_start(self.slug_entry, True, True, 0) self.slug_change_button = Gtk.Button(_("Change")) self.slug_change_button.connect("clicked", self.on_slug_change_clicked) slug_box.pack_start(self.slug_change_button, False, False, 0) return slug_box def _get_directory_box(self): """Return widget displaying the location of the game and allowing to move it""" box = Gtk.Box(spacing=12, margin_right=12, margin_left=12, visible=True) label = Label(_("Directory")) box.pack_start(label, False, False, 0) self.directory_entry = Gtk.Entry(visible=True) self.directory_entry.set_text(self.game.directory) self.directory_entry.set_sensitive(False) box.pack_start(self.directory_entry, True, True, 0) move_button = Gtk.Button(_("Move"), visible=True) move_button.connect("clicked", self.on_move_clicked) box.pack_start(move_button, False, False, 0) return box def _get_launch_config_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12, visible=True) if self.game.config: game_config = self.game.config.game_level.get("game", {}) else: game_config = {} preferred_name = game_config.get("preferred_launch_config_name") if preferred_name: spacer = Gtk.Box() spacer.set_size_request(230, -1) box.pack_start(spacer, False, False, 0) if preferred_name == Game.PRIMARY_LAUNCH_CONFIG_NAME: text = _("The default launch option will be used for this game") else: text = _("The '%s' launch option will be used for this game") % preferred_name label = Gtk.Label(text) label.set_line_wrap(True) label.set_halign(Gtk.Align.START) label.set_xalign(0.0) label.set_valign(Gtk.Align.CENTER) box.pack_start(label, True, True, 0) button = Gtk.Button(_("Reset")) button.connect("clicked", self.on_reset_preferred_launch_config_clicked, box) button.set_valign(Gtk.Align.CENTER) box.pack_start(button, False, False, 0) else: box.hide() return box @watch_errors() def on_reset_preferred_launch_config_clicked(self, _button, launch_config_box): game_config = self.game.config.game_level.get("game", {}) game_config.pop("preferred_launch_config_name", None) game_config.pop("preferred_launch_config_index", None) launch_config_box.hide() def _get_runner_box(self): runner_box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) runner_label = Label(_("Runner")) runner_box.pack_start(runner_label, False, False, 0) self.runner_dropdown = self._get_runner_dropdown() runner_box.pack_start(self.runner_dropdown, True, True, 0) return runner_box def _get_banner_box(self): banner_box = Gtk.Grid() banner_box.set_margin_top(12) banner_box.set_column_spacing(12) banner_box.set_row_spacing(4) self._create_image_button(banner_box, "coverart_big", _("Set custom cover art"), _("Remove custom cover art")) self._create_image_button(banner_box, "banner", _("Set custom banner"), _("Remove custom banner")) self._create_image_button(banner_box, "icon", _("Set custom icon"), _("Remove custom icon")) return banner_box def _create_image_button(self, banner_box, image_type, image_tooltip, reset_tooltip): """This adds an image button and its reset button to the box given, and adds the image button to self.image_buttons for future reference.""" image_button_container = Gtk.VBox() reset_button_container = Gtk.HBox() image_button = Gtk.Button() self._set_image(image_type, image_button) image_button.set_valign(Gtk.Align.CENTER) image_button.set_tooltip_text(image_tooltip) image_button.connect("clicked", self.on_custom_image_select, image_type) image_button_container.pack_start(image_button, True, True, 0) reset_button = Gtk.Button.new_from_icon_name("edit-undo-symbolic", Gtk.IconSize.MENU) reset_button.set_relief(Gtk.ReliefStyle.NONE) reset_button.set_tooltip_text(reset_tooltip) reset_button.connect("clicked", self.on_custom_image_reset_clicked, image_type) reset_button.set_valign(Gtk.Align.CENTER) reset_button_container.pack_start(reset_button, True, False, 0) banner_box.add(image_button_container) banner_box.attach_next_to(reset_button_container, image_button_container, Gtk.PositionType.BOTTOM, 1, 1) self.image_buttons[image_type] = image_button def _get_year_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Label(_("Release year")) box.pack_start(label, False, False, 0) self.year_entry = NumberEntry() self.year_entry.set_max_length(10) if self.game: self.year_entry.set_text(str(self.game.year or "")) box.pack_start(self.year_entry, True, True, 0) return box def _set_image(self, image_format, image_button): scale_factor = self.get_scale_factor() service_media = self.service_medias[image_format] game_slug = self.slug or (self.game.slug if self.game else "") media_path = service_media.get_media_path(game_slug) image = ScaledImage.new_from_media_path(media_path, service_media.config_ui_size, scale_factor) image_button.set_image(image) def _get_runner_dropdown(self): runner_liststore = self._get_runner_liststore() runner_dropdown = Gtk.ComboBox.new_with_model(runner_liststore) runner_dropdown.set_id_column(1) runner_index = 0 if self.runner_name: for runner in runner_liststore: if self.runner_name == str(runner[1]): break runner_index += 1 self.runner_index = runner_index runner_dropdown.set_active(self.runner_index) runner_dropdown.connect("changed", self.on_runner_changed) cell = Gtk.CellRendererText() cell.props.ellipsize = Pango.EllipsizeMode.END runner_dropdown.pack_start(cell, True) runner_dropdown.add_attribute(cell, "text", 0) return runner_dropdown @staticmethod def _get_runner_liststore(): """Build a ListStore with available runners.""" runner_liststore = Gtk.ListStore(str, str) runner_liststore.append((_("Select a runner from the list"), "")) for runner in runners.get_installed(): description = runner.description runner_liststore.append(("%s (%s)" % (runner.human_name, description), runner.name)) return runner_liststore @watch_errors() def on_slug_change_clicked(self, widget): if self.slug_entry.get_sensitive() is False: widget.set_label(_("Apply")) self.slug_entry.set_sensitive(True) else: self.change_game_slug() @watch_errors() def on_slug_entry_activate(self, _widget): self.change_game_slug() def change_game_slug(self): slug = self.slug_entry.get_text() download_lutris_media(slug) self.slug = slug for image_type, image_button in self.image_buttons.items(): self._set_image(image_type, image_button) self.slug_entry.set_sensitive(False) self.slug_change_button.set_label(_("Change")) @watch_errors() def on_move_clicked(self, _button): new_location = DirectoryDialog("Select new location for the game", default_path=self.game.directory, parent=self) if not new_location.folder or new_location.folder == self.game.directory: return move_dialog = dialogs.MoveDialog(self.game, new_location.folder, parent=self) move_dialog.connect("game-moved", self.on_game_moved) move_dialog.move() @watch_errors() def on_game_moved(self, dialog): """Show a notification when the game is moved""" new_directory = dialog.new_directory if new_directory: self.game = Game(self.game.id) self.lutris_config = self.game.config self._rebuild_tabs() self.directory_entry.set_text(new_directory) send_notification("Finished moving game", "%s moved to %s" % (dialog.game, new_directory)) else: send_notification("Failed to move game", "Lutris could not move %s" % dialog.game) def _build_game_tab(self): def is_searchable(game): return game.runner and len(game.runner.game_options) > 8 def has_advanced(game): for opt in game.runner.game_options: if opt.get("advanced"): return True return False if self.game and self.runner_name: self.game.runner_name = self.runner_name self.game_box = self._build_options_tab(_("Game options"), lambda: GameBox(self.lutris_config, self.game), advanced=has_advanced(self.game), searchable=is_searchable(self.game)) elif self.runner_name: game = Game(None) game.runner_name = self.runner_name self.game_box = self._build_options_tab(_("Game options"), lambda: GameBox(self.lutris_config, game), advanced=has_advanced(game), searchable=is_searchable(game)) else: self._build_missing_options_tab(self.no_runner_label, _("Game options")) def _build_runner_tab(self, _config_level): if self.runner_name: self.runner_box = self._build_options_tab(_("Runner options"), lambda: RunnerBox(self.lutris_config)) else: self._build_missing_options_tab(self.no_runner_label, _("Runner options")) def _build_system_tab(self, _config_level): self.system_box = self._build_options_tab(_("System options"), lambda: SystemBox(self.lutris_config)) def _build_options_tab(self, notebook_label, box_factory, advanced=True, searchable=True): if not self.lutris_config: raise RuntimeError("Lutris config not loaded yet") config_box = box_factory() page_index = self._add_notebook_tab( self.build_scrolled_window(config_box), notebook_label ) if page_index == 0: config_box.generate_widgets() else: self.notebook_page_generators[page_index] = config_box.generate_widgets if advanced: self.option_page_indices.add(page_index) if searchable: self.searchable_page_indices.add(page_index) return config_box def _build_missing_options_tab(self, missing_label, notebook_label): label = Gtk.Label(label=self.no_runner_label) page_index = self._add_notebook_tab(label, notebook_label) self.option_page_indices.add(page_index) def _add_notebook_tab(self, widget, label): return self.notebook.append_page(widget, Gtk.Label(label=label)) def build_header_bar(self): self.search_entry = Gtk.SearchEntry(width_chars=30, placeholder_text=_("Search options")) self.search_entry.connect("search-changed", self.on_search_entry_changed) self.search_entry.show_all() # Advanced settings toggle switch_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5, no_show_all=True, visible=True) switch_box.set_tooltip_text(_("Show advanced options")) switch_label = Gtk.Label(_("Advanced"), no_show_all=True, visible=True) switch = Gtk.Switch(no_show_all=True, visible=True, valign=Gtk.Align.CENTER) switch.set_state(settings.read_setting("show_advanced_options") == "True") switch.connect("state_set", lambda _w, s: self.on_show_advanced_options_toggled(bool(s))) switch_box.pack_start(switch_label, False, False, 0) switch_box.pack_end(switch, False, False, 0) header_bar = self.get_header_bar() header_bar.pack_end(switch_box) # These lists need to be distinct, so they can be separately # hidden or shown without interfering with each other. self.advanced_switch_widgets = [switch_label, switch] self.header_bar_widgets = [self.cancel_button, self.save_button, switch_box] if self.notebook: self.update_advanced_switch_visibilty(self.notebook.get_current_page()) def on_search_entry_changed(self, entry): """Callback for the search input keypresses""" text = entry.get_text().lower().strip() self._set_filter(text) def on_show_advanced_options_toggled(self, is_active): settings.write_setting("show_advanced_options", is_active) self._set_advanced_options_visible(is_active) def _set_advanced_options_visible(self, value): """Change visibility of advanced options across all config tabs.""" self.system_box.advanced_visibility = value if self.runner_box: self.runner_box.advanced_visibility = value if self.game_box: self.game_box.advanced_visibility = value def _set_filter(self, value): self.system_box.filter = value if self.runner_name: self.runner_box.filter = value if self.game: self.game_box.filter = value @watch_errors() def on_runner_changed(self, widget): """Action called when runner drop down is changed.""" new_runner_index = widget.get_active() if self.runner_index and new_runner_index != self.runner_index: dlg = QuestionDialog( { "parent": self, "question": _("Are you sure you want to change the runner for this game ? " "This will reset the full configuration for this game and " "is not reversible."), "title": _("Confirm runner change"), } ) if dlg.result == Gtk.ResponseType.YES: self.runner_index = new_runner_index self._switch_runner(widget) else: # Revert the dropdown menu to the previously selected runner widget.set_active(self.runner_index) else: self.runner_index = new_runner_index self._switch_runner(widget) def _switch_runner(self, widget): """Rebuilds the UI on runner change""" current_page = self.notebook.get_current_page() if self.runner_index == 0: logger.info("No runner selected, resetting configuration") self.runner_name = None self.lutris_config = None else: runner_name = widget.get_model()[self.runner_index][1] if runner_name == self.runner_name: logger.debug("Runner unchanged, not creating a new config") return logger.info("Creating new configuration with runner %s", runner_name) self.runner_name = runner_name self.lutris_config = LutrisConfig(runner_slug=self.runner_name, level="game") self._rebuild_tabs() self.notebook.set_current_page(current_page) def _rebuild_tabs(self): """Rebuild notebook pages""" for i in range(self.notebook.get_n_pages(), 1, -1): self.notebook.remove_page(i - 1) self.option_page_indices.clear() self.searchable_page_indices.clear() self._build_game_tab() self._build_runner_tab("game") self._build_system_tab("game") self.show_all() def on_response(self, _widget, response): if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): # Reload the config to clean out any changes we may have made if self.game: self.game.reload_config() super().on_response(_widget, response) def is_valid(self): if not self.runner_name: ErrorDialog(_("Runner not provided"), parent=self) return False if not self.name_entry.get_text(): ErrorDialog(_("Please fill in the name"), parent=self) return False if self.runner_name == "steam" and not self.lutris_config.game_config.get("appid"): ErrorDialog(_("Steam AppID not provided"), parent=self) return False invalid_fields = [] runner_class = import_runner(self.runner_name) runner_instance = runner_class() for config in ["game", "runner"]: for k, v in getattr(self.lutris_config, config + "_config").items(): option = runner_instance.find_option(config + "_options", k) if option is None: continue validator = option.get("validator") if validator is not None: try: res = validator(v) logger.debug("%s validated successfully: %s", k, res) except Exception: invalid_fields.append(option.get("label")) if invalid_fields: ErrorDialog(_("The following fields have invalid values: ") + ", ".join(invalid_fields), parent=self) return False return True @watch_errors() def on_save(self, _button): """Save game info and destroy widget.""" if not self.is_valid(): logger.warning(_("Current configuration is not valid, ignoring save request")) return name = self.name_entry.get_text() sortname = self.sortname_entry.get_text() if not self.slug: self.slug = slugify(name) if not self.game: self.game = Game() year = None if self.year_entry.get_text(): year = int(self.year_entry.get_text()) if not self.lutris_config.game_config_id: self.lutris_config.game_config_id = make_game_config_id(self.slug) self.game.name = name self.game.sortname = sortname self.game.slug = self.slug self.game.year = year self.game.is_installed = True self.game.config = self.lutris_config self.game.runner_name = self.runner_name if "icon" not in self.game.custom_images: self.game.runner.extract_icon(self.slug) self.game.save() self.destroy() self.saved = True return True @watch_errors() def on_custom_image_select(self, _widget, image_type): dialog = Gtk.FileChooserNative.new( _("Please choose a custom image"), self, Gtk.FileChooserAction.OPEN, None, None, ) image_filter = Gtk.FileFilter() image_filter.set_name(_("Images")) image_filter.add_pixbuf_formats() dialog.add_filter(image_filter) response = dialog.run() if response == Gtk.ResponseType.ACCEPT: slug = self.slug or self.game.slug image_path = dialog.get_filename() service_media = self.service_medias[image_type] self.game.custom_images.add(image_type) dest_path = service_media.get_media_path(slug) file_format = service_media.file_format if image_path != dest_path: if file_format == get_image_file_format(image_path): shutil.copy(image_path, dest_path, follow_symlinks=True) else: # If we must transcode the image, we'll scale the image up based on # the UI scale factor, to try to avoid blurriness. Of course this won't # work if the user changes the scaling later, but what can you do. scale_factor = self.get_scale_factor() width, height = service_media.custom_media_storage_size width = width * scale_factor height = height * scale_factor pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(image_path, width, height) # JPEG encoding looks rather better at high quality; # PNG encoding just ignores this option. pixbuf.savev(dest_path, file_format, ["quality"], ["100"]) invalidate_media_caches() self._set_image(image_type, self.image_buttons[image_type]) service_media.update_desktop() dialog.destroy() @watch_errors() def on_custom_image_reset_clicked(self, _widget, image_type): slug = self.slug or self.game.slug service_media = self.service_medias[image_type] dest_path = service_media.get_media_path(slug) self.game.custom_images.discard(image_type) if os.path.isfile(dest_path): os.remove(dest_path) download_lutris_media(self.game.slug) invalidate_media_caches() self._set_image(image_type, self.image_buttons[image_type]) def on_watched_error(self, error): dialogs.ErrorDialog(error, parent=self) lutris-0.5.14/lutris/gui/config/edit_category_games.py000066400000000000000000000126721451435154700230670ustar00rootroot00000000000000# pylint: disable=no-member from gettext import gettext as _ from gi.repository import Gtk from lutris.database import categories as categories_db from lutris.database import games as games_db from lutris.exceptions import watch_errors from lutris.game import Game from lutris.gui.dialogs import ErrorDialog, QuestionDialog, SavableModelessDialog class EditCategoryGamesDialog(SavableModelessDialog): """Games assigned to category dialog.""" def __init__(self, parent, category): super().__init__(_("Configure %s") % category['name'], parent=parent, border_width=10) self.category = category['name'] self.category_id = category['id'] self.available_games = [Game(x['id']) for x in games_db.get_games(sorts=[("installed", "DESC"), ("name", "COLLATE NOCASE ASC") ])] self.category_games = [Game(x) for x in categories_db.get_game_ids_for_category(self.category)] self.grid = Gtk.Grid() self.set_default_size(500, 350) self.vbox.set_homogeneous(False) self.vbox.set_spacing(10) name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) name_label = Gtk.Label(_("Name")) self.name_entry = Gtk.Entry() self.name_entry.set_text(self.category) name_box.pack_start(name_label, False, False, 0) name_box.pack_start(self.name_entry, True, True, 0) self.vbox.pack_start(name_box, False, False, 0) self.vbox.pack_start(self._create_games_checkboxes(), True, True, 0) delete_button = self.add_styled_button(Gtk.STOCK_DELETE, Gtk.ResponseType.NONE, css_class="destructive-action") delete_button.connect("clicked", self.on_delete_clicked) self.show_all() def _create_games_checkboxes(self): frame = Gtk.Frame() sw = Gtk.ScrolledWindow() row = Gtk.VBox() category_games_names = [x.name for x in self.category_games] for game in self.available_games: label = game.name checkbutton_option = Gtk.CheckButton(label) if label in category_games_names: checkbutton_option.set_active(True) self.grid.attach_next_to(checkbutton_option, None, Gtk.PositionType.BOTTOM, 3, 1) row.pack_start(self.grid, True, True, 0) sw.add_with_viewport(row) frame.add(sw) return frame @watch_errors() def on_delete_clicked(self, _button): dlg = QuestionDialog( { "title": _("Do you want to delete the category '%s'?") % self.category, "question": _( "This will permanently destroy the category, but the games themselves will not be deleted."), "parent": self } ) if dlg.result == Gtk.ResponseType.YES: for game in self.category_games: game.remove_category(self.category) self.destroy() @watch_errors() def on_save(self, _button): """Save game info and destroy widget.""" removed_games = [] added_games = [] new_name = categories_db.strip_category_name(self.name_entry.get_text()) if not new_name or self.category == new_name: category_games_names = [x.name for x in self.category_games] for game_checkbox in self.grid.get_children(): label = game_checkbox.get_label() game_id = games_db.get_game_by_field(label, 'name')['id'] if label in category_games_names: if not game_checkbox.get_active(): removed_games.append(game_id) else: if game_checkbox.get_active(): added_games.append(game_id) for game_id in added_games: game = Game(game_id) game.add_category(self.category) for game_id in removed_games: game = Game(game_id) game.remove_category(self.category) elif categories_db.is_reserved_category(new_name): raise RuntimeError(_("'%s' is a reserved category name.") % new_name) else: if new_name in (c["name"] for c in categories_db.get_categories()): dlg = QuestionDialog( { "title": _("Merge the category '%s' into '%s'?") % (self.category, new_name), "question": _( "If you rename this category, it will be combined with '%s'. " "Do you want to merge them?") % new_name, "parent": self } ) if dlg.result != Gtk.ResponseType.YES: return for game in self.category_games: game.remove_category(self.category) for game_checkbox in self.grid.get_children(): if game_checkbox.get_active(): label = game_checkbox.get_label() game_id = games_db.get_game_by_field(label, 'name')['id'] added_games.append(game_id) for game_id in added_games: game = Game(game_id) game.add_category(new_name) self.destroy() def on_watched_error(self, error): ErrorDialog(error, parent=self) lutris-0.5.14/lutris/gui/config/edit_game.py000066400000000000000000000007731451435154700210060ustar00rootroot00000000000000from gettext import gettext as _ from lutris.gui.config.common import GameDialogCommon class EditGameConfigDialog(GameDialogCommon): """Game config edit dialog.""" def __init__(self, parent, game): super().__init__(_("Configure %s") % game.name, parent=parent) self.game = game self.lutris_config = game.config self.slug = game.slug self.runner_name = game.runner_name self.build_notebook() self.build_tabs("game") self.show_all() lutris-0.5.14/lutris/gui/config/edit_game_categories.py000066400000000000000000000073241451435154700232120ustar00rootroot00000000000000# pylint: disable=no-member import locale from gettext import gettext as _ from gi.repository import Gtk from lutris.database import categories as categories_db from lutris.exceptions import watch_errors from lutris.gui import dialogs from lutris.gui.dialogs import SavableModelessDialog class EditGameCategoriesDialog(SavableModelessDialog): """Game category edit dialog.""" def __init__(self, parent, game): super().__init__(_("Categories - %s") % game.name, parent=parent, border_width=10) self.game = game self.game_id = game.id self.game_categories = categories_db.get_categories_in_game(self.game_id) self.grid = Gtk.Grid() self.set_default_size(350, 250) self.vbox.set_homogeneous(False) self.vbox.set_spacing(10) self.vbox.pack_start(self._create_category_checkboxes(), True, True, 0) self.vbox.pack_start(self._create_add_category(), False, False, 0) self.show_all() def _create_category_checkboxes(self): frame = Gtk.Frame() # frame.set_label("Categories") # probably too much redundancy sw = Gtk.ScrolledWindow() row = Gtk.VBox() categories = sorted([c for c in categories_db.get_categories() if c['name'] != 'favorite'], key=lambda c: locale.strxfrm(c['name'])) for category in categories: label = category['name'] checkbutton_option = Gtk.CheckButton(label) if label in self.game_categories: checkbutton_option.set_active(True) self.grid.attach_next_to(checkbutton_option, None, Gtk.PositionType.BOTTOM, 3, 1) row.pack_start(self.grid, True, True, 0) sw.add_with_viewport(row) frame.add(sw) return frame def _create_add_category(self): def on_add_category(widget=None): category_text = categories_db.strip_category_name(category_entry.get_text()) if not categories_db.is_reserved_category(category_text): for category_checkbox in self.grid.get_children(): if category_checkbox.get_label() == category_text: return category_entry.set_text("") checkbutton_option = Gtk.CheckButton(category_text) checkbutton_option.set_active(True) self.grid.attach_next_to(checkbutton_option, None, Gtk.PositionType.TOP, 3, 1) categories_db.add_category(category_text) self.vbox.show_all() hbox = Gtk.HBox() hbox.set_spacing(10) category_entry = Gtk.Entry() category_entry.set_text("") hbox.pack_start(category_entry, True, True, 0) button = Gtk.Button.new_with_label(_("Add Category")) button.connect("clicked", on_add_category) button.set_tooltip_text(_("Adds the category to the list.")) hbox.pack_start(button, False, False, 0) return hbox @watch_errors() def on_save(self, _button): """Save game info and destroy widget.""" removed_categories = set() added_categories = set() for category_checkbox in self.grid.get_children(): label = category_checkbox.get_label() if label in self.game_categories: if not category_checkbox.get_active(): removed_categories.add(label) else: if category_checkbox.get_active(): added_categories.add(label) if added_categories or removed_categories: self.game.update_game_categories(added_categories, removed_categories) self.destroy() def on_watched_error(self, error): dialogs.ErrorDialog(error, parent=self) lutris-0.5.14/lutris/gui/config/preferences_box.py000066400000000000000000000040331451435154700222320ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gio, Gtk from lutris import settings from lutris.gui.config.base_config_box import BaseConfigBox class InterfacePreferencesBox(BaseConfigBox): settings_options = { "hide_client_on_game_start": _("Minimize client when a game is launched"), "hide_text_under_icons": _("Hide text under icons"), "hide_badges_on_icons": _("Hide badges on icons (Ctrl+p to toggle)"), "show_tray_icon": _("Show Tray Icon"), "dark_theme": _("Use dark theme (requires dark theme variant for Gtk)"), "discord_rpc": _("Enable Discord Rich Presence for Available Games"), } settings_accelerators = { "hide_badges_on_icons": "p" } def __init__(self, accelerators): super().__init__() self.accelerators = accelerators self.add(self.get_section_label(_("Interface options"))) frame = Gtk.Frame(visible=True, shadow_type=Gtk.ShadowType.ETCHED_IN) listbox = Gtk.ListBox(visible=True) frame.add(listbox) self.pack_start(frame, False, False, 12) for setting_key, label in self.settings_options.items(): list_box_row = Gtk.ListBoxRow(visible=True) list_box_row.set_selectable(False) list_box_row.set_activatable(False) list_box_row.add(self.get_setting_box(setting_key, label)) listbox.add(list_box_row) def _on_setting_change(self, widget, state, setting_key): """Save a setting when an option is toggled""" settings.write_setting(setting_key, state) application = Gio.Application.get_default() # That should be implemented in the # application's event handler for settings-changed. if setting_key == "dark_theme": application.style_manager.is_config_dark = state elif setting_key == "show_tray_icon": if application.window.get_visible(): application.set_tray_icon() self.get_toplevel().emit("settings-changed", setting_key) lutris-0.5.14/lutris/gui/config/preferences_dialog.py000066400000000000000000000115441451435154700227060ustar00rootroot00000000000000"""Configuration dialog for client and system options""" # pylint: disable=no-member from gettext import gettext as _ from gi.repository import GObject, Gtk from lutris.config import LutrisConfig from lutris.gui.config.accounts_box import AccountsBox from lutris.gui.config.boxes import SystemBox from lutris.gui.config.common import GameDialogCommon from lutris.gui.config.preferences_box import InterfacePreferencesBox from lutris.gui.config.runners_box import RunnersBox from lutris.gui.config.services_box import ServicesBox from lutris.gui.config.sysinfo_box import SysInfoBox class PreferencesDialog(GameDialogCommon): __gsignals__ = { "settings-changed": (GObject.SIGNAL_RUN_LAST, None, (str, )), } def __init__(self, parent=None): super().__init__(_("Lutris settings"), parent=parent) self.set_border_width(0) self.set_default_size(1010, 600) self.lutris_config = LutrisConfig() self.page_generators = {} self.accelerators = Gtk.AccelGroup() self.add_accel_group(self.accelerators) hbox = Gtk.HBox(visible=True) sidebar = Gtk.ListBox(visible=True) sidebar.connect("row-selected", self.on_sidebar_activated) sidebar.add(self.get_sidebar_button("prefs-stack", _("Interface"), "view-grid-symbolic")) sidebar.add(self.get_sidebar_button("runners-stack", _("Runners"), "applications-utilities-symbolic")) sidebar.add(self.get_sidebar_button("services-stack", _("Sources"), "application-x-addon-symbolic")) sidebar.add(self.get_sidebar_button("accounts-stack", _("Accounts"), "system-users-symbolic")) sidebar.add(self.get_sidebar_button("sysinfo-stack", _("Hardware information"), "computer-symbolic")) sidebar.add(self.get_sidebar_button("system-stack", _("Global options"), "emblem-system-symbolic")) hbox.pack_start(sidebar, False, False, 0) self.stack = Gtk.Stack(visible=True) self.stack.set_vhomogeneous(False) self.stack.set_interpolate_size(True) hbox.add(self.stack) self.vbox.pack_start(hbox, True, True, 0) self.vbox.set_border_width(0) # keep everything flush with the window edge self.stack.add_named( self.build_scrolled_window(InterfacePreferencesBox(self.accelerators)), "prefs-stack" ) self.runners_box = RunnersBox() self.page_generators["runners-stack"] = self.runners_box.populate_runners self.stack.add_named( self.build_scrolled_window(self.runners_box), "runners-stack" ) self.stack.add_named( self.build_scrolled_window(ServicesBox()), "services-stack" ) self.stack.add_named( self.build_scrolled_window(AccountsBox()), "accounts-stack" ) sysinfo_box = SysInfoBox() self.page_generators["sysinfo-stack"] = sysinfo_box.populate self.stack.add_named( self.build_scrolled_window(sysinfo_box), "sysinfo-stack" ) self.system_box = SystemBox(self.lutris_config) self.page_generators["system-stack"] = self.system_box.generate_widgets self.stack.add_named( self.build_scrolled_window(self.system_box), "system-stack" ) def on_sidebar_activated(self, _listbox, row): stack_id = row.get_children()[0].stack_id generator = self.page_generators.get(stack_id) if generator: del self.page_generators[stack_id] generator() show_actions = stack_id == "system-stack" self.set_header_bar_widgets_visibility(show_actions) if stack_id == "system-stack": self.set_search_entry_visibility(True) elif stack_id == "runners-stack": self.set_search_entry_visibility(True, self.runners_box.search_entry_placeholder_text) else: self.set_search_entry_visibility(False) self.get_header_bar().set_show_close_button(not show_actions) self.stack.set_visible_child_name(row.get_children()[0].stack_id) def get_search_entry_placeholder(self): return _("Search global options") def get_sidebar_button(self, stack_id, text, icon_name): hbox = Gtk.HBox(visible=True) hbox.stack_id = stack_id hbox.set_margin_top(12) hbox.set_margin_bottom(12) hbox.set_margin_right(40) icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU) icon.show() hbox.pack_start(icon, False, False, 6) label = Gtk.Label(text, visible=True) label.set_alignment(0, 0.5) hbox.pack_start(label, False, False, 6) return hbox def on_save(self, _widget): self.lutris_config.save() self.destroy() def _set_filter(self, value): super()._set_filter(value) self.runners_box.filter = value lutris-0.5.14/lutris/gui/config/runner.py000066400000000000000000000015211451435154700203710ustar00rootroot00000000000000from gettext import gettext as _ from lutris.config import LutrisConfig from lutris.gui.config.common import GameDialogCommon from lutris.runners import get_runner_human_name class RunnerConfigDialog(GameDialogCommon): """Runner config edit dialog.""" def __init__(self, runner, parent=None): super().__init__(_("Configure %s") % runner.human_name, parent=parent) self.runner_name = runner.__class__.__name__ self.saved = False self.lutris_config = LutrisConfig(runner_slug=self.runner_name) self.build_notebook() self.build_tabs("runner") self.show_all() def get_search_entry_placeholder(self): return _("Search %s options") % get_runner_human_name(self.runner_name) def on_save(self, wigdet, data=None): self.lutris_config.save() self.destroy() lutris-0.5.14/lutris/gui/config/runner_box.py000066400000000000000000000137361451435154700212540ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import GObject, Gtk from lutris import runners from lutris.exceptions import watch_errors from lutris.gui.config.runner import RunnerConfigDialog from lutris.gui.dialogs import ErrorDialog, QuestionDialog from lutris.gui.dialogs.runner_install import RunnerInstallDialog from lutris.gui.widgets.scaled_image import ScaledImage from lutris.util.log import logger class RunnerBox(Gtk.Box): __gsignals__ = { "runner-installed": (GObject.SIGNAL_RUN_FIRST, None, ()), "runner-removed": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, runner_name): super().__init__(visible=True) self.connect("runner-installed", self.on_runner_installed) self.connect("runner-removed", self.on_runner_removed) self.set_margin_bottom(12) self.set_margin_top(12) self.set_margin_left(12) self.set_margin_right(12) self.runner = runners.import_runner(runner_name)() runner_icon = ScaledImage.get_runtime_icon_image(self.runner.name, scale_factor=self.get_scale_factor(), visible=True) runner_icon.set_margin_right(12) self.pack_start(runner_icon, False, True, 6) self.runner_label_box = Gtk.VBox(visible=True) self.runner_label_box.set_margin_top(12) runner_label = Gtk.Label(visible=True) runner_label.set_alignment(0, 0.5) runner_label.set_markup("%s" % self.runner.human_name) self.runner_label_box.pack_start(runner_label, False, False, 0) desc_label = Gtk.Label(visible=True) desc_label.set_line_wrap(True) desc_label.set_alignment(0, 0.5) desc_label.set_text(self.runner.description) self.runner_label_box.pack_start(desc_label, False, False, 0) self.pack_start(self.runner_label_box, True, True, 0) self.configure_button = Gtk.Button.new_from_icon_name("preferences-system-symbolic", Gtk.IconSize.BUTTON) self.configure_button.set_valign(Gtk.Align.CENTER) self.configure_button.set_margin_right(12) self.configure_button.connect("clicked", self.on_configure_clicked) self.pack_start(self.configure_button, False, False, 0) if not self.runner.is_installed(): self.runner_label_box.set_sensitive(False) self.configure_button.show() self.action_alignment = Gtk.Alignment.new(0.5, 0.5, 0, 0) self.action_alignment.show() self.action_alignment.add(self.get_action_button()) self.pack_start(self.action_alignment, False, False, 0) def get_action_button(self): """Return a install or remove button""" if self.runner.multiple_versions: _button = Gtk.Button.new_from_icon_name("system-software-install-symbolic", Gtk.IconSize.BUTTON) _button.get_style_context().add_class("circular") _button.connect("clicked", self.on_versions_clicked) else: if self.runner.can_uninstall(): _button = Gtk.Button.new_from_icon_name("edit-delete-symbolic", Gtk.IconSize.BUTTON) _button.get_style_context().add_class("circular") _button.connect("clicked", self.on_remove_clicked) _button.set_sensitive(self.runner.can_uninstall()) else: _button = Gtk.Button.new_from_icon_name("system-software-install-symbolic", Gtk.IconSize.BUTTON) _button.get_style_context().add_class("circular") _button.connect("clicked", self.on_install_clicked) _button.show() return _button @watch_errors() def on_versions_clicked(self, widget): window = self.get_toplevel() application = window.get_application() title = _("Manage %s versions") % self.runner.name application.show_window(RunnerInstallDialog, title=title, runner=self.runner, parent=window) @watch_errors() def on_install_clicked(self, widget): """Install a runner.""" logger.debug("Install of %s requested", self.runner) window = self.get_toplevel() try: self.runner.install(window) except ( runners.RunnerInstallationError, runners.NonInstallableRunnerError, ) as ex: logger.error(ex) ErrorDialog(ex.message, parent=self.get_toplevel()) return if self.runner.is_installed(): self.emit("runner-installed") else: ErrorDialog("Runner failed to install", parent=self.get_toplevel()) @watch_errors() def on_configure_clicked(self, widget): window = self.get_toplevel() application = window.get_application() application.show_window(RunnerConfigDialog, runner=self.runner, parent=window) @watch_errors() def on_remove_clicked(self, widget): dialog = QuestionDialog( { "parent": self.get_toplevel(), "title": _("Do you want to uninstall %s?") % self.runner.human_name, "question": _("This will remove %s and all associated data." % self.runner.human_name) } ) if Gtk.ResponseType.YES == dialog.result: self.runner.uninstall() self.emit("runner-removed") @watch_errors() def on_runner_installed(self, widget): """Called after the runnner is installed""" self.runner_label_box.set_sensitive(True) self.action_alignment.get_children()[0].destroy() self.action_alignment.add(self.get_action_button()) @watch_errors() def on_runner_removed(self, widget): """Called after the runner is removed""" self.runner_label_box.set_sensitive(False) self.action_alignment.get_children()[0].destroy() self.action_alignment.add(self.get_action_button()) def on_watched_error(self, error): ErrorDialog(error, parent=self.get_toplevel()) lutris-0.5.14/lutris/gui/config/runners_box.py000066400000000000000000000050461451435154700214320ustar00rootroot00000000000000"""Add, remove and configure runners""" from gettext import gettext as _ from gi.repository import Gtk from lutris import runners, settings from lutris.gui.config.base_config_box import BaseConfigBox from lutris.gui.config.runner_box import RunnerBox from lutris.gui.widgets.utils import open_uri class RunnersBox(BaseConfigBox): """List of all available runners""" def __init__(self): super().__init__() self._filter = "" self.search_entry_placeholder_text = "" self.add(self.get_section_label(_("Add, remove or configure runners"))) self.add(self.get_description_label( _("Runners are programs such as emulators, engines or " "translation layers capable of running games.") )) self.search_failed_label = Gtk.Label(_("No runners matched the search")) self.pack_start(self.search_failed_label, False, False, 6) self.runner_list_frame = Gtk.Frame(visible=True, shadow_type=Gtk.ShadowType.ETCHED_IN) self.runner_listbox = Gtk.ListBox(visible=True) self.runner_list_frame.add(self.runner_listbox) self.pack_start(self.runner_list_frame, False, False, 6) def populate_runners(self): runner_count = 0 for runner_name in sorted(runners.__all__): list_box_row = Gtk.ListBoxRow(visible=True) list_box_row.set_selectable(False) list_box_row.set_activatable(False) list_box_row.add(RunnerBox(runner_name)) self.runner_listbox.add(list_box_row) runner_count += 1 self._update_row_visibility() # pretty sure there will always be many runners, so assume plural self.search_entry_placeholder_text = _("Search %s runners") % runner_count @staticmethod def on_folder_clicked(_widget): open_uri("file://" + settings.RUNNER_DIR) @property def filter(self): return self._filter @filter.setter def filter(self, value): self._filter = value self._update_row_visibility() def _update_row_visibility(self): text = self.filter.lower() any_matches = False for row in self.runner_listbox.get_children(): runner_box = row.get_child() runner = runner_box.runner match = text in runner.name.lower() or text in runner.description.lower() row.set_visible(match) if match: any_matches = True self.runner_list_frame.set_visible(any_matches) self.search_failed_label.set_visible(not any_matches) lutris-0.5.14/lutris/gui/config/services_box.py000066400000000000000000000057511451435154700215640ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import GLib, GObject, Gtk from lutris import settings from lutris.gui.config.base_config_box import BaseConfigBox from lutris.gui.widgets.scaled_image import ScaledImage from lutris.services import SERVICES class ServicesBox(BaseConfigBox): __gsignals__ = { "services-changed": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self): super().__init__() self.add(self.get_section_label(_("Enable integrations with game sources"))) self.add(self.get_description_label( _("Access your game libraries from various sources. " "Changes require a restart to take effect.") )) self.frame = Gtk.Frame(visible=True, shadow_type=Gtk.ShadowType.ETCHED_IN) self.listbox = Gtk.ListBox(visible=True) self.frame.add(self.listbox) self.pack_start(self.frame, False, False, 12) GLib.idle_add(self.populate_services) def populate_services(self): for service_key in SERVICES: list_box_row = Gtk.ListBoxRow(visible=True) list_box_row.set_selectable(False) list_box_row.set_activatable(False) list_box_row.add(self._get_service_box(service_key)) self.listbox.add(list_box_row) def _get_service_box(self, service_key): box = Gtk.Box( spacing=12, margin_right=12, margin_left=12, margin_top=12, margin_bottom=12, visible=True, ) service = SERVICES[service_key] icon = ScaledImage.get_runtime_icon_image(service.icon, service.id, scale_factor=self.get_scale_factor(), visible=True) box.pack_start(icon, False, False, 0) service_label_box = Gtk.VBox(visible=True) label = Gtk.Label(visible=True) label.set_markup(f"{service.name}") label.set_alignment(0, 0.5) service_label_box.pack_start(label, False, False, 0) desc_label = Gtk.Label(visible=True) desc_label.set_alignment(0, 0.5) desc_label.set_text(service.description) service_label_box.pack_start(desc_label, False, False, 0) box.pack_start(service_label_box, True, True, 0) checkbox = Gtk.Switch(visible=True) if settings.read_setting(service_key, section="services").lower() == "true": checkbox.set_active(True) checkbox.connect("state-set", self._on_service_change, service_key) alignment = Gtk.Alignment.new(0.5, 0.5, 0, 0) alignment.show() alignment.add(checkbox) box.pack_start(alignment, False, False, 6) return box def _on_service_change(self, widget, state, setting_key): """Save a setting when an option is toggled""" settings.write_setting(setting_key, state, section="services") self.emit("services-changed") lutris-0.5.14/lutris/gui/config/sysinfo_box.py000066400000000000000000000041061451435154700214240ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gdk, Gtk from lutris.gui.widgets.log_text_view import LogTextView from lutris.util.linux import gather_system_info_str class SysInfoBox(Gtk.Box): settings_options = { "hide_client_on_game_start": _("Minimize client when a game is launched"), "hide_text_under_icons": _("Hide text under icons"), "hide_badges_on_icons": _("Hide badges on icons"), "show_tray_icon": _("Show Tray Icon"), } def __init__(self): super().__init__( orientation=Gtk.Orientation.VERTICAL, visible=True, spacing=6, margin_top=40, margin_bottom=40, margin_right=100, margin_left=100) self._clipboard_buffer = None sysinfo_frame = Gtk.Frame(visible=True) scrolled_window = Gtk.ScrolledWindow(visible=True) scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) self.sysinfo_view = LogTextView(autoscroll=False, wrap_mode=Gtk.WrapMode.NONE) self.sysinfo_view.set_cursor_visible(False) scrolled_window.add(self.sysinfo_view) sysinfo_frame.add(scrolled_window) self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) button_copy = Gtk.Button(_("Copy to clipboard"), halign=Gtk.Align.START, visible=True) button_copy.connect("clicked", self._copy_text) sysinfo_label = Gtk.Label(halign=Gtk.Align.START, visible=True) sysinfo_label.set_markup(_("System information")) self.pack_start(sysinfo_label, False, False, 0) # 60, 0) self.pack_start(sysinfo_frame, True, True, 0) # 60, 24) self.pack_start(button_copy, False, False, 0) # 60, 486) def populate(self): sysinfo_str = gather_system_info_str() text_buffer = self.sysinfo_view.get_buffer() text_buffer.set_text(sysinfo_str) self._clipboard_buffer = sysinfo_str def _copy_text(self, widget): # pylint: disable=unused-argument self.clipboard.set_text(self._clipboard_buffer, -1) lutris-0.5.14/lutris/gui/dialogs/000077500000000000000000000000001451435154700166645ustar00rootroot00000000000000lutris-0.5.14/lutris/gui/dialogs/__init__.py000066400000000000000000000550021451435154700207770ustar00rootroot00000000000000"""Commonly used dialogs""" import os from gettext import gettext as _ import gi gi.require_version('Gdk', '3.0') gi.require_version('Gtk', '3.0') from gi.repository import Gdk, GLib, GObject, Gtk from lutris import api, settings from lutris.gui.widgets.log_text_view import LogTextView from lutris.util import datapath from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.strings import gtk_safe class Dialog(Gtk.Dialog): def __init__(self, title=None, parent=None, flags=0, buttons=None, **kwargs): super().__init__(title, parent, flags, buttons, **kwargs) self.connect("delete-event", self.on_destroy) def on_destroy(self, _widget, _data=None): self.destroy() def add_styled_button(self, button_text, response_id, css_class): button = self.add_button(button_text, response_id) if css_class: style_context = button.get_style_context() style_context.add_class(css_class) return button def add_default_button(self, button_text, response_id, css_class="suggested-action"): """Adds a button to the dialog with a particular response id, but also makes it the default and styles it as the suggested action.""" button = self.add_styled_button(button_text, response_id, css_class) self.set_default_response(response_id) return button class ModalDialog(Dialog): """A base class of modal dialogs, which sets the flag for you.""" def __init__(self, title=None, parent=None, flags=0, buttons=None, **kwargs): super().__init__(title, parent, flags | Gtk.DialogFlags.MODAL, buttons, **kwargs) self.set_destroy_with_parent(True) class ModelessDialog(Dialog): """A base class for modeless dialogs. They have a parent only temporarily, so they can be centered over it during creation. But each modeless dialog gets its own window group, so it treats its own modal dialogs separately, and it resets its transient-for after being created.""" def __init__(self, title=None, parent=None, flags=0, buttons=None, **kwargs): super().__init__(title, parent, flags, buttons, **kwargs) # These are not stuck above the 'main' window, but can be # re-ordered freely. self.set_type_hint(Gdk.WindowTypeHint.NORMAL) # These are independent windows, but start centered over # a parent like a dialog. Not modal, not really transient, # and does not share modality with other windows - so it # needs its own window group. Gtk.WindowGroup().add_window(self) GLib.idle_add(self._clear_transient_for) def _clear_transient_for(self): # we need the parent set to be centered over the parent, but # we don't want to be transient really - we want other windows # able to come to the front. self.set_transient_for(None) return False class SavableModelessDialog(ModelessDialog): """This is a modeless dialog that has a Cancel and a Save button in the header-bar, with a ctrl-S keyboard shortcut to save.""" def __init__(self, title, parent=None, **kwargs): super().__init__(title, parent=parent, use_header_bar=True, **kwargs) self.cancel_button = self.add_button(_("Cancel"), Gtk.ResponseType.CANCEL) self.cancel_button.set_valign(Gtk.Align.CENTER) self.save_button = self.add_styled_button(_("Save"), Gtk.ResponseType.NONE, css_class="suggested-action") self.save_button.set_valign(Gtk.Align.CENTER) self.save_button.connect("clicked", self.on_save) self.accelerators = Gtk.AccelGroup() self.add_accel_group(self.accelerators) key, mod = Gtk.accelerator_parse("s") self.save_button.add_accelerator("clicked", self.accelerators, key, mod, Gtk.AccelFlags.VISIBLE) self.connect("response", self.on_response) def on_response(self, _widget, response): if response != Gtk.ResponseType.NONE: self.destroy() def on_save(self, _button): pass class GtkBuilderDialog(GObject.Object): dialog_object = NotImplemented __gsignals__ = { "destroy": (GObject.SignalFlags.RUN_LAST, None, ()), } def __init__(self, parent=None, **kwargs): # pylint: disable=no-member super().__init__() ui_filename = os.path.join(datapath.get(), "ui", self.glade_file) if not os.path.exists(ui_filename): raise ValueError("ui file does not exists: %s" % ui_filename) self.builder = Gtk.Builder() self.builder.add_from_file(ui_filename) self.dialog = self.builder.get_object(self.dialog_object) self.builder.connect_signals(self) if parent: self.dialog.set_transient_for(parent) self.dialog.show_all() self.dialog.connect("delete-event", self.on_close) self.initialize(**kwargs) def initialize(self, **kwargs): """Implement further customizations in subclasses""" def present(self): self.dialog.present() def on_close(self, *args): # pylint: disable=unused-argument """Propagate the destroy event after closing the dialog""" self.dialog.destroy() self.emit("destroy") def on_response(self, widget, response): # pylint: disable=unused-argument if response == Gtk.ResponseType.DELETE_EVENT: try: self.dialog.hide() except AttributeError: pass class AboutDialog(GtkBuilderDialog): glade_file = "about-dialog.ui" dialog_object = "about_dialog" def initialize(self): # pylint: disable=arguments-differ self.dialog.set_version(settings.VERSION) class NoticeDialog(Gtk.MessageDialog): """Display a message to the user.""" def __init__(self, message, secondary=None, parent=None): super().__init__(message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, parent=parent) self.set_markup(message) if secondary: self.format_secondary_text(secondary[:256]) # So you can copy warning text for child in self.get_message_area().get_children(): if isinstance(child, Gtk.Label): child.set_selectable(True) self.run() self.destroy() class WarningDialog(Gtk.MessageDialog): """Display a warning to the user, who responds with whether to proceed, like a QuestionDialog.""" def __init__(self, message, secondary=None, parent=None): super().__init__(message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK_CANCEL, parent=parent) self.set_markup(message) if secondary: self.format_secondary_text(secondary[:256]) # So you can copy warning text for child in self.get_message_area().get_children(): if isinstance(child, Gtk.Label): child.set_selectable(True) self.result = self.run() self.destroy() class ErrorDialog(Gtk.MessageDialog): """Display an error message.""" def __init__(self, error, secondary=None, parent=None): super().__init__(buttons=Gtk.ButtonsType.OK, parent=parent) # Some errors contain < and > and lok like markup, but aren't- # we'll need to protect the message box against this message = gtk_safe(error) if isinstance(error, BaseException) else str(error) # Gtk doesn't wrap long labels containing no space correctly # the length of the message is limited to avoid display issues self.set_markup(message[:256]) if secondary: self.format_secondary_text(secondary[:256]) # So you can copy error text for child in self.get_message_area().get_children(): if isinstance(child, Gtk.Label): child.set_selectable(True) self.run() self.destroy() class QuestionDialog(Gtk.MessageDialog): """Ask the user a yes or no question.""" YES = Gtk.ResponseType.YES NO = Gtk.ResponseType.NO def __init__(self, dialog_settings): super().__init__(message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO) self.set_markup(dialog_settings["question"]) self.set_title(dialog_settings["title"]) if "parent" in dialog_settings: self.set_transient_for(dialog_settings["parent"]) if "widgets" in dialog_settings: for widget in dialog_settings["widgets"]: self.get_message_area().add(widget) self.result = self.run() self.destroy() class InputDialog(ModalDialog): """Ask the user for a text input""" def __init__(self, dialog_settings): super().__init__(parent=dialog_settings["parent"]) self.set_border_width(12) self.user_value = "" self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.ok_button = self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.ok_button.set_sensitive(False) self.set_title(dialog_settings["title"]) label = Gtk.Label(visible=True) label.set_markup(dialog_settings["question"]) self.get_content_area().pack_start(label, True, True, 12) self.entry = Gtk.Entry(visible=True) self.entry.connect("changed", self.on_entry_changed) self.get_content_area().pack_start(self.entry, True, True, 12) def on_entry_changed(self, widget): self.user_value = widget.get_text() self.ok_button.set_sensitive(bool(self.user_value)) class DirectoryDialog: """Ask the user to select a directory.""" def __init__(self, message, default_path=None, parent=None): self.folder = None dialog = Gtk.FileChooserNative.new( message, parent, Gtk.FileChooserAction.SELECT_FOLDER, _("_OK"), _("_Cancel"), ) if default_path: dialog.set_current_folder(default_path) self.result = dialog.run() if self.result == Gtk.ResponseType.ACCEPT: self.folder = dialog.get_filename() dialog.destroy() class FileDialog: """Ask the user to select a file.""" def __init__(self, message=None, default_path=None, mode="open", parent=None): self.filename = None if not message: message = _("Please choose a file") if mode == "save": action = Gtk.FileChooserAction.SAVE else: action = Gtk.FileChooserAction.OPEN dialog = Gtk.FileChooserNative.new( message, parent, action, _("_OK"), _("_Cancel"), ) if default_path and os.path.exists(default_path): dialog.set_current_folder(default_path) dialog.set_local_only(False) response = dialog.run() if response == Gtk.ResponseType.ACCEPT: self.filename = dialog.get_filename() dialog.destroy() class LutrisInitDialog(Gtk.Dialog): def __init__(self, runtime_updater): super().__init__() self.runtime_updater = runtime_updater self.set_icon_name("lutris") self.set_size_request(320, 60) self.set_border_width(24) self.set_decorated(False) vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 12) self.label = Gtk.Label(_("Checking for runtime updates, please wait…")) vbox.add(self.label) self.progress = Gtk.ProgressBar(visible=True) vbox.add(self.progress) self.get_content_area().add(vbox) self.progress_timeout = GLib.timeout_add(125, self.show_progress) self.show_all() self.connect("destroy", self.on_destroy) AsyncCall(self.runtime_updater.update_runtimes, self.init_cb) def show_progress(self): self.progress.set_fraction(self.runtime_updater.percentage_completed()) if self.runtime_updater.status_text and self.label.get_text() != self.runtime_updater.status_text: self.label.set_text(self.runtime_updater.status_text) return True def init_cb(self, _result, error: Exception): if error: ErrorDialog(error, parent=self) self.destroy() def on_destroy(self, window): GLib.source_remove(self.progress_timeout) return True class InstallOrPlayDialog(ModalDialog): def __init__(self, game_name, parent=None): super().__init__(title=_("%s is already installed") % game_name, parent=parent, border_width=10) self.action = "play" self.action_confirmed = False self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.connect("response", self.on_response) self.set_size_request(320, 120) vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 6) self.get_content_area().add(vbox) play_button = Gtk.RadioButton.new_with_label_from_widget(None, _("Launch game")) play_button.connect("toggled", self.on_button_toggled, "play") vbox.pack_start(play_button, False, False, 0) install_button = Gtk.RadioButton.new_from_widget(play_button) install_button.set_label(_("Install the game again")) install_button.connect("toggled", self.on_button_toggled, "install") vbox.pack_start(install_button, False, False, 0) self.show_all() self.run() def on_button_toggled(self, _button, action): logger.debug("Action set to %s", action) self.action = action def on_response(self, _widget, response): logger.debug("Dialog response %s", response) if response == Gtk.ResponseType.CANCEL: self.action = None self.destroy() class LaunchConfigSelectDialog(ModalDialog): def __init__(self, game, configs, title, parent=None): super().__init__(title=title, parent=parent, border_width=10) self.config_index = 0 self.dont_show_again = False self.confirmed = False self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.connect("response", self.on_response) self.set_size_request(320, 120) vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 6) self.get_content_area().add(vbox) primary_game_radio = Gtk.RadioButton.new_with_label_from_widget(None, game.name) primary_game_radio.connect("toggled", self.on_button_toggled, 0) vbox.pack_start(primary_game_radio, False, False, 0) for i, config in enumerate(configs): _button = Gtk.RadioButton.new_from_widget(primary_game_radio) _button.set_label(config["name"]) _button.connect("toggled", self.on_button_toggled, i + 1) vbox.pack_start(_button, False, False, 0) dont_show_checkbutton = Gtk.CheckButton(_("Do not ask again for this game.")) dont_show_checkbutton.connect("toggled", self.on_dont_show_checkbutton_toggled) vbox.pack_end(dont_show_checkbutton, False, False, 6) self.show_all() self.run() def on_button_toggled(self, _button, index): self.config_index = index def on_dont_show_checkbutton_toggled(self, _button): self.dont_show_again = _button.get_active() def on_response(self, _widget, response): self.confirmed = response == Gtk.ResponseType.OK self.destroy() class ClientLoginDialog(GtkBuilderDialog): glade_file = "dialog-lutris-login.ui" dialog_object = "lutris-login" __gsignals__ = { "connected": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,)), "cancel": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,)), } def __init__(self, parent): super().__init__(parent=parent) self.parent = parent self.username_entry = self.builder.get_object("username_entry") self.password_entry = self.builder.get_object("password_entry") cancel_button = self.builder.get_object("cancel_button") cancel_button.connect("clicked", self.on_close) connect_button = self.builder.get_object("connect_button") connect_button.connect("clicked", self.on_connect) def get_credentials(self): username = self.username_entry.get_text() password = self.password_entry.get_text() return username, password def on_username_entry_activate(self, widget): # pylint: disable=unused-argument if all(self.get_credentials()): self.on_connect(None) else: self.password_entry.grab_focus() def on_password_entry_activate(self, widget): # pylint: disable=unused-argument if all(self.get_credentials()): self.on_connect(None) else: self.username_entry.grab_focus() def on_connect(self, widget): # pylint: disable=unused-argument username, password = self.get_credentials() token = api.connect(username, password) if not token: NoticeDialog(_("Login failed"), parent=self.parent) else: self.emit("connected", username) self.dialog.destroy() class InstallerSourceDialog(ModelessDialog): """Show install script source""" def __init__(self, code, name, parent): super().__init__(title=_("Install script for {}").format(name), parent=parent, border_width=0) self.set_size_request(500, 350) ok_button = self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) ok_button.set_border_width(10) self.connect("response", self.on_response) self.scrolled_window = Gtk.ScrolledWindow() self.scrolled_window.set_hexpand(True) self.scrolled_window.set_vexpand(True) source_buffer = Gtk.TextBuffer() source_buffer.set_text(code) source_box = LogTextView(source_buffer, autoscroll=False) self.get_content_area().set_border_width(0) self.get_content_area().add(self.scrolled_window) self.scrolled_window.add(source_box) self.show_all() def on_response(self, *args): self.destroy() class WarningMessageDialog(Gtk.MessageDialog): def __init__(self, message, secondary_message="", parent=None): super().__init__(type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK, parent=parent) self.set_default_response(Gtk.ResponseType.OK) self.set_markup("%s" % message) if secondary_message: self.props.secondary_use_markup = True self.props.secondary_text = secondary_message self.run() self.destroy() class WineNotInstalledWarning(WarningMessageDialog): """Display a warning if Wine is not detected on the system""" def __init__(self, parent=None, cancellable=False): super().__init__( _("Wine is not installed on your system."), secondary_message=_( "Having Wine installed on your system guarantees that " "Wine builds from Lutris will have all required dependencies.\n\nPlease " "follow the instructions given in the Lutris Wiki to " "install Wine." ), parent=parent ) class MoveDialog(ModelessDialog): __gsignals__ = { "game-moved": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, game, destination, parent=None): super().__init__(parent=parent, border_width=24) self.game = game self.destination = destination self.new_directory = None self.set_size_request(320, 60) self.set_decorated(False) vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 12) label = Gtk.Label(_("Moving %s to %s..." % (game, destination))) vbox.add(label) self.progress = Gtk.ProgressBar(visible=True) self.progress.set_pulse_step(0.1) vbox.add(self.progress) self.get_content_area().add(vbox) GLib.timeout_add(125, self.show_progress) self.show_all() def move(self): AsyncCall(self._move_game, self.on_game_moved) def show_progress(self): self.progress.pulse() return True def _move_game(self): self.new_directory = self.game.move(self.destination) def on_game_moved(self, _result, error): if error: ErrorDialog(error, parent=self) self.emit("game-moved") self.destroy() class HumbleBundleCookiesDialog(ModalDialog): def __init__(self, parent=None): super().__init__(_("Humble Bundle Cookie Authentication"), parent) self.cookies_content = None self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.connect("response", self.on_response) self.set_size_request(640, 512) vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 6) self.get_content_area().add(vbox) label = Gtk.Label() label.set_markup(_( "Humble Bundle Authentication via cookie import\n" "\n" "In Firefox\n" "- Install the following extension: " "" "https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/" "\n" "- Open a tab to humblebundle.com and make sure you are logged in.\n" "- Click the cookie icon in the top right corner, next to the settings menu\n" "- Check 'Prefix HttpOnly cookies' and click 'humblebundle.com'\n" "- Open the generated file and paste the contents below. Click OK to finish.\n" "- You can delete the cookies file generated by Firefox\n" "- Optionally, " "open a support ticket to ask Humble Bundle to fix their configuration." )) vbox.pack_start(label, False, False, 24) self.textview = Gtk.TextView() self.textview.set_left_margin(12) self.textview.set_right_margin(12) scrolledwindow = Gtk.ScrolledWindow() scrolledwindow.set_hexpand(True) scrolledwindow.set_vexpand(True) scrolledwindow.add(self.textview) vbox.pack_start(scrolledwindow, True, True, 24) self.show_all() self.run() def on_response(self, _widget, response): if response == Gtk.ResponseType.CANCEL: self.cookies_content = None else: buffer = self.textview.get_buffer() self.cookies_content = buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True) self.destroy() lutris-0.5.14/lutris/gui/dialogs/cache.py000066400000000000000000000042371451435154700203070ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris.cache import get_cache_path, save_cache_path from lutris.gui.dialogs import ModalDialog from lutris.gui.widgets.common import FileChooserEntry class CacheConfigurationDialog(ModalDialog): def __init__(self, parent=None): super().__init__( _("Download cache configuration"), parent=parent, flags=Gtk.DialogFlags.MODAL, border_width=10 ) self.timer_id = None self.set_size_request(480, 150) self.cache_path = get_cache_path() or "" self.get_content_area().add(self.get_cache_config()) self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.show_all() result = self.run() if result == Gtk.ResponseType.OK: save_cache_path(self.cache_path) self.destroy() def get_cache_config(self): """Return the widgets for the cache configuration""" prefs_box = Gtk.VBox() box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Gtk.Label(_("Cache path")) box.pack_start(label, False, False, 0) path_chooser = FileChooserEntry( title=_("Set the folder for the cache path"), action=Gtk.FileChooserAction.SELECT_FOLDER, text=self.cache_path, activates_default=True ) path_chooser.connect("changed", self._on_cache_path_set) box.pack_start(path_chooser, True, True, 0) prefs_box.pack_start(box, False, False, 6) cache_help_label = Gtk.Label(visible=True) cache_help_label.set_size_request(400, -1) cache_help_label.set_markup(_( "If provided, this location will be used by installers to cache " "downloaded files locally for future re-use. \nIf left empty, the " "installer files are discarded after the install completion." )) prefs_box.pack_start(cache_help_label, False, False, 6) return prefs_box def _on_cache_path_set(self, entry): self.cache_path = entry.get_text() lutris-0.5.14/lutris/gui/dialogs/delegates.py000066400000000000000000000173321451435154700212010ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gdk, Gtk from lutris.exceptions import UnavailableRunnerError from lutris.game import Game from lutris.gui import dialogs from lutris.gui.dialogs.download import DownloadDialog from lutris.runners import wine from lutris.util.downloader import Downloader class LaunchUIDelegate: """These objects provide UI for the game while it is being launched; one provided to the launch() method. The default implementation provides no UI and makes default choices for the user, but DialogLaunchUIDelegate implements this to show dialogs and ask the user questions. Windows then inherit from DialogLaunchUIDelegate. If these methods throw any errors are reported via tha game-error signal; that is not part of this delegate because errors can be report outside of the launch() method, where no delegate is available. """ def check_game_launchable(self, game): """See if the game can be launched. If there are adverse conditions, this can warn the user and ask whether to launch. If this returs False, the launch is cancelled. The default is to return True with no actual checks. """ if not game.runner.is_installed(): raise UnavailableRunnerError("The required runner '%s' is not installed." % game.runner.name) return True def select_game_launch_config(self, game): """Prompt the user for which launch config to use. Returns None if the user cancelled, an empty dict for the primary game configuration and the launch_config as a dict if one is selected. The default is the select the primary game. """ return {} # primary game class InstallUIDelegate: """These objects provide UI for a runner as it is installing itself. One of these must be provided to the install() method. The default implementation provides no UI and makes default choices for the user, but DialogInstallUIDelegate implements this to show dialogs and ask the user questions. Windows then inherit from DialogLaunchUIDelegate. """ def show_install_yesno_inquiry(self, question, title): """Called to ask the user a yes/no question. The default is 'yes'.""" return True def show_install_file_inquiry(self, question, title, message): """Called to ask the user for a file. Lutris first asks the user the question given (showing the title); if the user answers 'Yes', it asks for the file using the message. Returns None if the user answers 'No' or cancels out. Returns the file path if the user selected one. The default is to return None always. """ return None def download_install_file(self, url, destination): """Downloads a file from a URL to a destination, overwriting any file at that path. Returns True if sucessful, and False if the user cancels. The default is to download with no UI, and no option to cancel. """ downloader = Downloader(url, destination, overwrite=True) downloader.start() return downloader.join() class CommandLineUIDelegate(LaunchUIDelegate): """This delegate can provide user selections that were provided on the command line.""" def __init__(self, launch_config_name): self.launch_config_name = launch_config_name def select_game_launch_config(self, game): if not self.launch_config_name: return {} game_config = game.config.game_level.get("game", {}) configs = game_config.get("launch_configs") for config in configs: if config.get("name") == self.launch_config_name: return config raise RuntimeError("The launch configuration '%s' could not be found." % self.launch_config_name) class DialogInstallUIDelegate(InstallUIDelegate): """This provides UI for runner installation via dialogs.""" def show_install_yesno_inquiry(self, question, title): dialog = dialogs.QuestionDialog({ "parent": self, "question": question, "title": title, }) return Gtk.ResponseType.YES == dialog.result def show_install_file_inquiry(self, question, title, message): dlg = dialogs.QuestionDialog({ "parent": self, "question": question, "title": title, }) if dlg.result == dlg.YES: dlg = dialogs.FileDialog(message) return dlg.filename def download_install_file(self, url, destination): dialog = DownloadDialog(url, destination, parent=self) dialog.run() return dialog.downloader.state == Downloader.COMPLETED class DialogLaunchUIDelegate(LaunchUIDelegate): """This provides UI for game launch via dialogs.""" def check_game_launchable(self, game): if not game.runner.is_installed(): installed = game.runner.install_dialog(self) if not installed: return False if "wine" in game.runner_name and not wine.get_system_wine_version(): dialogs.WineNotInstalledWarning(parent=self) return False return True def select_game_launch_config(self, game): game_config = game.config.game_level.get("game", {}) configs = game_config.get("launch_configs") def get_preferred_config_index(): # Validate that the settings are still valid; we need the index to # cope when two configs have the same name but we insist on a name # match. Returns None if it can't find a match, and then the user # must decide. preferred_name = game_config.get("preferred_launch_config_name") preferred_index = game_config.get("preferred_launch_config_index") if preferred_index == 0 or preferred_name == Game.PRIMARY_LAUNCH_CONFIG_NAME: return 0 if preferred_name: if preferred_index: try: if configs[preferred_index - 1].get("name") == preferred_name: return preferred_index except IndexError: pass for index, config in enumerate(configs): if config.get("name") == preferred_name: return index + 1 return None def save_preferred_config(index): name = configs[index - 1].get("name") if index > 0 else Game.PRIMARY_LAUNCH_CONFIG_NAME game_config["preferred_launch_config_index"] = index game_config["preferred_launch_config_name"] = name game.config.save() def reset_preferred_config(): game_config.pop("preferred_launch_config_index", None) game_config.pop("preferred_launch_config_name", None) game.config.save() if not configs: return {} # use primary configuration keymap = Gdk.Keymap.get_default() if keymap.get_modifier_state() & Gdk.ModifierType.SHIFT_MASK: config_index = None else: config_index = get_preferred_config_index() if config_index is None: dlg = dialogs.LaunchConfigSelectDialog( game, configs, title=_("Select game to launch"), parent=self ) if not dlg.confirmed: return None # no error here- the user cancelled out config_index = dlg.config_index if dlg.dont_show_again: save_preferred_config(config_index) else: reset_preferred_config() return configs[config_index - 1] if config_index > 0 else {} lutris-0.5.14/lutris/gui/dialogs/download.py000066400000000000000000000027551451435154700210560ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris.gui.dialogs import ModalDialog from lutris.gui.widgets.download_progress_box import DownloadProgressBox class DownloadDialog(ModalDialog): """Dialog showing a download in progress.""" def __init__(self, url=None, dest=None, title=None, label=None, downloader=None, parent=None): super().__init__(title=title or _("Downloading file"), parent=parent, border_width=10) self.set_size_request(485, 104) params = {"url": url, "dest": dest, "title": label or _("Downloading %s") % url} self.dialog_progress_box = DownloadProgressBox(params, downloader=downloader) self.dialog_progress_box.connect("complete", self.download_complete) self.dialog_progress_box.connect("cancel", self.download_cancelled) self.connect("response", self.on_response) self.get_content_area().add(self.dialog_progress_box) self.show_all() self.dialog_progress_box.start() @property def downloader(self): return self.dialog_progress_box.downloader def download_complete(self, _widget, _data): self.response(Gtk.ResponseType.OK) self.destroy() def download_cancelled(self, _widget): self.response(Gtk.ResponseType.CANCEL) self.destroy() def on_response(self, _dialog, response): if response == Gtk.ResponseType.DELETE_EVENT: self.dialog_progress_box.downloader.cancel() self.destroy() lutris-0.5.14/lutris/gui/dialogs/game_import.py000066400000000000000000000244471451435154700215540ustar00rootroot00000000000000from collections import OrderedDict from copy import deepcopy from gettext import gettext as _ from gi.repository import GLib, Gtk from lutris.config import write_game_config from lutris.database.games import add_game from lutris.game import Game from lutris.gui.dialogs import ModelessDialog from lutris.scanners.default_installers import DEFAULT_INSTALLERS from lutris.scanners.lutris import get_path_cache from lutris.scanners.tosec import clean_rom_name, guess_platform, search_tosec_by_md5 from lutris.services.lutris import download_lutris_media from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.strings import gtk_safe, slugify from lutris.util.system import get_md5_hash, get_md5_in_zip class ImportGameDialog(ModelessDialog): def __init__(self, files, parent=None) -> None: super().__init__( _("Import a game"), parent=parent, border_width=10 ) self.files = files self.progress_labels = {} self.checksum_labels = {} self.description_labels = {} self.category_labels = {} self.error_labels = {} self.launch_buttons = {} self.platform = None self.search_call = None self.set_size_request(500, 560) self.accelerators = Gtk.AccelGroup() self.add_accel_group(self.accelerators) scrolledwindow = Gtk.ScrolledWindow(child=self.get_file_labels_listbox(files)) scrolledwindow.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) frame = Gtk.Frame( shadow_type=Gtk.ShadowType.ETCHED_IN, child=scrolledwindow) self.get_content_area().pack_start(frame, True, True, 6) self.close_button = self.add_button(Gtk.STOCK_STOP, Gtk.ResponseType.CANCEL) key, mod = Gtk.accelerator_parse("Escape") self.close_button.add_accelerator("clicked", self.accelerators, key, mod, Gtk.AccelFlags.VISIBLE) self.connect("response", self.on_response) self.show_all() self.search_call = AsyncCall(self.search_checksums, self.search_result_finished) def on_response(self, _widget, response): if response in (Gtk.ResponseType.CLOSE, Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): if self.search_call: self.search_call.stop_request.set() else: self.destroy() def get_file_labels_listbox(self, files): listbox = Gtk.ListBox(vexpand=True) listbox.set_selection_mode(Gtk.SelectionMode.NONE) for file_path in files: row = Gtk.ListBoxRow() hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) hbox.set_margin_left(12) hbox.set_margin_right(12) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) description_label = Gtk.Label(halign=Gtk.Align.START) vbox.pack_start(description_label, True, True, 5) self.description_labels[file_path] = description_label file_path_label = Gtk.Label(file_path, halign=Gtk.Align.START, xalign=0) file_path_label.set_line_wrap(True) vbox.pack_start(file_path_label, True, True, 5) progress_label = Gtk.Label(halign=Gtk.Align.START) vbox.pack_start(progress_label, True, True, 5) self.progress_labels[file_path] = progress_label checksum_label = Gtk.Label(no_show_all=True, halign=Gtk.Align.START) vbox.pack_start(checksum_label, True, True, 5) self.checksum_labels[file_path] = checksum_label category_label = Gtk.Label(no_show_all=True, halign=Gtk.Align.START) vbox.pack_start(category_label, True, True, 5) self.category_labels[file_path] = category_label error_label = Gtk.Label(no_show_all=True, halign=Gtk.Align.START, xalign=0) error_label.set_line_wrap(True) vbox.pack_start(error_label, True, True, 5) self.error_labels[file_path] = error_label hbox.pack_start(vbox, True, True, 0) launch_button = Gtk.Button(_("Launch"), valign=Gtk.Align.CENTER, sensitive=False) hbox.pack_end(launch_button, False, False, 0) self.launch_buttons[file_path] = launch_button row.add(hbox) listbox.add(row) return listbox @property def search_stopping(self): return self.search_call and self.search_call.stop_request.is_set() def search_checksums(self): game_path_cache = get_path_cache() def show_progress(filepath, message): # It's not safe to directly update labels from a worker thread, so # this will do it on the GUI main thread instead. GLib.idle_add(lambda: self.progress_labels[filepath].set_markup("%s" % gtk_safe(message))) def get_existing_game(filepath): for game_id, game_path in game_path_cache.items(): if game_path == filepath: return Game(game_id) return None def search_single(filepath): existing_game = get_existing_game(filepath) if existing_game: # Found a game to launch instead of installing, but we can't safely # do this on this thread, so we return the game and handle it later. return [{"name": existing_game.name, "game": existing_game, "roms": []}] show_progress(filepath, _("Calculating checksum...")) if filepath.lower().endswith(".zip"): md5 = get_md5_in_zip(filepath) else: md5 = get_md5_hash(filepath) if self.search_stopping: return None show_progress(filename, _("Looking up checksum on Lutris.net...")) result = search_tosec_by_md5(md5) if not result: raise RuntimeError(_("This ROM could not be identified.")) return result results = OrderedDict() # must preserve order, on any Python version for filename in self.files: if self.search_stopping: break try: show_progress(filename, _("Looking for installed game...")) result = search_single(filename) except Exception as error: result = [{"error": error, "roms": []}] finally: show_progress(filename, "") if result: results[filename] = result return results def search_result_finished(self, results, error): self.search_call = None self.close_button.set_label(Gtk.STOCK_CLOSE) if error: logger.error(error) return for filename, result in results.items(): for rom_set in result: if self.import_rom(rom_set, filename): break def import_rom(self, rom_set, filename): """Tries to install a specific ROM, or reports failure. Returns True if successful, False if not.""" try: self.progress_labels[filename].hide() if "error" in rom_set: raise rom_set["error"] if "game" in rom_set: game = rom_set["game"] self.display_existing_game_info(filename, game) self.enable_game_launch(filename, game) return True for rom in rom_set["roms"]: self.display_new_game_info(filename, rom_set, rom["md5"]) game_id = self.add_game(rom_set, filename) game = Game(game_id) game.emit("game-installed") game.emit("game-updated") self.enable_game_launch(filename, game) return True except Exception as ex: logger.exception(_("Failed to import a ROM: %s"), ex) error_label = self.error_labels[filename] error_label.set_markup( "%s" % gtk_safe(str(ex))) error_label.show() return False def enable_game_launch(self, filename, game): launch_button = self.launch_buttons[filename] launch_button.set_sensitive(True) launch_button.connect("clicked", self.on_launch_clicked, game) def on_launch_clicked(self, _button, game): game.emit("game-launch") self.destroy() def display_existing_game_info(self, filename, game): label = self.checksum_labels[filename] label.set_markup("%s" % _("Game already installed in Lutris")) label.show() label = self.description_labels[filename] label.set_markup("%s" % game.name) category = game.platform label = self.category_labels[filename] label.set_text(category) label.show() def display_new_game_info(self, filename, rom_set, checksum): label = self.checksum_labels[filename] label.set_text(checksum) label.show() label = self.description_labels[filename] label.set_markup("%s" % rom_set["name"]) category = rom_set["category"]["name"] label = self.category_labels[filename] label.set_text(category) label.show() self.platform = guess_platform(rom_set) if not self.platform: raise RuntimeError(_("The platform '%s' is unknown to Lutris.") % category) def add_game(self, rom_set, filepath): name = clean_rom_name(rom_set["name"]) logger.info("Installing %s", name) try: installer = deepcopy(DEFAULT_INSTALLERS[self.platform]) except KeyError as error: raise RuntimeError( _("Lutris does not have a default installer for the '%s' platform.") % self.platform) from error for key, value in installer["game"].items(): if value == "rom": installer["game"][key] = filepath slug = slugify(name) configpath = write_game_config(slug, installer) game_id = add_game( name=name, runner=installer["runner"], slug=slug, directory="", installed=1, installer_slug="%s-%s" % (slug, installer["runner"]), configpath=configpath ) download_lutris_media(slug) return game_id lutris-0.5.14/lutris/gui/dialogs/issue.py000066400000000000000000000062021451435154700203660ustar00rootroot00000000000000"""GUI dialog for reporting issues""" import json import os from gettext import gettext as _ from gi.repository import Gtk from lutris.gui.dialogs import NoticeDialog from lutris.gui.widgets.window import BaseApplicationWindow from lutris.util.linux import gather_system_info class IssueReportWindow(BaseApplicationWindow): """Window for collecting and sending issue reports""" def __init__(self, application): super().__init__(application) self.title_label = Gtk.Label(visible=True) self.vbox.add(self.title_label) title_label = Gtk.Label() title_label.set_markup(_("Submit an issue")) self.vbox.add(title_label) self.vbox.add(Gtk.HSeparator()) issue_entry_label = Gtk.Label(_( "Describe the problem you're having in the text box below. " "This information will be sent the Lutris team along with your system information. " "You can also save this information locally if you are offline." )) issue_entry_label.set_max_width_chars(80) issue_entry_label.set_property("wrap", True) self.vbox.add(issue_entry_label) self.textview = Gtk.TextView() self.textview.set_pixels_above_lines(12) self.textview.set_pixels_below_lines(12) self.textview.set_left_margin(12) self.textview.set_right_margin(12) self.vbox.pack_start(self.textview, True, True, 0) self.action_buttons = Gtk.Box(spacing=6) action_buttons_alignment = Gtk.Alignment.new(1, 0, 0, 0) action_buttons_alignment.add(self.action_buttons) self.vbox.pack_start(action_buttons_alignment, False, True, 0) cancel_button = self.get_action_button(_("C_ancel"), handler=self.on_destroy) self.action_buttons.add(cancel_button) save_button = self.get_action_button(_("_Save"), handler=self.on_save) self.action_buttons.add(save_button) self.show_all() def get_issue_info(self): buffer = self.textview.get_buffer() return { 'comment': buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True), 'system': gather_system_info() } def on_save(self, _button): """Signal handler for the save button""" save_dialog = Gtk.FileChooserNative.new( _("Select a location to save the issue"), self, Gtk.FileChooserAction.SELECT_FOLDER, _("_OK"), _("_Cancel"), ) save_dialog.connect("response", self.on_folder_selected, save_dialog) save_dialog.show() def on_folder_selected(self, dialog, response, _dialog): if response != Gtk.ResponseType.ACCEPT: return target_path = dialog.get_filename() if not target_path: return issue_path = os.path.join(target_path, "lutris-issue-report.json") issue_info = self.get_issue_info() with open(issue_path, "w", encoding='utf-8') as issue_file: json.dump(issue_info, issue_file, indent=2) dialog.destroy() NoticeDialog(_("Issue saved in %s") % issue_path) self.destroy() lutris-0.5.14/lutris/gui/dialogs/log.py000066400000000000000000000055501451435154700200240ustar00rootroot00000000000000"""Window to show game logs""" import os from datetime import datetime from gettext import gettext as _ from gi.repository import Gdk, GObject, Gtk from lutris.game import Game from lutris.gui.dialogs import FileDialog from lutris.gui.widgets.log_text_view import LogTextView from lutris.util import datapath class LogWindow(GObject.Object): def __init__(self, game, buffer, application=None): super().__init__() ui_filename = os.path.join(datapath.get(), "ui/log-window.ui") builder = Gtk.Builder() builder.add_from_file(ui_filename) builder.connect_signals(self) self.window = builder.get_object("log_window") self.game_id = game.id self.title = _("Log for {}").format(game) self.window.set_title(self.title) self.buffer = buffer self.logtextview = LogTextView(self.buffer) scrolled_window = builder.get_object("scrolled_window") scrolled_window.add(self.logtextview) self.search_entry = builder.get_object("search_entry") self.search_entry.connect("search-changed", self.logtextview.find_first) self.search_entry.connect("next-match", self.logtextview.find_next) self.search_entry.connect("previous-match", self.logtextview.find_previous) save_button = builder.get_object("save_button") save_button.connect("clicked", self.on_save_clicked) self.window.connect("key-press-event", self.on_key_press_event) self.window.connect("destroy", self.on_destroy) self.game_removed_hook_id = GObject.add_emission_hook(Game, "game-removed", self.on_game_removed) self.window.show_all() def on_key_press_event(self, widget, event): shift = event.state & Gdk.ModifierType.SHIFT_MASK if event.keyval == Gdk.KEY_Return: if shift: self.search_entry.emit("previous-match") else: self.search_entry.emit("next-match") def on_game_removed(self, game): if str(self.game_id) == str(game.id): self.window.destroy() def on_save_clicked(self, _button): """Handler to save log to a file""" now = datetime.now() log_filename = "%s (%s).log" % (self.title, now.strftime("%Y-%m-%d-%H-%M")) file_dialog = FileDialog( message="Save the logs to...", default_path=os.path.expanduser("~/%s" % log_filename), mode="save" ) log_path = file_dialog.filename if not log_path: return text = self.buffer.get_text( self.buffer.get_start_iter(), self.buffer.get_end_iter(), True ) with open(log_path, "w", encoding='utf-8') as log_file: log_file.write(text) def on_destroy(self, widget): GObject.remove_emission_hook(Game, "game-removed", self.game_removed_hook_id) lutris-0.5.14/lutris/gui/dialogs/runner_install.py000066400000000000000000000410111451435154700222720ustar00rootroot00000000000000"""Dialog used to install versions of a runner""" # pylint: disable=no-member import gettext import os import re from collections import defaultdict from gettext import gettext as _ from gi.repository import GLib, Gtk from lutris import api, settings from lutris.database.games import get_games_by_runner from lutris.game import Game from lutris.gui.dialogs import ErrorDialog, ModelessDialog from lutris.gui.widgets.utils import has_stock_icon from lutris.util import jobs, system from lutris.util.downloader import Downloader from lutris.util.extract import extract_archive from lutris.util.log import logger class ShowAppsDialog(ModelessDialog): def __init__(self, title, parent, runner_name, runner_version): super().__init__(title, parent, border_width=10) self.runner_name = runner_name self.runner_version = runner_version self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.set_default_size(600, 400) self.apps = [] label = Gtk.Label.new(_("Showing games using %s") % runner_version) self.vbox.add(label) scrolled_listbox = Gtk.ScrolledWindow() self.listbox = Gtk.ListBox() self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) scrolled_listbox.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled_listbox.set_shadow_type(Gtk.ShadowType.ETCHED_OUT) scrolled_listbox.add(self.listbox) self.vbox.pack_start(scrolled_listbox, True, True, 14) self.show_all() jobs.AsyncCall(self.load_apps, self.on_apps_loaded) def load_apps(self): runner_games = get_games_by_runner(self.runner_name) for db_game in runner_games: if not db_game["installed"]: continue game = Game(db_game["id"]) version = game.config.runner_config["version"] if version != self.runner_version: continue self.apps.append(game) def on_apps_loaded(self, _result, _error): for app in self.apps: row = Gtk.ListBoxRow(visible=True) hbox = Gtk.Box(visible=True, orientation=Gtk.Orientation.HORIZONTAL) lbl_game = Gtk.Label(app.name, visible=True) lbl_game.set_halign(Gtk.Align.START) hbox.pack_start(lbl_game, True, True, 5) row.add(hbox) self.listbox.add(row) class RunnerInstallDialog(ModelessDialog): """Dialog displaying available runner version and downloads them""" COL_VER = 0 COL_ARCH = 1 COL_URL = 2 COL_INSTALLED = 3 COL_PROGRESS = 4 COL_USAGE = 5 INSTALLED_ICON_NAME = "software-installed-symbolic" \ if has_stock_icon("software-installed-symbolic") else "wine-symbolic" def __init__(self, title, parent, runner): super().__init__(title, parent, 0, border_width=10) self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.runner_name = runner.name self.runner_directory = runner.directory self.runner_info = {} self.installing = {} self.set_default_size(640, 480) self.runners = [] self.listbox = None label = Gtk.Label.new(_("Waiting for response from %s") % settings.SITE_URL) self.vbox.pack_start(label, False, False, 18) spinner = Gtk.Spinner(visible=True) spinner.start() self.vbox.pack_start(spinner, False, False, 18) self.show_all() self.runner_store = Gtk.ListStore(str, str, str, bool, int, int) jobs.AsyncCall(api.get_runners, self.runner_fetch_cb, self.runner_name) def runner_fetch_cb(self, runner_info, error): """Clear the box and display versions from runner_info""" if error: logger.error(error) ErrorDialog(_("Unable to get runner versions: %s") % error) return self.runner_info = runner_info remote_versions = {(v["version"], v["architecture"]) for v in self.runner_info["versions"]} local_versions = self.get_installed_versions() for local_version in local_versions - remote_versions: self.runner_info["versions"].append({ "version": local_version[0], "architecture": local_version[1], "url": "", }) if not self.runner_info: ErrorDialog(_("Unable to get runner versions from lutris.net")) return for child_widget in self.vbox.get_children(): if child_widget.get_name() not in "GtkBox": child_widget.destroy() label = Gtk.Label.new(_("%s version management") % self.runner_info["name"]) self.vbox.add(label) self.installing = {} self.connect("response", self.on_destroy) scrolled_listbox = Gtk.ScrolledWindow() self.listbox = Gtk.ListBox() self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) scrolled_listbox.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled_listbox.set_shadow_type(Gtk.ShadowType.ETCHED_OUT) scrolled_listbox.add(self.listbox) self.vbox.pack_start(scrolled_listbox, True, True, 14) self.populate_store() self.show_all() self.populate_listboxrows(self.runner_store) def populate_listboxrows(self, store): for runner in store: row = Gtk.ListBoxRow() row.runner = runner hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) row.hbox = hbox icon = Gtk.Image.new_from_icon_name(self.INSTALLED_ICON_NAME, Gtk.IconSize.MENU) icon.set_visible(runner[self.COL_INSTALLED]) icon_container = Gtk.Box() icon_container.set_size_request(16, 16) icon_container.pack_start(icon, False, False, 0) hbox.pack_start(icon_container, False, True, 0) row.icon = icon lbl_version = Gtk.Label(runner[self.COL_VER]) lbl_version.set_max_width_chars(20) lbl_version.set_property("width-chars", 20) lbl_version.set_halign(Gtk.Align.START) hbox.pack_start(lbl_version, False, False, 5) arch_label = Gtk.Label(runner[self.COL_ARCH]) arch_label.set_max_width_chars(8) arch_label.set_halign(Gtk.Align.START) hbox.pack_start(arch_label, False, True, 5) install_progress = Gtk.ProgressBar() install_progress.set_show_text(True) hbox.pack_end(install_progress, True, True, 5) row.install_progress = install_progress if runner[self.COL_INSTALLED]: # Check if there are apps installed, if so, show the view apps button app_count = runner[self.COL_USAGE] or 0 if app_count > 0: usage_button_text = gettext.ngettext( "View %d game", "View %d games", app_count ) % app_count usage_button = Gtk.LinkButton.new_with_label(usage_button_text) usage_button.set_valign(Gtk.Align.CENTER) usage_button.connect("clicked", self.on_show_apps_usage, row) hbox.pack_end(usage_button, False, True, 2) button = Gtk.Button() button.set_size_request(100, -1) hbox.pack_end(button, False, True, 0) hbox.reorder_child(button, 0) row.install_uninstall_cancel_button = button row.handler_id = None row.add(hbox) self.listbox.add(row) row.show_all() self.update_listboxrow(row) def update_listboxrow(self, row): row.install_progress.set_visible(False) runner = row.runner icon = row.icon icon.set_visible(runner[self.COL_INSTALLED]) button = row.install_uninstall_cancel_button style_context = button.get_style_context() if row.handler_id is not None: button.disconnect(row.handler_id) row.handler_id = None if runner[self.COL_VER] in self.installing: style_context.remove_class("destructive-action") button.set_label(_("Cancel")) handler_id = button.connect("clicked", self.on_cancel_install, row) else: if runner[self.COL_INSTALLED]: style_context.add_class("destructive-action") button.set_label(_("Uninstall")) handler_id = button.connect("clicked", self.on_uninstall_runner, row) else: style_context.remove_class("destructive-action") button.set_label(_("Install")) handler_id = button.connect("clicked", self.on_install_runner, row) row.install_uninstall_cancel_button = button row.handler_id = handler_id def on_show_apps_usage(self, _button, row): """Return grid with games that uses this wine version""" runner = row.runner runner_version = "%s-%s" % (runner[self.COL_VER], runner[self.COL_ARCH]) dialog = ShowAppsDialog(_("Wine version usage"), self.get_toplevel(), self.runner_name, runner_version) dialog.run() def populate_store(self): """Return a ListStore populated with the runner versions""" version_usage = self.get_usage_stats() ordered = sorted(self.runner_info["versions"], key=RunnerInstallDialog.get_version_sort_key) for version_info in reversed(ordered): is_installed = os.path.exists(self.get_runner_path(version_info["version"], version_info["architecture"])) games_using = version_usage.get("%(version)s-%(architecture)s" % version_info) self.runner_store.append( [ version_info["version"], version_info["architecture"], version_info["url"], is_installed, 0, len(games_using) if games_using else 0 ] ) @staticmethod def get_version_sort_key(version): """Generate a sorting key that sorts first on the version number part of the version, and which breaks the version number into its components, which are parsed as integers""" raw_version = version["version"] # Extract version numbers from the end of the version string. # We look for things like xx-7.2 or xxx-4.3-2. A leading period # will be part of the version, but a leading hyphen will not. match = re.search(r"^(.*?)\-?(\d[.\-\d]*)$", raw_version) if match: version_parts = [int(p) for p in match.group(2).replace("-", ".").split(".") if p] return version_parts, raw_version, version["architecture"] # If we fail to extract the version, we'll wind up sorting this one to the end. return [], raw_version, version["architecture"] def get_installed_versions(self): """List versions available locally""" if not os.path.exists(self.runner_directory): return set() return { tuple(p.rsplit("-", 1)) for p in os.listdir(self.runner_directory) if "-" in p } def get_runner_path(self, version, arch): """Return the local path where the runner is/will be installed""" return os.path.join(self.runner_directory, "{}-{}".format(version, arch)) def get_dest_path(self, row): """Return temporary path where the runners should be downloaded to""" return os.path.join(settings.CACHE_DIR, os.path.basename(row[self.COL_URL])) def on_cancel_install(self, widget, row): self.cancel_install(row) def cancel_install(self, row): """Cancel the installation of a runner version""" runner = row.runner self.installing[runner[self.COL_VER]].cancel() self.uninstall_runner(row) runner[self.COL_PROGRESS] = 0 self.installing.pop(runner[self.COL_VER]) self.update_listboxrow(row) row.install_progress.set_visible(False) def on_uninstall_runner(self, widget, row): self.uninstall_runner(row) def uninstall_runner(self, row): """Uninstall a runner version""" runner = row.runner version = runner[self.COL_VER] arch = runner[self.COL_ARCH] system.remove_folder(self.get_runner_path(version, arch)) runner[self.COL_INSTALLED] = False if self.runner_name == "wine": logger.debug("Clearing wine version cache") from lutris.util.wine.wine import get_installed_wine_versions get_installed_wine_versions.cache_clear() self.update_listboxrow(row) def on_install_runner(self, _widget, row): self.install_runner(row) def install_runner(self, row): """Download and install a runner version""" runner = row.runner row.install_progress.set_fraction(0.0) dest_path = self.get_dest_path(runner) url = runner[self.COL_URL] if not url: ErrorDialog(_("Version %s is not longer available") % runner[self.COL_VER]) return downloader = Downloader(url, dest_path, overwrite=True) GLib.timeout_add(100, self.get_progress, downloader, row) self.installing[runner[self.COL_VER]] = downloader downloader.start() self.update_listboxrow(row) def get_progress(self, downloader, row): """Update progress bar with download progress""" runner = row.runner if downloader.state == downloader.CANCELLED: return False if downloader.state == downloader.ERROR: self.cancel_install(row) return False row.install_progress.show() downloader.check_progress() percent_downloaded = downloader.progress_percentage if percent_downloaded >= 1: runner[self.COL_PROGRESS] = percent_downloaded row.install_progress.set_fraction(percent_downloaded / 100) else: runner[self.COL_PROGRESS] = 1 row.install_progress.pulse() row.install_progress.set_text = _("Downloading…") if downloader.state == downloader.COMPLETED: runner[self.COL_PROGRESS] = 99 row.install_progress.set_text = _("Extracting…") self.on_runner_downloaded(row) return False return True def progress_pulse(self, row): runner = row.runner row.install_progress.pulse() return not runner[self.COL_INSTALLED] def get_usage_stats(self): """Return the usage for each version""" runner_games = get_games_by_runner(self.runner_name) version_usage = defaultdict(list) for db_game in runner_games: if not db_game["installed"]: continue game = Game(db_game["id"]) version = game.config.runner_config["version"] version_usage[version].append(db_game["id"]) return version_usage def on_runner_downloaded(self, row): """Handler called when a runner version is downloaded""" runner = row.runner version = runner[self.COL_VER] architecture = runner[self.COL_ARCH] logger.debug("Runner %s for %s has finished downloading", version, architecture) src = self.get_dest_path(runner) dst = self.get_runner_path(version, architecture) GLib.timeout_add(100, self.progress_pulse, row) jobs.AsyncCall(self.extract, self.on_extracted, src, dst, row) @staticmethod def extract(src, dst, row): """Extract a runner archive to a destination""" extract_archive(src, dst) return src, row def on_extracted(self, row_info, error): """Called when a runner archive is extracted""" if error or not row_info: ErrorDialog(_("Failed to retrieve the runner archive"), parent=self) return src, row = row_info runner = row.runner os.remove(src) runner[self.COL_PROGRESS] = 0 runner[self.COL_INSTALLED] = True self.installing.pop(runner[self.COL_VER]) row.install_progress.set_text = "" row.install_progress.set_fraction(0.0) row.install_progress.hide() self.update_listboxrow(row) if self.runner_name == "wine": logger.debug("Clearing wine version cache") from lutris.util.wine.wine import get_installed_wine_versions get_installed_wine_versions.cache_clear() def on_destroy(self, _dialog, _data=None): """Override delete handler to prevent closing while downloads are active""" if self.installing: return True self.destroy() return True lutris-0.5.14/lutris/gui/dialogs/uninstall_game.py000066400000000000000000000135241451435154700222450ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk, Pango from lutris.database.games import get_games from lutris.game import Game from lutris.gui.dialogs import ModalDialog, QuestionDialog from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.strings import gtk_safe, human_size from lutris.util.system import get_disk_size, is_removeable, path_exists, reverse_expanduser class UninstallGameDialog(ModalDialog): def __init__(self, game_id, parent=None): super().__init__(parent=parent, border_width=10) self.set_size_request(640, 128) self.game = Game(game_id) self.delete_files = False self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.delete_button = self.add_default_button(_("Uninstall"), Gtk.ResponseType.OK, css_class="destructive-action") self.connect("response", self.on_response) container = Gtk.VBox(visible=True) self.get_content_area().add(container) title_label = Gtk.Label(visible=True) title_label.set_line_wrap(True) title_label.set_alignment(0, 0.5) title_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) title_label.set_markup(_("Uninstall %s") % gtk_safe(self.game.name)) container.pack_start(title_label, False, False, 4) self.folder_label = Gtk.Label(visible=True) self.folder_label.set_alignment(0, 0.5) if not self.game.directory: self.folder_label.set_markup(_("No file will be deleted")) elif len(get_games(filters={"directory": self.game.directory})) > 1: self.folder_label.set_markup( _("The folder %s is used by other games and will be kept.") % self.game.directory) elif self.game.config and is_removeable(self.game.directory, self.game.config.system_config): self.delete_button.set_sensitive(False) self.folder_label.set_markup(_("Calculating size…")) AsyncCall(get_disk_size, self.folder_size_cb, self.game.directory) elif not path_exists(self.game.directory): self.folder_label.set_markup( _("%s does not exist.") % reverse_expanduser(self.game.directory) ) else: self.folder_label.set_markup( _("Content of %s are protected and will not be deleted.") % reverse_expanduser(self.game.directory) ) container.pack_start(self.folder_label, False, False, 4) self.confirm_delete_button = Gtk.CheckButton() self.confirm_delete_button.set_active(True) container.pack_start(self.confirm_delete_button, False, False, 4) def folder_size_cb(self, folder_size, error): if error: logger.error(error) return self.delete_files = True self.delete_button.set_sensitive(True) self.folder_label.hide() self.confirm_delete_button.show() self.confirm_delete_button.set_label( _("Delete %s (%s)") % ( reverse_expanduser(self.game.directory), human_size(folder_size) ) ) def on_response(self, _widget, response): if response == Gtk.ResponseType.OK: self.delete_button.set_sensitive(False) if not self.confirm_delete_button.get_active(): self.delete_files = False if self.delete_files and not hasattr(self.game.runner, "no_game_remove_warning"): dlg = QuestionDialog( { "parent": self, "question": _( "Please confirm.\nEverything under %s\n" "will be deleted." ) % gtk_safe(self.game.directory), "title": _("Permanently delete files?"), } ) if dlg.result != Gtk.ResponseType.YES: self.delete_button.set_sensitive(True) self.stop_emission_by_name("response") return if self.delete_files: self.folder_label.set_markup(_("Uninstalling game and deleting files...")) else: self.folder_label.set_markup(_("Uninstalling game...")) self.game.remove(self.delete_files) self.destroy() class RemoveGameDialog(ModalDialog): def __init__(self, game_id, parent=None): super().__init__(parent=parent, border_width=10) self.set_size_request(640, 128) self.game = Game(game_id) self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.remove_button = self.add_default_button(_("Remove"), Gtk.ResponseType.OK, css_class="destructive-action") self.connect("response", self.on_response) container = Gtk.VBox(visible=True) self.get_content_area().add(container) title_label = Gtk.Label(visible=True) title_label.set_line_wrap(True) title_label.set_alignment(0, 0.5) title_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) title_label.set_markup(_("Remove %s") % gtk_safe(self.game.name)) container.pack_start(title_label, False, False, 4) self.delete_label = Gtk.Label(visible=True) self.delete_label.set_alignment(0, 0.5) self.delete_label.set_markup( _("Completely remove %s from the library?\nAll play time will be lost.") % self.game) container.pack_start(self.delete_label, False, False, 4) def on_response(self, _widget, response): if response == Gtk.ResponseType.OK: self.remove_button.set_sensitive(False) self.game.delete() self.destroy() lutris-0.5.14/lutris/gui/dialogs/webconnect_dialog.py000066400000000000000000000121031451435154700227010ustar00rootroot00000000000000"""isort:skip_file""" import os from gettext import gettext as _ import gi try: gi.require_version("WebKit2", "4.1") except ValueError: gi.require_version("WebKit2", "4.0") from gi.repository import WebKit2 from lutris.gui.dialogs import ModalDialog DEFAULT_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0" class WebConnectDialog(ModalDialog): """Login form for external services""" def __init__(self, service, parent=None): self.context = WebKit2.WebContext.new() if "http_proxy" in os.environ: proxy = WebKit2.NetworkProxySettings.new(os.environ["http_proxy"]) self.context.set_network_proxy_settings(WebKit2.NetworkProxyMode.CUSTOM, proxy) WebKit2.CookieManager.set_persistent_storage( self.context.get_cookie_manager(), service.cookies_path, WebKit2.CookiePersistentStorage(0), ) self.service = service super().__init__(title=service.name, parent=parent) self.set_default_size(self.service.login_window_width, self.service.login_window_height) self.webview = WebKit2.WebView.new_with_context(self.context) self.webview.load_uri(service.login_url) self.webview.connect("load-changed", self.on_navigation) self.webview.connect("create", self.on_webview_popup) self.vbox.set_border_width(0) # pylint: disable=no-member self.vbox.pack_start(self.webview, True, True, 0) # pylint: disable=no-member webkit_settings = self.webview.get_settings() # Set a User Agent webkit_settings.set_user_agent(service.login_user_agent) # Allow popups (Doesn't work...) webkit_settings.set_allow_modal_dialogs(True) # Enable developer options for troubleshooting (Can be disabled in # releases) # webkit_settings.set_enable_write_console_messages_to_stdout(True) # webkit_settings.set_javascript_can_open_windows_automatically(True) webkit_settings.set_enable_developer_extras(True) webkit_settings.set_enable_webgl(False) # self.enable_inspector() self.show_all() def enable_inspector(self): """If you want a full blown Webkit inspector, call this""" # WARNING: For some reason this doesn't work as intended. # The inspector shows ups but it's impossible to interact with it # All inputs are blocked by the the webkit dialog. inspector = self.webview.get_inspector() inspector.show() def on_navigation(self, widget, load_event): if load_event == WebKit2.LoadEvent.FINISHED: url = widget.get_uri() if url in self.service.scripts: script = self.service.scripts[url] widget.run_javascript(script, None, None) return True if url.startswith(self.service.redirect_uri): if self.service.requires_login_page: resource = widget.get_main_resource() resource.get_data(None, self._get_response_data_finish, None) else: self.service.login_callback(url) self.destroy() return True def _get_response_data_finish(self, resource, result, user_data=None): html_response = resource.get_data_finish(result) self.service.login_callback(html_response) self.destroy() def on_webview_popup(self, widget, navigation_action): """Handles web popups created by this dialog's webview""" uri = navigation_action.get_request().get_uri() view = WebKit2.WebView.new_with_related_view(widget) popup_dialog = WebPopupDialog(view, uri, parent=self) popup_dialog.show() return view class WebPopupDialog(ModalDialog): """Dialog for handling web popups""" def __init__(self, webview, uri, parent=None): # pylint: disable=no-member self.parent = parent super().__init__(title=_('Loading...'), parent=parent) self.webview = webview self.webview.connect("ready-to-show", self.on_ready_webview) self.webview.connect("notify::title", self.on_available_webview_title) self.webview.connect("create", self.on_new_webview_popup) self.webview.connect("close", self.on_webview_close) self.webview.load_uri(uri) self.vbox.pack_start(self.webview, True, True, 0) self.vbox.set_border_width(0) self.set_default_size(390, 500) def on_ready_webview(self, webview): self.show_all() def on_available_webview_title(self, webview, gparamstring): self.set_title(webview.get_title()) def on_new_webview_popup(self, webview, navigation_action): """Handles web popups created by this dialog's webview""" uri = navigation_action.get_request().get_uri() view = WebKit2.WebView.new_with_related_view(webview) view.load_uri(uri) dialog = WebPopupDialog(view, uri, parent=self) dialog.show() return view def on_webview_close(self, webview): self.close() return True lutris-0.5.14/lutris/gui/installer/000077500000000000000000000000001451435154700172375ustar00rootroot00000000000000lutris-0.5.14/lutris/gui/installer/__init__.py000066400000000000000000000001361451435154700213500ustar00rootroot00000000000000class UnsupportedProvider(Exception): """Error that represents an unsupported provider""" lutris-0.5.14/lutris/gui/installer/file_box.py000066400000000000000000000236421451435154700214070ustar00rootroot00000000000000"""Widgets for the installer window""" import os from gettext import gettext as _ from gi.repository import GObject, Gtk from lutris.gui.installer import UnsupportedProvider from lutris.gui.installer.widgets import InstallerLabel from lutris.gui.widgets.common import FileChooserEntry from lutris.installer.installer_file_collection import InstallerFileCollection from lutris.installer.steam_installer import SteamInstaller from lutris.util import system from lutris.util.log import logger from lutris.util.strings import gtk_safe class InstallerFileBox(Gtk.VBox): """Container for an installer file downloader / selector""" __gsignals__ = { "file-available": (GObject.SIGNAL_RUN_FIRST, None, ()), "file-ready": (GObject.SIGNAL_RUN_FIRST, None, ()), "file-unready": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, installer_file): super().__init__() self.installer_file = installer_file self.cache_to_pga = self.installer_file.uses_pga_cache() self.started = False self.start_func = None self.stop_func = None self.state_label = None # Use this label to display status update self.set_margin_left(12) self.set_margin_right(12) self.provider = self.installer_file.default_provider self.file_provider_widget = None self.add(self.get_widgets()) @property def is_ready(self): """Whether the file is ready to be downloaded / fetched from its provider""" return self.installer_file.is_ready(self.provider) def get_download_progress(self): """Return the widget for the download progress bar""" download_progress = self.installer_file.create_download_progress_box() download_progress.connect("complete", self.on_download_complete) download_progress.connect("cancel", self.on_download_cancelled) download_progress.show() self.installer_file.remove_previous() return download_progress def get_file_provider_widget(self): """Return the widget used to track progress of file""" box = Gtk.VBox(spacing=6) if self.provider == "download": download_progress = self.get_download_progress() self.start_func = download_progress.start self.stop_func = download_progress.on_cancel_clicked box.pack_start(download_progress, False, False, 0) return box if self.provider == "pga": url_label = InstallerLabel("In cache: %s" % self.installer_file.get_label(), wrap=False) box.pack_start(url_label, False, False, 6) return box if self.provider == "user": user_label = InstallerLabel(gtk_safe(self.installer_file.human_url)) box.pack_start(user_label, False, False, 0) return box # InstallerFileCollection should not have steam provider if self.provider == "steam": if isinstance(self.installer_file, InstallerFileCollection): raise UnsupportedProvider( "Installer file is type InstallerFileCollection and do not support 'steam' provider") steam_installer = SteamInstaller(self.installer_file.url, self.installer_file.id) steam_installer.connect("steam-game-installed", self.on_download_complete) steam_installer.connect("steam-state-changed", self.on_state_changed) self.start_func = steam_installer.install_steam_game self.stop_func = steam_installer.stop_func steam_box = Gtk.HBox(spacing=6) info_box = Gtk.VBox(spacing=6) steam_label = InstallerLabel(_("Steam game {appid}").format( appid=steam_installer.appid )) info_box.add(steam_label) self.state_label = InstallerLabel("") info_box.add(self.state_label) steam_box.add(info_box) return steam_box raise ValueError("Invalid provider %s" % self.provider) def get_combobox_model(self): """"Return the combobox's model""" model = Gtk.ListStore(str, str) if "download" in self.installer_file.providers: model.append(["download", _("Download")]) if "pga" in self.installer_file.providers: model.append(["pga", _("Use Cache")]) if "steam" in self.installer_file.providers: model.append(["steam", _("Steam")]) if not (isinstance(self.installer_file, InstallerFileCollection) and self.installer_file.service == "amazon"): model.append(["user", _("Select File")]) return model def get_combobox(self): """Return the combobox widget to select file source""" combobox = Gtk.ComboBox.new_with_model(self.get_combobox_model()) combobox.set_id_column(0) renderer_text = Gtk.CellRendererText() combobox.pack_start(renderer_text, True) combobox.add_attribute(renderer_text, "text", 1) combobox.connect("changed", self.on_source_changed) combobox.set_active_id(self.provider) return combobox def replace_file_provider_widget(self): """Replace the file provider label and the source button with the actual widget""" self.file_provider_widget.destroy() widget_box = self.get_children()[0] if self.started: self.file_provider_widget = self.get_file_provider_widget() # Also remove the the source button for child in widget_box.get_children(): child.destroy() else: self.file_provider_widget = self.get_file_provider_label() widget_box.pack_start(self.file_provider_widget, True, True, 0) widget_box.reorder_child(self.file_provider_widget, 0) widget_box.show_all() def on_source_changed(self, combobox): """Change the source to a new provider, emit a new state""" tree_iter = combobox.get_active_iter() if tree_iter is None: return model = combobox.get_model() source = model[tree_iter][0] if source == self.provider: return self.provider = source self.replace_file_provider_widget() if self.provider == "user": self.emit("file-unready") else: self.emit("file-ready") def get_file_provider_label(self): """Return the label displayed before the download starts""" if self.provider == "user": box = Gtk.VBox(spacing=6) label = InstallerLabel(self.installer_file.get_label()) label.props.can_focus = True box.pack_start(label, False, False, 0) location_entry = FileChooserEntry( self.installer_file.human_url, Gtk.FileChooserAction.OPEN ) location_entry.connect("changed", self.on_location_changed) location_entry.show() box.pack_start(location_entry, False, False, 0) if self.installer_file.is_user_pga_caching_allowed: cache_option = Gtk.CheckButton(_("Cache file for future installations")) cache_option.set_active(self.cache_to_pga) cache_option.connect("toggled", self.on_user_file_cached) box.pack_start(cache_option, False, False, 0) return box return InstallerLabel(self.installer_file.get_label()) def get_widgets(self): """Return the widget with the source of the file and a way to change its source""" box = Gtk.HBox( spacing=12, margin_top=6, margin_bottom=6 ) self.file_provider_widget = self.get_file_provider_label() box.pack_start(self.file_provider_widget, True, True, 0) source_box = Gtk.HBox() source_box.props.valign = Gtk.Align.START box.pack_start(source_box, False, False, 0) aux_info = self.installer_file.auxiliary_info if aux_info: source_box.pack_start(InstallerLabel(aux_info), False, False, 0) source_box.pack_start(InstallerLabel(_("Source:")), False, False, 0) combobox = self.get_combobox() source_box.pack_start(combobox, False, False, 0) return box def on_location_changed(self, widget): """Open a file picker when the browse button is clicked""" file_path = os.path.expanduser(widget.get_text()) self.installer_file.override_dest_file(file_path) if system.path_exists(file_path): self.emit("file-ready") else: self.emit("file-unready") def on_user_file_cached(self, checkbutton): """Enable or disable caching of user provided files""" self.cache_to_pga = checkbutton.get_active() def on_state_changed(self, _widget, state): """Update the state label with a new state""" self.state_label.set_text(state) def start(self): """Starts the download of the file""" self.started = True self.installer_file.prepare() self.replace_file_provider_widget() if self.provider in ("pga", "user") and self.is_ready: self.emit("file-available") self.cache_file() return if self.start_func: return self.start_func() def cache_file(self): """Copy file to the PGA cache""" if self.cache_to_pga: self.installer_file.save_to_cache() def on_download_cancelled(self, downloader): """Handle cancellation of installers""" logger.error("Download from %s cancelled", downloader) downloader.set_retry_button() def on_download_complete(self, widget, _data=None): """Action called on a completed download.""" logger.info("Download completed") if isinstance(widget, SteamInstaller): self.installer_file.dest_file = widget.get_steam_data_path() else: self.cache_file() self.emit("file-available") lutris-0.5.14/lutris/gui/installer/files_box.py000066400000000000000000000106641451435154700215720ustar00rootroot00000000000000from gi.repository import GObject, Gtk from lutris.gui.installer.file_box import InstallerFileBox from lutris.util.log import logger class InstallerFilesBox(Gtk.ListBox): """List box presenting all files needed for an installer""" max_downloads = 3 __gsignals__ = { "files-ready": (GObject.SIGNAL_RUN_LAST, None, (bool, )), "files-available": (GObject.SIGNAL_RUN_LAST, None, ()) } def __init__(self): super().__init__() self.installer = None self.ready_files = set() self.available_files = set() self.installer_files_boxes = {} self._file_queue = [] def load_installer(self, installer): self.stop_all() self.installer = installer self.available_files.clear() self.ready_files.clear() self.installer_files_boxes.clear() self._file_queue.clear() for child in self.get_children(): child.destroy() for installer_file in installer.files: installer_file_box = InstallerFileBox(installer_file) installer_file_box.connect("file-ready", self.on_file_ready) installer_file_box.connect("file-unready", self.on_file_unready) installer_file_box.connect("file-available", self.on_file_available) self.installer_files_boxes[installer_file.id] = installer_file_box self.add(installer_file_box) if installer_file_box.is_ready: self.ready_files.add(installer_file.id) self.show_all() self.check_files_ready() def start_all(self): """Iterates through installer files while keeping the number of simultaneously downloaded files down to a maximum number""" if len(self.available_files) == len(self.installer.files): logger.info("All files remain available") self.emit("files-available") return started_downloads = 0 for file_id, file_entry in self.installer_files_boxes.items(): if file_id not in self.available_files: if file_entry.provider == "download": started_downloads += 1 if started_downloads <= self.max_downloads: file_entry.start() else: self._file_queue.append(file_id) else: file_entry.start() def stop_all(self): """Stops all ongoing files gathering. Iterates through installer files, and call the "stop" command if they've been started and not available yet. """ self._file_queue.clear() for file_id, file_box in self.installer_files_boxes.items(): if file_box.started and file_id not in self.available_files and file_box.stop_func is not None: file_box.stop_func() @property def is_ready(self): """Return True if all files are ready to be fetched""" return len(self.ready_files) == len(self.installer.files) def check_files_ready(self): """Checks if all installer files are ready and emit a signal if so""" self.emit("files-ready", self.is_ready) def on_file_ready(self, widget): """Fired when a file has a valid provider. If the file is user provided, it must set to a valid path. """ file_id = widget.installer_file.id self.ready_files.add(file_id) self.check_files_ready() def on_file_unready(self, widget): """Fired when a file can't be provided. Blocks the installer from continuing. """ file_id = widget.installer_file.id self.ready_files.discard(file_id) self.check_files_ready() def on_file_available(self, widget): """A new file is available""" file_id = widget.installer_file.id logger.debug("%s is available", file_id) self.available_files.add(file_id) if self._file_queue: next_file_id = self._file_queue.pop() self.installer_files_boxes[next_file_id].start() if len(self.available_files) == len(self.installer.files): logger.info("All files available") self.emit("files-available") def get_game_files(self): """Return a mapping of the local files usable by the interpreter""" files = {} for installer_file in self.installer.files: files.update(installer_file.get_dest_files_by_id()) return files lutris-0.5.14/lutris/gui/installer/script_box.py000066400000000000000000000067331451435154700217760ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris.gui.installer.widgets import InstallerLabel from lutris.util.strings import add_url_tags, gtk_safe class InstallerScriptBox(Gtk.VBox): """Box displaying the details of a script, with associated action buttons""" def __init__(self, script, parent=None, revealed=False): super().__init__() self.script = script self.parent = parent self.revealer = None self.set_margin_left(12) self.set_margin_right(12) box = Gtk.Box(spacing=12, margin_top=6, margin_bottom=6) box.pack_start(self.get_infobox(), True, True, 0) box.add(self.get_install_button()) self.add(box) self.add(self.get_revealer(revealed)) def get_rating(self): """Return a string representation of the API rating""" return "" def get_infobox(self): """Return the central information box""" info_box = Gtk.VBox(spacing=6) title_box = Gtk.HBox(spacing=6) runner_label = InstallerLabel("%s" % self.script["runner"]) runner_label.get_style_context().add_class("info-pill") title_box.pack_start(runner_label, False, False, 0) title_box.add(InstallerLabel("%s" % gtk_safe(self.script["version"]), selectable=True)) title_box.pack_start(InstallerLabel(""), True, True, 0) rating_label = InstallerLabel(self.get_rating(), selectable=True) rating_label.set_alignment(1, 0.5) title_box.pack_end(rating_label, False, False, 0) info_box.add(title_box) info_box.add(self.get_credits()) info_box.add(InstallerLabel(add_url_tags(self.script["description"]), selectable=True)) return info_box def get_revealer(self, revealed): """Return the revelaer widget""" self.revealer = Gtk.Revealer() box = Gtk.VBox(visible=True) box.add(self.get_notes()) self.revealer.add(box) self.revealer.set_reveal_child(revealed) return self.revealer def get_install_button(self): """Return the install button widget""" align = Gtk.Alignment() align.set(0, 0, 0, 0) install_button = Gtk.Button(_("Install")) install_button.connect("clicked", self.on_install_clicked) style_context = install_button.get_style_context() style_context.add_class("suggested-action") align.add(install_button) return align def get_notes(self): """Return the notes widget""" notes = self.script["notes"].strip() if not notes: return Gtk.Alignment() return self._get_installer_label(notes) def get_credits(self): credits_text = self.script.get("credits", "").strip() if not credits_text: return Gtk.Alignment() return self._get_installer_label(add_url_tags(credits_text)) def _get_installer_label(self, text): _label = InstallerLabel(text, selectable=True) _label.set_margin_top(12) _label.set_margin_bottom(12) _label.set_margin_right(12) _label.set_margin_left(12) return _label def reveal(self, reveal=True): """Show or hide the information in the revealer""" if self.revealer: self.revealer.set_reveal_child(reveal) def on_install_clicked(self, _widget): """Handler to notify the parent of the selected installer""" self.parent.emit("installer-selected", self.script["version"]) lutris-0.5.14/lutris/gui/installer/script_picker.py000066400000000000000000000016741451435154700224620ustar00rootroot00000000000000from gi.repository import GObject, Gtk from lutris.gui.installer.script_box import InstallerScriptBox class InstallerPicker(Gtk.ListBox): """List box to pick between several installers""" __gsignals__ = {"installer-selected": (GObject.SIGNAL_RUN_FIRST, None, (str, ))} def __init__(self, scripts): super().__init__() revealed = True for script in scripts: self.add(InstallerScriptBox(script, parent=self, revealed=revealed)) revealed = False # Only reveal the first installer. self.connect('row-selected', self.on_activate) self.show_all() @staticmethod def on_activate(widget, row): """Handler for hiding and showing the revealers in children""" for script_box_row in widget: script_box = script_box_row.get_children()[0] script_box.reveal(False) installer_row = row.get_children()[0] installer_row.reveal() lutris-0.5.14/lutris/gui/installer/widgets.py000066400000000000000000000011351451435154700212570ustar00rootroot00000000000000from gi.repository import Gtk, Pango class InstallerLabel(Gtk.Label): """A label for installers""" def __init__(self, text, wrap=True, selectable=False): super().__init__() if wrap: self.set_line_wrap(True) self.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) else: self.set_property("ellipsize", Pango.EllipsizeMode.MIDDLE) self.set_alignment(0, 0.5) self.set_margin_right(12) self.set_markup(text) self.props.can_focus = False self.set_tooltip_text(text) self.set_selectable(selectable) lutris-0.5.14/lutris/gui/installerwindow.py000066400000000000000000001151661451435154700210530ustar00rootroot00000000000000"""Window used for game installers""" # pylint: disable=too-many-lines import os from gettext import gettext as _ from gi.repository import Gio, GLib, Gtk from lutris.config import LutrisConfig from lutris.exceptions import watch_errors from lutris.game import Game from lutris.gui.dialogs import DirectoryDialog, ErrorDialog, InstallerSourceDialog, ModelessDialog, QuestionDialog from lutris.gui.dialogs.cache import CacheConfigurationDialog from lutris.gui.dialogs.delegates import DialogInstallUIDelegate from lutris.gui.installer.files_box import InstallerFilesBox from lutris.gui.installer.script_picker import InstallerPicker from lutris.gui.widgets.common import FileChooserEntry from lutris.gui.widgets.log_text_view import LogTextView from lutris.gui.widgets.navigation_stack import NavigationStack from lutris.installer import InstallationKind, get_installers, interpreter from lutris.installer.errors import MissingGameDependency, ScriptingError from lutris.installer.interpreter import ScriptInterpreter from lutris.util import xdgshortcuts from lutris.util.log import logger from lutris.util.steam import shortcut as steam_shortcut from lutris.util.strings import gtk_safe, human_size from lutris.util.system import is_removeable class InstallerWindow(ModelessDialog, DialogInstallUIDelegate, ScriptInterpreter.InterpreterUIDelegate): # pylint: disable=too-many-public-methods """GUI for the install process. This window is divided into pages; as you go through the install each page is created and displayed to you. You can also go back and visit previous pages again. Going *forward* triggers installation work- it does not all way until the very end. Most pages are defined by a load_X_page() function that initializes the page when arriving at it, and which presents it. But this uses a present_X_page() page that shows the page (and is used alone for the 'Back' button), and this in turn uses a create_X_page() function to create the page the first time it is visited. """ def __init__( self, installers, service=None, appid=None, installation_kind=InstallationKind.INSTALL, **kwargs ): ModelessDialog.__init__(self, use_header_bar=True, **kwargs) ScriptInterpreter.InterpreterUIDelegate.__init__(self, service, appid) self.set_default_size(740, 460) self.installers = installers self.config = {} self.install_in_progress = False self.install_complete = False self.interpreter = None self.installation_kind = installation_kind self.continue_handler = None self.accelerators = Gtk.AccelGroup() self.add_accel_group(self.accelerators) content_area = self.get_content_area() content_area.set_margin_top(18) content_area.set_margin_bottom(18) content_area.set_margin_right(18) content_area.set_margin_left(18) content_area.set_spacing(12) # Header labels self.status_label = InstallerWindow.MarkupLabel(no_show_all=True) content_area.pack_start(self.status_label, False, False, 0) # Header bar buttons self.back_button = self.add_start_button(_("Back"), self.on_back_clicked) self.back_button.set_no_show_all(True) key, mod = Gtk.accelerator_parse("Left") self.back_button.add_accelerator("clicked", self.accelerators, key, mod, Gtk.AccelFlags.VISIBLE) key, mod = Gtk.accelerator_parse("Home") self.accelerators.connect(key, mod, Gtk.AccelFlags.VISIBLE, self.on_navigate_home) self.cancel_button = self.add_start_button(_("Cancel"), self.on_cancel_clicked) self.get_header_bar().set_show_close_button(False) self.continue_button = self.add_end_button(_("_Continue")) # The cancel button doubles as 'Close' and 'Abort' depending on the state of the install key, mod = Gtk.accelerator_parse("Escape") self.cancel_button.add_accelerator("clicked", self.accelerators, key, mod, Gtk.AccelFlags.VISIBLE) # Navigation stack self.stack = NavigationStack(self.back_button, cancel_button=self.cancel_button) self.register_page_creators() content_area.pack_start(self.stack, True, True, 0) # Menu buttons menu_icon = Gtk.Image.new_from_icon_name("open-menu-symbolic", Gtk.IconSize.MENU) self.menu_button = Gtk.MenuButton(child=menu_icon) self.menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True, halign=Gtk.Align.END) self.menu_box.set_border_width(9) self.menu_box.set_spacing(3) self.menu_box.set_can_focus(False) self.menu_button.set_popover(Gtk.Popover(child=self.menu_box, can_focus=False, relative_to=self.menu_button)) self.get_header_bar().pack_end(self.menu_button) self.cache_button = self.add_menu_button(_("Configure download cache"), self.on_cache_clicked, tooltip=_( "Change where Lutris downloads game installer files.")) self.source_button = self.add_menu_button(_("View installer source"), self.on_source_clicked) # Pre-create some UI bits we need to refer to in several places. # (We lazy allocate more of it, but these are a pain.) self.extras_tree_store = Gtk.TreeStore( bool, # is selected? bool, # is inconsistent? str, # id str, # label ) self.location_entry = FileChooserEntry( "Select folder", Gtk.FileChooserAction.SELECT_FOLDER, warn_if_non_empty=True, warn_if_ntfs=True ) self.location_entry.connect("changed", self.on_location_entry_changed) self.installer_files_box = InstallerFilesBox() self.installer_files_box.connect("files-available", self.on_files_available) self.installer_files_box.connect("files-ready", self.on_files_ready) self.log_buffer = Gtk.TextBuffer() self.error_reporter = self.load_error_message_page self.load_choose_installer_page() # And... go! self.show_all() self.present() def add_start_button(self, label, handler=None, tooltip=None, sensitive=True): button = Gtk.Button.new_with_mnemonic(label) button.set_sensitive(sensitive) button.set_no_show_all(True) if tooltip: button.set_tooltip_text(tooltip) if handler: button.connect("clicked", handler) header_bar = self.get_header_bar() header_bar.pack_start(button) return button def add_end_button(self, label, handler=None, tooltip=None, sensitive=True): """Add a button to the action buttons box""" button = Gtk.Button.new_with_mnemonic(label) button.set_sensitive(sensitive) button.set_no_show_all(True) if tooltip: button.set_tooltip_text(tooltip) if handler: button.connect("clicked", handler) header_bar = self.get_header_bar() header_bar.pack_end(button) return button def add_menu_button(self, label, handler=None, tooltip=None, sensitive=True): """Add a button to the menu in the header bar""" button = Gtk.ModelButton(label, visible=True, xalign=0.0) button.set_sensitive(sensitive) button.set_no_show_all(True) if tooltip: button.set_tooltip_text(tooltip) if handler: button.connect("clicked", handler) self.menu_box.pack_start(button, False, False, 0) return button @watch_errors() def on_cache_clicked(self, _button): """Open the cache configuration dialog""" CacheConfigurationDialog(parent=self) @watch_errors() def on_back_clicked(self, _button): self.stack.navigate_back() @watch_errors() def on_navigate_home(self, _accel_group, _window, _keyval, _modifier): self.stack.navigate_home() def on_destroy(self, _widget, _data=None): self.on_cancel_clicked() @watch_errors() def on_cancel_clicked(self, _button=None): """Ask a confirmation before cancelling the installation, if it has started.""" if self.install_in_progress: widgets = [] remove_checkbox = Gtk.CheckButton.new_with_label(_("Remove game files")) if self.interpreter and self.interpreter.target_path and \ self.interpreter.game_dir_created and \ self.installation_kind == InstallationKind.INSTALL and \ is_removeable(self.interpreter.target_path, LutrisConfig().system_config): remove_checkbox.set_active(self.interpreter.game_dir_created) remove_checkbox.show() widgets.append(remove_checkbox) confirm_cancel_dialog = QuestionDialog( { "parent": self, "question": _("Are you sure you want to cancel the installation?"), "title": _("Cancel installation?"), "widgets": widgets } ) if confirm_cancel_dialog.result != Gtk.ResponseType.YES: logger.debug("User aborted installation cancellation") return self.installer_files_box.stop_all() if self.interpreter: self.interpreter.revert(remove_game_dir=remove_checkbox.get_active()) else: self.installer_files_box.stop_all() if self.interpreter: self.interpreter.cleanup() # still remove temporary downloads in any case self.destroy() @watch_errors() def on_source_clicked(self, _button): InstallerSourceDialog( self.interpreter.installer.script_pretty, self.interpreter.installer.game_name, self ) def on_watched_error(self, error): ErrorDialog(error, parent=self) self.stack.navigation_reset() def set_status(self, text): """Display a short status text.""" self.status_label.set_text(text) self.status_label.set_visible(bool(text)) def register_page_creators(self): self.stack.add_named_factory("choose_installer", self.create_choose_installer_page) self.stack.add_named_factory("destination", self.create_destination_page) self.stack.add_named_factory("installer_files", self.create_installer_files_page) self.stack.add_named_factory("extras", self.create_extras_page) self.stack.add_named_factory("spinner", self.create_spinner_page) self.stack.add_named_factory("log", self.create_log_page) self.stack.add_named_factory("nothing", lambda *x: Gtk.Box()) # Interpreter UI Delegate # # These methods are called from the ScriptInterpreter, and defer work until idle time # so the installation itself is not interrupted or paused for UI updates. def report_error(self, error): message = repr(error) GLib.idle_add(self.error_reporter, message) def report_status(self, status): GLib.idle_add(self.set_status, status) def attach_log(self, command): # Hook the log buffer right now, lest we miss updates. command.set_log_buffer(self.log_buffer) GLib.idle_add(self.load_log_page) def begin_disc_prompt(self, message, requires, installer, callback): GLib.idle_add(self.load_ask_for_disc_page, message, requires, installer, callback, ) def begin_input_menu(self, alias, options, preselect, callback): GLib.idle_add(self.load_input_menu_page, alias, options, preselect, callback) def report_finished(self, game_id, status): GLib.idle_add(self.load_finish_install_page, game_id, status) # Choose Installer Page # # This page offers a choice of installer scripts to run. def load_choose_installer_page(self): self.validate_scripts(self.installers) self.stack.navigate_to_page(self.present_choose_installer_page) def create_choose_installer_page(self): installer_picker = InstallerPicker(self.installers) installer_picker.connect("installer-selected", self.on_installer_selected) return Gtk.ScrolledWindow( hexpand=True, vexpand=True, child=installer_picker, shadow_type=Gtk.ShadowType.ETCHED_IN ) def present_choose_installer_page(self): """Stage where we choose an install script.""" self.set_status("") self.set_title(_("Install %s") % gtk_safe(self.installers[0]["name"])) self.stack.present_page("choose_installer") self.display_cancel_button(extra_buttons=[self.cache_button]) @watch_errors() def on_installer_selected(self, _widget, installer_version): """Sets the script interpreter to the correct script then proceed to install folder selection. If the installed game depends on another one and it's not installed, prompt the user to install it and quit this installer. """ try: script = None for _script in self.installers: if _script["version"] == installer_version: script = _script self.interpreter = interpreter.ScriptInterpreter(script, self) self.interpreter.connect("runners-installed", self.on_runners_ready) except MissingGameDependency as ex: dlg = QuestionDialog( { "parent": self, "question": _("This game requires %s. Do you want to install it?") % ex.slug, "title": _("Missing dependency"), } ) if dlg.result == Gtk.ResponseType.YES: installers = get_installers(game_slug=ex.slug) application = Gio.Application.get_default() application.show_installer_window(installers) return self.set_title(_("Installing {}").format(gtk_safe(self.interpreter.installer.game_name))) self.load_destination_page() def validate_scripts(self, installers): """Auto-fixes some script aspects and checks for mandatory fields""" if not installers: raise ScriptingError(_("No installer available")) for script in installers: for item in ["description", "notes"]: script[item] = script.get(item) or "" for item in ["name", "runner", "version"]: if item not in script: raise ScriptingError(_('Missing field "%s" in install script') % item) for file_desc in script["script"].get("files", {}): if len(file_desc) > 1: raise ScriptingError(_('Improperly formatted file "%s"') % file_desc) # Destination Page # # This page selects the directory where the game will be installed, # as well as few other minor options. def load_destination_page(self): """Stage where we select the install directory.""" if not self.interpreter.installer.creates_game_folder: self.on_destination_confirmed() return default_path = self.interpreter.get_default_target() self.location_entry.set_text(default_path) self.stack.navigate_to_page(self.present_destination_page) self.continue_button.grab_focus() def create_destination_page(self): vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) vbox.pack_start(self.location_entry, False, False, 0) desktop_shortcut_button = Gtk.CheckButton(_("Create desktop shortcut"), visible=True) desktop_shortcut_button.connect("clicked", self.on_create_desktop_shortcut_clicked) vbox.pack_start(desktop_shortcut_button, False, False, 0) menu_shortcut_button = Gtk.CheckButton(_("Create application menu shortcut"), visible=True) menu_shortcut_button.connect("clicked", self.on_create_menu_shortcut_clicked) vbox.pack_start(menu_shortcut_button, False, False, 0) if steam_shortcut.vdf_file_exists(): steam_shortcut_button = Gtk.CheckButton(_("Create steam shortcut"), visible=True) steam_shortcut_button.connect("clicked", self.on_create_steam_shortcut_clicked) vbox.pack_start(steam_shortcut_button, False, False, 0) return vbox def present_destination_page(self): """Display the destination chooser.""" self.set_status(_("Select installation directory")) self.stack.present_page("destination") self.display_continue_button(self.on_destination_confirmed, extra_buttons=[self.cache_button, self.source_button]) @watch_errors() def on_destination_confirmed(self, _button=None): """Let the interpreter take charge of the next stages.""" @watch_errors(handler_object=self) def launch_install(): # This is a shim method to allow exceptions from # the interpreter to be reported via watch_errors(). if not self.interpreter.launch_install(self): self.stack.navigation_reset() self.load_spinner_page(_("Preparing Lutris for installation"), cancellable=False, extra_buttons=[self.cache_button, self.source_button]) GLib.idle_add(launch_install) @watch_errors() def on_location_entry_changed(self, entry, _data=None): """Set the installation target for the game.""" self.interpreter.target_path = os.path.expanduser(entry.get_text()) @watch_errors() def on_create_desktop_shortcut_clicked(self, checkbutton): self.config["create_desktop_shortcut"] = checkbutton.get_active() @watch_errors() def on_create_menu_shortcut_clicked(self, checkbutton): self.config["create_menu_shortcut"] = checkbutton.get_active() @watch_errors() def on_create_steam_shortcut_clicked(self, checkbutton): self.config["create_steam_shortcut"] = checkbutton.get_active() @watch_errors() def on_runners_ready(self, _widget=None): self.load_extras_page() # Extras Page # # This pages offers to download the extras that come with the game; the # user specifies the specific extras desired. # # If there are no extras, the page triggers as if the user had clicked 'Continue', # moving on to pre-installation, then the installer files page. def load_extras_page(self): def get_extra_label(extra): """Return a label for the extras picker""" label = extra["name"] _infos = [] if extra.get("total_size"): _infos.append(human_size(extra["total_size"])) if extra.get("type"): _infos.append(extra["type"]) if _infos: label += " (%s)" % ", ".join(_infos) return label all_extras = self.interpreter.get_extras() if all_extras: self.extras_tree_store.clear() for extra_source, extras in all_extras.items(): parent = self.extras_tree_store.append(None, (None, None, None, extra_source)) for extra in extras: self.extras_tree_store.append(parent, (False, False, extra["id"], get_extra_label(extra))) self.stack.navigate_to_page(self.present_extras_page) else: self.on_extras_ready() def create_extras_page(self): treeview = Gtk.TreeView(self.extras_tree_store) treeview.set_headers_visible(False) treeview.expand_all() renderer_toggle = Gtk.CellRendererToggle() renderer_toggle.connect("toggled", self.on_extra_toggled, self.extras_tree_store) renderer_text = Gtk.CellRendererText() installed_column = Gtk.TreeViewColumn(None, renderer_toggle, active=0, inconsistent=1) treeview.append_column(installed_column) label_column = Gtk.TreeViewColumn(None, renderer_text) label_column.add_attribute(renderer_text, "text", 3) label_column.set_property("min-width", 80) treeview.append_column(label_column) return Gtk.ScrolledWindow( hexpand=True, vexpand=True, child=treeview, visible=True, shadow_type=Gtk.ShadowType.ETCHED_IN ) def present_extras_page(self): """Show installer screen with the extras picker""" def on_continue(_button): self.on_extras_confirmed(self.extras_tree_store) self.set_status(_( "This game has extra content. \nSelect which one you want and " "they will be available in the 'extras' folder where the game is installed." )) self.stack.present_page("extras") self.display_continue_button(on_continue, extra_buttons=[self.cache_button, self.source_button]) @watch_errors() def on_extra_toggled(self, _widget, path, model): toggled_row = model[path] toggled_row_iter = model.get_iter(path) toggled_row[0] = not toggled_row[0] toggled_row[1] = False if model.iter_has_child(toggled_row_iter): extra_iter = model.iter_children(toggled_row_iter) while extra_iter: extra_row = model[extra_iter] extra_row[0] = toggled_row[0] extra_iter = model.iter_next(extra_iter) else: for heading_row in model: all_extras_active = True any_extras_active = False extra_iter = model.iter_children(heading_row.iter) while extra_iter: extra_row = model[extra_iter] if extra_row[0]: any_extras_active = True else: all_extras_active = False extra_iter = model.iter_next(extra_iter) heading_row[0] = all_extras_active heading_row[1] = any_extras_active @watch_errors() def on_extras_confirmed(self, extra_store): """Resume install when user has selected extras to download""" selected_extras = [] def save_extra(store, path, iter_): selected, _inconsistent, id_, _label = store[iter_] if selected and id_: selected_extras.append(id_) extra_store.foreach(save_extra) self.interpreter.extras = selected_extras GLib.idle_add(self.on_extras_ready) @watch_errors() def on_extras_ready(self, *args): if not self.load_installer_files_page(): logger.debug("Installer doesn't require files") self.launch_installer_commands() # Installer Files & Downloading Page # # This page shows the files that are needed, and can download them. The user can # also select pre-existing files. The downloading page uses the same page widget, # but different buttons at the bottom. def load_installer_files_page(self): if self.installation_kind == InstallationKind.UPDATE: patch_version = self.interpreter.installer.version else: patch_version = None self.interpreter.installer.prepare_game_files(patch_version) if not self.interpreter.installer.files: return False self.installer_files_box.load_installer(self.interpreter.installer) self.stack.navigate_to_page(self.present_installer_files_page) return True def create_installer_files_page(self): return Gtk.ScrolledWindow( hexpand=True, vexpand=True, child=self.installer_files_box, visible=True, shadow_type=Gtk.ShadowType.ETCHED_IN ) def present_installer_files_page(self): """Show installer screen with the file picker / downloader""" self.set_status(_( "Please review the files needed for the installation then click 'Continue'")) self.stack.present_page("installer_files") self.display_install_button(self.on_files_confirmed, sensitive=self.installer_files_box.is_ready) def present_downloading_files_page(self): def on_exit_page(): self.installer_files_box.stop_all() self.set_status(_("Downloading game data")) self.stack.present_page("installer_files") self.display_install_button(None, sensitive=False) return on_exit_page @watch_errors() def on_files_ready(self, _widget, files_ready): """Toggle state of continue button based on ready state""" self.display_install_button(self.on_files_confirmed, sensitive=files_ready) @watch_errors() def on_files_confirmed(self, _button): """Call this when the user confirms the install files This will start the downloads. """ try: self.installer_files_box.start_all() self.stack.jump_to_page(self.present_downloading_files_page) except PermissionError as ex: raise ScriptingError(_("Unable to get files: %s") % ex) from ex @watch_errors() def on_files_available(self, widget): """All files are available, continue the install""" logger.info("All files are available, continuing install") self.interpreter.game_files = widget.get_game_files() # Idle-add here to ensure that the launch occurs after # on_files_confirmed(), since they can race when no actual # download is required. GLib.idle_add(self.launch_installer_commands) def launch_installer_commands(self): self.install_in_progress = True self.load_spinner_page(_("Installing game data")) self.stack.discard_navigation() # once we really start installing, no going back! self.interpreter.launch_installer_commands() # Spinner Page # # Provides a generic progress spinner and displays a status. The back button # is disabled for this page. def load_spinner_page(self, status, cancellable=True, extra_buttons=None): def present_spinner_page(): """Show a spinner in the middle of the view""" def on_exit_page(): self.stack.set_back_allowed(True) self.set_status(status) self.stack.present_page("spinner") if cancellable: self.display_cancel_button(extra_buttons=extra_buttons) else: self.display_buttons(extra_buttons or []) self.stack.set_back_allowed(False) return on_exit_page self.stack.jump_to_page(present_spinner_page) def create_spinner_page(self): spinner = Gtk.Spinner(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER) spinner.start() return spinner # Log Page # # This page shos a LogTextView where an installer command can display # output. This appears when summons by the installer script. def load_log_page(self): self.stack.jump_to_page(self.present_log_page) def create_log_page(self): log_textview = LogTextView(self.log_buffer) return Gtk.ScrolledWindow( hexpand=True, vexpand=True, child=log_textview, shadow_type=Gtk.ShadowType.ETCHED_IN ) def present_log_page(self): """Creates a TextBuffer and attach it to a command""" def on_exit_page(): self.error_reporter = saved_reporter def on_error(error): self.set_status(str(error)) saved_reporter = self.error_reporter self.error_reporter = on_error self.stack.present_page("log") self.display_cancel_button() return on_exit_page # Input Menu Page # # This page shows a list of choices to the user, and calls # back into a callback when the user makes a choice. This is summoned # by the installer script as well. def load_input_menu_page(self, alias, options, preselect, callback): def present_input_menu_page(): """Display an input request as a dropdown menu with options.""" def on_continue(_button): try: callback(alias, combobox) self.stack.restore_current_page(previous_page) except Exception as err: # If the callback fails, the installation does not continue # to run, so we'll go to error page. self.load_error_message_page(str(err)) model = Gtk.ListStore(str, str) for option in options: key, label = option.popitem() model.append([key, label]) combobox = Gtk.ComboBox.new_with_model(model) renderer_text = Gtk.CellRendererText() combobox.pack_start(renderer_text, True) combobox.add_attribute(renderer_text, "text", 1) combobox.set_id_column(0) combobox.set_halign(Gtk.Align.CENTER) combobox.set_valign(Gtk.Align.START) combobox.set_active_id(preselect) combobox.connect("changed", self.on_input_menu_changed) self.stack.present_replacement_page("input_menu", combobox) self.display_continue_button(on_continue) self.continue_button.grab_focus() self.on_input_menu_changed(combobox) # we must use jump_to_page() here since it would be unsave to return # back to this page and re-execute the callback. previous_page = self.stack.save_current_page() self.stack.jump_to_page(present_input_menu_page) @watch_errors() def on_input_menu_changed(self, combobox): """Enable continue button if a non-empty choice is selected""" self.continue_button.set_sensitive(bool(combobox.get_active_id())) # Ask for Disc Page # # This page asks the user for a disc; it also has a callback used when # the user selects a disc. Again, this is summoned by the installer script. def load_ask_for_disc_page(self, message, requires, installer, callback): def present_ask_for_disc_page(): """Ask the user to do insert a CD-ROM.""" def wrapped_callback(*args, **kwargs): try: callback(*args, **kwargs) self.stack.restore_current_page(previous_page) except Exception as err: # If the callback fails, the installation does not continue # to run, so we'll go to error page. self.load_error_message_page(str(err)) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) label = InstallerWindow.MarkupLabel(message) vbox.pack_start(label, False, False, 0) buttons_box = Gtk.Box() buttons_box.set_margin_top(40) buttons_box.set_margin_bottom(40) vbox.pack_start(buttons_box, False, False, 0) autodetect_button = Gtk.Button(label=_("Autodetect")) autodetect_button.connect("clicked", wrapped_callback, requires) autodetect_button.grab_focus() buttons_box.pack_start(autodetect_button, True, True, 40) browse_button = Gtk.Button(label=_("Browse…")) callback_data = {"callback": wrapped_callback, "requires": requires} browse_button.connect("clicked", self.on_browse_clicked, callback_data) buttons_box.pack_start(browse_button, True, True, 40) self.stack.present_replacement_page("ask_for_disc", vbox) if installer.runner == "wine": eject_button = Gtk.Button(_("Eject"), halign=Gtk.Align.END) eject_button.connect("clicked", self.on_eject_clicked) vbox.pack_end(eject_button, False, False, 0) vbox.pack_end(Gtk.Separator(), False, False, 0) vbox.show_all() self.display_cancel_button() previous_page = self.stack.save_current_page() self.stack.jump_to_page(present_ask_for_disc_page) @watch_errors() def on_browse_clicked(self, widget, callback_data): dialog = DirectoryDialog(_("Select the folder where the disc is mounted"), parent=self) folder = dialog.folder callback = callback_data["callback"] requires = callback_data["requires"] callback(widget, requires, folder) @watch_errors() def on_eject_clicked(self, _widget, data=None): self.interpreter.eject_wine_disc() # Error Message Page # # This is used to display an error; such a error halts the installation, # and isn't recoverable. Used by the installer script. def load_error_message_page(self, message): self.stack.navigate_to_page(lambda *x: self.present_error_page(message)) self.cancel_button.grab_focus() def present_error_page(self, message): self.set_status(message) self.stack.present_page("nothing") self.display_cancel_button() # Finished Page # # This is used to inidcate that the install is complete. The user # can launch the game a this point, or just close out of the window. # # Loading this page does some final installation steps before the UI updates. def load_finish_install_page(self, game_id, status): if self.config.get("create_desktop_shortcut"): self.create_shortcut(desktop=True) if self.config.get("create_menu_shortcut"): self.create_shortcut() # Save game to trigger a game-updated signal, # but take care not to create a blank game if game_id: game = Game(game_id) if self.config.get("create_steam_shortcut"): steam_shortcut.create_shortcut(game) game.save() self.install_in_progress = False self.install_complete = True self.stack.jump_to_page(lambda *x: self.present_finished_page(game_id, status)) self.stack.discard_navigation() self.cancel_button.grab_focus() if not self.is_active(): self.set_urgency_hint(True) # Blink in taskbar self.connect("focus-in-event", self.on_window_focus) def present_finished_page(self, game_id, status): self.set_status(status) self.stack.present_page("nothing") self.display_continue_button(self.on_launch_clicked, continue_button_label=_("_Launch"), suggested_action=False) @watch_errors() def on_launch_clicked(self, button): """Launch a game after it's been installed.""" button.set_sensitive(False) self.on_cancel_clicked(button) game = Game(self.interpreter.installer.game_id) if game.id: game.emit("game-launch") else: logger.error("Game has no ID, launch button should not be drawn") @watch_errors() def on_window_focus(self, _widget, *_args): """Remove urgency hint (flashing indicator) when window receives focus""" self.set_urgency_hint(False) def create_shortcut(self, desktop=False): """Create desktop or global menu shortcuts.""" game_slug = self.interpreter.installer.game_slug game_id = self.interpreter.installer.game_id game_name = self.interpreter.installer.game_name if desktop: xdgshortcuts.create_launcher(game_slug, game_id, game_name, desktop=True) else: xdgshortcuts.create_launcher(game_slug, game_id, game_name, menu=True) # Buttons def display_continue_button(self, handler, continue_button_label=_("_Continue"), sensitive=True, suggested_action=True, extra_buttons=None): """This shows the continue button, the close button, and any extra buttons you indicate. This will also set the label and sensitivity of the continue button. Finally, you cna provide the clicked handler for the continue button, though that can be None to leave it disconnected. We call this repeatedly, as we arrive at each page. Each call disconnects the previous clicked handler and connects the new one. """ self.continue_button.set_label(continue_button_label) self.continue_button.set_sensitive(sensitive) style_context = self.continue_button.get_style_context() if suggested_action: style_context.add_class("suggested-action") else: style_context.remove_class("suggested-action") if self.continue_handler: self.continue_button.disconnect(self.continue_handler) if handler: self.continue_handler = self.continue_button.connect("clicked", handler) else: self.continue_handler = None buttons = [self.continue_button] + (extra_buttons or []) self.display_buttons(buttons) def display_install_button(self, handler, sensitive=True): """Displays the continue button, but labels it 'Install'.""" self.display_continue_button(handler, continue_button_label=_( "_Install"), sensitive=sensitive, extra_buttons=[self.source_button]) def display_cancel_button(self, extra_buttons=None): self.display_buttons(extra_buttons or []) def display_buttons(self, buttons): """Shows exactly the buttons given, and hides the others. Updates the close button according to whether the install has started.""" style_context = self.cancel_button.get_style_context() if self.install_in_progress: self.cancel_button.set_label(_("_Abort")) self.cancel_button.set_tooltip_text(_("Abort and revert the installation")) style_context.add_class("destructive-action") else: self.cancel_button.set_label(_("_Close") if self.install_complete else _("Cancel")) self.cancel_button.set_tooltip_text("") style_context.remove_class("destructive-action") all_buttons = [self.cache_button, self.source_button, self.continue_button] for b in all_buttons: b.set_visible(b in buttons) any_visible = False for b in self.menu_box.get_children(): if b.get_visible(): any_visible = True break self.menu_button.set_visible(any_visible) class MarkupLabel(Gtk.Label): """Label for installer window""" def __init__(self, markup=None, **kwargs): super().__init__( label=markup, use_markup=True, wrap=True, max_width_chars=80, **kwargs) self.set_alignment(0.5, 0) lutris-0.5.14/lutris/gui/lutriswindow.py000066400000000000000000001325161451435154700203760ustar00rootroot00000000000000"""Main window for the Lutris interface.""" # pylint:disable=too-many-lines import os import re from collections import namedtuple from gettext import gettext as _ from urllib.parse import unquote, urlparse from gi.repository import Gdk, Gio, GLib, GObject, Gtk from lutris import services, settings from lutris.database import categories as categories_db from lutris.database import games as games_db from lutris.database.services import ServiceGameCollection from lutris.exceptions import EsyncLimitError, FsyncUnsupportedError, watch_errors from lutris.game import Game from lutris.gui import dialogs from lutris.gui.addgameswindow import AddGamesWindow from lutris.gui.config.preferences_dialog import PreferencesDialog from lutris.gui.dialogs.delegates import DialogInstallUIDelegate, DialogLaunchUIDelegate from lutris.gui.dialogs.game_import import ImportGameDialog from lutris.gui.views import COL_ID, COL_NAME from lutris.gui.views.grid import GameGridView from lutris.gui.views.list import GameListView from lutris.gui.views.store import GameStore from lutris.gui.widgets.game_bar import GameBar from lutris.gui.widgets.gi_composites import GtkTemplate from lutris.gui.widgets.sidebar import LutrisSidebar from lutris.gui.widgets.utils import load_icon_theme, open_uri from lutris.scanners.lutris import add_to_path_cache, get_missing_game_ids, remove_from_path_cache # pylint: disable=no-member from lutris.services.base import BaseService from lutris.services.lutris import LutrisService from lutris.util import datapath from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.system import update_desktop_icons from lutris.util.wine.wine import esync_display_limit_warning, fsync_display_support_warning @GtkTemplate(ui=os.path.join(datapath.get(), "ui", "lutris-window.ui")) class LutrisWindow(Gtk.ApplicationWindow, DialogLaunchUIDelegate, DialogInstallUIDelegate): # pylint: disable=too-many-public-methods """Handler class for main window signals.""" default_view_type = "grid" default_width = 800 default_height = 600 __gtype_name__ = "LutrisWindow" __gsignals__ = { "view-updated": (GObject.SIGNAL_RUN_FIRST, None, ()), } games_stack = GtkTemplate.Child() sidebar_revealer = GtkTemplate.Child() sidebar_scrolled = GtkTemplate.Child() game_revealer = GtkTemplate.Child() search_entry = GtkTemplate.Child() zoom_adjustment = GtkTemplate.Child() blank_overlay = GtkTemplate.Child() viewtype_icon = GtkTemplate.Child() def __init__(self, application, **kwargs): width = int(settings.read_setting("width") or self.default_width) height = int(settings.read_setting("height") or self.default_height) super().__init__( default_width=width, default_height=height, window_position=Gtk.WindowPosition.NONE, name="lutris", icon_name="lutris", application=application, **kwargs ) update_desktop_icons() load_icon_theme() self.application = application self.window_x, self.window_y = self.get_position() self.restore_window_position() self.threads_stoppers = [] self.window_size = (width, height) self.maximized = settings.read_setting("maximized") == "True" self.service = None self.search_timer_id = None self.filters = self.load_filters() self.set_service(self.filters.get("service")) self.icon_type = self.load_icon_type() self.game_store = GameStore(self.service, self.service_media) self.current_view = Gtk.Box() self.views = {} self.dynamic_categories_game_factories = { "recent": self.get_recent_games, "missing": self.get_missing_games, "running": self.get_running_games, } self.connect("delete-event", self.on_window_delete) self.connect("configure-event", self.on_window_configure) self.connect("realize", self.on_load) self.connect("drag-data-received", self.on_drag_data_received) self.connect("notify::visible", self.on_visible_changed) if self.maximized: self.maximize() self.init_template() self._init_actions() # Setup Drag and drop self.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY) self.drag_dest_add_uri_targets() self.set_viewtype_icon(self.current_view_type) lutris_icon = Gtk.Image.new_from_icon_name("lutris", Gtk.IconSize.MENU) lutris_icon.set_margin_right(3) self.sidebar = LutrisSidebar(self.application) self.sidebar.connect("selected-rows-changed", self.on_sidebar_changed) # "realize" is order sensitive- must connect after sidebar itself connects the same signal self.sidebar.connect("realize", self.on_sidebar_realize) self.sidebar_scrolled.add(self.sidebar) # This must wait until the selected-rows-changed signal is connected self.sidebar.initialize_rows() self.sidebar_revealer.set_reveal_child(self.side_panel_visible) self.sidebar_revealer.set_transition_duration(300) self.game_bar = None self.revealer_box = Gtk.HBox(visible=True) self.game_revealer.add(self.revealer_box) self.update_action_state() self.connect("view-updated", self.update_store) GObject.add_emission_hook(BaseService, "service-login", self.on_service_login) GObject.add_emission_hook(BaseService, "service-logout", self.on_service_logout) GObject.add_emission_hook(BaseService, "service-games-loaded", self.on_service_games_updated) GObject.add_emission_hook(Game, "game-updated", self.on_game_updated) GObject.add_emission_hook(Game, "game-stopped", self.on_game_stopped) GObject.add_emission_hook(Game, "game-installed", self.on_game_installed) GObject.add_emission_hook(Game, "game-removed", self.on_game_removed) GObject.add_emission_hook(Game, "game-unhandled-error", self.on_game_unhandled_error) GObject.add_emission_hook(PreferencesDialog, "settings-changed", self.on_settings_changed) # Finally trigger the initialization of the view here selected_category = settings.read_setting("selected_category", default="runner:all") self.sidebar.selected_category = selected_category.split(":") if selected_category else None def _init_actions(self): Action = namedtuple("Action", ("callback", "type", "enabled", "default", "accel")) Action.__new__.__defaults__ = (None, None, None, None, None) actions = { "add-game": Action(self.on_add_game_button_clicked), "preferences": Action(self.on_preferences_activate), "about": Action(self.on_about_clicked), "show-installed-only": Action( # delete? self.on_show_installed_state_change, type="b", default=self.filter_installed, accel="i", ), "toggle-viewtype": Action(self.on_toggle_viewtype), "toggle-badges": Action( self.on_toggle_badges, type="b", default=settings.read_setting("hide_badges_on_icons"), accel="p" ), "icon-type": Action(self.on_icontype_state_change, type="s", default=self.icon_type), "view-sorting": Action( self.on_view_sorting_state_change, type="s", default=self.view_sorting, enabled=lambda: self.is_view_sort_active ), "view-sorting-installed-first": Action( self.on_view_sorting_installed_first_change, type="b", default=self.view_sorting_installed_first, enabled=lambda: self.is_view_sort_active ), "view-sorting-ascending": Action( self.on_view_sorting_direction_change, type="b", default=self.view_sorting_ascending, enabled=lambda: self.is_view_sort_active ), "show-side-panel": Action( self.on_side_panel_state_change, type="b", default=self.side_panel_visible, accel="F9", ), "show-hidden-games": Action( self.hidden_state_change, type="b", default=self.show_hidden_games, accel="h", ), "open-forums": Action(lambda *x: open_uri("https://forums.lutris.net/")), "open-discord": Action(lambda *x: open_uri("https://discord.gg/Pnt5CuY")), "donate": Action(lambda *x: open_uri("https://lutris.net/donate")), } self.actions = {} self.action_state_updaters = [] app = self.props.application for name, value in actions.items(): if not value.type: action = Gio.SimpleAction.new(name) action.connect("activate", value.callback) else: default_value = None param_type = None if value.default is not None: default_value = GLib.Variant(value.type, value.default) if value.type != "b": param_type = default_value.get_type() action = Gio.SimpleAction.new_stateful(name, param_type, default_value) action.connect("change-state", value.callback) self.actions[name] = action if value.enabled: def updater(action=action, value=value): action.props.enabled = value.enabled() self.action_state_updaters.append(updater) self.add_action(action) if value.accel: app.add_accelerator(value.accel, "win." + name) def update_action_state(self): """This invokes the functions to update the enabled states of all the actions which can be disabled.""" for updater in self.action_state_updaters: updater() @property def service_media(self): return self.get_service_media(self.load_icon_type()) @property def selected_category(self): return self.sidebar.selected_category def on_load(self, widget, data=None): """Finish initializing the view""" self._bind_zoom_adjustment() AsyncCall(get_missing_game_ids, self.on_get_missing_game_ids) self.current_view.grab_focus() def on_sidebar_realize(self, widget, data=None): """Grab the initial focus after the sidebar is initialized - so the view is ready.""" self.current_view.grab_focus() @watch_errors() def on_drag_data_received(self, widget, drag_context, x, y, data, info, time): """Handler for drop event""" file_paths = [unquote(urlparse(uri).path) for uri in data.get_uris()] dialog = ImportGameDialog(file_paths, parent=self) dialog.show() def load_filters(self): """Load the initial filters when creating the view""" # The main sidebar-category filter will be populated when the sidebar row is selected, after this filters = { "hidden": settings.read_setting("show_hidden_games").lower() == "true", "installed": settings.read_setting("filter_installed").lower() == "true" } return filters def hidden_state_change(self, action, value): """Hides or shows the hidden games""" action.set_state(value) settings.write_setting("show_hidden_games", str(value).lower(), section="lutris") self.filters["hidden"] = bool(value) self.emit("view-updated") @property def current_view_type(self): """Returns which kind of view is currently presented (grid or list)""" return settings.read_setting("view_type") or "grid" @property def filter_installed(self): return settings.read_setting("filter_installed").lower() == "true" @property def side_panel_visible(self): return settings.read_setting("side_panel_visible").lower() != "false" @property def show_tray_icon(self): """Setting to hide or show status icon""" return settings.read_setting("show_tray_icon", default="false").lower() == "true" @property def view_sorting(self): value = settings.read_setting("view_sorting") or "name" if value.endswith("_text"): value = value[:-5] return value @property def view_sorting_ascending(self): return settings.read_setting("view_sorting_ascending").lower() != "false" @property def view_sorting_installed_first(self): return settings.read_setting("view_sorting_installed_first").lower() != "false" @property def show_hidden_games(self): return settings.read_setting("show_hidden_games").lower() == "true" @property def sort_params(self): """This provides a list of sort options for SQL generation; this isn't exactly a match for what self.apply_view_sort does, but it is as close as may be, in the hope that a faster DB sort will get is close and result in a faster sort overall.""" params = [] if self.view_sorting_installed_first: params.append(("installed", "COLLATE NOCASE DESC")) if self.view_sorting == "name": key = "CASE WHEN sortname <> '' THEN sortname ELSE name END" else: key = self.view_sorting params.append(( key, "COLLATE NOCASE ASC" if self.view_sorting_ascending else "COLLATE NOCASE DESC" )) return params @property def is_view_sort_active(self): """True if the iew sorting options will be effective; dynamic categories ignore them.""" return self.filters.get("dynamic_category") not in self.dynamic_categories_game_factories def apply_view_sort(self, items, resolver=lambda i: i): """This sorts a list of items according to the view settings of this window; the items can be anything, but you can provide a lambda that provides a database game dictionary for each one; this dictionary carries the data we sort on (though any field may be missing). This sort always sorts installed games ahead of uninstalled ones, even when the sort is set to descending. This treats 'name' sorting specially, applying a natural sort so that 'Mega slap battler 20' comes after 'Mega slap battler 3'. For this reason, we can't just accept the sort the database gives us via self.sort_params; that'll get us close, but we must resort to get it right.""" view_sorting = self.view_sorting sort_defaults = { "name": "", "year": 0, "lastplayed": 0.0, "installed_at": 0.0, "playtime": 0.0, } def natural_sort_key(value): def pad_numbers(text): return text.zfill(16) if text.isdigit() else text.casefold() key = [pad_numbers(c) for c in re.split('([0-9]+)', value)] return key def get_sort_value(item): db_game = resolver(item) if not db_game: installation_flag = False value = sort_defaults.get(view_sorting, "") else: installation_flag = bool(db_game.get("installed")) # When sorting by name, check for a valid sortname first, then fall back # on name if valid sortname is not available. sortname = db_game.get("sortname") if view_sorting == "name" and sortname: value = sortname else: value = db_game.get(view_sorting) if view_sorting == "name": value = natural_sort_key(value) # Users may have obsolete view_sorting settings, so # we must tolerate them. We treat them all as blank. value = value or sort_defaults.get(view_sorting, "") if self.view_sorting_installed_first: # We want installed games to always be first, even in # a descending sort. if self.view_sorting_ascending: installation_flag = not installation_flag return [installation_flag, value] return value return sorted(items, key=get_sort_value, reverse=not self.view_sorting_ascending) def get_running_games(self): """Return a list of currently running games""" return games_db.get_games_by_ids([game.id for game in self.application.running_games]) def on_get_missing_game_ids(self, missing_ids, error): if error: logger.error(str(error)) return self.get_missing_games(missing_ids) def get_missing_games(self, missing_ids: list = None) -> list: if missing_ids is None: missing_ids = get_missing_game_ids() missing_games = games_db.get_games_by_ids(missing_ids) if missing_games: self.sidebar.missing_row.show() else: if missing_ids: logger.warning("Path cache out of date? (%s IDs missing)", len(missing_ids)) self.sidebar.missing_row.hide() return missing_games def get_recent_games(self): """Return a list of currently running games""" searches, _filters, excludes = self.get_sql_filters() games = games_db.get_games(searches=searches, filters={'installed': '1'}, excludes=excludes) return sorted( games, key=lambda game: max(game["installed_at"] or 0, game["lastplayed"] or 0), reverse=True ) def game_matches(self, game): if self.filters.get("installed"): if game["appid"] not in games_db.get_service_games(self.service.id): return False if not self.filters.get("text"): return True return self.filters["text"] in game["name"].lower() def set_service(self, service_name): if self.service and self.service.id == service_name: return self.service if not service_name: self.service = None return try: self.service = services.SERVICES[service_name]() except KeyError: logger.error("Non existent service '%s'", service_name) self.service = None return self.service @staticmethod def combine_games(service_game, lutris_game): """Inject lutris game information into a service game""" if lutris_game and service_game["appid"] == lutris_game["service_id"]: for field in ("platform", "runner", "year", "installed_at", "lastplayed", "playtime", "installed"): service_game[field] = lutris_game[field] return service_game def get_service_games(self, service_name): """Switch the current service to service_name and return games if available""" service_games = ServiceGameCollection.get_for_service(service_name) if service_name == "lutris": lutris_games = {g["slug"]: g for g in games_db.get_games()} else: lutris_games = {g["service_id"]: g for g in games_db.get_games(filters={"service": self.service.id})} return [ self.combine_games(game, lutris_games.get(game["appid"])) for game in self.apply_view_sort( service_games, lambda game: lutris_games.get(game["appid"]) or game ) if self.game_matches(game) ] def get_games_from_filters(self): service_name = self.filters.get("service") if service_name in services.SERVICES: if self.service.online and not self.service.is_authenticated(): self.show_label(_("Connect your %s account to access your games") % self.service.name) return [] return self.get_service_games(service_name) if self.filters.get("dynamic_category") in self.dynamic_categories_game_factories: return self.dynamic_categories_game_factories[self.filters["dynamic_category"]]() if self.filters.get("category") and self.filters["category"] != "all": game_ids = categories_db.get_game_ids_for_category(self.filters["category"]) else: game_ids = None searches, filters, excludes = self.get_sql_filters() games = games_db.get_games( searches=searches, filters=filters, excludes=excludes, sorts=self.sort_params ) if game_ids is not None: return [game for game in games if game["id"] in game_ids] return self.apply_view_sort(games) def get_sql_filters(self): """Return the current filters for the view""" sql_filters = {} sql_excludes = {} if self.filters.get("runner"): sql_filters["runner"] = self.filters["runner"] if self.filters.get("platform"): sql_filters["platform"] = self.filters["platform"] if self.filters.get("installed"): sql_filters["installed"] = "1" if self.filters.get("text"): searches = {"name": self.filters["text"]} else: searches = None if not self.filters.get("hidden"): sql_excludes["hidden"] = 1 return searches, sql_filters, sql_excludes def get_service_media(self, icon_type): """Return the ServiceMedia class used for this view""" service = self.service if self.service else LutrisService medias = service.medias if icon_type in medias: return medias[icon_type]() return medias[service.default_format]() def update_revealer(self, game=None): if game: if self.game_bar: self.game_bar.destroy() self.game_bar = GameBar(game, self.application, self) self.revealer_box.pack_start(self.game_bar, True, True, 0) elif self.game_bar: # The game bar can't be destroyed here because the game gets unselected on Wayland # whenever the game bar is interacted with. Instead, we keep the current game bar open # when the game gets unselected, which is somewhat closer to what the intended behavior # should be anyway. Might require closing the game bar manually in some cases. pass # self.game_bar.destroy() if self.revealer_box.get_children(): self.game_revealer.set_reveal_child(True) else: self.game_revealer.set_reveal_child(False) def show_empty_label(self): """Display a label when the view is empty""" filter_text = self.filters.get("text") has_uninstalled_games = games_db.get_game_count("installed", "0") has_hidden_games = games_db.get_game_count("hidden", "1") if filter_text: if self.filters.get("category") == "favorite": self.show_label(_("Add a game matching '%s' to your favorites to see it here.") % filter_text) elif self.filters.get("installed") and has_uninstalled_games: self.show_label( _("No installed games matching '%s' found. Press Ctrl+I to show uninstalled games.") % filter_text) elif self.filters.get("hidden") is False and has_hidden_games: # but not if missing! self.show_label(_("No visible games matching '%s' found. Press Ctrl+H to show hidden games.") % filter_text) else: self.show_label(_("No games matching '%s' found ") % filter_text) else: if self.filters.get("category") == "favorite": self.show_label(_("Add games to your favorites to see them here.")) elif self.filters.get("installed") and has_uninstalled_games: self.show_label(_("No installed games found. Press Ctrl+I to show uninstalled games.")) elif self.filters.get("hidden") is False and has_hidden_games: # but not if missing! self.show_label(_("No visible games found. Press Ctrl+H to show hidden games.")) elif ( not self.filters.get("runner") and not self.filters.get("service") and not self.filters.get("platform") and not self.filters.get("dynamic_category") ): self.show_splash() else: self.show_label(_("No games found")) def update_store(self, *_args, **_kwargs): self.game_store.store.clear() self.hide_overlay() games = self.get_games_from_filters() if games: if len(games) > 1: self.search_entry.set_placeholder_text(_("Search %s games") % len(games)) else: self.search_entry.set_placeholder_text(_("Search 1 game")) else: self.search_entry.set_placeholder_text(_("Search games")) for view in self.views.values(): view.service = self.service GLib.idle_add(self.update_revealer) for game in games: self.game_store.add_game(game) if not games: self.show_empty_label() self.search_timer_id = None return False def _bind_zoom_adjustment(self): """Bind the zoom slider to the supported banner sizes""" service = self.service if self.service else LutrisService media_services = list(service.medias.keys()) self.load_icon_type() self.zoom_adjustment.set_lower(0) self.zoom_adjustment.set_upper(len(media_services) - 1) if self.icon_type in media_services: value = media_services.index(self.icon_type) else: value = 0 self.zoom_adjustment.props.value = value self.zoom_adjustment.connect("value-changed", self.on_zoom_changed) @watch_errors() def on_zoom_changed(self, adjustment): """Handler for zoom modification""" media_index = round(adjustment.props.value) adjustment.props.value = media_index service = self.service if self.service else LutrisService media_services = list(service.medias.keys()) if len(media_services) <= media_index: media_index = media_services.index(service.default_format) icon_type = media_services[media_index] if icon_type != self.icon_type: GLib.idle_add(self.save_icon_type, icon_type) def show_overlay(self, widget, halign=Gtk.Align.FILL, valign=Gtk.Align.FILL): """Display a widget in the blank overlay""" for child in self.blank_overlay.get_children(): child.destroy() self.blank_overlay.set_halign(halign) self.blank_overlay.set_valign(valign) self.blank_overlay.add(widget) self.blank_overlay.props.visible = True def show_label(self, message): """Display a label in the middle of the UI""" self.show_overlay(Gtk.Label(message, visible=True)) def show_splash(self): theme = "dark" if self.application.style_manager.is_dark else "light" side_splash = Gtk.Image(visible=True) side_splash.set_from_file(os.path.join(datapath.get(), "media/side-%s.svg" % theme)) side_splash.set_alignment(0, 0) center_splash = Gtk.Image(visible=True) center_splash.set_alignment(.5, .5) center_splash.set_from_file(os.path.join(datapath.get(), "media/splash-%s.svg" % theme)) splash_box = Gtk.HBox(visible=True, margin_top=24) splash_box.pack_start(side_splash, False, False, 12) splash_box.set_center_widget(center_splash) self.show_overlay(splash_box, Gtk.Align.FILL, Gtk.Align.FILL) def show_spinner(self): spinner = Gtk.Spinner(visible=True) spinner.start() for child in self.blank_overlay.get_children(): child.destroy() self.blank_overlay.add(spinner) self.blank_overlay.props.visible = True def hide_overlay(self): self.blank_overlay.props.visible = False for child in self.blank_overlay.get_children(): child.destroy() @property def view_type(self): """Return the type of view saved by the user""" view_type = settings.read_setting("view_type") if view_type in ["grid", "list"]: return view_type return self.default_view_type def do_key_press_event(self, event): # pylint: disable=arguments-differ # XXX: This block of code below is to enable searching on type. # Enabling this feature steals focus from other entries so it needs # some kind of focus detection before enabling library search. # Probably not ideal for non-english, but we want to limit # which keys actually start searching if event.keyval == Gdk.KEY_Escape: self.search_entry.set_text("") self.current_view.grab_focus() return Gtk.ApplicationWindow.do_key_press_event(self, event) if ( # pylint: disable=too-many-boolean-expressions not Gdk.KEY_0 <= event.keyval <= Gdk.KEY_z or event.state & Gdk.ModifierType.CONTROL_MASK or event.state & Gdk.ModifierType.SHIFT_MASK or event.state & Gdk.ModifierType.META_MASK or event.state & Gdk.ModifierType.MOD1_MASK or self.search_entry.has_focus() ): return Gtk.ApplicationWindow.do_key_press_event(self, event) self.search_entry.grab_focus() return self.search_entry.do_key_press_event(self.search_entry, event) def load_icon_type(self): """Return the icon style depending on the type of view.""" setting_key = "icon_type_%sview" % self.current_view_type if self.service and self.service.id != "lutris": setting_key += "_%s" % self.service.id self.icon_type = settings.read_setting(setting_key) return self.icon_type def save_icon_type(self, icon_type): """Save icon type to settings""" self.icon_type = icon_type setting_key = "icon_type_%sview" % self.current_view_type if self.service and self.service.id != "lutris": setting_key += "_%s" % self.service.id settings.write_setting(setting_key, self.icon_type) self.redraw_view() def redraw_view(self): """Completely reconstruct the main view""" if not self.game_store: logger.error("No game store yet") return self.game_store = GameStore(self.service, self.service_media) view_type = self.current_view_type if view_type in self.views: self.current_view = self.views[view_type] self.current_view.set_game_store(self.game_store) else: if view_type == "grid": self.current_view = GameGridView( self.game_store, hide_text=settings.read_setting("hide_text_under_icons") == "True" ) else: self.current_view = GameListView(self.game_store) self.current_view.connect("game-selected", self.on_game_selection_changed) self.current_view.connect("game-activated", self.on_game_activated) self.views[view_type] = self.current_view scrolledwindow = self.games_stack.get_child_by_name(view_type) if not scrolledwindow: scrolledwindow = Gtk.ScrolledWindow() self.games_stack.add_named(scrolledwindow, view_type) if not scrolledwindow.get_child(): scrolledwindow.add(self.current_view) scrolledwindow.show_all() self.update_view_settings() self.games_stack.set_visible_child_name(view_type) self.update_store() self.update_action_state() def rebuild_view(self, view_type): """Discards the view named by 'view_type' and if it is the current view, regenerates it. This is used to update view settings that can only be set during view construction, and not updated later.""" if view_type in self.views: scrolledwindow = self.games_stack.get_child_by_name(view_type) scrolledwindow.remove(self.views[view_type]) del self.views["grid"] if self.current_view_type == view_type: self.redraw_view() def update_view_settings(self): if self.current_view and self.current_view_type == "grid": show_badges = settings.read_setting("hide_badges_on_icons") != 'True' self.current_view.show_badges = show_badges and not bool( self.filters.get("platform")) def set_viewtype_icon(self, view_type): self.viewtype_icon.set_from_icon_name("view-%s-symbolic" % view_type, Gtk.IconSize.BUTTON) def set_show_installed_state(self, filter_installed): """Shows or hide uninstalled games""" settings.write_setting("filter_installed", bool(filter_installed)) self.filters["installed"] = filter_installed @watch_errors() def on_service_games_updated(self, service): """Request a view update when service games are loaded""" if self.service and service.id == self.service.id: self.emit("view-updated") return True def save_window_state(self): """Saves the window's size position and state as settings.""" width, height = self.window_size settings.write_setting("width", width) settings.write_setting("height", height) if self.window_x and self.window_y: settings.write_setting("window_x", self.window_x) settings.write_setting("window_y", self.window_y) settings.write_setting("maximized", self.maximized) def restore_window_position(self): """Restores the window position only; we call this when showing the window, but restore the other settings only when creating it.""" self.window_x = settings.read_setting("window_x") self.window_y = settings.read_setting("window_y") if self.window_x and self.window_y: self.move(int(self.window_x), int(self.window_y)) @watch_errors() def on_service_login(self, service): service.start_reload(self._service_reloaded_cb) return True def _service_reloaded_cb(self, error): if error: dialogs.ErrorDialog(error, parent=self) @watch_errors() def on_service_logout(self, service): if self.service and service.id == self.service.id: self.emit("view-updated") return True @watch_errors() @GtkTemplate.Callback def on_resize(self, widget, *_args): """Size-allocate signal. Updates stored window size and maximized state. """ if not widget.get_window(): return self.maximized = widget.is_maximized() size = widget.get_size() if not self.maximized: self.window_size = size self.search_entry.set_size_request(min(max(50, size[0] - 470), 800), -1) def on_window_delete(self, *_args): if self.application.running_games.get_n_items(): self.hide() return True def on_visible_changed(self, window, param): if self.application.tray: self.application.tray.update_present_menu() def on_window_configure(self, *_args): """Callback triggered when the window is moved, resized...""" self.window_x, self.window_y = self.get_position() @GtkTemplate.Callback def on_destroy(self, *_args): """Signal for window close.""" # Stop cancellable running threads for stopper in self.threads_stoppers: stopper() @GtkTemplate.Callback @watch_errors() def on_hide(self, *_args): self.save_window_state() @GtkTemplate.Callback def on_show(self, *_args): self.restore_window_position() @GtkTemplate.Callback @watch_errors() def on_preferences_activate(self, *_args): """Callback when preferences is activated.""" self.application.show_window(PreferencesDialog, parent=self) def on_show_installed_state_change(self, action, value): """Callback to handle uninstalled game filter switch""" action.set_state(value) self.set_show_installed_state(value.get_boolean()) self.emit("view-updated") @GtkTemplate.Callback @watch_errors() def on_search_entry_changed(self, entry): """Callback for the search input keypresses""" if self.search_timer_id: GLib.source_remove(self.search_timer_id) self.filters["text"] = entry.get_text().lower().strip() self.search_timer_id = GLib.timeout_add(500, self.update_store) @GtkTemplate.Callback @watch_errors() def on_search_entry_key_press(self, widget, event): if event.keyval == Gdk.KEY_Down: if self.current_view_type == 'grid': self.current_view.select_path(Gtk.TreePath('0')) # needed for gridview only # if game_bar is alive at this point it can mess grid item selection up # for some unknown reason, # it is safe to close it here, it will be reopened automatically. if self.game_bar: self.game_bar.destroy() # for gridview only self.current_view.set_cursor(Gtk.TreePath('0'), None, False) # needed for both view types self.current_view.grab_focus() @GtkTemplate.Callback @watch_errors() def on_about_clicked(self, *_args): """Open the about dialog.""" dialogs.AboutDialog(parent=self) @watch_errors() def on_game_unhandled_error(self, game, error): """Called when a game has sent the 'game-error' signal""" if isinstance(error, FsyncUnsupportedError): fsync_display_support_warning(parent=self) elif isinstance(error, EsyncLimitError): esync_display_limit_warning(parent=self) else: dialogs.ErrorDialog(error, parent=self) return True @GtkTemplate.Callback @watch_errors() def on_add_game_button_clicked(self, *_args): """Add a new game manually with the AddGameDialog.""" self.application.show_window(AddGamesWindow, parent=self) return True @watch_errors() def on_toggle_viewtype(self, *args): view_type = "list" if self.current_view_type == "grid" else "grid" logger.debug("View type changed to %s", view_type) self.set_viewtype_icon(view_type) settings.write_setting("view_type", view_type) self.redraw_view() self._bind_zoom_adjustment() @watch_errors() def on_icontype_state_change(self, action, value): action.set_state(value) self._set_icon_type(value.get_string()) @watch_errors() def on_view_sorting_state_change(self, action, value): self.actions["view-sorting"].set_state(value) value = str(value).strip("'") settings.write_setting("view_sorting", value) self.emit("view-updated") @watch_errors() def on_view_sorting_direction_change(self, action, value): self.actions["view-sorting-ascending"].set_state(value) settings.write_setting("view_sorting_ascending", bool(value)) self.emit("view-updated") @watch_errors() def on_view_sorting_installed_first_change(self, action, value): self.actions["view-sorting-installed-first"].set_state(value) settings.write_setting("view_sorting_installed_first", bool(value)) self.emit("view-updated") @watch_errors() def on_side_panel_state_change(self, action, value): """Callback to handle side panel toggle""" action.set_state(value) side_panel_visible = value.get_boolean() settings.write_setting("side_panel_visible", bool(side_panel_visible)) self.sidebar_revealer.set_reveal_child(side_panel_visible) @watch_errors() def on_sidebar_changed(self, widget): """Handler called when the selected element of the sidebar changes""" for filter_type in ("category", "dynamic_category", "service", "runner", "platform"): if filter_type in self.filters: self.filters.pop(filter_type) row_type, row_id = widget.selected_category if row_type == "user_category": row_type = "category" self.filters[row_type] = row_id service_name = self.filters.get("service") self.set_service(service_name) self._bind_zoom_adjustment() self.redraw_view() @watch_errors() def on_game_selection_changed(self, view, selection): if not selection: GLib.idle_add(self.update_revealer) return False game_id = view.get_model().get_value(selection, COL_ID) if not game_id: GLib.idle_add(self.update_revealer) return False if self.service: game = ServiceGameCollection.get_game(self.service.id, game_id) else: game = games_db.get_game_by_field(int(game_id), "id") if not game: game = { "id": game_id, "appid": game_id, "name": view.get_model().get_value(selection, COL_NAME), "slug": game_id, "service": self.service.id if self.service else None, } logger.warning("No game found. Replacing with placeholder %s", game) GLib.idle_add(self.update_revealer, game) return False @watch_errors() def on_toggle_badges(self, _widget, _data): """Event handler to toggle badge visibility""" state = settings.read_setting("hide_badges_on_icons").lower() == "true" settings.write_setting("hide_badges_on_icons", not state) self.on_settings_changed(None, "hide_badges_on_icons") @watch_errors() def on_settings_changed(self, dialog, settings_key): if settings_key == "hide_text_under_icons": self.rebuild_view("grid") else: self.update_view_settings() return True def is_game_displayed(self, game): """Return whether a game should be displayed on the view""" if game.is_hidden and not self.show_hidden_games: return False row = self.sidebar.get_selected_row() if row: # Stopped games do not get displayed on the running page if row.type == "dynamic_category" and row.id == "running" and game.state == game.STATE_STOPPED: return False # If the update took the row out of this view's category, we'll need # to update the view to reflect that. if row.type in ("category", "user_category"): if row.id != "all" and row.id not in game.get_categories(): return False return True @watch_errors() def on_game_updated(self, game): """Updates an individual entry in the view when a game is updated""" add_to_path_cache(game) if game.appid and self.service: db_game = ServiceGameCollection.get_game(self.service.id, game.appid) else: db_game = games_db.get_game_by_field(game.id, "id") if not self.is_game_displayed(game): self.game_store.remove_game(db_game["id"]) return True if db_game: updated = self.game_store.update(db_game) if not updated: self.update_store() else: logger.debug("Can't get DB game for %s (service: %s)", game, self.service) return True @watch_errors() def on_game_stopped(self, game): """Updates the game list when a game stops; this keeps the 'running' page updated.""" selected_row = self.sidebar.get_selected_row() # Only update the running page- we lose the selected row when we do this, # but on the running page this is okay. if selected_row is not None and selected_row.id == "running": self.game_store.remove_game(game.id) return True def on_game_installed(self, game): return True @watch_errors() def on_game_removed(self, game): """Simple method used to refresh the view""" remove_from_path_cache(game) self.get_missing_games() self.emit("view-updated") return True @watch_errors() def on_game_activated(self, view, game_id): """Handles view activations (double click, enter press)""" if self.service: logger.debug("Looking up %s game %s", self.service.id, game_id) db_game = games_db.get_game_for_service(self.service.id, game_id) if self.service.id == "lutris": if not db_game or not db_game["installed"]: self.service.install(game_id) return game_id = db_game["id"] else: if db_game and db_game["installed"]: game_id = db_game["id"] else: service_game = ServiceGameCollection.get_game(self.service.id, game_id) if not service_game: logger.error("No game %s found for %s", game_id, self.service.id) return game_id = self.service.install(service_game) if game_id: game = Game(game_id) if game.is_installed: game.emit("game-launch") else: game.emit("game-install") def on_watched_error(self, error): dialogs.ErrorDialog(error, parent=self) lutris-0.5.14/lutris/gui/views/000077500000000000000000000000001451435154700163775ustar00rootroot00000000000000lutris-0.5.14/lutris/gui/views/__init__.py000066400000000000000000000011431451435154700205070ustar00rootroot00000000000000"""Common values used for views""" ( COL_ID, COL_SLUG, COL_NAME, COL_SORTNAME, COL_MEDIA_PATH, COL_YEAR, COL_RUNNER, COL_RUNNER_HUMAN_NAME, COL_PLATFORM, COL_LASTPLAYED, COL_LASTPLAYED_TEXT, COL_INSTALLED, COL_INSTALLED_AT, COL_INSTALLED_AT_TEXT, COL_PLAYTIME, COL_PLAYTIME_TEXT, ) = list(range(16)) COLUMN_NAMES = { COL_NAME: "name", COL_YEAR: "year", COL_RUNNER_HUMAN_NAME: "runner", COL_PLATFORM: "platform", COL_LASTPLAYED_TEXT: "lastplayed", COL_INSTALLED_AT_TEXT: "installedat", COL_PLAYTIME_TEXT: "playtime", } lutris-0.5.14/lutris/gui/views/base.py000066400000000000000000000064351451435154700176730ustar00rootroot00000000000000from gi.repository import Gdk, Gio, GObject, Gtk from lutris.database.games import get_game_for_service from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.game_actions import GameActions from lutris.gui.views import COL_ID from lutris.gui.widgets.contextual_menu import ContextualMenu class GameView: # pylint: disable=no-member __gsignals__ = { "game-selected": (GObject.SIGNAL_RUN_FIRST, None, (Gtk.TreeIter,)), "game-activated": (GObject.SIGNAL_RUN_FIRST, None, (str,)), } def __init__(self, service): self.current_path = None self.service = service def connect_signals(self): """Signal handlers common to all views""" self.connect("button-press-event", self.popup_contextual_menu) self.connect("key-press-event", self.handle_key_press) def popup_contextual_menu(self, view, event): """Contextual menu.""" if event.button != Gdk.BUTTON_SECONDARY: return view.current_path = view.get_path_at_pos(event.x, event.y) if view.current_path: view.select() model = self.get_model() _iter = model.get_iter(view.current_path[0]) if not _iter: return col_id = str(model.get_value(_iter, COL_ID)) game_actions = self.get_game_actions(col_id) if game_actions: contextual_menu = ContextualMenu(game_actions.get_game_actions()) contextual_menu.popup(event, game_actions) def get_game_actions(self, game_id): if self.service: db_game = get_game_for_service(self.service.id, game_id) if db_game: game = self.get_game_by_id(db_game["id"]) else: db_game = ServiceGameCollection.get_game(self.service.id, game_id) game = Game.create_empty_service_game(db_game, self.service) elif game_id: game = self.get_game_by_id(game_id) else: return None return GameActions(game, window=self.get_toplevel()) def get_game_by_id(self, game_id): application = Gio.Application.get_default() game = application.get_running_game_by_id(game_id) if application else None return game or Game(game_id) def get_selected_game_id(self): selected_item = self.get_selected_item() if selected_item: return self.get_model().get_value(selected_item, COL_ID) return None def select(self): """Selects the object pointed by current_path""" raise NotImplementedError def handle_key_press(self, widget, event): # pylint: disable=unused-argument key = event.keyval if key == Gdk.KEY_Delete: game_id = self.get_selected_game_id() if game_id: game_actions = self.get_game_actions(game_id) if game_actions and game_actions.is_game_removable: game_actions.on_remove_game(self) elif key == Gdk.KEY_Break: game_id = self.get_selected_game_id() if game_id: game_actions = self.get_game_actions(game_id) if game_actions and game_actions.is_game_running: game_actions.on_game_stop(self) lutris-0.5.14/lutris/gui/views/grid.py000066400000000000000000000073411451435154700177030ustar00rootroot00000000000000"""Grid view for the main window""" # pylint: disable=no-member from gi.repository import Gtk from lutris import settings from lutris.gui.views import COL_ID, COL_INSTALLED, COL_MEDIA_PATH, COL_NAME, COL_PLATFORM from lutris.gui.views.base import GameView from lutris.gui.widgets.cellrenderers import GridViewCellRendererImage, GridViewCellRendererText from lutris.util.log import logger class GameGridView(Gtk.IconView, GameView): __gsignals__ = GameView.__gsignals__ min_width = 70 # Minimum width for a cell def __init__(self, store, hide_text=False): Gtk.IconView.__init__(self) GameView.__init__(self, store.service) self.set_column_spacing(6) self._show_badges = True if settings.SHOW_MEDIA: self.image_renderer = GridViewCellRendererImage() self.pack_start(self.image_renderer, False) self._initialize_image_renderer_attributes() else: self.image_renderer = None self.set_item_padding(1) if hide_text: self.text_renderer = None else: self.text_renderer = GridViewCellRendererText() self.pack_end(self.text_renderer, False) self.add_attribute(self.text_renderer, "markup", COL_NAME) self.set_game_store(store) self.connect_signals() self.connect("item-activated", self.on_item_activated) self.connect("selection-changed", self.on_selection_changed) self.connect("style-updated", self.on_style_updated) def set_game_store(self, game_store): self.game_store = game_store self.service_media = game_store.service_media self.model = game_store.store self.set_model(self.model) size = game_store.service_media.size if self.image_renderer: self.image_renderer.media_width = size[0] self.image_renderer.media_height = size[1] if self.text_renderer: cell_width = max(size[0], self.min_width) self.text_renderer.set_width(cell_width) @property def show_badges(self): return self._show_badges @show_badges.setter def show_badges(self, value): if self._show_badges != value: self._show_badges = value self._initialize_image_renderer_attributes() self.queue_draw() def _initialize_image_renderer_attributes(self): if self.image_renderer: self.image_renderer.show_badges = self.show_badges self.clear_attributes(self.image_renderer) self.add_attribute(self.image_renderer, "game_id", COL_ID) self.add_attribute(self.image_renderer, "media_path", COL_MEDIA_PATH) self.add_attribute(self.image_renderer, "platform", COL_PLATFORM) self.add_attribute(self.image_renderer, "is_installed", COL_INSTALLED) def select(self): self.select_path(self.current_path) def get_selected_item(self): """Return the currently selected game's id.""" selection = self.get_selected_items() if not selection: return self.current_path = selection[0] return self.get_model().get_iter(self.current_path) def on_item_activated(self, _view, _path): """Handles double clicks""" selected_id = self.get_selected_game_id() logger.debug("Item activated: %s", selected_id) self.emit("game-activated", selected_id) def on_selection_changed(self, _view): """Handles selection changes""" selected_items = self.get_selected_item() if selected_items: self.emit("game-selected", selected_items) def on_style_updated(self, widget): if self.text_renderer: self.text_renderer.clear_caches() lutris-0.5.14/lutris/gui/views/list.py000066400000000000000000000165541451435154700177370ustar00rootroot00000000000000"""TreeView based game list""" from gettext import gettext as _ # Third Party Libraries # pylint: disable=no-member from gi.repository import Gdk, Gtk, Pango # Lutris Modules from lutris import settings from lutris.gui.views import ( COL_INSTALLED, COL_INSTALLED_AT, COL_INSTALLED_AT_TEXT, COL_LASTPLAYED, COL_LASTPLAYED_TEXT, COL_MEDIA_PATH, COL_NAME, COL_PLATFORM, COL_PLAYTIME, COL_PLAYTIME_TEXT, COL_RUNNER_HUMAN_NAME, COL_SORTNAME, COL_YEAR, COLUMN_NAMES ) from lutris.gui.views.base import GameView from lutris.gui.views.store import sort_func from lutris.gui.widgets.cellrenderers import GridViewCellRendererImage class GameListView(Gtk.TreeView, GameView): """Show the main list of games.""" __gsignals__ = GameView.__gsignals__ def __init__(self, store): Gtk.TreeView.__init__(self) GameView.__init__(self, store.service) self.set_rules_hint(True) # Image column if settings.SHOW_MEDIA: self.image_renderer = GridViewCellRendererImage() self.media_column = Gtk.TreeViewColumn("", self.image_renderer, media_path=COL_MEDIA_PATH, is_installed=COL_INSTALLED) self.media_column.set_reorderable(True) self.media_column.set_sort_indicator(False) self.media_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) self.append_column(self.media_column) else: self.image_renderer = None self.media_column = None self.set_game_store(store) # Text columns default_text_cell = self.set_text_cell() name_cell = self.set_text_cell() name_cell.set_padding(5, 0) self.set_column(name_cell, _("Name"), COL_NAME, 200, always_visible=True) self.set_sort_with_column(COL_NAME, COL_SORTNAME) self.set_column(default_text_cell, _("Year"), COL_YEAR, 60) self.set_column(default_text_cell, _("Runner"), COL_RUNNER_HUMAN_NAME, 120) self.set_column(default_text_cell, _("Platform"), COL_PLATFORM, 120) self.set_column(default_text_cell, _("Last Played"), COL_LASTPLAYED_TEXT, 120) self.set_column(default_text_cell, _("Play Time"), COL_PLAYTIME_TEXT, 100) self.set_column(default_text_cell, _("Installed At"), COL_INSTALLED_AT_TEXT, 120) self.get_selection().set_mode(Gtk.SelectionMode.SINGLE) self.connect_signals() self.connect("row-activated", self.on_row_activated) self.get_selection().connect("changed", self.on_cursor_changed) def set_game_store(self, game_store): self.game_store = game_store self.service_media = game_store.service_media self.model = game_store.store self.set_model(self.model) self.set_sort_with_column(COL_LASTPLAYED_TEXT, COL_LASTPLAYED) self.set_sort_with_column(COL_INSTALLED_AT_TEXT, COL_INSTALLED_AT) self.set_sort_with_column(COL_PLAYTIME_TEXT, COL_PLAYTIME) size = game_store.service_media.size if self.image_renderer: self.image_renderer.media_width = size[0] self.image_renderer.media_height = size[1] if self.media_column: media_width = size[0] self.media_column.set_fixed_width(media_width) @staticmethod def set_text_cell(): text_cell = Gtk.CellRendererText() text_cell.set_padding(10, 0) text_cell.set_property("ellipsize", Pango.EllipsizeMode.END) return text_cell def set_column(self, cell, header, column_id, default_width, always_visible=False, sort_id=None): column = Gtk.TreeViewColumn(header, cell, markup=column_id) column.set_sort_indicator(True) column.set_sort_column_id(column_id if sort_id is None else sort_id) self.set_column_sort(column_id if sort_id is None else sort_id) column.set_resizable(True) column.set_reorderable(True) width = settings.read_setting("%s_column_width" % COLUMN_NAMES[column_id], "list view") is_visible = settings.read_setting("%s_visible" % COLUMN_NAMES[column_id], "list view") column.set_fixed_width(int(width) if width else default_width) column.set_visible(is_visible == "True" or always_visible if is_visible else True) self.append_column(column) column.connect("notify::width", self.on_column_width_changed) column.get_button().connect('button-press-event', self.on_column_header_button_pressed) return column def set_column_sort(self, col): """Sort a column and fallback to sorting by name and runner.""" model = self.get_model() if model: model.set_sort_func(col, sort_func, col) def set_sort_with_column(self, col, sort_col): """Sort a column by using another column's data""" self.model.set_sort_func(col, sort_func, sort_col) def get_selected_item(self): """Return the currently selected game's id.""" selection = self.get_selection() if not selection: return None _model, select_iter = selection.get_selected() if select_iter: return select_iter def select(self): self.set_cursor(self.current_path[0]) def set_selected_game(self, game_id): row = self.game_store.get_row_by_id(game_id, filtered=True) if row: self.set_cursor(row.path) def on_column_header_button_pressed(self, button, event): """Handles column header button press events""" if event.button == Gdk.BUTTON_SECONDARY: menu = GameListColumnToggleMenu(self.get_columns()) menu.popup_at_pointer(None) return True def on_row_activated(self, widget, line=None, column=None): """Handles double clicks""" selected_id = self.get_selected_game_id() self.emit("game-activated", selected_id) def on_cursor_changed(self, widget, _line=None, _column=None): selected_item = self.get_selected_item() self.emit("game-selected", selected_item) @staticmethod def on_column_width_changed(col, *args): col_name = col.get_title() if col_name: settings.write_setting( col_name.replace(" ", "") + "_column_width", col.get_fixed_width(), "list view", ) class GameListColumnToggleMenu(Gtk.Menu): def __init__(self, columns): super().__init__() self.columns = columns self.column_map = {} self.create_menuitems() self.show_all() def create_menuitems(self): for column in self.columns: title = column.get_title() if title == "": continue checkbox = Gtk.CheckMenuItem(title) checkbox.set_active(column.get_visible()) if title == _("Name"): checkbox.set_sensitive(False) else: checkbox.connect("toggled", self.on_toggle_column) self.column_map[checkbox] = column self.append(checkbox) def on_toggle_column(self, check_menu_item): column = self.column_map[check_menu_item] is_visible = check_menu_item.get_active() column.set_visible(is_visible) settings.write_setting( column.get_title().replace(" ", "") + "_visible", str(is_visible), "list view", ) lutris-0.5.14/lutris/gui/views/media_loader.py000066400000000000000000000022111451435154700213520ustar00rootroot00000000000000"""Loads game media in parallel""" import concurrent.futures from lutris.gui.widgets.utils import invalidate_media_caches from lutris.util import system from lutris.util.log import logger def download_media(media_urls, service_media): """Download a list of media files concurrently. Limits the number of simultaneous downloads to avoid API throttling and UI being overloaded with signals. """ icons = {} num_workers = 5 with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: future_downloads = { executor.submit(service_media.download, slug, url): slug for slug, url in media_urls.items() if url } for future in concurrent.futures.as_completed(future_downloads): slug = future_downloads[future] try: path = future.result() except Exception as ex: # pylint: disable=broad-except logger.exception('%r failed: %s', slug, ex) path = None if system.path_exists(path): icons[slug] = path invalidate_media_caches() return icons lutris-0.5.14/lutris/gui/views/store.py000066400000000000000000000143061451435154700201110ustar00rootroot00000000000000"""Store object for a list of games""" # pylint: disable=not-an-iterable import time from gi.repository import GLib, GObject, Gtk from lutris import settings from lutris.database import sql from lutris.database.games import get_games from lutris.gui.views.store_item import StoreItem from lutris.util.strings import gtk_safe from . import ( COL_ID, COL_INSTALLED, COL_INSTALLED_AT, COL_INSTALLED_AT_TEXT, COL_LASTPLAYED, COL_LASTPLAYED_TEXT, COL_MEDIA_PATH, COL_NAME, COL_PLATFORM, COL_PLAYTIME, COL_PLAYTIME_TEXT, COL_RUNNER, COL_RUNNER_HUMAN_NAME, COL_SLUG, COL_SORTNAME, COL_YEAR ) def try_lower(value): try: out = value.lower() except AttributeError: out = value return out def sort_func(model, row1, row2, sort_col): """Sorting function for the game store""" value1 = model.get_value(row1, sort_col) value2 = model.get_value(row2, sort_col) if value1 is None and value2 is None: value1 = value2 = 0 elif value1 is None: value1 = type(value2)() elif value2 is None: value2 = type(value1)() value1 = try_lower(value1) value2 = try_lower(value2) diff = -1 if value1 < value2 else 0 if value1 == value2 else 1 if diff == 0: value1 = try_lower(model.get_value(row1, COL_SORTNAME)) value2 = try_lower(model.get_value(row2, COL_SORTNAME)) try: diff = -1 if value1 < value2 else 0 if value1 == value2 else 1 except TypeError: diff = 0 if diff == 0: value1 = try_lower(model.get_value(row1, COL_RUNNER_HUMAN_NAME)) value2 = try_lower(model.get_value(row2, COL_RUNNER_HUMAN_NAME)) try: return -1 if value1 < value2 else 0 if value1 == value2 else 1 except TypeError: return 0 class GameStore(GObject.Object): __gsignals__ = { "icons-changed": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, service, service_media): super().__init__() self.service = service self.service_media = service_media self._installed_games = [] self._installed_games_accessed = False self._icon_updates = {} self.store = Gtk.ListStore( str, str, str, str, str, str, str, str, str, int, str, bool, int, str, float, str, ) @property def installed_game_slugs(self): previous_access = self._installed_games_accessed or 0 self._installed_games_accessed = time.time() if self._installed_games_accessed - previous_access > 1: self._installed_games = [g["slug"] for g in get_games(filters={"installed": "1"})] return self._installed_games def add_games(self, games): """Add games to the store""" for game in list(games): GLib.idle_add(self.add_game, game) def get_row_by_slug(self, slug): for model_row in self.store: if model_row[COL_SLUG] == slug: return model_row def get_row_by_id(self, _id): if not _id: return for model_row in self.store: try: if model_row[COL_ID] == str(_id): return model_row except TypeError: return def remove_game(self, _id): """Remove a game from the view.""" row = self.get_row_by_id(_id) if row: self.store.remove(row.iter) def update(self, db_game): """Update game informations Return whether a row was updated; False if the game was not already present. """ store_item = StoreItem(db_game, self.service_media) row = self.get_row_by_id(store_item.id) if not row: row = self.get_row_by_id(db_game["service_id"]) if not row: return False row[COL_ID] = str(store_item.id) row[COL_SLUG] = store_item.slug row[COL_NAME] = store_item.name row[COL_SORTNAME] = store_item.sortname if store_item.sortname else store_item.name row[COL_MEDIA_PATH] = store_item.get_media_path() if settings.SHOW_MEDIA else None row[COL_YEAR] = store_item.year row[COL_RUNNER] = store_item.runner row[COL_RUNNER_HUMAN_NAME] = store_item.runner_text row[COL_PLATFORM] = store_item.platform row[COL_LASTPLAYED] = store_item.lastplayed row[COL_LASTPLAYED_TEXT] = store_item.lastplayed_text row[COL_INSTALLED] = store_item.installed row[COL_INSTALLED_AT] = store_item.installed_at row[COL_INSTALLED_AT_TEXT] = store_item.installed_at_text row[COL_PLAYTIME] = store_item.playtime row[COL_PLAYTIME_TEXT] = store_item.playtime_text return True def add_game(self, db_game): """Add a PGA game to the store""" game = StoreItem(db_game, self.service_media) self.store.append( ( str(game.id), game.slug, game.name, game.sortname if game.sortname else game.name, game.get_media_path() if settings.SHOW_MEDIA else None, game.year, game.runner, game.runner_text, gtk_safe(game.platform), game.lastplayed, game.lastplayed_text, game.installed, game.installed_at, game.installed_at_text, game.playtime, game.playtime_text, ) ) def on_game_updated(self, game): if self.service: db_games = sql.filtered_query( settings.PGA_DB, "service_games", filters=({ "service": self.service_media.service, "appid": game.appid }) ) else: db_games = sql.filtered_query( settings.PGA_DB, "games", filters=({ "id": game.id }) ) for db_game in db_games: GLib.idle_add(self.update, db_game) return True lutris-0.5.14/lutris/gui/views/store_item.py000066400000000000000000000124631451435154700211310ustar00rootroot00000000000000"""Game representation for views""" import time from lutris.database import games from lutris.database.games import get_service_games from lutris.runners import get_runner_human_name from lutris.services import SERVICES from lutris.util.log import logger from lutris.util.strings import get_formatted_playtime, gtk_safe class StoreItem: """Representation of a game for views TODO: Fix overlap with Game class """ def __init__(self, game_data, service_media): if not game_data: raise RuntimeError("No game data provided") self._game_data = game_data self._cached_installed_game_data = None self.service_media = service_media def __str__(self): return self.name def __repr__(self): return "" % (self.id, self.slug) @property def _installed_game_data(self): """Provides- and caches- the DB data for the installed game corresponding to this one, if it's a service game. We can get away with caching this because StoreItem instances are very short-lived, so the game won't be changed underneath us.""" appid = self._game_data.get("appid") if appid: if self._cached_installed_game_data is None: self._cached_installed_game_data = games.get_game_for_service(self.service, self._game_data["appid"]) or {} return self._cached_installed_game_data return None def _get_game_attribute(self, key): value = self._game_data.get(key) if not value: game_data = self._installed_game_data if game_data: value = game_data.get(key) return value @property def id(self): # pylint: disable=invalid-name """Game internal ID""" # Return a unique identifier for the game. # Since service games are not related to lutris, use the appid if "service_id" not in self._game_data: if "appid" in self._game_data: return self._game_data["appid"] return self._game_data["slug"] return self._game_data["id"] @property def service(self): return gtk_safe(self._game_data.get("service")) @property def slug(self): """Slug identifier""" return gtk_safe(self._game_data["slug"]) @property def name(self): """Name""" return gtk_safe(self._game_data["name"]) @property def sortname(self): """Name used for sorting""" return gtk_safe(self._get_game_attribute("sortname") or "") @property def year(self): """Year""" return str(self._get_game_attribute("year") or "") @property def runner(self): """Runner slug""" _runner = self._get_game_attribute("runner") return gtk_safe(_runner) or "" @property def runner_text(self): """Runner name""" return gtk_safe(get_runner_human_name(self.runner)) @property def platform(self): """Platform""" _platform = self._get_game_attribute("platform") if not _platform and self.service in SERVICES: service = SERVICES[self.service]() _platforms = service.get_game_platforms(self._game_data) if _platforms: _platform = ", ".join(_platforms) return gtk_safe(_platform) @property def installed(self): """Game is installed""" if "service_id" not in self._game_data: return self.id in get_service_games(self.service) if not self._game_data.get("runner"): return False return self._game_data.get("installed") def get_media_path(self): """Returns the path to the image file for this item""" if self._game_data.get("icon"): return self._game_data["icon"] return self.service_media.get_media_path(self.slug) @property def installed_at(self): """Date of install""" return self._get_game_attribute("installed_at") @property def installed_at_text(self): """Date of install (textual representation)""" return gtk_safe( time.strftime("%X %x", time.localtime(self.installed_at)) if self.installed_at else "" ) @property def lastplayed(self): """Date of last play""" return self._get_game_attribute("lastplayed") @property def lastplayed_text(self): """Date of last play (textual representation)""" return gtk_safe( time.strftime( "%X %x", time.localtime(self.lastplayed) ) if self.lastplayed else "" ) @property def playtime(self): """Playtime duration in hours""" try: return float(self._get_game_attribute("playtime") or 0) except (TypeError, ValueError): return 0.0 @property def playtime_text(self): """Playtime duration in hours (textual representation)""" try: _playtime_text = get_formatted_playtime(self.playtime) except ValueError: logger.warning("Invalid playtime value %s for %s", self.playtime, self) _playtime_text = "" # Do not show erroneous values return gtk_safe(_playtime_text) lutris-0.5.14/lutris/gui/widgets/000077500000000000000000000000001451435154700167105ustar00rootroot00000000000000lutris-0.5.14/lutris/gui/widgets/__init__.py000066400000000000000000000000001451435154700210070ustar00rootroot00000000000000lutris-0.5.14/lutris/gui/widgets/cellrenderers.py000066400000000000000000000446251451435154700221260ustar00rootroot00000000000000# pylint:disable=using-constant-test # pylint:disable=comparison-with-callable from gettext import gettext as _ from math import floor import gi gi.require_version('PangoCairo', '1.0') import cairo from gi.repository import GLib, GObject, Gtk, Pango, PangoCairo from lutris.gui.widgets.utils import ( get_default_icon_path, get_media_generation_number, get_runtime_icon_path, get_scaled_surface_by_path, get_surface_size ) from lutris.scanners.lutris import is_game_missing class GridViewCellRendererText(Gtk.CellRendererText): """CellRendererText adjusted for grid view display, removes extra padding and caches cell metrics for improved resize performance.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.props.alignment = Pango.Alignment.CENTER self.props.wrap_mode = Pango.WrapMode.WORD self.props.xalign = 0.5 self.props.yalign = 0 self.fixed_width = None self.cached_height = {} self.cached_width = {} def set_width(self, width): self.fixed_width = width self.props.wrap_width = width self.clear_caches() def clear_caches(self): self.cached_height.clear() self.cached_width.clear() def do_get_preferred_width(self, widget): text = self.props.text # pylint:disable=no-member if self.fixed_width and text in self.cached_width: return self.cached_width[text] width = Gtk.CellRendererText.do_get_preferred_width(self, widget) if self.fixed_width: self.cached_width[text] = width return width def do_get_preferred_width_for_height(self, widget, width): text = self.props.text # pylint:disable=no-member if self.fixed_width and text in self.cached_width: return self.cached_width[text] width = Gtk.CellRendererText.do_get_preferred_width_for_height(self, widget, width) if self.fixed_width: self.cached_width[text] = width return width def do_get_preferred_height(self, widget): text = self.props.text # pylint:disable=no-member if self.fixed_width and text in self.cached_height: return self.cached_height[text] height = Gtk.CellRendererText.do_get_preferred_height(self, widget) if self.fixed_width: self.cached_height[text] = height return height def do_get_preferred_height_for_width(self, widget, width): text = self.props.text # pylint:disable=no-member if self.fixed_width and text in self.cached_height: return self.cached_height[text] height = Gtk.CellRendererText.do_get_preferred_height_for_width(self, widget, width) if self.fixed_width: self.cached_height[text] = height return height class GridViewCellRendererImage(Gtk.CellRenderer): """A pixbuf cell renderer that takes not the pixbuf but a path to an image file; it loads that image only when rendering. It also has properties for its width and height, so it need not load the pixbuf to know its size.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._media_width = 0 self._media_height = 0 self._game_id = None self._media_path = None self._show_badges = True self._platform = None self._is_installed = True self.cached_surfaces_new = {} self.cached_surfaces_old = {} self.cached_surfaces_loaded = 0 self.cycle_cache_idle_id = None self.cached_surface_generation = 0 self.badge_size = 0, 0 self.badge_alpha = 0.6 self.badge_fore_color = 1, 1, 1 self.badge_back_color = 0, 0, 0 @GObject.Property(type=int, default=0) def media_width(self): """This is the width of the media being rendered; if the cell is larger it will be centered in the cell area.""" return self._media_width @media_width.setter def media_width(self, value): self._media_width = value self.clear_cache() @GObject.Property(type=int, default=0) def media_height(self): """This is the height of the media being rendered; if the cell is larger it will be at the bottom of the cell area.""" return self._media_height @media_height.setter def media_height(self, value): self._media_height = value self.clear_cache() @GObject.Property(type=str) def game_id(self): """This is the path to the media file to be displayed.""" return self._game_id @game_id.setter def game_id(self, value): self._game_id = value @GObject.Property(type=str) def media_path(self): """This is the path to the media file to be displayed.""" return self._media_path @media_path.setter def media_path(self, value): self._media_path = value @GObject.Property(type=bool, default=True) def show_badges(self): """This is the path to the media file to be displayed.""" return self._show_badges @show_badges.setter def show_badges(self, value): self._show_badges = value @GObject.Property(type=str) def platform(self): """This is the platform text, a comma separated list; we try to convert this into icons, if it is not None.""" return self._platform @platform.setter def platform(self, value): self._platform = value @GObject.Property(type=bool, default=True) def is_installed(self): """This flag indicates if the game is installed; if not the media is shown faded out.""" return self._is_installed @is_installed.setter def is_installed(self, value): self._is_installed = value def do_get_preferred_width(self, widget): return self.media_width, self.media_width def do_get_preferred_height(self, widget): return self.media_height, self.media_height def do_render(self, cr, widget, background_area, cell_area, flags): media_width = self.media_width media_height = self.media_height path = self.media_path alpha = 1 if self.is_installed else 100 / 255 if media_width > 0 and media_height > 0 and path: surface = self.get_cached_surface_by_path(widget, path) if not surface: # The default icon needs to be scaled to fill the cell space. path = get_default_icon_path((media_width, media_height)) surface = self.get_cached_surface_by_path(widget, path, preserve_aspect_ratio=False) if surface: x, y = self.get_media_position(surface, cell_area) self.select_badge_metrics(surface) if alpha >= 1: self.render_media(cr, widget, surface, x, y) if self.show_badges: self.render_platforms(cr, widget, surface, x, cell_area) if self.game_id and is_game_missing(self.game_id): self.render_text_badge(cr, widget, _("Missing"), x, cell_area.y + cell_area.height) else: cr.push_group() self.render_media(cr, widget, surface, x, y) if self.show_badges: self.render_platforms(cr, widget, surface, x, cell_area) cr.pop_group_to_source() cr.paint_with_alpha(alpha) # Idle time will wait until the widget has drawn whatever it wants to; # we can then discard surfaces we aren't using anymore. if not self.cycle_cache_idle_id: self.cycle_cache_idle_id = GLib.idle_add(self.cycle_cache) def select_badge_metrics(self, surface): """Updates fields holding data about the appearance of the badges; this sets self.badge_size to None if no badges should be shown at all.""" def get_badge_icon_size(): """Returns the size of the badge icons to render, or None to hide them. We check width for the smallest size because Dolphin has very thin banners, but we only hide badges for icons, not banners.""" if self.media_width < 64: return None if self.media_height < 128: return 16, 16 if self.media_height < 256: return 24, 24 return 32, 32 self.badge_size = get_badge_icon_size() on_bright_surface = self.badge_size and GridViewCellRendererImage.is_bright_corner(surface, self.badge_size) bright_color = 0.8, 0.8, 0.8 dark_color = 0.2, 0.2, 0.2 self.badge_fore_color = dark_color if on_bright_surface else bright_color self.badge_back_color = bright_color if on_bright_surface else dark_color @staticmethod def is_bright_corner(surface, corner_size): """Tests several pixels near the corner of the surface where the badges are drawn. If all are 'bright', we'll render the badges differently. This means all 4 components must be at least 128/255.""" surface_format = surface.get_format() # We only use the ARGB32 format, so we just give up # for anything else. if surface_format != cairo.FORMAT_ARGB32: # pylint:disable=no-member return False # Scale the corner according to the surface's scale factor - # normally the same as our UI scale factor. device_scale_x, device_scale_y = surface.get_device_scale() corner_pixel_width = int(corner_size[0] * device_scale_x) corner_pixel_height = int(corner_size[1] * device_scale_y) pixel_width = surface.get_width() pixel_height = surface.get_height() def is_bright_pixel(x, y): # Checks if a pixel is 'bright'; this does not care # if the pixel is big or little endian- it just checks # all four channels. if 0 <= x < pixel_width and 0 <= y < pixel_height: stride = surface.get_stride() data = surface.get_data() offset = (y * stride) + x * 4 pixel = data[offset: offset + 4] for channel in pixel: if channel < 128: return False return True return False return ( is_bright_pixel(pixel_width - 1, pixel_height - 1) and is_bright_pixel(pixel_width - corner_pixel_width, pixel_height - 1) and is_bright_pixel(pixel_width - 1, pixel_height - corner_pixel_height) and is_bright_pixel(pixel_width - corner_pixel_width, pixel_height - corner_pixel_height) ) def get_media_position(self, surface, cell_area): """Computes the position of the upper left corner where we will render a surface within the cell area.""" width, height = get_surface_size(surface) x = round(cell_area.x + (cell_area.width - width) / 2) # centered y = round(cell_area.y + cell_area.height - height) # at bottom of cell return x, y def render_media(self, cr, widget, surface, x, y): """Renders the media itself, given the surface containing it and the position.""" width, height = get_surface_size(surface) cr.set_source_surface(surface, x, y) cr.get_source().set_extend(cairo.Extend.PAD) # pylint: disable=no-member cr.rectangle(x, y, width, height) cr.fill() def render_platforms(self, cr, widget, surface, surface_x, cell_area): """Renders the stack of platform icons. They appear lined up vertically to the right of 'media_right', if that will fit in 'cell_area'.""" platform = self.platform if platform and self.badge_size: if "," in platform: platforms = platform.split(",") # pylint:disable=no-member else: platforms = [platform] icon_paths = [get_runtime_icon_path(p + "-symbolic") for p in platforms] icon_paths = [path for path in icon_paths if path] if icon_paths: self.render_badge_stack(cr, widget, surface, surface_x, icon_paths, cell_area) def render_badge_stack(self, cr, widget, surface, surface_x, icon_paths, cell_area): """Renders a vertical stack of badges, placed at the edge of the media, off to the right of 'media_right' if this will fit in the 'cell_area'. The icons in icon_paths are drawn from top to bottom, and spaced to fit in 'cell_area', even if they overlap because of this.""" badge_width = self.badge_size[0] badge_height = self.badge_size[1] alpha = self.badge_alpha fore_color = self.badge_fore_color back_color = self.badge_back_color def render_badge(badge_x, badge_y, path): cr.rectangle(badge_x, badge_y, badge_width, badge_height) cr.set_source_rgba(back_color[0], back_color[1], back_color[2], alpha) cr.fill() icon = self.get_cached_surface_by_path(widget, path, size=self.badge_size) cr.set_source_rgba(fore_color[0], fore_color[1], fore_color[2], alpha) cr.mask_surface(icon, badge_x, badge_y) media_right = surface_x + get_surface_size(surface)[0] x = media_right - badge_width spacing = (cell_area.height - badge_height * len(icon_paths)) / max(1, len(icon_paths) - 1) spacing = min(spacing, 1) y_offset = floor(badge_height + spacing) y = cell_area.y + cell_area.height - badge_height - y_offset * (len(icon_paths) - 1) for icon_path in icon_paths: render_badge(x, y, icon_path) y = y + y_offset def render_text_badge(self, cr, widget, text, left, bottom): """Draws a short text in the lower left corner of the media, in the style of a badge.""" def get_layout(): """Constructs a layout with the text to draw, but also returns its size in pixels. This is boldfaced, but otherwise in the default font.""" lo = widget.create_pango_layout(text) font = lo.get_context().get_font_description() font.set_weight(Pango.Weight.BOLD) lo.set_font_description(font) _, text_bounds = lo.get_extents() return lo, text_bounds.width / Pango.SCALE, text_bounds.height / Pango.SCALE if self.badge_size: alpha = self.badge_alpha fore_color = self.badge_fore_color back_color = self.badge_back_color layout, text_width, text_height = get_layout() cr.save() # To get the text to be as tall as a badge, we'll scale it # with Cairo. Scaling the font size does not work; the font # size measures the wrong height for this. text_scale = self.badge_size[1] / text_height text_height = self.badge_size[1] text_width = round(text_width * text_scale) cr.rectangle(left, bottom - text_height, text_width + 4, text_height) cr.set_source_rgba(back_color[0], back_color[1], back_color[2], alpha) cr.fill() cr.translate(left + 2, bottom - text_height) cr.scale(text_scale, text_scale) cr.set_source_rgba(fore_color[0], fore_color[1], fore_color[2], alpha) PangoCairo.update_layout(cr, layout) PangoCairo.show_layout(cr, layout) cr.restore() # Looks like we need to make cr.restore() take effect for # explicitly, or further text in this cairo context winds up scaled. # It must be doing something squirrely with the context that we just # spoiled with cr.restore(), and this fixes that. PangoCairo.update_layout(cr, layout) def clear_cache(self): """Discards all cached surfaces; used when some properties are changed.""" self.cached_surfaces_old.clear() self.cached_surfaces_new.clear() def cycle_cache(self): """Is the key cache size control trick. When called, the surfaces cached or used since the last call are preserved, but those not touched are discarded. We call this at idle time after rendering a cell; this should keep all the surfaces rendered at that time, so during scrolling the visible media are kept and scrolling is smooth. At other times we may discard almost all surfaces, saving memory. We skip clearing anything if no surfaces have been loaded; this happens if drawing was serviced entirely from cache. GTK may have redrawn just one image or something, so let's not disturb the cache for that.""" if self.cached_surfaces_loaded > 0: self.cached_surfaces_old = self.cached_surfaces_new self.cached_surfaces_new = {} self.cached_surfaces_loaded = 0 self.cycle_cache_idle_id = None def get_cached_surface_by_path(self, widget, path, size=None, preserve_aspect_ratio=True): """This obtains the scaled surface to rander for a given media path; this is cached in this render, but we'll clear that cache when the media generation number is changed, or certain properties are. We also age surfaces from the cache at idle time after rendering.""" if self.cached_surface_generation != get_media_generation_number(): self.cached_surface_generation = get_media_generation_number() self.clear_cache() key = widget, path, size, preserve_aspect_ratio if key in self.cached_surfaces_new: return self.cached_surfaces_new[key] if key in self.cached_surfaces_old: surface = self.cached_surfaces_old[key] else: surface = self.get_surface_by_path(widget, path, size, preserve_aspect_ratio) # We cache missing surfaces too, but only a successful load trigger # cache cycling if surface: self.cached_surfaces_loaded += 1 self.cached_surfaces_new[key] = surface return surface def get_surface_by_path(self, widget, path, size=None, preserve_aspect_ratio=True): cell_size = size or (self.media_width, self.media_height) scale_factor = widget.get_scale_factor() if widget else 1 return get_scaled_surface_by_path(path, cell_size, scale_factor, preserve_aspect_ratio=preserve_aspect_ratio) lutris-0.5.14/lutris/gui/widgets/common.py000066400000000000000000000334331451435154700205600ustar00rootroot00000000000000"""Misc widgets used in the GUI.""" # Standard Library import os import shlex import urllib.parse from gettext import gettext as _ # Third Party Libraries from gi.repository import GLib, GObject, Gtk, Pango # Lutris Modules from lutris.util import system from lutris.util.linux import LINUX_SYSTEM class SlugEntry(Gtk.Entry, Gtk.Editable): def do_insert_text(self, new_text, length, position): """Filter inserted characters to only accept alphanumeric and dashes""" new_text = "".join([c for c in new_text if c.isalnum() or c == "-"]).lower() length = len(new_text) self.get_buffer().insert_text(position, new_text, length) return position + length class NumberEntry(Gtk.Entry, Gtk.Editable): def do_insert_text(self, new_text, length, position): """Filter inserted characters to only accept numbers""" new_text = "".join([c for c in new_text if c.isnumeric()]) if new_text: self.get_buffer().insert_text(position, new_text, length) return position + length return position class FileChooserEntry(Gtk.Box): """Editable entry with a file picker button""" max_completion_items = 15 # Maximum number of items to display in the autocompletion dropdown. __gsignals__ = { "changed": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__( self, title=_("Select file"), action=Gtk.FileChooserAction.OPEN, text=None, default_path=None, warn_if_non_empty=False, warn_if_ntfs=False, activates_default=False, shell_quoting=False ): super().__init__( orientation=Gtk.Orientation.VERTICAL, spacing=0, visible=True ) self.title = title self.action = action self.warn_if_non_empty = warn_if_non_empty self.warn_if_ntfs = warn_if_ntfs self.shell_quoting = shell_quoting self.path_completion = Gtk.ListStore(str) self.entry = Gtk.Entry(visible=True) self.set_text(text) # do before set up signal handlers self.original_text = self.get_text() self.default_path = os.path.expanduser(default_path) if default_path else self.get_path() self.entry.set_activates_default(activates_default) self.entry.set_completion(self.get_completion()) self.entry.connect("changed", self.on_entry_changed) self.entry.connect("activate", self.on_activate) self.entry.connect("focus-out-event", self.on_focus_out) self.entry.connect("backspace", self.on_backspace) browse_button = Gtk.Button(_("Browse..."), visible=True) browse_button.connect("clicked", self.on_browse_clicked) box = Gtk.Box(spacing=6, visible=True) box.pack_start(self.entry, True, True, 0) box.add(browse_button) self.pack_start(box, False, False, 0) def set_text(self, text): if self.shell_quoting and text: command_array = shlex.split(text) if command_array: expanded = os.path.expanduser(command_array[0]) command_array[0] = expanded rejoined = shlex.join(command_array) self.original_text = rejoined self.entry.set_text(rejoined) return expanded = os.path.expanduser(text) if text else "" self.original_text = expanded self.entry.set_text(expanded) def set_path(self, path): if self.shell_quoting: command_array = shlex.split(self.get_text()) if command_array: command_array[0] = os.path.expanduser(path) if path else "" rejoined = shlex.join(command_array) self.original_text = rejoined self.entry.set_text(rejoined) return expanded = os.path.expanduser(path) if path else "" self.original_text = expanded self.entry.set_text(expanded) def get_text(self): """Return the entry's text. If shell_quoting is one, this is actually a command line (with argument quoting) and not a simple path.""" return self.entry.get_text() def get_path(self): """Returns the path in the entry; if shell_quoting is set, this extracts the command from the text and returns only that.""" text = self.get_text() if self.shell_quoting: command_array = shlex.split(text) return command_array[0] if command_array else "" return text def get_completion(self): """Return an EntryCompletion widget""" completion = Gtk.EntryCompletion() completion.set_model(self.path_completion) completion.set_text_column(0) return completion def get_filechooser_dialog(self): """Return an instance of a FileChooserNative configured for this widget""" dialog = Gtk.FileChooserNative.new(self.title, self.get_toplevel(), self.action, _("_OK"), _("_Cancel")) dialog.set_create_folders(True) dialog.set_current_folder(self.get_default_folder()) return dialog def get_default_folder(self): """Return the default folder for the file picker""" default_path = self.get_path() or self.default_path or "" if not default_path or not system.path_exists(default_path): current_entry = self.get_text() if system.path_exists(current_entry): default_path = current_entry if not os.path.isdir(default_path): default_path = os.path.dirname(default_path) return os.path.expanduser(default_path or "~") def on_browse_clicked(self, _widget): """Browse button click callback""" file_chooser_dialog = self.get_filechooser_dialog() response = file_chooser_dialog.run() if response == Gtk.ResponseType.ACCEPT: target_path = file_chooser_dialog.get_filename() if target_path and self.shell_quoting: command_array = shlex.split(self.entry.get_text()) text = shlex.join([target_path] + command_array[1:]) else: text = target_path self.original_text = text self.entry.set_text(text) file_chooser_dialog.destroy() def on_entry_changed(self, widget): """Entry changed callback""" self.clear_warnings() # If the user isn't editing this entry, we'll apply updates # immediately upon any change if not self.entry.has_focus(): if self.normalize_path(): # We changed the text on commit, so we return here to avoid a double changed signal return text = self.get_text() path = self.get_path() self.original_text = text if self.warn_if_ntfs and LINUX_SYSTEM.get_fs_type_for_path(path) == "ntfs": ntfs_box = Gtk.Box(spacing=6, visible=True) warning_image = Gtk.Image(visible=True) warning_image.set_from_icon_name("dialog-warning", Gtk.IconSize.DND) ntfs_box.add(warning_image) ntfs_label = Gtk.Label(visible=True) ntfs_label.set_markup(_( "Warning! The selected path is located on a drive formatted by Windows.\n" "Games and programs installed on Windows drives don't work." )) ntfs_box.add(ntfs_label) self.pack_end(ntfs_box, False, False, 10) if self.warn_if_non_empty and os.path.exists(path) and os.listdir(path): non_empty_label = Gtk.Label(visible=True) non_empty_label.set_markup(_( "Warning! The selected path " "contains files. Installation will not work properly." )) self.pack_end(non_empty_label, False, False, 10) parent = system.get_existing_parent(path) if parent is not None and not os.access(parent, os.W_OK): non_writable_destination_label = Gtk.Label(visible=True) non_writable_destination_label.set_markup(_( "Warning The destination folder " "is not writable by the current user." )) self.pack_end(non_writable_destination_label, False, False, 10) self.emit("changed") def on_activate(self, _widget): self.normalize_path() self.detect_changes() def on_focus_out(self, _widget, _event): self.normalize_path() self.detect_changes() def on_backspace(self, _widget): GLib.idle_add(self.detect_changes) def detect_changes(self): """Detects if the text has changed and updates self.original_text and fires the changed signal. Lame, but Gtk.Entry does not always fire its changed event when edited!""" new_text = self.get_text() if self.original_text != new_text: self.original_text = new_text self.emit("changed") return False # used as idle function def normalize_path(self): original_path = self.get_path() path = original_path.strip("\r\n") if path.startswith('file:///'): path = urllib.parse.unquote(path[len('file://'):]) path = os.path.expanduser(path) self.update_completion(path) if path != original_path: self.set_path(path) return True return False def update_completion(self, current_path): """Update the auto-completion widget with the current path""" self.path_completion.clear() if not os.path.exists(current_path): current_path, filefilter = os.path.split(current_path) else: filefilter = None if os.path.isdir(current_path): index = 0 for filename in sorted(os.listdir(current_path)): if filename.startswith("."): continue if filefilter is not None and not filename.startswith(filefilter): continue self.path_completion.append([os.path.join(current_path, filename)]) index += 1 if index > self.max_completion_items: break def clear_warnings(self): """Delete all the warning labels from the container""" for index, child in enumerate(self.get_children()): if index > 0: child.destroy() class Label(Gtk.Label): """Standardised label for config vboxes.""" def __init__(self, message=None, width_request=230): """Custom init of label.""" super().__init__(label=message) self.set_line_wrap(True) self.set_max_width_chars(22) self.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) self.set_size_request(width_request, -1) self.set_alignment(0, 0.5) self.set_justify(Gtk.Justification.LEFT) class VBox(Gtk.Box): def __init__(self, **kwargs): super().__init__(orientation=Gtk.Orientation.VERTICAL, margin_top=18, **kwargs) class EditableGrid(Gtk.Grid): __gsignals__ = {"changed": (GObject.SIGNAL_RUN_FIRST, None, ())} def __init__(self, data, columns): self.columns = columns super().__init__() self.set_column_homogeneous(True) self.set_row_homogeneous(True) self.set_row_spacing(6) self.set_column_spacing(6) self.liststore = Gtk.ListStore(str, str) for item in data: self.liststore.append([str(value) for value in item]) self.treeview = Gtk.TreeView.new_with_model(self.liststore) self.treeview.set_grid_lines(Gtk.TreeViewGridLines.BOTH) for i, column_title in enumerate(self.columns): renderer = Gtk.CellRendererText() renderer.set_property("editable", True) renderer.connect("edited", self.on_text_edited, i) column = Gtk.TreeViewColumn(column_title, renderer, text=i) column.set_resizable(True) column.set_min_width(100) column.set_sort_column_id(0) self.treeview.append_column(column) self.buttons = [] self.add_button = Gtk.Button(_("Add")) self.buttons.append(self.add_button) self.add_button.connect("clicked", self.on_add) self.delete_button = Gtk.Button(_("Delete")) self.buttons.append(self.delete_button) self.delete_button.connect("clicked", self.on_delete) self.scrollable_treelist = Gtk.ScrolledWindow() self.scrollable_treelist.set_vexpand(True) self.scrollable_treelist.add(self.treeview) self.scrollable_treelist.set_shadow_type(Gtk.ShadowType.IN) self.attach(self.scrollable_treelist, 0, 0, 5, 5) self.attach(self.add_button, 5 - len(self.buttons), 6, 1, 1) for i, button in enumerate(self.buttons[1:]): self.attach_next_to(button, self.buttons[i], Gtk.PositionType.RIGHT, 1, 1) self.show_all() def on_add(self, widget): # pylint: disable=unused-argument self.liststore.append(["", ""]) row_position = len(self.liststore) - 1 self.treeview.set_cursor(row_position, None, False) self.treeview.scroll_to_cell(row_position, None, False, 0.0, 0.0) self.emit("changed") def on_delete(self, widget): # pylint: disable=unused-argument selection = self.treeview.get_selection() _, iteration = selection.get_selected() self.liststore.remove(iteration) self.emit("changed") def on_text_edited(self, widget, path, text, field): # pylint: disable=unused-argument self.liststore[path][field] = text.strip() # pylint: disable=unsubscriptable-object self.emit("changed") def get_data(self): # pylint: disable=arguments-differ model_data = [] for row in self.liststore: # pylint: disable=not-an-iterable model_data.append(row) return model_data lutris-0.5.14/lutris/gui/widgets/contextual_menu.py000066400000000000000000000051721451435154700225010ustar00rootroot00000000000000from gi.repository import Gtk def update_action_widget_visibility(widgets, visible_predicate): """This sets the visibility on a set of widgets, like menu items. You provide a function that indicates if an item is visible, or None for separators that are visible based on their neighbors.""" previous_visible_widget = None for w in widgets: visible = visible_predicate(w) if visible is None: if previous_visible_widget is None: visible = False else: visible = visible_predicate(previous_visible_widget) is not None w.set_visible(visible) if visible: previous_visible_widget = w if visible_predicate(previous_visible_widget) is None: previous_visible_widget.set_visible(False) class ContextualMenu(Gtk.Menu): def __init__(self, main_entries): super().__init__() self.main_entries = main_entries def add_menuitem(self, entry): """Add a menu item to the current menu Params: entry (tuple): tuple containing name, label and callback Returns: Gtk.MenuItem """ name, label, callback = entry if label == "-": separator = Gtk.SeparatorMenuItem() self.append(separator) return separator action = Gtk.Action(name=name, label=label) action.connect("activate", callback) menu_item = action.create_menu_item() menu_item.action_id = name self.append(menu_item) return menu_item def get_runner_entries(self, game): if not game: return None runner = game.runner if not runner: return None return runner.context_menu_entries def popup(self, event, game_actions, game=None, service=None): for item in self.get_children(): self.remove(item) for entry in self.main_entries: self.add_menuitem(entry) if game_actions.game.runner_name and game_actions.game.is_installed: runner_entries = self.get_runner_entries(game) if runner_entries: self.append(Gtk.SeparatorMenuItem()) for entry in runner_entries: self.add_menuitem(entry) self.show_all() displayed = game_actions.get_displayed_entries() def is_visible(w): if isinstance(w, Gtk.SeparatorMenuItem): return None return displayed.get(w.action_id, True) update_action_widget_visibility(self.get_children(), is_visible) super().popup_at_pointer(event) lutris-0.5.14/lutris/gui/widgets/download_collection_progress_box.py000066400000000000000000000207421451435154700261050ustar00rootroot00000000000000import time from gettext import gettext as _ from gi.repository import GLib, GObject, Gtk, Pango from lutris.util.downloader import Downloader from lutris.util.log import logger from lutris.util.strings import gtk_safe, human_size # Same reason as Downloader get_time = time.monotonic class DownloadCollectionProgressBox(Gtk.Box): """Progress bar used to monitor a collection of files download.""" max_retries = 3 __gsignals__ = { "complete": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,)), "cancel": (GObject.SignalFlags.RUN_LAST, None, ()), "error": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,)), } def __init__(self, file_collection, cancelable=True, downloader=None): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.downloader = downloader self.is_complete = False self._file_queue = file_collection.files_list.copy() self._file_download = None # file being downloaded self.title = file_collection.human_url self.num_files_downloaded = 0 self.num_files_to_download = file_collection.num_files self.num_retries = 0 self.full_size = file_collection.full_size self.current_size = 0 self.time_left = "00:00:00" self.time_left_check_time = 0 self.last_size = 0 self.avg_speed = 0 self.speed_list = [] top_box = Gtk.Box() self.main_label = Gtk.Label(self.title) self.main_label.set_alignment(0, 0) self.main_label.set_property("wrap", True) self.main_label.set_margin_bottom(10) self.main_label.set_selectable(True) self.main_label.set_property("ellipsize", Pango.EllipsizeMode.MIDDLE) top_box.pack_start(self.main_label, False, False, 0) self.cancel_button = Gtk.Button.new_with_mnemonic(_("_Cancel")) self.cancel_cb_id = self.cancel_button.connect("clicked", self.on_cancel_clicked) if not cancelable: self.cancel_button.set_sensitive(False) top_box.pack_end(self.cancel_button, False, False, 0) self.pack_start(top_box, True, True, 0) self.file_name_label = Gtk.Label() self.file_name_label.set_alignment(0, 0) self.file_name_label.set_property("wrap", True) self.file_name_label.set_margin_bottom(10) self.file_name_label.set_selectable(True) self.file_name_label.set_property("ellipsize", Pango.EllipsizeMode.MIDDLE) self.pack_start(self.file_name_label, True, True, 0) progress_box = Gtk.Box() self.progressbar = Gtk.ProgressBar() self.progressbar.set_margin_top(5) self.progressbar.set_margin_bottom(5) self.progressbar.set_margin_right(10) progress_box.pack_start(self.progressbar, True, True, 0) self.pack_start(progress_box, False, False, 0) self.progress_label = Gtk.Label() self.progress_label.set_alignment(0, 0) self.pack_start(self.progress_label, True, True, 0) self.show_all() self.cancel_button.hide() def update_download_file_label(self, file_name): """Update file label to file being downloaded""" self.file_name_label.set_text(file_name) def get_new_file_from_queue(self): """Set downloaded file to new file from queue or None if empty""" if self._file_queue: self._file_download = self._file_queue.pop() return self._file_download = None def start(self): """Start downloading a file.""" if not self._file_queue: self.cancel_button.set_sensitive(False) self.is_complete = True self.emit("complete", {}) return None if not self._file_download: self.get_new_file_from_queue() self.num_retries = 0 file = self._file_download self.update_download_file_label(file.filename) if not self.downloader: try: self.downloader = Downloader(file.url, file.dest_file, referer=file.referer, overwrite=True) except RuntimeError as ex: from lutris.gui.dialogs import ErrorDialog ErrorDialog(ex.args[0]) self.emit("cancel") return None timer_id = GLib.timeout_add(500, self._progress) self.cancel_button.show() self.cancel_button.set_sensitive(True) if not self.downloader.state == self.downloader.DOWNLOADING: self.downloader.start() return timer_id def set_retry_button(self): """Transform the cancel button into a retry button""" self.cancel_button.set_label(_("Retry")) self.cancel_button.disconnect(self.cancel_cb_id) self.cancel_cb_id = self.cancel_button.connect("clicked", self.on_retry_clicked) self.cancel_button.set_sensitive(True) def on_retry_clicked(self, button): """Retry current download.""" logger.debug("Retrying download") button.set_label(_("Cancel")) button.disconnect(self.cancel_cb_id) self.cancel_cb_id = button.connect("clicked", self.on_cancel_clicked) self.downloader.reset() self.start() def on_cancel_clicked(self, _widget=None): """Cancel the current download.""" logger.debug("Download cancel requested") if self.downloader: self.downloader.cancel() self.cancel_button.set_sensitive(False) self.emit("cancel") def _progress(self): """Show download progress of current file.""" if self.downloader.state in [self.downloader.CANCELLED, self.downloader.ERROR]: self.progressbar.set_fraction(0) if self.downloader.state == self.downloader.CANCELLED: self._set_text(_("Download interrupted")) self.emit("cancel") else: if self.num_retries > self.max_retries: self._set_text(str(self.downloader.error)[:80]) self.emit("error") return False self.num_retries += 1 if self.downloader: self.downloader.reset() self.start() return False downloaded_size = self.current_size + self.downloader.downloaded_size progress = 0 if self.full_size > 0: progress = min(downloaded_size / self.full_size, 1) self.progressbar.set_fraction(progress) self.update_speed_and_time() megabytes = 1024 * 1024 progress_text = _( "{downloaded} / {size} ({speed:0.2f}MB/s), {time} remaining" ).format( downloaded=human_size(downloaded_size), size=human_size(self.full_size), speed=float(self.avg_speed) / megabytes, time=self.time_left, ) self._set_text(progress_text) if self.downloader.state == self.downloader.COMPLETED: self.num_files_downloaded += 1 self.current_size += self.downloader.downloaded_size # set file to None to get next one self._file_download = None self.downloader = None # start the downloader to a new file or finish self.start() return False return True def update_speed_and_time(self): """Update time left and average speed.""" elapsed_time = get_time() - self.time_left_check_time if elapsed_time < 1: # Minimum delay return if not self.downloader: self.time_left = "???" return downloaded_size = self.current_size + self.downloader.downloaded_size elapsed_size = downloaded_size - self.last_size self.last_size = downloaded_size speed = elapsed_size / elapsed_time # last 20 speeds if len(self.speed_list) >= 20: self.speed_list.pop(0) self.speed_list.append(speed) self.avg_speed = sum(self.speed_list) / len(self.speed_list) if self.avg_speed == 0: self.time_left = "???" return average_time_left = (self.full_size - downloaded_size) / self.avg_speed minutes, seconds = divmod(average_time_left, 60) hours, minutes = divmod(minutes, 60) self.time_left_check_time = get_time() self.time_left = "%d:%02d:%02d" % (hours, minutes, seconds) def _set_text(self, text): markup = "{}".format(gtk_safe(text)) self.progress_label.set_markup(markup) lutris-0.5.14/lutris/gui/widgets/download_progress_box.py000066400000000000000000000123731451435154700236730ustar00rootroot00000000000000from gettext import gettext as _ from urllib.parse import urlparse from gi.repository import GLib, GObject, Gtk, Pango from lutris.util.downloader import Downloader from lutris.util.log import logger from lutris.util.strings import gtk_safe, human_size class DownloadProgressBox(Gtk.Box): """Progress bar used to monitor a file download.""" __gsignals__ = { "complete": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT, )), "cancel": (GObject.SignalFlags.RUN_LAST, None, ()), "error": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT, )), } def __init__(self, params, cancelable=True, downloader=None): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.downloader = downloader self.is_complete = False self.url = params.get("url") self.dest = params.get("dest") self.referer = params.get("referer") self.main_label = Gtk.Label(self.get_title()) self.main_label.set_alignment(0, 0) self.main_label.set_property("wrap", True) self.main_label.set_margin_bottom(10) # self.main_label.set_max_width_chars(70) self.main_label.set_selectable(True) self.main_label.set_property("ellipsize", Pango.EllipsizeMode.MIDDLE) self.pack_start(self.main_label, True, True, 0) progress_box = Gtk.Box() self.progressbar = Gtk.ProgressBar() self.progressbar.set_margin_top(5) self.progressbar.set_margin_bottom(5) self.progressbar.set_margin_right(10) progress_box.pack_start(self.progressbar, True, True, 0) self.cancel_button = Gtk.Button.new_with_mnemonic(_("_Cancel")) self.cancel_cb_id = self.cancel_button.connect("clicked", self.on_cancel_clicked) if not cancelable: self.cancel_button.set_sensitive(False) progress_box.pack_end(self.cancel_button, False, False, 0) self.pack_start(progress_box, False, False, 0) self.progress_label = Gtk.Label() self.progress_label.set_alignment(0, 0) self.pack_start(self.progress_label, True, True, 0) self.show_all() self.cancel_button.hide() def get_title(self): """Return the main label text for the widget""" parsed = urlparse(self.url) return "%s%s" % (parsed.netloc, parsed.path) def start(self): """Start downloading a file.""" if not self.downloader: try: self.downloader = Downloader(self.url, self.dest, referer=self.referer, overwrite=True) except RuntimeError as ex: from lutris.gui.dialogs import ErrorDialog ErrorDialog(ex.args[0]) self.emit("cancel") return None timer_id = GLib.timeout_add(500, self._progress) self.cancel_button.show() self.cancel_button.set_sensitive(True) if not self.downloader.state == self.downloader.DOWNLOADING: self.downloader.start() return timer_id def set_retry_button(self): """Transform the cancel button into a retry button""" self.cancel_button.set_label(_("Retry")) self.cancel_button.disconnect(self.cancel_cb_id) self.cancel_cb_id = self.cancel_button.connect("clicked", self.on_retry_clicked) self.cancel_button.set_sensitive(True) def on_retry_clicked(self, button): logger.debug("Retrying download") button.set_label(_("Cancel")) button.disconnect(self.cancel_cb_id) self.cancel_cb_id = button.connect("clicked", self.on_cancel_clicked) self.downloader.reset() self.start() def on_cancel_clicked(self, _widget=None): """Cancel the current download.""" logger.debug("Download cancel requested") if self.downloader: self.downloader.cancel() self.cancel_button.set_sensitive(False) self.emit("cancel") def _progress(self): """Show download progress.""" progress = min(self.downloader.check_progress(), 1) if self.downloader.state in [self.downloader.CANCELLED, self.downloader.ERROR]: self.progressbar.set_fraction(0) if self.downloader.state == self.downloader.CANCELLED: self._set_text(_("Download interrupted")) self.emit("cancel") else: self._set_text(str(self.downloader.error)[:80]) return False self.progressbar.set_fraction(progress) megabytes = 1024 * 1024 progress_text = _( "{downloaded} / {size} ({speed:0.2f}MB/s), {time} remaining" ).format( downloaded=human_size(self.downloader.downloaded_size), size=human_size(self.downloader.full_size), speed=float(self.downloader.average_speed) / megabytes, time=self.downloader.time_left, ) self._set_text(progress_text) if self.downloader.state == self.downloader.COMPLETED: self.cancel_button.set_sensitive(False) self.is_complete = True self.emit("complete", {}) return False return True def _set_text(self, text): markup = "{}".format(gtk_safe(text)) self.progress_label.set_markup(markup) lutris-0.5.14/lutris/gui/widgets/game_bar.py000066400000000000000000000300101451435154700210110ustar00rootroot00000000000000from datetime import datetime from gettext import gettext as _ from gi.repository import GObject, Gtk, Pango from lutris import runners, services from lutris.database.games import get_game_for_service from lutris.exceptions import watch_errors from lutris.game import Game from lutris.game_actions import GameActions from lutris.gui.dialogs import ErrorDialog from lutris.gui.widgets.contextual_menu import update_action_widget_visibility from lutris.util.strings import gtk_safe class GameBar(Gtk.Box): def __init__(self, db_game, application, window): """Create the game bar with a database row""" super().__init__(orientation=Gtk.Orientation.VERTICAL, visible=True, margin_top=12, margin_left=12, margin_bottom=12, margin_right=12, spacing=6) self.application = application self.window = window self.game_start_hook_id = GObject.add_emission_hook(Game, "game-start", self.on_game_state_changed) self.game_started_hook_id = GObject.add_emission_hook(Game, "game-started", self.on_game_state_changed) self.game_stopped_hook_id = GObject.add_emission_hook(Game, "game-stopped", self.on_game_state_changed) self.game_updated_hook_id = GObject.add_emission_hook(Game, "game-updated", self.on_game_state_changed) self.game_removed_hook_id = GObject.add_emission_hook(Game, "game-removed", self.on_game_state_changed) self.game_installed_hook_id = GObject.add_emission_hook(Game, "game-installed", self.on_game_state_changed) self.connect("destroy", self.on_destroy) self.set_margin_bottom(12) self.db_game = db_game self.service = None if db_game.get("service"): try: self.service = services.SERVICES[db_game["service"]]() except KeyError: pass game_id = None if "service_id" in db_game: self.appid = db_game["service_id"] game_id = db_game["id"] elif self.service: self.appid = db_game["appid"] game = get_game_for_service(self.service.id, self.appid) if game: game_id = game["id"] if game_id: self.game = application.get_running_game_by_id(game_id) or Game(game_id) else: self.game = Game.create_empty_service_game(db_game, self.service) self.update_view() def on_destroy(self, widget): GObject.remove_emission_hook(Game, "game-start", self.game_start_hook_id) GObject.remove_emission_hook(Game, "game-started", self.game_started_hook_id) GObject.remove_emission_hook(Game, "game-stopped", self.game_stopped_hook_id) GObject.remove_emission_hook(Game, "game-updated", self.game_updated_hook_id) GObject.remove_emission_hook(Game, "game-removed", self.game_removed_hook_id) GObject.remove_emission_hook(Game, "game-installed", self.game_installed_hook_id) return True def clear_view(self): """Clears all widgets from the container""" for child in self.get_children(): child.destroy() def update_view(self): """Populate the view with widgets""" game_actions = GameActions(self.game, window=self.window, application=self.application) game_label = self.get_game_name_label() game_label.set_halign(Gtk.Align.START) self.pack_start(game_label, False, False, 0) hbox = Gtk.Box(Gtk.Orientation.HORIZONTAL, spacing=6) self.pack_start(hbox, False, False, 0) self.play_button = self.get_play_button(game_actions) hbox.pack_start(self.play_button, False, False, 0) if self.game.is_installed: hbox.pack_start(self.get_runner_button(), False, False, 0) hbox.pack_start(self.get_platform_label(), False, False, 0) if self.game.lastplayed: hbox.pack_start(self.get_last_played_label(), False, False, 0) if self.game.playtime: hbox.pack_start(self.get_playtime_label(), False, False, 0) hbox.show_all() @staticmethod def get_popover_box(primary_button, popover_buttons, primary_opens_popover=False): """Creates a box that contains a primary button and a second button that opens a popover with the popover_buttons in it; these have a linked style so this looks like a single button. If primary_opens_popover is true, this method also handled the 'clicked' signal of the primary button to trigger the popover as well.""" def get_popover(parent): # Creates the popover widget containing the list of link buttons pop = Gtk.Popover() vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True) vbox.set_border_width(9) vbox.set_spacing(3) for button in popover_buttons: vbox.pack_end(button, False, False, 0) pop.add(vbox) pop.set_position(Gtk.PositionType.TOP) pop.set_constrain_to(Gtk.PopoverConstraint.NONE) pop.set_relative_to(parent) return pop box = Gtk.HBox(visible=True) style_context = box.get_style_context() style_context.add_class("linked") box.pack_start(primary_button, False, False, 0) if popover_buttons: popover_button = Gtk.MenuButton(direction=Gtk.ArrowType.UP, visible=True) popover_button.set_size_request(32, 32) popover = get_popover(popover_button) popover_button.set_popover(popover) box.pack_start(popover_button, False, False, 0) if primary_opens_popover: primary_button.connect("clicked", lambda _x: popover_button.emit("clicked")) return box def get_link_button(self, text, callback=None): """Return a suitable button for a menu popover; this must be a ModelButton to be styled correctly.""" if text == "-": return Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL, visible=True) button = Gtk.ModelButton(text, visible=True, xalign=0.0) if callback: button.connect("clicked", self.on_link_button_clicked, callback) return button def get_game_name_label(self): """Return the label with the game's title""" title_label = Gtk.Label(visible=True) title_label.set_ellipsize(Pango.EllipsizeMode.END) title_label.set_markup("%s" % gtk_safe(self.game.name)) return title_label def get_runner_button(self): icon_name = self.game.runner.name + "-symbolic" runner_icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU) runner_popover_buttons = self.get_runner_buttons() if runner_popover_buttons: runner_button = Gtk.Button(image=runner_icon, visible=True) return GameBar.get_popover_box(runner_button, runner_popover_buttons, primary_opens_popover=True) runner_icon.set_margin_left(49) runner_icon.set_margin_right(6) return runner_icon def get_platform_label(self): platform_label = Gtk.Label(visible=True) platform_label.set_size_request(120, -1) platform_label.set_alignment(0, 0.5) platform = gtk_safe(self.game.platform) platform_label.set_tooltip_markup(platform) platform_label.set_markup(_("Platform:\n%s") % platform) platform_label.set_property("ellipsize", Pango.EllipsizeMode.END) return platform_label def get_playtime_label(self): """Return the label containing the playtime info""" playtime_label = Gtk.Label(visible=True) playtime_label.set_size_request(120, -1) playtime_label.set_alignment(0, 0.5) playtime_label.set_markup(_("Time played:\n%s") % self.game.formatted_playtime) return playtime_label def get_last_played_label(self): """Return the label containing the last played info""" last_played_label = Gtk.Label(visible=True) last_played_label.set_size_request(120, -1) last_played_label.set_alignment(0, 0.5) lastplayed = datetime.fromtimestamp(self.game.lastplayed) last_played_label.set_markup(_("Last played:\n%s") % lastplayed.strftime("%b %-d %Y")) return last_played_label def get_locate_installed_game_button(self, game_actions): """Return a button to locate an existing install""" button = self.get_link_button(_("Locate installed game")) button.connect("clicked", game_actions.on_locate_installed_game, self.game) return button def get_play_button(self, game_actions): """Return the widget for install/play/stop and game config""" button = Gtk.Button(visible=True) button.set_size_request(120, 32) game_buttons = None if self.game.is_installed: game_buttons = self.get_game_buttons(game_actions) if self.game.state == self.game.STATE_STOPPED: button.set_label(_("Play")) button.connect("clicked", game_actions.on_game_launch) elif self.game.state == self.game.STATE_LAUNCHING: button.set_label(_("Launching")) button.set_sensitive(False) else: button.set_label(_("Stop")) button.connect("clicked", game_actions.on_game_stop) else: button.set_label(_("Install")) button.connect("clicked", game_actions.on_install_clicked) if self.service: if self.service.local: # Local services don't show an install dialog, they can be launched directly button.set_label(_("Play")) if self.service.drm_free: game_buttons = [self.get_locate_installed_game_button(game_actions)] if game_buttons: button.set_size_request(84, 32) box = GameBar.get_popover_box(button, game_buttons) return box return button def get_game_buttons(self, game_actions): """Return a list of buttons to use in the panel""" displayed = game_actions.get_displayed_entries() buttons = [] button_visibility = {} for action_id, label, callback in game_actions.get_game_actions(): if action_id in ("play", "stop", "install"): continue button = self.get_link_button(label, callback) if action_id: button_visibility[button] = displayed.get(action_id, True) buttons.append(button) update_action_widget_visibility(buttons, lambda w: button_visibility.get(w, None)) return buttons def get_runner_buttons(self): buttons = [] if self.game.runner_name and self.game.is_installed: runner = runners.import_runner(self.game.runner_name)(self.game.config) for _name, label, callback in runner.context_menu_entries: button = self.get_link_button(label, callback) buttons.append(button) return buttons @watch_errors() def on_link_button_clicked(self, button, callback): """Callback for link buttons. Closes the popover then runs the actual action""" popover = button.get_parent().get_parent() popover.popdown() callback(button) @watch_errors() def on_install_clicked(self, button): """Handler for installing service games""" self.service.install(self.db_game) @watch_errors() def on_game_state_changed(self, game): """Handler called when the game has changed state""" if ( (self.game.is_db_stored and game.id == self.game.id) or (self.appid and game.appid == self.appid) ): self.game = game elif self.game != game: return True self.clear_view() self.update_view() return True def on_watched_error(self, error): ErrorDialog(error, parent=self.get_toplevel()) lutris-0.5.14/lutris/gui/widgets/gi_composites.py000066400000000000000000000226561451435154700221410ustar00rootroot00000000000000"""GtkTemplate implementation for PyGI Blog post http://www.virtualroadside.com/blog/index.php/2015/05/24/gtk3-composite-widget-templates-for-python/ Github https://github.com/virtuald/pygi-composite-templates/blob/master/gi_composites.py This should have landed in PyGObect and will be available without this shim in the future. See: https://gitlab.gnome.org/GNOME/pygobject/merge_requests/52 """ # # Copyright (C) 2015 Dustin Spicuzza # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 # USA # Standard Library import inspect import warnings from os.path import abspath, join # Third Party Libraries from gi.repository import Gio, GLib, GObject, Gtk # Lutris Modules from lutris.gui.dialogs import ErrorDialog __all__ = ["GtkTemplate"] class GtkTemplateWarning(UserWarning): pass def _connect_func(builder, obj, signal_name, handler_name, connect_object, flags, cls): """Handles GtkBuilder signal connect events""" if connect_object is None: extra = () else: extra = (connect_object, ) # The handler name refers to an attribute on the template instance, # so ask GtkBuilder for the template instance template_inst = builder.get_object(cls.__gtype_name__) if template_inst is None: # This should never happen errmsg = ( "Internal error: cannot find template instance! obj: %s; " "signal: %s; handler: %s; connect_obj: %s; class: %s" % (obj, signal_name, handler_name, connect_object, cls) ) warnings.warn(errmsg, GtkTemplateWarning) return handler = getattr(template_inst, handler_name) if flags == GObject.ConnectFlags.AFTER: obj.connect_after(signal_name, handler, *extra) else: obj.connect(signal_name, handler, *extra) template_inst.__connected_template_signals__.add(handler_name) def _register_template(cls, template_bytes): """Registers the template for the widget and hooks init_template""" # This implementation won't work if there are nested templates, but # we can't do that anyways due to PyGObject limitations so it's ok if not hasattr(cls, "set_template"): ErrorDialog("Your Linux distribution is too old. Lutris won't function properly.") raise TypeError("Requires PyGObject 3.13.2 or greater") cls.set_template(template_bytes) bound_methods = set() bound_widgets = set() # Walk the class, find marked callbacks and child attributes for name in dir(cls): o = getattr(cls, name, None) if inspect.ismethod(o): if hasattr(o, "_gtk_callback"): bound_methods.add(name) # Don't need to call this, as connect_func always gets called # cls.bind_template_callback_full(name, o) elif isinstance(o, _Child): cls.bind_template_child_full(name, True, 0) bound_widgets.add(name) # Have to setup a special connect function to connect at template init # because the methods are not bound yet cls.set_connect_func(_connect_func, cls) cls.__gtemplate_methods__ = bound_methods cls.__gtemplate_widgets__ = bound_widgets base_init_template = cls.init_template cls.init_template = lambda s: _init_template(s, cls, base_init_template) def _init_template(self, cls, base_init_template): """This would be better as an override for Gtk.Widget""" # TODO: could disallow using a metaclass.. but this is good enough # .. if you disagree, feel free to fix it and issue a PR :) if self.__class__ is not cls: raise TypeError("Inheritance from classes with @GtkTemplate decorators is not allowed at this time") connected_signals = set() self.__connected_template_signals__ = connected_signals base_init_template(self) for name in self.__gtemplate_widgets__: widget = self.get_template_child(cls, name) self.__dict__[name] = widget if widget is None: # Bug: if you bind a template child, and one of them was # not present, then the whole template is broken (and # it's not currently possible for us to know which # one is broken either -- but the stderr should show # something useful with a Gtk-CRITICAL message) raise AttributeError( "A missing child widget was set using " "GtkTemplate.Child and the entire " "template is now broken (widgets: %s)" % ", ".join(self.__gtemplate_widgets__) ) for name in self.__gtemplate_methods__.difference(connected_signals): errmsg = ("Signal '%s' was declared with @GtkTemplate.Callback " + "but was not present in template") % name warnings.warn(errmsg, GtkTemplateWarning) # TODO: Make it easier for IDE to introspect this class _Child: """ Assign this to an attribute in your class definition and it will be replaced with a widget defined in the UI file when init_template is called """ __slots__ = [] @staticmethod def widgets(count): """ Allows declaring multiple widgets with less typing:: button \ label1 \ label2 = GtkTemplate.Child.widgets(3) """ return [_Child() for _ in range(count)] class _GtkTemplate: """ Use this class decorator to signify that a class is a composite widget which will receive widgets and connect to signals as defined in a UI template. You must call init_template to cause the widgets/signals to be initialized from the template:: @GtkTemplate(ui='foo.ui') class Foo(Gtk.Box): def __init__(self): super().__init__() self.init_template() The 'ui' parameter can either be a file path or a GResource resource path:: @GtkTemplate(ui='/org/example/foo.ui') class Foo(Gtk.Box): pass To connect a signal to a method on your instance, do:: @GtkTemplate.Callback def on_thing_happened(self, widget): pass To create a child attribute that is retrieved from your template, add this to your class definition:: @GtkTemplate(ui='foo.ui') class Foo(Gtk.Box): widget = GtkTemplate.Child() Note: This is implemented as a class decorator, but if it were included with PyGI I suspect it might be better to do this in the GObject metaclass (or similar) so that init_template can be called automatically instead of forcing the user to do it. .. note:: Due to limitations in PyGObject, you may not inherit from python objects that use the GtkTemplate decorator. """ __ui_path__ = None @staticmethod def Callback(f): """ Decorator that designates a method to be attached to a signal from the template """ f._gtk_callback = True # pylint: disable=protected-access return f Child = _Child @staticmethod def set_ui_path(*path): """ If using file paths instead of resources, call this *before* loading anything that uses GtkTemplate, or it will fail to load your template file :param path: one or more path elements, will be joined together to create the final path TODO: Alternatively, could wait until first class instantiation before registering templates? Would need a metaclass... """ _GtkTemplate.__ui_path__ = abspath(join(*path)) # pylint: disable=no-value-for-parameter def __init__(self, ui): self.ui = ui def __call__(self, cls): if not issubclass(cls, Gtk.Widget): raise TypeError("Can only use @GtkTemplate on Widgets") # Nested templates don't work if hasattr(cls, "__gtemplate_methods__"): raise TypeError("Cannot nest template classes") # Load the template either from a resource path or a file # - Prefer the resource path first try: template_bytes = Gio.resources_lookup_data(self.ui, Gio.ResourceLookupFlags.NONE) except GLib.GError: ui = self.ui if isinstance(ui, (list, tuple)): ui = join(ui) if _GtkTemplate.__ui_path__ is not None: ui = join(_GtkTemplate.__ui_path__, ui) with open(ui, "rb") as fp: template_bytes = GLib.Bytes.new(fp.read()) _register_template(cls, template_bytes) return cls # Future shim support if this makes it into PyGI? # if hasattr(Gtk, 'GtkTemplate'): # GtkTemplate = lambda c: c # else: GtkTemplate = _GtkTemplate lutris-0.5.14/lutris/gui/widgets/log_text_view.py000066400000000000000000000065071451435154700221510ustar00rootroot00000000000000# Third Party Libraries from gi.repository import Gtk class LogTextView(Gtk.TextView): # pylint: disable=no-member def __init__(self, buffer=None, autoscroll=True, wrap_mode=Gtk.WrapMode.CHAR): super().__init__(visible=True) if buffer: self.set_buffer(buffer) self.set_editable(False) self.set_cursor_visible(False) self.set_monospace(True) self.set_left_margin(10) self.scroll_max = 0 self.set_wrap_mode(wrap_mode) self.get_style_context().add_class("lutris-logview") self.mark = self.create_new_mark(self.props.buffer.get_start_iter()) if autoscroll: self.connect("size-allocate", self.autoscroll) def autoscroll(self, *args): # pylint: disable=unused-argument adj = self.get_vadjustment() if adj.get_value() == self.scroll_max or self.scroll_max == 0: adj.set_value(adj.get_upper() - adj.get_page_size()) self.scroll_max = adj.get_value() else: self.scroll_max = adj.get_upper() - adj.get_page_size() def create_new_mark(self, buffer_iter): return self.props.buffer.create_mark(None, buffer_iter, True) def reset_search(self): self.props.buffer.delete_mark(self.mark) self.mark = self.create_new_mark(self.props.buffer.get_start_iter()) self.props.buffer.place_cursor(self.props.buffer.get_iter_at_mark(self.mark)) def find_first(self, searched_entry): self.reset_search() self.find_next(searched_entry) def find_next(self, searched_entry): buffer_iter = self.props.buffer.get_iter_at_mark(self.mark) next_occurence = buffer_iter.forward_search( searched_entry.get_text(), Gtk.TextSearchFlags.CASE_INSENSITIVE, None ) # Found nothing try from the beginning if next_occurence is None: next_occurence = self.props.buffer.get_start_iter( ).forward_search(searched_entry.get_text(), Gtk.TextSearchFlags.CASE_INSENSITIVE, None) # Highlight if result if next_occurence is not None: self.highlight(next_occurence[0], next_occurence[1]) self.props.buffer.delete_mark(self.mark) self.mark = self.create_new_mark(next_occurence[1]) def find_previous(self, searched_entry): # First go to the beginning of searched_entry string buffer_iter = self.props.buffer.get_iter_at_mark(self.mark) buffer_iter.backward_chars(len(searched_entry.get_text())) previous_occurence = buffer_iter.backward_search( searched_entry.get_text(), Gtk.TextSearchFlags.CASE_INSENSITIVE, None ) # Found nothing ? Try from the end if previous_occurence is None: previous_occurence = self.props.buffer.get_end_iter( ).backward_search(searched_entry.get_text(), Gtk.TextSearchFlags.CASE_INSENSITIVE, None) # Highlight if result if previous_occurence is not None: self.highlight(previous_occurence[0], previous_occurence[1]) self.props.buffer.delete_mark(self.mark) self.mark = self.create_new_mark(previous_occurence[1]) def highlight(self, range_start, range_end): self.props.buffer.select_range(range_start, range_end) # Focus self.scroll_mark_onscreen(self.mark) lutris-0.5.14/lutris/gui/widgets/navigation_stack.py000066400000000000000000000162621451435154700226150ustar00rootroot00000000000000"""Window used for game installers""" # pylint: disable=too-many-lines from gi.repository import Gtk class NavigationStack(Gtk.Stack): """ This is a Stack widget that supports a back button and lazy-creation of pages. Pages should be set up via add_named_factory(), then displayed with present_page(). However, you are meant to have 'present_X_page' functions that you pass to navigate_to_page(); this tracks the pages you visit, and when you navigate back,the presenter function will be called again. A presenter function can do more than just call present_page(); it can configure other aspects of the InstallerWindow. Packaging all this into a presenter function keeps things in sync as you navigate. A presenter function can return an exit function, called when you navigate away from the page again. """ def __init__(self, back_button, cancel_button=None, **kwargs): super().__init__(**kwargs) self.back_button = back_button self.cancel_button = cancel_button self.page_factories = {} self.stack_pages = {} self.navigation_stack = [] self.navigation_exit_handler = None self.current_page_presenter = None self.current_navigated_page_presenter = None self.back_allowed = True self.cancel_allowed = True def add_named_factory(self, name, factory): """This specifies the factory functioin for the page named; this function takes no arguments, but returns the page's widget.""" self.page_factories[name] = factory def set_back_allowed(self, is_allowed=True): """This turns the back button off, or back on.""" self.back_allowed = is_allowed self._update_back_button() def set_cancel_allowed(self, is_allowed=True): """This turns the back button off, or back on.""" self.cancel_allowed = is_allowed self._update_back_button() def _update_back_button(self): can_go_back = self.back_allowed and self.navigation_stack self.back_button.set_visible(can_go_back) self.cancel_button.set_visible(not can_go_back and self.cancel_allowed) def navigate_to_page(self, page_presenter): """Navigates to a page, by invoking 'page_presenter'. In addition, this updates the navigation state so navigate_back() and such work, they may call the presenter again. """ if self.current_navigated_page_presenter: self.navigation_stack.append(self.current_navigated_page_presenter) self._update_back_button() self._go_to_page(page_presenter, True, Gtk.StackTransitionType.SLIDE_LEFT) def jump_to_page(self, page_presenter): """Jumps to a page, without updating navigation state. This does not disturb the behavior of navigate_back(). This does invoke the exit handler of the current page. """ self._go_to_page(page_presenter, False, Gtk.StackTransitionType.NONE) def navigate_back(self): """This navigates to the previous page, if any. This will invoke the current page's exit function, and the previous page's presenter function. """ if self.navigation_stack and self.back_allowed: try: back_to = self.navigation_stack.pop() self._go_to_page(back_to, True, Gtk.StackTransitionType.SLIDE_RIGHT) finally: self._update_back_button() def navigate_home(self): """This navigates to the first page, effectively navigating back until it can go no further back. It does not actually traverse the intermediate pages though, but goes directly to the first.""" if self.navigation_stack and self.back_allowed: try: home = self.navigation_stack[0] self.navigation_stack.clear() self._go_to_page(home, True, Gtk.StackTransitionType.SLIDE_RIGHT) finally: self._update_back_button() def navigation_reset(self): """This reverse the effect of jump_to_page(), returning to the last page actually navigate to.""" if self.current_navigated_page_presenter: if self.current_page_presenter != self.current_navigated_page_presenter: self._go_to_page(self.current_navigated_page_presenter, True, Gtk.StackTransitionType.SLIDE_RIGHT) def save_current_page(self): """Returns a tuple containing information about the current page, to pass to restore_current_page().""" return (self.current_page_presenter, self.current_navigated_page_presenter) def restore_current_page(self, state): """Restores the current page to the one in effect when the state was generated. This does not disturb the navigation stack.""" page_presenter, navigated_presenter = state navigated = page_presenter == navigated_presenter self._go_to_page(page_presenter, navigated, Gtk.StackTransitionType.NONE) def _go_to_page(self, page_presenter, navigated, transition_type): """Switches to a page. If 'navigated' is True, then when you navigate away from this page, it can go on the navigation stack. It should be False for 'temporary' pages that are not part of normal navigation.""" exit_handler = self.navigation_exit_handler self.set_transition_type(transition_type) self.navigation_exit_handler = page_presenter() self.current_page_presenter = page_presenter if navigated: self.current_navigated_page_presenter = page_presenter if exit_handler: exit_handler() self._update_back_button() def discard_navigation(self): """This throws away the navigation history, so the back button is disabled. Previous pages before the current become inaccessible.""" self.navigation_stack.clear() self._update_back_button() def present_page(self, name): """This displays the page names, creating it if required. It also calls show_all() on newly created pages. This should be called by your presenter functions.""" if name not in self.stack_pages: factory = self.page_factories[name] page = factory() page.show_all() self.add_named(page, name) self.stack_pages[name] = page self.set_visible_child_name(name) return self.stack_pages[name] def present_replacement_page(self, name, page): """This displays a page that is given, rather than lazy-creating one. It still needs a name, but if you re-use a name this will replace the old page. This is useful for pages that need special initialization each time they appear, but generally such pages can't be returned to via the back button. The caller must protect against this if required. """ old_page = self.stack_pages.get(name) if old_page != page: if old_page: self.remove(old_page) page.show_all() self.add_named(page, name) self.stack_pages[name] = page self.set_visible_child_name(name) return page lutris-0.5.14/lutris/gui/widgets/notifications.py000066400000000000000000000007501451435154700221350ustar00rootroot00000000000000from gi.repository import Gio from lutris.util.log import logger def send_notification(title, text, file_path_to_icon="lutris"): icon_file = Gio.File.new_for_path(file_path_to_icon) icon = Gio.FileIcon.new(icon_file) notification = Gio.Notification.new(title) notification.set_body(text) notification.set_icon(icon) application = Gio.Application.get_default() application.send_notification(None, notification) logger.info(title) logger.info(text) lutris-0.5.14/lutris/gui/widgets/scaled_image.py000066400000000000000000000101511451435154700216550ustar00rootroot00000000000000from gi.repository import Gtk from lutris.gui.widgets.utils import ( ICON_SIZE, get_default_icon_path, get_pixbuf_by_path, get_runtime_icon_path, has_stock_icon ) from lutris.util.log import logger class ScaledImage(Gtk.Image): """This class provides a rather basic feature the GtkImage doesn't offer - the ability to scale the image rendered. Scaling a pixbuf is not the same thing - that discards pixel data. This will preserve it on high-DPI displays by scaling only at drawing time.""" __gtype_name__ = 'ScaledImage' def __init__(self, scale_factor, *args, **kwargs): super().__init__(*args, **kwargs) self.scale_factor = scale_factor @staticmethod def new_scaled_from_path(path, size=None, preserve_aspect_ratio=True, scale_factor=1): """Constructs an image showing the image at the path, scaled to the size given. The scale factor is used to scale up the pixbuf, but scale down the image so a higher-res image can be shown in the same space on a High-DPI screen. You pass your widget's get_scale_factor() here.""" pixbuf_size = (size[0] * scale_factor, size[1] * scale_factor) if size else None pixbuf = get_pixbuf_by_path(path, pixbuf_size) if not pixbuf: return None image = ScaledImage(1 / scale_factor) image.set_from_pixbuf(pixbuf) return image @staticmethod def new_from_media_path(path, size, scale_factor=1): """Constructs an image showing Lutris media, read from the path, scaled to the size given, as with new_scaled_from_path(). However, if the path is not readable, this will substitute a default icon or banner. If 'size' is square, you get the icon; if not it is a gradient filling the full size given.""" pixbuf_size = (size[0] * scale_factor, size[1] * scale_factor) pixbuf = get_pixbuf_by_path(path, pixbuf_size) if not pixbuf: default_icon = get_default_icon_path(size) pixbuf = get_pixbuf_by_path(default_icon, pixbuf_size, preserve_aspect_ratio=False) if not pixbuf: logger.error("The default media '%s' could not be loaded", default_icon) return None image = ScaledImage(1 / scale_factor) image.set_from_pixbuf(pixbuf) return image @staticmethod def get_runtime_icon_image(icon_name, fallback_stock_icon_name=None, scale_factor=1, visible=False): """Returns a ScaledImage of an icon for runtime or service; the image has the default icon size. If the icon can't be found, we'll fall back onto another, stock icon. If you don't supply one (or it's not available) we'll fall back further to 'package-x-generic-symbolic'; we always give you something.""" path = get_runtime_icon_path(icon_name) icon = ScaledImage.new_scaled_from_path(path, size=ICON_SIZE, scale_factor=scale_factor) if not icon: if not has_stock_icon(fallback_stock_icon_name): fallback_stock_icon_name = "package-x-generic-symbolic" icon = Gtk.Image.new_from_icon_name(fallback_stock_icon_name, Gtk.IconSize.DND) icon.set_visible(visible) return icon def do_get_preferred_width(self): minimum, natural = Gtk.Image.do_get_preferred_width(self) return minimum * self.scale_factor, natural * self.scale_factor def do_get_preferred_height(self): minimum, natural = Gtk.Image.do_get_preferred_height(self) return minimum * self.scale_factor, natural * self.scale_factor def do_draw(self, cr): if self.scale_factor != 1: # we need to scale around the center of the image, # but cr.scale() scales around (0, 0). So we move # the co-ordinates before scaling. allocation = self.get_allocation() center_x = allocation.width / 2 center_y = allocation.height / 2 cr.translate(center_x, center_y) cr.scale(self.scale_factor, self.scale_factor) cr.translate(-center_x, -center_y) Gtk.Image.do_draw(self, cr) lutris-0.5.14/lutris/gui/widgets/searchable_combobox.py000066400000000000000000000067051451435154700232530ustar00rootroot00000000000000"""Extended combobox with search""" # pylint: disable=unsubscriptable-object from gi.repository import GLib, GObject, Gtk from lutris.gui.dialogs import ErrorDialog from lutris.util.jobs import AsyncCall class SearchableCombobox(Gtk.Bin): """Combox box with autocompletion. Well fitted for large lists. """ __gsignals__ = { "changed": (GObject.SIGNAL_RUN_FIRST, None, (str, )), } def __init__(self, choice_func, initial=None): super().__init__() self.initial = initial self.liststore = Gtk.ListStore(str, str) self.combobox = Gtk.ComboBox.new_with_model_and_entry(self.liststore) self.combobox.set_entry_text_column(0) self.combobox.set_id_column(1) self.combobox.set_valign(Gtk.Align.CENTER) completion = Gtk.EntryCompletion() completion.set_model(self.liststore) completion.set_text_column(0) completion.set_match_func(self.search_store) completion.connect("match-selected", self.set_id_from_completion) entry = self.combobox.get_child() entry.set_icon_from_icon_name(Gtk.EntryIconPosition.PRIMARY, "content-loading-symbolic") entry.set_completion(completion) self.combobox.connect("changed", self.on_combobox_change) self.combobox.connect("scroll-event", self._on_combobox_scroll) self.add(self.combobox) GLib.idle_add(self._populate_combobox_choices, choice_func) def get_model(self): """Proxy to the liststore""" return self.liststore def get_active(self): """Proxy to the get_active method""" return self.combobox.get_active() @staticmethod def get_has_entry(): """The entry present is not for editing custom values, only search""" return False def search_store(self, _completion, string, _iter): """Return true if any word of a string is present in a row""" for word in string.split(): if word not in self.liststore[_iter][0].lower(): # search is always lower case return False return True def set_id_from_completion(self, _completion, model, _iter): """Sets the active ID to the appropriate ID column in the model otherwise the value is set to the entry's value. """ self.combobox.set_active_id(model[_iter][1]) def _populate_combobox_choices(self, choice_func): AsyncCall(self._do_populate_combobox_choices, self._populate_combobox_choices_cb, choice_func) def _do_populate_combobox_choices(self, choice_func): for choice in choice_func(): self.liststore.append(choice) entry = self.combobox.get_child() entry.set_icon_from_icon_name(Gtk.EntryIconPosition.PRIMARY, None) self.combobox.set_active_id(self.initial) def _populate_combobox_choices_cb(self, _result, error): if error: ErrorDialog(error, parent=self.get_toplevel()) @staticmethod def _on_combobox_scroll(combobox, _event): """Prevents users from accidentally changing configuration values while scrolling down dialogs. """ combobox.stop_emission_by_name("scroll-event") return False def on_combobox_change(self, _widget): """Action triggered on combobox 'changed' signal.""" active = self.combobox.get_active() if active < 0: return option_value = self.liststore[active][1] self.emit("changed", option_value) lutris-0.5.14/lutris/gui/widgets/sidebar.py000066400000000000000000000552521451435154700207040ustar00rootroot00000000000000"""Sidebar for the main window""" import locale from gettext import gettext as _ from gi.repository import GLib, GObject, Gtk, Pango from lutris import runners, services from lutris.database import categories as categories_db from lutris.database import games as games_db from lutris.exceptions import watch_errors from lutris.game import Game from lutris.gui import dialogs from lutris.gui.config.edit_category_games import EditCategoryGamesDialog from lutris.gui.config.runner import RunnerConfigDialog from lutris.gui.config.runner_box import RunnerBox from lutris.gui.config.services_box import ServicesBox from lutris.gui.dialogs import ErrorDialog from lutris.gui.dialogs.runner_install import RunnerInstallDialog from lutris.gui.widgets.utils import has_stock_icon from lutris.installer.interpreter import ScriptInterpreter from lutris.runners import InvalidRunner from lutris.services import SERVICES from lutris.services.base import AuthTokenExpired, BaseService TYPE = 0 SLUG = 1 ICON = 2 LABEL = 3 GAMECOUNT = 4 SERVICE_INDICES = {name: index for index, name in enumerate(SERVICES.keys())} class SidebarRow(Gtk.ListBoxRow): """A row in the sidebar containing possible action buttons""" MARGIN = 9 SPACING = 6 def __init__(self, id_, type_, name, icon, application=None): """Initialize the row Parameters: id_: identifier of the row type: type of row to display (still used?) name (str): Text displayed on the row icon (GtkImage): icon displayed next to the label application (GtkApplication): reference to the running application """ super().__init__() self.application = application self.type = type_ self.id = id_ self.runner = None self.name = name self.is_updating = False self.buttons = {} self.box = Gtk.Box(spacing=self.SPACING, margin_start=self.MARGIN, margin_end=self.MARGIN) self.connect("realize", self.on_realize) self.add(self.box) if not icon: icon = Gtk.Box(spacing=self.SPACING, margin_start=self.MARGIN, margin_end=self.MARGIN) self.box.add(icon) label = Gtk.Label( label=name, halign=Gtk.Align.START, hexpand=True, margin_top=self.SPACING, margin_bottom=self.SPACING, ellipsize=Pango.EllipsizeMode.END, ) self.box.pack_start(label, True, True, 0) self.btn_box = Gtk.Box(spacing=3, no_show_all=True, valign=Gtk.Align.CENTER, homogeneous=True) self.box.pack_end(self.btn_box, False, False, 0) self.spinner = Gtk.Spinner() self.box.pack_end(self.spinner, False, False, 0) @property def sort_key(self): """An index indicate the place this row has within its type. The id is used as a tie-breaker.""" return 0 def get_actions(self): return [] def is_row_active(self): """Return true if the row is hovered or is the one selected""" flags = self.get_state_flags() # Naming things sure is hard... But "prelight" instead of "hover"? Come on... return flags & Gtk.StateFlags.PRELIGHT or flags & Gtk.StateFlags.SELECTED def do_state_flags_changed(self, previous_flags): # pylint: disable=arguments-differ if self.id: self.update_buttons() Gtk.ListBoxRow.do_state_flags_changed(self, previous_flags) def update_buttons(self): if self.is_updating: self.btn_box.hide() self.spinner.show() self.spinner.start() return self.spinner.stop() self.spinner.hide() if self.is_row_active(): self.btn_box.show() elif self.btn_box.get_visible(): self.btn_box.hide() def create_button_box(self): """Adds buttons in the button box based on the row's actions""" for child in self.btn_box.get_children(): child.destroy() for action in self.get_actions(): btn = Gtk.Button(tooltip_text=action[1], relief=Gtk.ReliefStyle.NONE, visible=True) image = Gtk.Image.new_from_icon_name(action[0], Gtk.IconSize.MENU) image.show() btn.add(image) btn.connect("clicked", action[2]) self.buttons[action[3]] = btn self.btn_box.add(btn) def on_realize(self, widget): self.create_button_box() class ServiceSidebarRow(SidebarRow): def __init__(self, service): super().__init__( service.id, "service", service.name, LutrisSidebar.get_sidebar_icon(service.icon) ) self.service = service @property def sort_key(self): return SERVICE_INDICES[self.id] def get_actions(self): """Return the definition of buttons to be added to the row""" displayed_buttons = [] if self.service.is_launchable(): displayed_buttons.append( ("media-playback-start-symbolic", _("Run"), self.on_service_run, "run") ) displayed_buttons.append( ("view-refresh-symbolic", _("Reload"), self.on_refresh_clicked, "refresh") ) return displayed_buttons def on_service_run(self, button): """Run a launcher associated with a service""" self.service.run() def on_refresh_clicked(self, button): """Reload the service games""" button.set_sensitive(False) if self.service.online and not self.service.is_connected(): self.service.logout() return self.service.start_reload(self.service_reloaded_cb) def service_reloaded_cb(self, error): if error: if isinstance(error, AuthTokenExpired): self.service.logout() self.service.login(parent=self.get_toplevel()) else: ErrorDialog(error, parent=self.get_toplevel()) GLib.timeout_add(2000, self.enable_refresh_button) def enable_refresh_button(self): self.buttons["refresh"].set_sensitive(True) return False class OnlineServiceSidebarRow(ServiceSidebarRow): def get_buttons(self): return { "run": (("media-playback-start-symbolic", _("Run"), self.on_service_run, "run")), "refresh": ("view-refresh-symbolic", _("Reload"), self.on_refresh_clicked, "refresh"), "disconnect": ("system-log-out-symbolic", _("Disconnect"), self.on_connect_clicked, "disconnect"), "connect": ("avatar-default-symbolic", _("Connect"), self.on_connect_clicked, "connect") } def get_actions(self): buttons = self.get_buttons() displayed_buttons = [] if self.service.is_launchable(): displayed_buttons.append(buttons["run"]) if self.service.is_authenticated(): displayed_buttons += [buttons["refresh"], buttons["disconnect"]] else: displayed_buttons += [buttons["connect"]] return displayed_buttons def on_connect_clicked(self, button): button.set_sensitive(False) if self.service.is_authenticated(): self.service.logout() else: self.service.login(parent=self.get_toplevel()) self.create_button_box() class RunnerSidebarRow(SidebarRow): def get_actions(self): """Return the definition of buttons to be added to the row""" if not self.id: return [] entries = [] # Creation is delayed because only installed runners can be imported # and all visible boxes should be installed. try: self.runner = runners.import_runner(self.id)() except InvalidRunner: return entries if self.runner.multiple_versions: entries.append(( "system-software-install-symbolic", _("Manage Versions"), self.on_manage_versions, "manage-versions" )) if self.runner.runnable_alone: entries.append(("media-playback-start-symbolic", _("Run"), self.on_run_runner, "run")) entries.append(("emblem-system-symbolic", _("Configure"), self.on_configure_runner, "configure")) return entries @watch_errors() def on_run_runner(self, *_args): """Runs the runner without no game.""" self.runner.run(self.get_toplevel()) @watch_errors() def on_configure_runner(self, *_args): """Show runner configuration""" self.application.show_window(RunnerConfigDialog, runner=self.runner, parent=self.get_toplevel()) @watch_errors() def on_manage_versions(self, *_args): """Manage runner versions""" dlg_title = _("Manage %s versions") % self.runner.name self.application.show_window(RunnerInstallDialog, title=dlg_title, runner=self.runner, parent=self.get_toplevel()) def on_watched_error(self, error): dialogs.ErrorDialog(error, parent=self.get_toplevel()) class CategorySidebarRow(SidebarRow): def __init__(self, category, application): super().__init__( category['name'], "user_category", category['name'], Gtk.Image.new_from_icon_name("folder-symbolic", Gtk.IconSize.MENU), application=application ) self.category = category self._sort_name = locale.strxfrm(category['name']) def get_actions(self): """Return the definition of buttons to be added to the row""" return [ ("applications-system-symbolic", _("Edit Games"), self.on_category_clicked, "manage-category-games") ] def on_category_clicked(self, button): self.application.show_window(EditCategoryGamesDialog, category=self.category, parent=self.get_toplevel()) return True def __lt__(self, other): if not isinstance(other, CategorySidebarRow): raise ValueError('Cannot compare %s to %s' % (self.__class__.__name__, other.__class__.__name__)) return self._sort_name < other._sort_name def __gt__(self, other): if not isinstance(other, CategorySidebarRow): raise ValueError('Cannot compare %s to %s' % (self.__class__.__name__, other.__class__.__name__)) return self._sort_name > other._sort_name class SidebarHeader(Gtk.Box): """Header shown on top of each sidebar section""" def __init__(self, name, header_index): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.header_index = header_index self.first_row = None self.get_style_context().add_class("sidebar-header") label = Gtk.Label( halign=Gtk.Align.START, hexpand=True, use_markup=True, label="{}".format(name), ) label.get_style_context().add_class("dim-label") box = Gtk.Box(margin_start=9, margin_top=6, margin_bottom=6, margin_right=9) box.add(label) self.add(box) self.add(Gtk.Separator()) self.show_all() class DummyRow(): """Dummy class for rows that may not be initialized.""" def show(self): """Dummy method for showing the row""" def hide(self): """Dummy method for hiding the row""" class LutrisSidebar(Gtk.ListBox): __gtype_name__ = "LutrisSidebar" def __init__(self, application): super().__init__() self.set_size_request(200, -1) self.application = application self.get_style_context().add_class("sidebar") # Empty values until LutrisWindow explicitly initializes the rows # at the right time. self.installed_runners = [] self.used_categories = set() self.active_services = {} self.active_platforms = [] self.service_rows = {} self.runner_rows = {} self.platform_rows = {} self.category_rows = {} # A dummy objects that allows inspecting why/when we have a show() call on the object. self.running_row = DummyRow() self.missing_row = DummyRow() self.row_headers = { "library": SidebarHeader(_("Library"), header_index=0), "user_category": SidebarHeader(_("Categories"), header_index=1), "service": SidebarHeader(_("Sources"), header_index=2), "runner": SidebarHeader(_("Runners"), header_index=3), "platform": SidebarHeader(_("Platforms"), header_index=4), } GObject.add_emission_hook(RunnerBox, "runner-installed", self.update_rows) GObject.add_emission_hook(RunnerBox, "runner-removed", self.update_rows) GObject.add_emission_hook(ScriptInterpreter, "runners-installed", self.update_rows) GObject.add_emission_hook(ServicesBox, "services-changed", self.update_rows) GObject.add_emission_hook(Game, "game-start", self.on_game_start) GObject.add_emission_hook(Game, "game-stop", self.on_game_stop) GObject.add_emission_hook(Game, "game-updated", self.update_rows) GObject.add_emission_hook(Game, "game-removed", self.update_rows) GObject.add_emission_hook(BaseService, "service-login", self.on_service_auth_changed) GObject.add_emission_hook(BaseService, "service-logout", self.on_service_auth_changed) GObject.add_emission_hook(BaseService, "service-games-load", self.on_service_games_updating) GObject.add_emission_hook(BaseService, "service-games-loaded", self.on_service_games_updated) self.set_filter_func(self._filter_func) self.set_header_func(self._header_func) self.show_all() @staticmethod def get_sidebar_icon(icon_name): name = icon_name if has_stock_icon(icon_name) else "package-x-generic-symbolic" icon = Gtk.Image.new_from_icon_name(name, Gtk.IconSize.MENU) # We can wind up with an icon of the wrong size, if that's what is # available. So we'll fix that. icon_size = Gtk.IconSize.lookup(Gtk.IconSize.MENU) if icon_size[0]: icon.set_pixel_size(icon_size[2]) return icon def initialize_rows(self): """ Select the initial row; this triggers the initialization of the game view, so we must do this even if this sidebar is never realized, but only after the sidebar's signals are connected. """ # Create the basic rows that are not data dependant self.add( SidebarRow( "all", "category", _("Games"), Gtk.Image.new_from_icon_name("applications-games-symbolic", Gtk.IconSize.MENU) ) ) self.add( SidebarRow( "recent", "dynamic_category", _("Recent"), Gtk.Image.new_from_icon_name("document-open-recent-symbolic", Gtk.IconSize.MENU) ) ) self.add( SidebarRow( "favorite", "category", _("Favorites"), Gtk.Image.new_from_icon_name("favorite-symbolic", Gtk.IconSize.MENU) ) ) self.missing_row = SidebarRow( "missing", "dynamic_category", _("Missing"), Gtk.Image.new_from_icon_name("dialog-warning-symbolic", Gtk.IconSize.MENU) ) self.add(self.missing_row) self.running_row = SidebarRow( "running", "dynamic_category", _("Running"), Gtk.Image.new_from_icon_name("media-playback-start-symbolic", Gtk.IconSize.MENU) ) # I wanted this to be on top but it really messes with the headers when showing/hiding the row. self.add(self.running_row) self.show_all() self.running_row.hide() # Create the dynamic rows that are initially needed self.update_rows() @property def selected_category(self): """The selected sidebar row, as a tuple of category type and category value, like ('service', 'lutris').""" row = self.get_selected_row() return (row.type, row.id) if row else ("category", "all") @selected_category.setter def selected_category(self, value): """Selects the row for the category indicated by a category tuple, like ('service', 'lutris')""" selected_row_type, selected_row_id = value or ("category", "all") for row in self.get_children(): if row.type == selected_row_type and row.id == selected_row_id: self.select_row(row) break def _filter_func(self, row): if not row or not row.id or row.type in ("category", "dynamic_category"): return True if row.type == "user_category": return row.id in self.used_categories if row.type == "service": return row.id in self.active_services if row.type == "runner": if row.id is None: return True # 'All' return row.id in self.installed_runners return row.id in self.active_platforms def _header_func(self, row, before): if not before: header = self.row_headers["library"] elif before.type in ("category", "dynamic_category") and row.type == "user_category": header = self.row_headers[row.type] elif before.type in ("category", "dynamic_category", "user_category") and row.type == "service": header = self.row_headers[row.type] elif before.type == "service" and row.type == "runner": header = self.row_headers[row.type] elif before.type == "runner" and row.type == "platform": header = self.row_headers[row.type] else: header = None if row.get_header() != header: # GTK is messy here; a header can't belong to two rows at once, # so we must remove it from the one that owns it, if any, and # also from the sidebar itself. Then we can reuse it. if header.first_row: header.first_row.set_header(None) if header.get_parent() == self: self.remove(header) header.first_row = row row.set_header(header) def update_rows(self, *_args): """Generates any missing rows that are now needed, and re-evaluate the filter to hide any no longer needed. GTK has a lot of trouble dynamically updating and re-arranging rows, so this will have to do. This keeps the total row count down reasonably well.""" def get_sort_key(row): """Returns a key used to sort the rows. This keeps rows for a header together, and rows in a hopefully reasonable order as we insert them.""" header_row = self.row_headers.get(row.type) if row.type else None header_index = header_row.header_index if header_row else 0 return header_index, row.sort_key, row.id def insert_row(row): """Find the best place to insert the row, to maintain order, and inserts it there.""" index = 0 seq = get_sort_key(row) while True: r = self.get_row_at_index(index) if not r or get_sort_key(r) > seq: break index += 1 row.show_all() self.insert(row, index) categories_db.remove_unused_categories() categories = [c for c in categories_db.get_categories() if not categories_db.is_reserved_category(c["name"])] self.used_categories = {c["name"] for c in categories} self.active_services = services.get_enabled_services() self.installed_runners = [runner.name for runner in runners.get_installed()] self.active_platforms = games_db.get_used_platforms() for service_name, service_class in self.active_services.items(): if service_name not in self.service_rows: service = service_class() row_class = OnlineServiceSidebarRow if service.online else ServiceSidebarRow service_row = row_class(service) self.service_rows[service_name] = service_row insert_row(service_row) for runner_name in self.installed_runners: if runner_name not in self.runner_rows: icon_name = runner_name.lower().replace(" ", "") + "-symbolic" runner = runners.import_runner(runner_name)() runner_row = RunnerSidebarRow( runner_name, "runner", runner.human_name, self.get_sidebar_icon(icon_name), application=self.application ) self.runner_rows[runner_name] = runner_row insert_row(runner_row) for platform in self.active_platforms: if platform not in self.platform_rows: icon_name = platform.lower().replace(" ", "").replace("/", "_") + "-symbolic" platform_row = SidebarRow( platform, "platform", platform, self.get_sidebar_icon(icon_name), application=self.application ) self.platform_rows[platform] = platform_row insert_row(platform_row) for category in categories: if category["name"] not in self.category_rows: new_category_row = CategorySidebarRow(category, application=self.application) self.category_rows[category["name"]] = new_category_row insert_row(new_category_row) self.invalidate_filter() return True def on_game_start(self, _game): """Show the "running" section when a game start""" self.running_row.show() return True def on_game_stop(self, _game): """Hide the "running" section when no games are running""" if not self.application.running_games.get_n_items(): self.running_row.hide() if self.get_selected_row() == self.running_row: self.select_row(self.get_children()[0]) return True def on_service_auth_changed(self, service): if service.id in self.service_rows: self.service_rows[service.id].create_button_box() self.service_rows[service.id].update_buttons() return True def on_service_games_updating(self, service): if service.id in self.service_rows: self.service_rows[service.id].is_updating = True self.service_rows[service.id].update_buttons() return True def on_service_games_updated(self, service): if service.id in self.service_rows: self.service_rows[service.id].is_updating = False self.service_rows[service.id].update_buttons() return True lutris-0.5.14/lutris/gui/widgets/status_icon.py000066400000000000000000000113101451435154700216110ustar00rootroot00000000000000"""AppIndicator based tray icon""" from gettext import gettext as _ import gi from gi.repository import Gtk from lutris.database.games import get_games from lutris.game import Game try: gi.require_version('AppIndicator3', '0.1') from gi.repository import AppIndicator3 as AppIndicator APP_INDICATOR_SUPPORTED = True except (ImportError, ValueError): APP_INDICATOR_SUPPORTED = False class LutrisStatusIcon: def __init__(self, application): self.application = application self.icon = self.create() self.menu = self.get_menu() self.set_visible(True) if APP_INDICATOR_SUPPORTED: self.icon.set_menu(self.menu) else: self.icon.connect("activate", self.on_activate) self.icon.connect("popup-menu", self.on_menu_popup) def create(self): """Create an appindicator""" if APP_INDICATOR_SUPPORTED: return AppIndicator.Indicator.new( "net.lutris.Lutris", "lutris", AppIndicator.IndicatorCategory.APPLICATION_STATUS ) return LutrisTray(self.application) def is_visible(self): """Whether the icon is visible""" if APP_INDICATOR_SUPPORTED: return self.icon.get_status() != AppIndicator.IndicatorStatus.PASSIVE return self.icon.get_visible() def set_visible(self, value): """Set the visibility of the icon""" if APP_INDICATOR_SUPPORTED: if value: visible = AppIndicator.IndicatorStatus.ACTIVE else: visible = AppIndicator.IndicatorStatus.ACTIVE self.icon.set_status(visible) else: self.icon.set_visible(value) def get_menu(self): """Instantiates the menu attached to the tray icon""" menu = Gtk.Menu() installed_games = self.add_games() number_of_games_in_menu = 10 for game in installed_games[:number_of_games_in_menu]: menu.append(self._make_menu_item_for_game(game)) menu.append(Gtk.SeparatorMenuItem()) self.present_menu = Gtk.ImageMenuItem() self.present_menu.set_image(Gtk.Image.new_from_icon_name("lutris", Gtk.IconSize.MENU)) self.present_menu.set_label(_("Show Lutris")) self.present_menu.connect("activate", self.on_activate) menu.append(self.present_menu) quit_menu = Gtk.MenuItem() quit_menu.set_label(_("Quit")) quit_menu.connect("activate", self.on_quit_application) menu.append(quit_menu) menu.show_all() return menu def update_present_menu(self): app_window = self.application.window if app_window: if app_window.get_visible(): self.present_menu.set_label(_("Hide Lutris")) else: self.present_menu.set_label(_("Show Lutris")) def on_activate(self, _status_icon, _event=None): """Callback to show or hide the window""" app_window = self.application.window if app_window.get_visible(): # If the window has any transients, hiding it will hide them too # never to be shown again, which is broken. So we don't allow that. windows = Gtk.Window.list_toplevels() for w in windows: if w.get_visible() and w.get_transient_for() == app_window: return app_window.hide() else: app_window.show() def on_menu_popup(self, _status_icon, button, time): """Callback to show the contextual menu""" self.menu.popup(None, None, None, None, button, time) def on_quit_application(self, _widget): """Callback to quit the program""" self.application.quit() def _make_menu_item_for_game(self, game): menu_item = Gtk.MenuItem() menu_item.set_label(game["name"]) menu_item.connect("activate", self.on_game_selected, game["id"]) return menu_item @staticmethod def add_games(): """Adds installed games in order of last use""" installed_games = get_games(filters={"installed": 1}) installed_games.sort( key=lambda game: max(game["lastplayed"] or 0, game["installed_at"] or 0), reverse=True, ) return installed_games def on_game_selected(self, _widget, game_id): launch_ui_delegate = self.application.get_launch_ui_delegate() Game(game_id).launch(launch_ui_delegate) class LutrisTray(Gtk.StatusIcon): """Lutris tray icon""" def __init__(self, application, **_kwargs): super().__init__() self.set_tooltip_text(_("Lutris")) self.set_visible(True) self.application = application self.set_from_icon_name("lutris") lutris-0.5.14/lutris/gui/widgets/utils.py000066400000000000000000000226601451435154700204300ustar00rootroot00000000000000"""Various utilities using the GObject framework""" import array import os import cairo from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk from lutris import settings from lutris.util import datapath, magic, system from lutris.util.log import logger try: from PIL import Image except ImportError: Image = None ICON_SIZE = (32, 32) BANNER_SIZE = (184, 69) _surface_generation_number = 0 def get_main_window(widget): """Return the application's main window from one of its widget""" parent = widget.get_toplevel() if not isinstance(parent, Gtk.Window): # The sync dialog may have closed parent = Gio.Application.get_default().props.active_window for window in parent.application.get_windows(): if "LutrisWindow" in window.__class__.__name__: return window return def open_uri(uri): """Opens a local or remote URI with the default application""" system.spawn(["xdg-open", uri]) def get_image_file_format(path): """Returns the file format fo an image, either 'jpeg' or 'png'; we deduce this from the file extension, or if that fails the file's 'magic' prefix bytes.""" ext = os.path.splitext(path)[1].lower() if ext in [".jpg", ".jpeg"]: return "jpeg" if path == ".png": return "png" file_type = magic.from_file(path).lower() if "jpeg image data" in file_type: return "jpeg" if "png image data" in file_type: return "png" return None def get_surface_size(surface): """Returns the size of a surface, accounting for the device scale; the surface's get_width() and get_height() are in physical pixels.""" device_scale_x, device_scale_y = surface.get_device_scale() width = surface.get_width() / device_scale_x height = surface.get_height() / device_scale_y return width, height def get_scaled_surface_by_path(path, size, device_scale, preserve_aspect_ratio=True): """Returns a Cairo surface containing the image at the path given. It has the size indicated. You specify the device_scale, and the bitmap is generated at an enlarged size accordingly, but with the device scale of the surface also set; in this way a high-DPI image can be rendered conveniently. If you pass True for preserve_aspect_ratio, the aspect ratio of the image is preserved, but will be no larger than the size (times the device_scale). If the path cannot be read, this returns None. """ pixbuf = get_pixbuf_by_path(path) if pixbuf: pixbuf_width = pixbuf.get_width() pixbuf_height = pixbuf.get_height() scale_x = (size[0] / pixbuf_width) * device_scale scale_y = (size[1] / pixbuf_height) * device_scale if preserve_aspect_ratio: scale_x = min(scale_x, scale_y) scale_y = scale_x pixel_width = int(round(pixbuf_width * scale_x)) pixel_height = int(round(pixbuf_height * scale_y)) surface = cairo.ImageSurface(cairo.Format.ARGB32, pixel_width, pixel_height) # pylint:disable=no-member cr = cairo.Context(surface) # pylint:disable=no-member cr.scale(scale_x, scale_y) Gdk.cairo_set_source_pixbuf(cr, pixbuf, 0, 0) cr.get_source().set_extend(cairo.Extend.PAD) # pylint: disable=no-member cr.paint() surface.set_device_scale(device_scale, device_scale) return surface def get_media_generation_number(): """Returns a number that is incremented whenever cached media may no longer be valid. Caller can check to see if this has changed before using their own caches.""" return _surface_generation_number def invalidate_media_caches(): """Increments the media generation number; this indicates that cached media from earlier generations may be invalid and should be reloaded.""" global _surface_generation_number _surface_generation_number += 1 def get_default_icon_path(size): """Returns the path to the default icon for the size given; it's a Lutris icon for a square size, and a gradient for other sizes.""" if not size or size[0] == size[1]: filename = "media/default_icon.png" else: filename = "media/default_banner.png" return os.path.join(datapath.get(), filename) def get_pixbuf_by_path(path, size=None, preserve_aspect_ratio=True): """Reads an image file and returns the pixbuf. If you provide a size, this scales the file to fit that size, preserving the aspect ratio if preserve_aspect_ratio is True. If the file is missing or unreadable, or if 'path' is None, this returns None.""" if not system.path_exists(path, exclude_empty=True): return None try: if size: # new_from_file_at_size scales but preserves aspect ratio width, height = size if preserve_aspect_ratio: return GdkPixbuf.Pixbuf.new_from_file_at_size(path, width, height) return GdkPixbuf.Pixbuf.new_from_file_at_scale(path, width, height, preserve_aspect_ratio=False) return GdkPixbuf.Pixbuf.new_from_file(path) except GLib.GError: logger.exception("Unable to load icon from image %s", path) def has_stock_icon(name): """This tests if a GTK stock icon is known; if not we can try a fallback.""" if not name: return False theme = Gtk.IconTheme.get_default() return theme.has_icon(name) def get_runtime_icon_path(icon_name): """Finds the icon file for an icon whose name is given; this searches the icons in Lutris's runtime directory. The name is normalized by removing spaces and lower-casing it, and both .png and .svg files with the name can be found. Arguments: icon_name -- The name of the icon to retrieve """ filename = icon_name.lower().replace(" ", "") # We prefer bitmaps over SVG, because we've got some SVG icons with the # wrong size (oops) and this avoids them. search_directories = [ "icons/hicolor/64x64/apps", "icons/hicolor/24x24/apps", "icons", "icons/hicolor/scalable/apps", "icons/hicolor/symbolic/apps"] extensions = [".png", ".svg"] for search_dir in search_directories: for ext in extensions: icon_path = os.path.join(settings.RUNTIME_DIR, search_dir, filename + ext) if os.path.exists(icon_path): return icon_path return None def convert_to_background(background_path, target_size=(320, 1080)): """Converts an image to a pane background""" coverart = Image.open(background_path) coverart = coverart.convert("RGBA") target_width, target_height = target_size image_height = int(target_height * 0.80) # 80% of the mask is visible orig_width, orig_height = coverart.size # Resize and crop coverart width = int(orig_width * (image_height / orig_height)) offset = int((width - target_width) / 2) coverart = coverart.resize((width, image_height), resample=Image.BICUBIC) coverart = coverart.crop((offset, 0, target_width + offset, image_height)) # Resize canvas of coverart by putting transparent pixels on the bottom coverart_bg = Image.new('RGBA', (target_width, target_height), (0, 0, 0, 0)) coverart_bg.paste(coverart, (0, 0, target_width, image_height)) # Apply a tint to the base image # tint = Image.new('RGBA', (target_width, target_height), (0, 0, 0, 255)) # coverart = Image.blend(coverart, tint, 0.6) # Paste coverart on transparent image while applying a gradient mask background = Image.new('RGBA', (target_width, target_height), (0, 0, 0, 0)) mask = Image.open(os.path.join(datapath.get(), "media/mask.png")) background.paste(coverart_bg, mask=mask) return background def thumbnail_image(base_image, target_size): base_width, base_height = base_image.size base_ratio = base_width / base_height target_width, target_height = target_size target_ratio = target_width / target_height # Resize and crop coverart if base_ratio >= target_ratio: width = int(base_width * (target_height / base_height)) height = target_height else: width = target_width height = int(base_height * (target_width / base_width)) x_offset = int((width - target_width) / 2) y_offset = int((height - target_height) / 2) base_image = base_image.resize((width, height), resample=Image.BICUBIC) base_image = base_image.crop((x_offset, y_offset, width - x_offset, height - y_offset)) return base_image def paste_overlay(base_image, overlay_image, position=0.7): base_width, base_height = base_image.size overlay_width, overlay_height = overlay_image.size offset_x = int((base_width - overlay_width) / 2) offset_y = int((base_height - overlay_height) / 2) base_image.paste( overlay_image, ( offset_x, offset_y, overlay_width + offset_x, overlay_height + offset_y ), mask=overlay_image ) return base_image def image2pixbuf(image): """Converts a PIL Image to a GDK Pixbuf""" image_array = array.array('B', image.tobytes()) width, height = image.size return GdkPixbuf.Pixbuf.new_from_data(image_array, GdkPixbuf.Colorspace.RGB, True, 8, width, height, width * 4) def load_icon_theme(): """Add the lutris icon folder to the default theme""" icon_theme = Gtk.IconTheme.get_default() local_theme_path = os.path.join(settings.RUNTIME_DIR, "icons") if local_theme_path not in icon_theme.get_search_path(): icon_theme.prepend_search_path(local_theme_path) lutris-0.5.14/lutris/gui/widgets/window.py000066400000000000000000000030651451435154700205750ustar00rootroot00000000000000# Third Party Libraries from gi.repository import Gtk class BaseApplicationWindow(Gtk.ApplicationWindow): """Window used to guide the user through a issue reporting process""" def __init__(self, application): Gtk.ApplicationWindow.__init__(self, icon_name="lutris", application=application) self.application = application self.set_show_menubar(False) self.set_position(Gtk.WindowPosition.CENTER) self.connect("delete-event", self.on_destroy) self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12, visible=True) self.vbox.set_margin_top(18) self.vbox.set_margin_bottom(18) self.vbox.set_margin_right(18) self.vbox.set_margin_left(18) self.add(self.vbox) self.action_buttons = Gtk.Box(spacing=6) self.vbox.pack_end(self.action_buttons, False, False, 0) def get_action_button(self, label, handler=None, tooltip=None): """Returns a button that can be used for the action bar""" button = Gtk.Button.new_with_mnemonic(label) if handler: button.connect("clicked", handler) if tooltip: button.set_tooltip_text(tooltip) return button def on_destroy(self, _widget=None, _data=None): """Destroy callback""" self.destroy() def present(self): # pylint: disable=arguments-differ """The base implementation doesn't always work, this one does.""" self.set_keep_above(True) super().present() self.set_keep_above(False) super().present() lutris-0.5.14/lutris/installer/000077500000000000000000000000001451435154700164535ustar00rootroot00000000000000lutris-0.5.14/lutris/installer/__init__.py000066400000000000000000000021241451435154700205630ustar00rootroot00000000000000"""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.14/lutris/installer/commands.py000066400000000000000000000701311451435154700206300ustar00rootroot00000000000000"""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 gi.repository import GLib from lutris import runtime from lutris.cache import get_cache_path from lutris.command import MonitoredCommand from lutris.config import LutrisConfig from lutris.database.games import get_game_by_field from lutris.exceptions import UnavailableRunnerError, watch_errors from lutris.game import Game from lutris.installer.errors import ScriptingError from lutris.runners import import_task from lutris.util import extract, linux, selective_merge, system from lutris.util.fileio import EvilConfigParser, MultiOrderedDict from lutris.util.log import logger from lutris.util.wine.wine import WINE_DEFAULT_ARCH, get_wine_version_exe class CommandsMixin: """The directives for the `installer:` part of the install script.""" def _get_runner_version(self): """Return the version of the runner used for the installer""" if self.installer.runner == "wine": # If a version is specified in the script choose this one if self.installer.script.get(self.installer.runner): return self.installer.script[self.installer.runner].get("version") # If the installer is an extension, use the wine version from the base game if self.installer.requires: db_game = get_game_by_field(self.installer.requires, field="installer_slug") if not db_game: db_game = get_game_by_field(self.installer.requires, field="slug") if not db_game: logger.warning("Can't find game %s", self.installer.requires) return None game = Game(db_game["id"]) return game.config.runner_config["version"] # Look up the runner config setting, but only if it is explicitly set; # install scripts do not get the usual default if it is not! 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 if self.installer.runner == "libretro": return self.installer.script["game"]["core"] return None @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, ) @staticmethod def _is_cached_file(file_path): """Return whether a file referenced by file_id is stored in the cache""" pga_cache_path = get_cache_path() if not pga_cache_path: return False return file_path.startswith(pga_cache_path) 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) exec_abs_path = system.find_executable(exec_path) if not exec_abs_path: raise ScriptingError(_("Unable to find executable %s") % exec_path) 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) self.heartbeat = GLib.timeout_add(1000, self._monitor_task, command) 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 self._is_cached_file(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 get_wine_path(self): """Return absolute path of wine version used during the install, but None if the wine exe can't be located.""" try: return get_wine_version_exe(self._get_runner_version()) except UnavailableRunnerError: return None 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"): wine_path = self.get_wine_path() if wine_path: data["wine_path"] = 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 command: command.accepted_return_code = return_code if isinstance(command, MonitoredCommand): # Monitor thread and continue when task has executed self.interpreter_ui_delegate.attach_log(command) self.heartbeat = GLib.timeout_add(1000, self._monitor_task, command) return "STOP" return None @watch_errors(error_result=False) 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.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.14/lutris/installer/errors.py000066400000000000000000000027671451435154700203550ustar00rootroot00000000000000"""Installer specific exceptions""" import sys from lutris.gui.dialogs import ErrorDialog from lutris.util.log import logger from lutris.util.strings import gtk_safe class ScriptingError(Exception): """Custom exception for scripting errors, can be caught by modifying excepthook.""" def __init__(self, message, faulty_data=None): self.message = message self.faulty_data = faulty_data super().__init__() logger.error(self.__str__()) def __str__(self): if self.faulty_data is None: return self.message faulty_data = repr(self.faulty_data) return self.message + "\n%s" % faulty_data if faulty_data else "" def __repr__(self): return self.message class FileNotAvailable(Exception): """Raised when a file has to be provided by the user""" class MissingGameDependency(Exception): """Raise when a game requires another game that isn't installed""" def __init__(self, slug=None): self.slug = slug super().__init__() _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.14/lutris/installer/installer.py000066400000000000000000000347641451435154700210400ustar00rootroot00000000000000"""Lutris installer class""" import json import os 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 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.installer.legacy import get_game_launcher 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 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"] 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.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, 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 self.files = [file.copy() for file in self.script_files if file.id != installer_file_id] # Run variable substitution on the URLs from the script for file in self.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) if self.service.has_extras: logger.info("Adding selected extras to downloads") self.service.selected_extras = self.interpreter.extras 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: installer_files = self.service.get_installer_files(self, installer_file_id, self.interpreter.extras) except UnavailableGameError as ex: logger.error("Game not available: %s", ex) installer_files = None if installer_files: for installer_file in installer_files: self.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) self.files.insert(0, InstallerFile(self.game_slug, installer_file_id, { "url": installer_file_url, "filename": "" })) 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.runner in self.script and self.script[self.runner]: config[self.runner] = self._substitute_config(self.script[self.runner]) launcher, launcher_config = self.get_game_launcher_config(self.interpreter.game_files) if launcher: config["game"][launcher] = launcher_config if "game" in self.script: try: config["game"].update(self.script["game"]) except ValueError as err: raise ScriptingError(_("Invalid 'game' section"), self.script["game"]) from err config["game"] = self._substitute_config(config["game"]) if AUTO_ELF_EXE in config["game"].get("exe", ""): config["game"]["exe"] = find_linux_game_executable(self.interpreter.target_path, make_executable=True) elif AUTO_WIN32_EXE in config["game"].get("exe", ""): config["game"]["exe"] = find_windows_game_executable(self.interpreter.target_path) # Fix possible case differences for key in ("iso", "rom", "main_file", "exe"): if config["game"].get(key): config["game"][key] = fix_path_case(config["game"][key]) 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()) runner_inst = import_runner(self.runner)() if self.service: service_id = self.service.id else: service_id = None self.game_id = add_or_update( name=self.game_name, runner=self.runner, slug=self.game_slug, platform=runner_inst.get_platform(), directory=self.interpreter.target_path, installed=1, hidden=0, installer_slug=self.slug, parent_slug=self.requires, year=self.year, configpath=configpath, service=service_id, service_id=self.service_appid, id=self.game_id, discord_id=self.discord_id, ) return self.game_id def get_game_launcher_config(self, game_files): """Game options such as exe or main_file can be added at the root of the script as a shortcut, this integrates them into the game config properly This should be deprecated. Game launchers should go in the game section. """ launcher, launcher_value = get_game_launcher(self.script) if isinstance(launcher_value, list): launcher_values = [] for game_file in launcher_value: if game_file in game_files: launcher_values.append(game_files[game_file]) else: launcher_values.append(game_file) return launcher, launcher_values if launcher_value: if launcher_value in game_files: launcher_value = game_files[launcher_value] elif self.interpreter.target_path and os.path.exists( os.path.join(self.interpreter.target_path, launcher_value) ): launcher_value = os.path.join(self.interpreter.target_path, launcher_value) return launcher, launcher_value lutris-0.5.14/lutris/installer/installer_file.py000066400000000000000000000232231451435154700220230ustar00rootroot00000000000000"""Manipulates installer files""" import os from gettext import gettext as _ from urllib.parse import urlparse from lutris import cache, settings from lutris.cache import 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.log import logger from lutris.util.strings import add_url_tags, gtk_safe class InstallerFile: """Representation of a file in the `files` sections of an installer""" def __init__(self, game_slug, file_id, file_meta, dest_file=None): self.game_slug = game_slug self.id = file_id.replace("-", "_") # pylint: disable=invalid-name self._file_meta = file_meta self._dest_file = dest_file # Used to override the destination def copy(self): """Copies this file object, so the copy can be modified safely.""" if isinstance(self._file_meta, dict): return InstallerFile(self.game_slug, self.id, self._file_meta.copy(), self._dest_file) return InstallerFile(self.game_slug, self.id, self._file_meta, self._dest_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) @property def referer(self): if isinstance(self._file_meta, dict): return self._file_meta.get("referer") @property def downloader(self): if isinstance(self._file_meta, dict): dl = self._file_meta.get("downloader") if dl and not dl.dest: dl.dest = self.dest_file return dl @property def checksum(self): if isinstance(self._file_meta, dict): return self._file_meta.get("checksum") @property def dest_file(self): if self._dest_file: return self._dest_file return os.path.join(self.cache_path, self.filename) @dest_file.setter def dest_file(self, value): self._dest_file = value 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 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 add_url_tags(gtk_safe(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 """ cache_path = cache.get_cache_path() if not cache_path: return False if system.path_exists(cache_path): return True logger.warning("Cache path %s does not exist", cache_path) return False @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""" _cache_path = cache.get_cache_path() if not _cache_path: _cache_path = os.path.join(settings.CACHE_DIR, "installer") url_parts = urlparse(self.url) if url_parts.netloc.endswith("gog.com"): folder = "gog" else: folder = self.id return os.path.join(_cache_path, self.game_slug, folder) def prepare(self): """Prepare the file for download, if we've not been redirected to an existing file.""" if not self._dest_file and not system.path_exists(self.cache_path): os.makedirs(self.cache_path) def create_download_progress_box(self): return DownloadProgressBox({ "url": self.url, "dest": self.dest_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 if system.get_file_checksum(self.dest_file, hash_type) != expected_hash: raise ScriptingError(hash_type.capitalize() + _(" checksum mismatch "), self.checksum) @property def size(self): if isinstance(self._file_meta, dict) and "size" in self._file_meta and isinstance(self._file_meta["size"], int): return self._file_meta["size"] return 0 @property def total_size(self): if isinstance(self._file_meta, dict) and "total_size" in self._file_meta: return self._file_meta["total_size"] return 0 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.remove_folder(self.dest_file) else: os.remove(self.dest_file) lutris-0.5.14/lutris/installer/installer_file_collection.py000066400000000000000000000141461451435154700242420ustar00rootroot00000000000000"""Manipulates installer files""" import os from gettext import gettext as _ from urllib.parse import urlparse from lutris import cache, settings from lutris.gui.widgets.download_collection_progress_box import DownloadCollectionProgressBox from lutris.util import system from lutris.util.log import logger from lutris.util.strings import add_url_tags, gtk_safe 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, dest_file=None): 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 = dest_file # 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 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()) return InstallerFileCollection(self.game_slug, self.id, new_file_list, self._dest_file) 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 = 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 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 add_url_tags(gtk_safe(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 """ cache_path = cache.get_cache_path() if not cache_path: return False if system.path_exists(cache_path): return True logger.warning("Cache path %s does not exist", cache_path) return False @property def is_user_pga_caching_allowed(self): return len(self.files_list) == 1 and self.files_list[0].is_user_pga_caching_allowed @property def cache_path(self): """Return the directory used as a cache for the duration of the installation""" _cache_path = cache.get_cache_path() if not _cache_path: _cache_path = os.path.join(settings.CACHE_DIR, "installer") return os.path.join(_cache_path, self.game_slug) def prepare(self): """Prepare the file for download, if we've not been redirected to an existing file.""" if not self._dest_file 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: return system.path_exists(self._dest_file) for installer_file in self.files_list: if not installer_file.is_ready(provider): return False return True @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 at already at destination, prior to starting the download.""" for installer_file in self.files_list: installer_file.remove_previous() lutris-0.5.14/lutris/installer/interpreter.py000066400000000000000000000516141451435154700213770ustar00rootroot00000000000000"""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 watch_errors from lutris.installer import AUTO_EXE_PREFIX from lutris.installer.commands import CommandsMixin from lutris.installer.errors import MissingGameDependency, ScriptingError from lutris.installer.installer import LutrisInstaller from lutris.installer.legacy import get_game_launcher from lutris.runners import InvalidRunner, NonInstallableRunnerError, RunnerInstallationError, import_runner, 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 from lutris.util.wine.wine import get_wine_version_exe class ScriptInterpreter(GObject.Object, CommandsMixin): """Control the execution of an installer""" __gsignals__ = { "runners-installed": (GObject.SIGNAL_RUN_FIRST, None, ()), } class InterpreterUIDelegate: """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.""" logger.exception("Error during installation: %s", error) 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.extras = [] 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() @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.CACHE_DIR, "installer/%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: bool(system.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.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 MissingGameDependency(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 MissingGameDependency(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: return [] return self.service.get_extras(self.installer.service_appid) 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: params = {} if self.installer.runner == "libretro": params["core"] = self.installer.script["game"]["core"] if self.installer.runner == "wine": params["fallback"] = False # Force the wine version to be installed version = self._get_runner_version() if version: params["version"] = version else: # Looking up default wine version default_wine = runner.get_runner_version() or {} if "version" in default_wine: logger.debug("Default wine version is %s", default_wine["version"]) if "architecture" in default_wine: version = "{}-{}".format(default_wine["version"], default_wine["architecture"]) else: version = default_wine["version"] params["version"] = version else: logger.error("Failed to get default wine version (got %s)", default_wine) if not runner.is_installed(**params): logger.info("Runner %s needs to be installed", runner) 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 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=self._get_runner_version(), callback=install_more_runners, ) except (NonInstallableRunnerError, RunnerInstallationError) as ex: logger.error(ex.message) raise ScriptingError(ex.message) from ex def get_runner_class(self, runner_name): """Runner the runner class from its name""" try: runner = import_runner(runner_name) except InvalidRunner as err: raise ScriptingError(_("Invalid runner provided %s") % runner_name) from err return runner 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) # Copy extras to game folder if self.extras and len(self.extras) == len(self.installer.files): # Reset the install script in case there are only extras. logger.warning("Installer with only extras and no game files") self.installer.script["installer"] = [] for extra in self.extras: self.installer.script["installer"].append( {"copy": {"src": extra, "dst": "$GAMEDIR/extras"}} ) self._iter_commands() def on_watched_error(self, error): self.interpreter_ui_delegate.report_error(error) @watch_errors() def _iter_commands(self, result=None, exception=None): if result == "STOP" or self.cancelled: return 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() @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() launcher_value = None path = None _launcher, launcher_value = get_game_launcher(self.installer.script) if launcher_value: path = self._substitute(launcher_value) if not os.path.isabs(path) and self.target_path: path = system.fix_path_case(os.path.join(self.target_path, path)) if ( path and AUTO_EXE_PREFIX not in path and not os.path.isfile(path) and self.installer.runner not in ("web", "browser") ): 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!")) download_lutris_media(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.remove_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""" 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(self.current_resolution), "RESOLUTION_WIDTH": self.current_resolution[0], "RESOLUTION_HEIGHT": self.current_resolution[1], "RESOLUTION_WIDTH_HEX": hex(int(self.current_resolution[0])), "RESOLUTION_HEIGHT_HEX": hex(int(self.current_resolution[1])), "WINEBIN": self.get_wine_path(), } # 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 = get_wine_version_exe(self._get_runner_version()) wine.eject_disc(wine_path, self.target_path) lutris-0.5.14/lutris/installer/legacy.py000066400000000000000000000017071451435154700202760ustar00rootroot00000000000000from lutris.util import linux from lutris.util.log import logger def get_game_launcher(script): """Return the key and value of the launcher exe64 can be provided to specify an executable for 64bit systems This should be deprecated when support for multiple binaries has been added. """ key = None launcher_value = None exe = "exe64" if "exe64" in script and linux.LINUX_SYSTEM.is_64_bit else "exe" if exe == "exe64": logger.warning("Stop using exe64, use launch configs to add support for 32 bit. Please update the script.") for launcher in (exe, "iso", "rom", "disk", "main_file"): if launcher not in script: continue launcher_value = script[launcher] if launcher == "exe64": key = "exe" # If exe64 is used, rename it to exe break if not launcher_value and "game" in script: return get_game_launcher(script["game"]) return key, launcher_value lutris-0.5.14/lutris/installer/steam_installer.py000066400000000000000000000100751451435154700222160ustar00rootroot00000000000000"""Collection of installer files""" import os import time from gettext import gettext as _ from gi.repository import GLib, GObject from lutris.config import LutrisConfig from lutris.installer.errors import ScriptingError from lutris.runners import steam from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.steam.log import get_app_state_log class SteamInstaller(GObject.Object): """Handles installation of Steam games""" __gsignals__ = { "steam-game-installed": (GObject.SIGNAL_RUN_FIRST, None, (str, )), "steam-state-changed": (GObject.SIGNAL_RUN_FIRST, None, (str, )), } 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 """ super().__init__() 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): """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.emit("steam-game-installed", self.appid) 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 = GLib.timeout_add(2000, self._monitor_steam_game_install) 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): 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.emit("steam-state-changed", self.state) # 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.emit("steam-game-installed", self.appid) return False return True lutris-0.5.14/lutris/migrations/000077500000000000000000000000001451435154700166325ustar00rootroot00000000000000lutris-0.5.14/lutris/migrations/__init__.py000066400000000000000000000017451451435154700207520ustar00rootroot00000000000000import importlib from lutris import settings from lutris.util.log import logger MIGRATION_VERSION = 13 # Never decrease this number # Replace deprecated migrations with empty lists MIGRATIONS = [ [], [], [], [], [], [], [], ["mess_to_mame"], ["migrate_hidden_ids"], ["migrate_steam_appids"], ["migrate_banners"], ["retrieve_discord_appids"], ["migrate_sortname"], ] 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.14/lutris/migrations/mess_to_mame.py000066400000000000000000000007071451435154700216600ustar00rootroot00000000000000"""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.14/lutris/migrations/migrate_banners.py000066400000000000000000000015531451435154700223500ustar00rootroot00000000000000"""Migrate banners from .local/share/lutris to .cache/lutris""" import os from lutris import settings from lutris.util.log import logger def migrate(): dest_dir = settings.BANNER_PATH src_dir = os.path.join(settings.DATA_DIR, "banners") try: # init_lutris() creates the new banners directory if os.path.isdir(src_dir) and os.path.isdir(dest_dir): for filename in os.listdir(src_dir): src_file = os.path.join(src_dir, filename) dest_file = os.path.join(dest_dir, filename) if not os.path.exists(dest_file): os.rename(src_file, dest_file) else: os.unlink(src_file) if not os.listdir(src_dir): os.rmdir(src_dir) except OSError as ex: logger.exception("Failed to migrate banners: %s", ex) lutris-0.5.14/lutris/migrations/migrate_hidden_ids.py000066400000000000000000000015301451435154700230050ustar00rootroot00000000000000"""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", section="lutris", default="").split(",") ignores = [ignore for ignore in ignores_raw if not ignore == ""] # Turn the strings into integers return [int(game_id) for game_id in ignores] 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.set_hidden(True) settings.write_setting("library_ignores", '', section="lutris") lutris-0.5.14/lutris/migrations/migrate_sortname.py000066400000000000000000000013631451435154700225470ustar00rootroot00000000000000from 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.PGA_DB, "games", {"sortname": ""}, {"slug": game['slug']} ) logger.info("Added blank sortname for %s", game['name']) lutris-0.5.14/lutris/migrations/migrate_steam_appids.py000066400000000000000000000010521451435154700233630ustar00rootroot00000000000000"""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.PGA_DB, "games", {"service": "steam", "service_id": game["steamid"]}, {"id": game["id"]} ) lutris-0.5.14/lutris/migrations/retrieve_discord_appids.py000066400000000000000000000013401451435154700240760ustar00rootroot00000000000000from 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.PGA_DB, "games", {"discord_id": game['discord_id']}, {"slug": game['slug']} ) logger.info("Updated %s", game['name']) lutris-0.5.14/lutris/runner_interpreter.py000066400000000000000000000163451451435154700207750ustar00rootroot00000000000000"""Transform runner parameters to data usable for runtime execution""" import os import shlex import stat from functools import lru_cache from lutris.util import system from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger def get_mangohud_conf(system_config): """Return correct launch arguments and environment variables for Mangohud.""" # The environment variable should be set to 0 on gamescope, otherwise the game will crash mangohud_val = "0" if system_config.get("gamescope") else "1" if system_config.get("mangohud") and system.find_executable("mangohud"): return ["mangohud"], {"MANGOHUD": mangohud_val, "MANGOHUD_DLSYM": "1"} return None, None def get_launch_parameters(runner, gameplay_info): system_config = runner.system_config launch_arguments = gameplay_info["command"] env = { "DISABLE_LAYER_AMD_SWITCHABLE_GRAPHICS_1": "1" } # 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 if system_config["locale"] != "": env["LC_ALL"] = system_config["locale"] # Optimus optimus = system_config.get("optimus") if optimus == "primusrun" and system.find_executable("primusrun"): launch_arguments.insert(0, "primusrun") elif optimus == "optirun" and system.find_executable("optirun"): launch_arguments.insert(0, "virtualgl") launch_arguments.insert(0, "-b") launch_arguments.insert(0, "optirun") elif optimus == "pvkrun" and system.find_executable("pvkrun"): launch_arguments.insert(0, "pvkrun") # MangoHud mango_args, mango_env = get_mangohud_conf(system_config) if mango_args: launch_arguments = mango_args + launch_arguments env.update(mango_env) # Libstrangle fps_limit = system_config.get("fps_limit") or "" if fps_limit: strangle_cmd = system.find_executable("strangle") if strangle_cmd: launch_arguments = [strangle_cmd, fps_limit] + launch_arguments else: logger.warning("libstrangle is not available on this system, FPS limiter disabled") prefix_command = system_config.get("prefix_command") or "" if prefix_command: launch_arguments = shlex.split(os.path.expandvars(prefix_command)) + launch_arguments single_cpu = system_config.get("single_cpu") or False if single_cpu: limit_cpu_count = system_config.get("limit_cpu_count") if limit_cpu_count and limit_cpu_count.isnumeric(): limit_cpu_count = int(limit_cpu_count) else: limit_cpu_count = 1 limit_cpu_count = max(1, limit_cpu_count) logger.info("The game will run on %d CPU core(s)", limit_cpu_count) launch_arguments.insert(0, "0-%d" % (limit_cpu_count - 1)) launch_arguments.insert(0, "-c") launch_arguments.insert(0, "taskset") env.update(runner.get_env()) env.update(gameplay_info.get("env") or {}) # Set environment variables dependent on gameplay info # LD_PRELOAD ld_preload = gameplay_info.get("ld_preload") if ld_preload: env["LD_PRELOAD"] = ld_preload # LD_LIBRARY_PATH game_ld_library_path = gameplay_info.get("ld_library_path") if game_ld_library_path: ld_library_path = env.get("LD_LIBRARY_PATH") env["LD_LIBRARY_PATH"] = os.pathsep.join(filter(None, [ game_ld_library_path, ld_library_path])) # Feral gamemode gamemode = system_config.get("gamemode") and LINUX_SYSTEM.gamemode_available() if gamemode: launch_arguments.insert(0, "gamemoderun") # Gamescope gamescope = system_config.get("gamescope") and system.find_executable("gamescope") if gamescope: launch_arguments = get_gamescope_args(launch_arguments, system_config) return launch_arguments, env def get_gamescope_args(launch_arguments, system_config): """Insert gamescope at the start of the launch arguments""" launch_arguments.insert(0, "--") if system_config.get("gamescope_force_grab_cursor"): launch_arguments.insert(0, "--force-grab-cursor") if system_config.get("gamescope_fsr_sharpness"): gamescope_fsr_sharpness = system_config["gamescope_fsr_sharpness"] launch_arguments.insert(0, gamescope_fsr_sharpness) launch_arguments.insert(0, "--fsr-sharpness") launch_arguments[0:0] = _get_gamescope_fsr_option() if system_config.get("gamescope_flags"): gamescope_flags = shlex.split(system_config["gamescope_flags"]) launch_arguments = gamescope_flags + launch_arguments if system_config.get("gamescope_window_mode"): gamescope_window_mode = system_config["gamescope_window_mode"] launch_arguments.insert(0, gamescope_window_mode) if system_config.get("gamescope_fps_limiter"): gamescope_fps_limiter = system_config["gamescope_fps_limiter"] launch_arguments.insert(0, gamescope_fps_limiter) launch_arguments.insert(0, "-r") if system_config.get("gamescope_output_res"): output_width, output_height = system_config["gamescope_output_res"].lower().split("x") launch_arguments.insert(0, output_height) launch_arguments.insert(0, "-H") launch_arguments.insert(0, output_width) launch_arguments.insert(0, "-W") if system_config.get("gamescope_game_res"): game_width, game_height = system_config["gamescope_game_res"].lower().split("x") launch_arguments.insert(0, game_height) launch_arguments.insert(0, "-h") launch_arguments.insert(0, game_width) launch_arguments.insert(0, "-w") launch_arguments.insert(0, "gamescope") return launch_arguments @lru_cache() def _get_gamescope_fsr_option(): """Returns a list containing the arguments to insert to trigger FSR in gamescope; this changes in later versions, so we have to check the help output. There seems to be no way to query the version number more directly.""" if bool(system.find_executable("gamescope")): # '-F fsr' is the trigger in gamescope 3.12. help_text = system.execute(["gamescope", "--help"], capture_stderr=True) if "-F, --filter" in help_text: return ["-F", "fsr"] # This is the old trigger, pre 3.12. return ["-U"] def export_bash_script(runner, gameplay_info, script_path): """Convert runner configuration into a bash script""" runner.prelaunch() command, env = get_launch_parameters(runner, gameplay_info) # Override TERM otherwise the script might not run env["TERM"] = "xterm" script_content = "#!/bin/bash\n\n\n" script_content += "# Environment variables\n" for name, value in env.items(): script_content += 'export %s="%s"\n' % (name, value) if "working_dir" in gameplay_info: script_content += "\n# Working Directory\n" script_content += "cd %s\n" % shlex.quote(gameplay_info["working_dir"]) script_content += "\n# Command\n" script_content += " ".join([shlex.quote(c) for c in command]) with open(script_path, "w", encoding='utf-8') as script_file: script_file.write(script_content) os.chmod(script_path, os.stat(script_path).st_mode | stat.S_IEXEC) lutris-0.5.14/lutris/runners/000077500000000000000000000000001451435154700161525ustar00rootroot00000000000000lutris-0.5.14/lutris/runners/__init__.py000066400000000000000000000057031451435154700202700ustar00rootroot00000000000000"""Runner loaders""" __all__ = [ # Native "linux", "steam", "web", "flatpak", # 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 "pcsx2", "rpcs3", # Sega "osmose", "reicast", "redream", # Fantasy consoles "pico8", # Misc legacy systems "jzintv", "o2em", "zdoom", ] ADDON_RUNNERS = {} _cached_runner_human_names = {} class InvalidRunner(Exception): def __init__(self, message): super().__init__(message) self.message = message class RunnerInstallationError(Exception): def __init__(self, message): super().__init__(message) self.message = message class NonInstallableRunnerError(Exception): def __init__(self, message): super().__init__(message) self.message = message def get_runner_module(runner_name): if runner_name not in __all__: raise InvalidRunner("Invalid runner name '%s'" % runner_name) return __import__("lutris.runners.%s" % runner_name, globals(), locals(), [runner_name], 0) 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) if not runner_module: return None return getattr(runner_module, runner_name) def import_task(runner, task): """Return a runner task.""" runner_module = get_runner_module(runner) if not runner_module: return None 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: ADDON_RUNNERS[runner_name] = runners[runner_name] __all__.append(runner_name) _cached_runner_human_names.clear() def get_runner_names(): return __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 InvalidRunner: _cached_runner_human_names[runner_name] = runner_name # an obsolete runner return _cached_runner_human_names[runner_name] return "" lutris-0.5.14/lutris/runners/atari800.py000066400000000000000000000123241451435154700200560ustar00rootroot00000000000000import logging import os.path from gettext import gettext as _ from lutris.config import LutrisConfig 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 = "http://kent.dl.sourceforge.net/project/atari800/ROM/Original%20XL%20ROM/xf25.zip" 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_chooser", "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_executable()] 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): return {"error": "NO_BIOS"} 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): return {"error": "FILE_NOT_FOUND", "file": rom} arguments.append(rom) return {"command": arguments} lutris-0.5.14/lutris/runners/cemu.py000066400000000000000000000050201451435154700174520ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ # 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_chooser", "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."), } ] runner_options = [ { "option": "fullscreen", "label": _("Fullscreen"), "type": "bool", "default": True, }, { "option": "mlc", "label": _("Custom mlc folder location"), "type": "directory_chooser", }, { "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): return {"error": "DIRECTORY_NOT_FOUND", "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 "" if not system.path_exists(gamedir): return {"error": "DIRECTORY NOT FOUND", "directory": gamedir} arguments += ["-g", gamedir] return {"command": arguments} lutris-0.5.14/lutris/runners/commands/000077500000000000000000000000001451435154700177535ustar00rootroot00000000000000lutris-0.5.14/lutris/runners/commands/__init__.py000066400000000000000000000000001451435154700220520ustar00rootroot00000000000000lutris-0.5.14/lutris/runners/commands/dosbox.py000066400000000000000000000033401451435154700216230ustar00rootroot00000000000000"""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.14/lutris/runners/commands/wine.py000066400000000000000000000360251451435154700212750ustar00rootroot00000000000000"""Wine commands for installers""" # pylint: disable=too-many-arguments import os import shlex import time from gettext import gettext as _ from lutris import runtime, settings from lutris.command import MonitoredCommand from lutris.exceptions import UnavailableRunnerError 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.cabinstall import CabInstaller from lutris.util.wine.prefix import WinePrefixManager from lutris.util.wine.wine import ( WINE_DEFAULT_ARCH, WINE_DIR, detect_arch, detect_prefix_arch, get_overrides_env, get_real_executable, is_installed_systemwide ) 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): """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" wineexec( "regedit", args="/S '%s'" % filename, wine_path=wine_path, prefix=prefix, arch=arch, blocking=True, ) def delete_registry_key(key, wine_path=None, prefix=None, arch=WINE_DEFAULT_ARCH): """Deletes a registry key from a Wine prefix""" wineexec( "regedit", args='/S /D "%s"' % key, wine_path=wine_path, prefix=prefix, arch=arch, blocking=True, ) def create_prefix( # noqa: C901 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() if not wine_path: logger.error("Wine not found, can't create prefix") return 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 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" 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")): 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 wine_path: if not runner: runner = import_runner("wine")() wine_path = runner.get_executable() wine_root = os.path.dirname(wine_path) if not env: env = {"WINEARCH": arch, "WINEPREFIX": prefix} command = [os.path.join(wine_root, "wineserver"), "-k"] logger.debug("Killing all wine processes: %s", command) logger.debug("\tWine prefix: %s", prefix) logger.debug("\tWine arch: %s", arch) if initial_pids: logger.debug("\tInitial pids: %s", initial_pids) 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 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( # noqa: C901 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 ): """ 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 wine_path: raise UnavailableRunnerError(_("Wine is not installed")) 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]) # Create prefix if necessary if arch not in ("win32", "win64"): arch = detect_arch(prefix, wine_path) if not detect_prefix_arch(prefix): wine_bin = winetricks_wine if winetricks_wine else wine_path create_prefix(prefix, wine_path=wine_bin, arch=arch, runner=runner) wineenv = {"WINEARCH": arch} if winetricks_wine: wineenv["WINE"] = winetricks_wine else: wineenv["WINE"] = wine_path if prefix: wineenv["WINEPREFIX"] = prefix 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): if WINE_DIR in wine_path: wine_root_path = os.path.dirname(os.path.dirname(wine_path)) elif 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) baseenv = runner.get_env(disable_runtime=disable_runtime) baseenv.update(wineenv) baseenv.update(env) command_parameters = [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_executable("winetricks") working_dir = None if not winetricks_path: raise RuntimeError("No installation of winetricks found") 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 ): """Execute winetricks.""" winetricks_path, working_dir, env = find_winetricks(env, system_winetricks) if wine_path: winetricks_wine = wine_path 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"): args = "--unattended " + args 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 ) def winecfg(wine_path=None, prefix=None, arch=WINE_DEFAULT_ARCH, config=None, env=None, runner=None): """Execute winecfg.""" if not wine_path: logger.debug("winecfg: Reverting to default wine") wine = import_runner("wine") wine_path = wine().get_executable() 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 ) def eject_disc(wine_path, prefix): """Use Wine to eject a drive""" wineexec("eject", prefix=prefix, wine_path=wine_path, args="-a") def install_cab_component(cabfile, component, wine_path=None, prefix=None, arch=None): """Install a component from a cabfile in a prefix""" 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) cab_installer.cleanup() def open_wine_terminal(terminal, wine_path, prefix, env, system_winetricks): winetricks_path, _working_dir, env = find_winetricks(env, system_winetricks) aliases = { "wine": wine_path, "winecfg": wine_path + "cfg", "wineserver": wine_path + "server", "wineboot": wine_path + "boot", "winetricks": winetricks_path, } env["WINEPREFIX"] = prefix # Ensure scripts you run see the desired version of WINE too # by putting it on the PATH. wine_directory = os.path.split(wine_path)[0] if wine_directory: path = env.get("PATH", os.environ["PATH"]) env["PATH"] = "%s:%s" % (wine_directory, path) shell_command = get_shell_command(prefix, env, aliases) terminal = terminal or linux.get_default_terminal() system.execute([terminal, "-e", shell_command]) lutris-0.5.14/lutris/runners/dolphin.py000066400000000000000000000041601451435154700201620ustar00rootroot00000000000000"""Dolphin runner""" from gettext import gettext as _ # 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_chooser", "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): return {"error": "FILE_NOT_FOUND", "file": iso} command.extend(["-e", iso]) return {"command": command} lutris-0.5.14/lutris/runners/dosbox.py000066400000000000000000000131161451435154700200240ustar00rootroot00000000000000# Standard Library import os import shlex from gettext import gettext as _ from lutris import settings # 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" "It can be left blank if the launch of the executable is " "managed in the config 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_chooser", "label": _("Working directory"), "help": _( "The location where the game is run from.\n" "By default, Lutris uses the directory of the " "executable." ), }, ] scaler_modes = [ (_("none"), "none"), ("normal2x", "normal2x"), ("normal3x", "normal3x"), ("hq2x", "hq2x"), ("hq3x", "hq3x"), ("advmame2x", "advmame2x"), ("advmame3x", "advmame3x"), ("2xsai", "2xsai"), ("super2xsai", "super2xsai"), ("supereagle", "supereagle"), ("advinterp2x", "advinterp2x"), ("advinterp3x", "advinterp3x"), ("tv2x", "tv2x"), ("tv3x", "tv3x"), ("rgb2x", "rgb2x"), ("rgb3x", "rgb3x"), ("scan2x", "scan2x"), ("scan3x", "scan3x"), ] 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": "scaler", "section": _("Graphics"), "label": _("Graphic scaler"), "type": "choice", "choices": scaler_modes, "default": "normal3x", "help": _("The algorithm used to scale up the game's base " "resolution, resulting in different visual styles. "), }, { "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 "" if os.path.isabs(path): return path if self.game_data.get("directory"): return os.path.join(self.game_data.get("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): return {"error": "FILE_NOT_FOUND", "file": 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"])) scaler = self.runner_config.get("scaler") if scaler and scaler != "none": command.append("-scaler") command.append(self.runner_config["scaler"]) 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.14/lutris/runners/easyrpg.py000066400000000000000000000507111451435154700202020ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ from os import path # 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_chooser", "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_chooser", "label": _("Save path"), "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_chooser", "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_chooser", "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_chooser", "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 game_path # 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_runner_command(self): cmd = [self.get_executable()] # 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", 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", music_volume)) sound_volume = self.runner_config.get("sound_volume") if sound_volume: cmd.extend(("--sound-volume", 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", 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_runner_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: return {"error": "CUSTOM", "text": _("No game directory provided")} if not path.isdir(self.game_path): return self.directory_not_found(self.game_path) cmd = self.get_runner_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): return self.directory_not_found(save_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): return {"error": "FILE_NOT_FOUND", "file": 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} @staticmethod def directory_not_found(directory): error = _( "The directory {} could not be found" ).format(directory.replace("&", "&")) return {"error": "CUSTOM", "text": error} lutris-0.5.14/lutris/runners/flatpak.py000066400000000000000000000115211451435154700201460ustar00rootroot00000000000000import os import shutil from gettext import gettext as _ from pathlib import Path from lutris.command import MonitoredCommand from lutris.runners import NonInstallableRunnerError from lutris.runners.runner import Runner from lutris.util import flatpak as _flatpak 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": "command", "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_chooser", "label": _("Working directory"), "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): return _flatpak.is_installed() def get_executable(self): return _flatpak.get_executable() 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): """Flatpak can't be uninstalled from Lutris""" @property def game_path(self): if shutil.which("flatpak-spawn"): return "/" install_type, application, arch, branch = ( self.game_config.get(key, "") for key in ("install_type", "appid", "arch", "branch") ) return os.path.join(self.install_locations[install_type or "user"], application, arch, 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 play(self): arch = self.game_config.get("arch", "") branch = self.game_config.get("branch", "") args = self.game_config.get("args", "") appid = self.game_config.get("appid", "") if not appid: return {"error": "CUSTOM", "text": "No application specified."} if appid.count(".") < 2: return {"error": "CUSTOM", "text": "Application ID is not specified in correct format." "Must be something like: tld.domain.app"} if any(x in appid for x in ("--", "/")): return {"error": "CUSTOM", "text": "Application ID field must not contain options or arguments."} command = _flatpak.get_run_command(appid, arch, branch) if args: command.extend(split_arguments(args)) return {"command": command} lutris-0.5.14/lutris/runners/fsuae.py000066400000000000000000000417431451435154700176400ustar00rootroot00000000000000import 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", "label": _("Additionnal 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", "advanced": True, "help": _("Location of extended Kickstart used for CD32"), }, { "option": "gfx_fullscreen_amiga", "section": _("Graphics"), "label": _("Fullscreen (F12 + S 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 = int(DISPLAY_MANAGER.get_current_resolution()[0]) params.append("--fullscreen") # params.append("--fullscreen_mode=fullscreen-window") params.append("--fullscreen_mode=fullscreen") params.append("--fullscreen_width=%d" % 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.14/lutris/runners/hatari.py000066400000000000000000000143311451435154700177760ustar00rootroot00000000000000# Standard Library import os import shutil from gettext import gettext as _ # Lutris Modules from lutris.config import LutrisConfig 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: return {"error": "NO_BIOS"} diska = self.game_config.get("disk-a") if not system.path_exists(diska): return {"error": "FILE_NOT_FOUND", "file": diska} params.append("--disk-a") params.append(diska) diskb = self.game_config.get("disk-b") params.append("--disk-b") params.append(diskb) return {"command": params} lutris-0.5.14/lutris/runners/json.py000066400000000000000000000061211451435154700174750ustar00rootroot00000000000000"""Base class and utilities for JSON based runners""" import json import os from lutris import settings 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"])) 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): return {"error": "FILE_NOT_FOUND", "file": 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.14/lutris/runners/jzintv.py000066400000000000000000000054531451435154700200570ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # 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_chooser", "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_executable()] 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: return {"error": "NO_BIOS"} rom_path = self.game_config.get("main_file") or "" if not system.path_exists(rom_path): return {"error": "FILE_NOT_FOUND", "file": 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.14/lutris/runners/libretro.py000066400000000000000000000270431451435154700203540ustar00rootroot00000000000000"""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.runners.runner import Runner from lutris.util import system from lutris.util.libretro import RetroConfig from lutris.util.log import logger def get_default_config_path(path=""): return os.path.join(settings.RUNNER_DIR, "retroarch", path) def get_libretro_cores(): cores = [] runner_path = get_default_config_path() if not os.path.exists(runner_path): return [] # Get core identifiers from info dir info_path = get_default_config_path("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(get_default_config_path('info.zip'), 'wb') as info_zip: info_zip.write(req.content) with ZipFile(get_default_config_path('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": get_default_config_path("retroarch.cfg"), }, { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": True, }, { "option": "verbose", "type": "bool", "label": _("Verbose logging"), "default": False, }, ] @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(settings.RUNNER_DIR, "retroarch", "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, core=None): 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() is_core_installed = system.path_exists(self.get_core_path(core)) return super().is_installed() and is_core_installed 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 get_default_config_path("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 = get_default_config_path("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.save() else: retro_config = RetroConfig(config_file) core = self.game_config.get("core") info_file = os.path.join(get_default_config_path("info"), "{}_libretro.info".format(core)) 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("Suggested md5sums:"): parts = notes.split("|") for part in parts[1:]: checksum, filename = part.split(" = ") checksums[filename] = checksum for index in range(firmware_count): firmware_filename = retro_config["firmware%d_path" % index] firmware_path = os.path.join(system_path, firmware_filename) if system.path_exists(firmware_path): if firmware_filename in checksums: checksum = system.get_md5_hash(firmware_path) if checksum == checksums[firmware_filename]: checksum_status = "Checksum good" else: checksum_status = "Checksum failed" else: checksum_status = "No checksum info" logger.info("Firmware '%s' found (%s)", firmware_filename, checksum_status) else: logger.warning("Firmware '%s' not found!", firmware_filename) # Before closing issue #431 # TODO check for firmware*_opt and display an error message if # firmware is missing # TODO Add dialog for copying the firmware in the correct # location 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: return { "error": "CUSTOM", "text": _("No core has been selected for this game"), } command.append("--libretro={}".format(self.get_core_path(core))) # Ensure the core is available if not self.is_installed(core): self.install(core) # Main file file = self.game_config.get("main_file") if not file: return {"error": "CUSTOM", "text": _("No game file specified")} if not system.path_exists(file): return {"error": "FILE_NOT_FOUND", "file": file} command.append(file) return {"command": command} # Checks whether the retroarch or libretro directories can be uninstalled. def can_uninstall(self): retroarch_path = os.path.join(settings.RUNNER_DIR, 'retroarch') return os.path.isdir(retroarch_path) or super().can_uninstall() # Remove the `retroarch` directory. def uninstall(self): retroarch_path = os.path.join(settings.RUNNER_DIR, 'retroarch') if os.path.isdir(retroarch_path): system.remove_folder(retroarch_path) super().uninstall() lutris-0.5.14/lutris/runners/linux.py000066400000000000000000000134471451435154700176740ustar00rootroot00000000000000"""Runner for Linux games""" # Standard Library import os import stat from gettext import gettext as _ # Lutris Modules from lutris.exceptions import GameConfigError 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_chooser", "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_chooser", "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 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): """Well of course Linux is installed, you're using Linux right ?""" return True 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 = launch_config.get("working_dir") or self.working_dir if "exe" in launch_config: command.append(self.get_relative_exe(launch_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 play(self): """Run native game.""" launch_info = {} if not self.game_exe or not system.path_exists(self.game_exe): return {"error": "FILE_NOT_FOUND", "file": self.game_exe} # Quit if the file is not executable mode = os.stat(self.game_exe).st_mode if not mode & stat.S_IXUSR: return {"error": "NOT_EXECUTABLE", "file": self.game_exe} if not system.path_exists(self.game_exe): return {"error": "FILE_NOT_FOUND", "file": self.game_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(self.game_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.14/lutris/runners/mame.py000066400000000000000000000304251451435154700174470ustar00rootroot00000000000000"""Runner for MAME""" import os from gettext import gettext as _ from lutris import runtime, settings 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"), (_("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": "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_chooser", "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): AsyncCall(write_mame_xml, notify_mame_xml) 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""" os.makedirs(self.cache_dir, exist_ok=True) output = system.execute( self.get_command() + ["-listxml"], env=runtime.get_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") 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"]) device = self.game_config.get("device") if not device: return {'error': "CUSTOM", "text": "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: return {'error': 'PATH_NOT_SET', 'path': '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.14/lutris/runners/mednafen.py000066400000000000000000000412311451435154700203020ustar00rootroot00000000000000# Standard Library import subprocess from gettext import gettext as _ # 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): return {"error": "FILE_NOT_FOUND", "file": rom} command = [self.get_executable()] for option in options: command.append(option) command.append(rom) return {"command": command} lutris-0.5.14/lutris/runners/mupen64plus.py000066400000000000000000000030751451435154700207330ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris import settings 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_executable()] 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): return {"error": "FILE_NOT_FOUND", "file": rom} arguments.append(rom) return {"command": arguments} lutris-0.5.14/lutris/runners/o2em.py000066400000000000000000000077361451435154700174030ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # 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): return {"error": "FILE_NOT_FOUND", "file": 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_executable()] + arguments} lutris-0.5.14/lutris/runners/openmsx.py000066400000000000000000000014131451435154700202140ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ # 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): return {"error": "FILE_NOT_FOUND", "file": rom} return {"command": self.get_command() + [rom]} lutris-0.5.14/lutris/runners/osmose.py000066400000000000000000000025411451435154700200330ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ # 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_executable()] rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": rom} arguments.append(rom) if self.runner_config.get("fullscreen"): arguments.append("-fs") arguments.append("-bilinear") return {"command": arguments} lutris-0.5.14/lutris/runners/pcsx2.py000066400000000000000000000032461451435154700175700ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ # 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): return {"error": "FILE_NOT_FOUND", "file": iso} arguments.append(iso) return {"command": arguments} lutris-0.5.14/lutris/runners/pico8.py000066400000000000000000000210471451435154700175520ustar00rootroot00000000000000"""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.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_executable()] 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_executable(), 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, version=None, fallback=True): """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): return {"error": "FILE_NOT_FOUND", "file": 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.14/lutris/runners/redream.py000066400000000000000000000113241451435154700201440ustar00rootroot00000000000000import 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_executable()] 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.14/lutris/runners/reicast.py000066400000000000000000000124671451435154700201700ustar00rootroot00000000000000# 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.14/lutris/runners/rpcs3.py000066400000000000000000000022551451435154700175620ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ # 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): return {"error": "FILE_NOT_FOUND", "file": eboot} arguments.append(eboot) return {"command": arguments} lutris-0.5.14/lutris/runners/runner.py000066400000000000000000000457661451435154700200570ustar00rootroot00000000000000"""Base module for runners""" import os import signal from gettext import gettext as _ from lutris import runtime, settings from lutris.api import get_default_runner_version from lutris.command import MonitoredCommand from lutris.config import LutrisConfig from lutris.database.games import get_game_by_field from lutris.exceptions import GameConfigError, UnavailableLibrariesError from lutris.runners import RunnerInstallationError from lutris.util import flatpak, strings, system from lutris.util.extract import ExtractFailure, extract_archive from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger 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 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 game_path 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, } ) return runner_options def get_executable(self): 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 ValueError("runner_executable not set for {}".format(self.name)) return os.path.join(settings.RUNNER_DIR, self.runner_executable) def get_command(self): exe = self.get_executable() if system.path_exists(exe): return [exe] if flatpak.is_app_installed(self.flatpak_id): return flatpak.get_run_command(self.flatpak_id) return [] def get_env(self, os_env=False, disable_runtime=False): """Return environment variables used for a game.""" env = {} if os_env: env.update(os.environ.copy()) # 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 # DRI Prime if self.system_config.get("dri_prime"): env["DRI_PRIME"] = "1" # Prime vars prime = self.system_config.get("prime") if prime: env["__NV_PRIME_RENDER_OFFLOAD"] = "1" env["__GLX_VENDOR_LIBRARY_NAME"] = "nvidia" env["__VK_LAYER_NV_optimus"] = "NVIDIA_only" # Set PulseAudio latency to 60ms if self.system_config.get("pulse_latency"): env["PULSE_LATENCY_MSEC"] = "60" # Vulkan ICD files vk_icd = self.system_config.get("vk_icd") if vk_icd: env["VK_ICD_FILENAMES"] = vk_icd 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 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"]) elif "command" in gameplay_info: command = [gameplay_info["command"][0]] else: logger.debug("No command in %s", gameplay_info) logger.debug(launch_config) # The 'file' sort of gameplay_info cannot be made to use a configuration raise GameConfigError(_("The runner could not find a command to apply the configuration to.")) 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 and 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. This is provided as a method so the WINE runner can try to convert Windows-style paths to usable paths. """ 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 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): """Return whether the runner is installed""" if system.path_exists(self.get_executable()): return True return self.flatpak_id and flatpak.is_app_installed(self.flatpak_id) def get_runner_version(self, version=None) -> dict: """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(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 = self.get_runner_version(version) if not runner_version: raise RunnerInstallationError(_("Failed to retrieve {} ({}) information").format(self.name, version)) if "wine" in self.name: opts["merge_single"] = True opts["dest"] = os.path.join( self.directory, "{}-{}".format(runner_version["version"], runner_version["architecture"]) ) 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["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 install_ui_delegate.download_install_file(url, runner_archive) self.extract(archive=runner_archive, dest=dest, merge_single=merge_single, callback=callback) 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 ExtractFailure 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 get_installed_wine_versions get_installed_wine_versions.cache_clear() 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): runner_path = self.directory if os.path.isdir(runner_path): system.remove_folder(runner_path) 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): """Stop the running game. If this leaves any game processes running, the caller will SIGKILL them (after a delay).""" game.kill_processes(signal.SIGTERM) 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.14/lutris/runners/ryujinx.py000066400000000000000000000057771451435154700202540ustar00rootroot00000000000000import filecmp import os from gettext import gettext as _ from shutil import copyfile 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): return {"error": "FILE_NOT_FOUND", "file": 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.14/lutris/runners/scummvm.py000066400000000000000000000456271451435154700202310ustar00rootroot00000000000000import os import subprocess from gettext import gettext as _ from lutris import settings 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(config, _option_key): if "scaler" in config and "renderer" in config: renderer = config["renderer"] if renderer and renderer != "software": scaler = config["scaler"] if scaler and scaler != "normal": return _("Warning Scalers may not work with OpenGL rendering.") return None def _get_scale_factor_warning(config, _option_key): """Generate a warning message for when the scaler and scale-factor can't be used together.""" if "scaler" in config and "scale-factor" in config: scaler = config["scaler"] if scaler in _supported_scale_factors: scale_factor = 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_chooser", "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_chooser", "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): """Scummvm runner ships additional, they may be removed in a future version.""" base_runner_path = os.path.join(settings.RUNNER_DIR, "scummvm") if not self.get_executable().startswith(base_runner_path): return "" path = os.path.join(settings.RUNNER_DIR, "scummvm/lib") return path if system.path_exists(path) else "" def get_command(self): command = super().get_command() if "flatpak" in command[0]: return command return command + [ "--extrapath=%s" % self.get_scummvm_data_dir(), "--themepath=%s" % self.get_scummvm_data_dir(), ] def get_scummvm_data_dir(self): 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): env = self.get_env() extra_libs = self.get_extra_libs() if extra_libs: env["LD_LIBRARY_PATH"] = ":".join([p for p in [extra_libs, env.get("LD_LIBRARY_PATH")] if p]) 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"] = extra_libs return output def get_game_list(self): """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.14/lutris/runners/snes9x.py000066400000000000000000000055551451435154700177670ustar00rootroot00000000000000# 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.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 value.__class__.__name__ == "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): return {"error": "FILE_NOT_FOUND", "file": rom} return {"command": self.get_command() + [rom]} lutris-0.5.14/lutris/runners/steam.py000066400000000000000000000223731451435154700176440ustar00rootroot00000000000000"""Steam for Linux runner""" import os from gettext import gettext as _ from lutris.command import MonitoredCommand from lutris.exceptions import UnavailableRunnerError 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}, ] def __init__(self, config=None): super().__init__(config) self.own_game_remove_method = _("Remove game data (through Steam)") self.no_game_remove_warning = True @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): if linux.LINUX_SYSTEM.is_flatpak: # Fallback to xgd-open for Steam URIs in Flatpak return system.find_executable("xdg-open") if self.runner_config.get("lsi_steam") and system.find_executable("lsi-steam"): return system.find_executable("lsi-steam") runner_executable = self.runner_config.get("runner_executable") if runner_executable and os.path.isfile(runner_executable): return runner_executable return system.find_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): return {"error": "FILE_NOT_FOUND", "file": 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.14/lutris/runners/vice.py000066400000000000000000000153151451435154700174570ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris import settings 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=None): if not machine: machine = "c64" executables = { "c64": "x64", "c128": "x128", "vic20": "xvic", "pet": "xpet", "plus4": "xplus4", "cbmii": "xcbm2", } try: executable = executables[machine] except KeyError as ex: raise ValueError("Invalid machine '%s'" % machine) from ex return os.path.join(settings.RUNNER_DIR, "vice/bin/%s" % executable) 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: return {"error": "CUSTOM", "text": "No rom provided"} if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": 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.14/lutris/runners/web.py000066400000000000000000000210141451435154700172770ustar00rootroot00000000000000"""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.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: return { "error": "CUSTOM", "text": _("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): return { "error": "CUSTOM", "text": _("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.14/lutris/runners/wine.py000066400000000000000000001325171451435154700174770ustar00rootroot00000000000000"""Wine runner""" # pylint: disable=too-many-lines import os import shlex from gettext import gettext as _ from lutris import runtime, settings from lutris.exceptions import EsyncLimitError, FsyncUnsupportedError 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.steam.config import get_steam_dir from lutris.util.strings import split_arguments 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_DIR, WINE_PATHS, detect_arch, get_default_version, get_installed_wine_versions, get_overrides_env, get_proton_paths, get_real_executable, get_system_wine_version, is_esync_limit_set, is_fsync_supported, is_gstreamer_build ) def _get_prefix_warning(config, _option_key): if config.get("prefix"): return None exe = 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(config, option_key): 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.\n" "Please upgrade your driver as described in our " "installation guide" ) % ( driver_info["nvrm"]["version"], settings.DRIVER_HOWTO_URL, ) return None def _get_dxvk_error(config, option_key): """Checks that required libraries are installed on the system""" missing_arch = LINUX_SYSTEM.get_missing_lib_arch("VULKAN") if missing_arch: arches = ", ".join(missing_arch) return _("Error Missing Vulkan libraries\n" "Lutris was unable to detect Vulkan support for " "the %s architecture.\n" "This will prevent many games and programs from working.\n" "To install it, please use the following guide: " "Installing Graphics Drivers" ) % (arches, settings.DRIVER_HOWTO_URL) if not LINUX_SYSTEM.is_vulkan_supported(): return _("Error Vulkan is not installed or is not supported by your system, so DXVK is not available.\n" "If you have compatible hardware, please follow " "the installation procedures as described in\n" "" "How-to:-DXVK (https://github.com/lutris/docs/blob/master/HowToDXVK.md)" ) return None def _get_simple_vulkan_support_error(config, option_key, feature): if not LINUX_SYSTEM.is_vulkan_supported(): return _("Error Vulkan is not installed or is not supported by your system, " "so %s is not available.") % feature return None def _get_dxvk_version_warning(config, _option_key): if config.get("dxvk") and LINUX_SYSTEM.is_vulkan_supported(): version = 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_path_for_version(config, version=None): """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: version = config["version"] if version in WINE_PATHS: return system.find_executable(WINE_PATHS[version]) if "Proton" in version: for proton_path in get_proton_paths(): if os.path.isfile(os.path.join(proton_path, version, "dist/bin/wine")): return os.path.join(proton_path, version, "dist/bin/wine") if os.path.isfile(os.path.join(proton_path, version, "files/bin/wine")): return os.path.join(proton_path, version, "files/bin/wine") if version == "custom": return config.get("custom_wine_path", "") return os.path.join(WINE_DIR, version, "bin/wine") def _get_esync_warning(config, _option_key): if 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(config, _option_key): if config.get("fsync"): fsync_supported = is_fsync_supported() if not fsync_supported: return _("Warning Your kernel is not patched for fsync.") return "" 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_chooser", "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_chooser", "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"), }, ] 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 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) else: label = version version_choices.append((label, version)) return version_choices self.runner_options = [ { "option": "version", "label": _("Wine version"), "type": "choice", "choices": get_wine_version_choices, "default": get_default_version(), "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": _get_dxvk_error, "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", "condition": LINUX_SYSTEM.is_vulkan_supported(), "choices": DXVKManager().version_choices, "default": DXVKManager().version, "warning": _get_dxvk_version_warning, }, { "option": "vkd3d", "section": _("Graphics"), "label": _("Enable VKD3D"), "type": "bool", "error": lambda c, k: _get_simple_vulkan_support_error(c, k, _("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", "condition": LINUX_SYSTEM.is_vulkan_supported(), "choices": VKD3DManager().version_choices, "default": VKD3DManager().version, }, { "option": "d3d_extras", "section": _("Graphics"), "label": _("Enable D3D Extras"), "type": "bool", "default": True, "advanced": True, "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, "type": "choice_with_entry", "choices": D3DExtrasManager().version_choices, "default": D3DExtrasManager().version, }, { "option": "dxvk_nvapi", "section": _("Graphics"), "label": _("Enable DXVK-NVAPI / DLSS"), "type": "bool", "error": lambda c, k: _get_simple_vulkan_support_error(c, k, _("DXVK-NVAPI / DLSS")), "default": True, "advanced": True, "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, "type": "choice_with_entry", "choices": DXVKNVAPIManager().version_choices, "default": 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": dgvoodoo2Manager().version_choices, "default": dgvoodoo2Manager().version, }, { "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", "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", "choices": DISPLAY_MANAGER.get_resolutions, "help": _("The size of the virtual desktop in pixels."), }, { "option": "Dpi", "section": _("DPI"), "label": _("Enable DPI Scaling"), "type": "bool", "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", "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"), }, { "option": "sandbox", "type": "bool", "section": _("Sandbox"), "label": _("Create a sandbox for Wine folders"), "default": True, "advanced": True, "help": _( "Do not use $HOME for desktop integration folders.\n" "By default, it will use the directories in the confined " "Windows environment." ), }, { "option": "sandbox_dir", "type": "directory_chooser", "section": _("Sandbox"), "label": _("Sandbox directory"), "help": _("Custom directory for desktop integration folders."), "advanced": True, }, ] @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), (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) 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 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 _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(self.prefix_path, self.get_executable()) else: arch = WINE_DEFAULT_ARCH return arch def get_runner_version(self, version=None): if not version: version = self.read_version_from_config(use_default=False) if version in WINE_PATHS: return {"version": version} return super().get_runner_version(version) def read_version_from_config(self, use_default=True): """Return the Wine version to use. use_default can be set to false to force the installation of a specific wine version""" # 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() or None rather. 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 use_default: return get_default_version() def get_path_for_version(self, version): """Return the absolute path of a wine executable for a given version""" return _get_path_for_version(self.runner_config, version) 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=None, fallback=True): """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 not version: return wine_path = self.get_path_for_version(version) if system.path_exists(wine_path): return wine_path if fallback: # Fallback to default version default_version = get_default_version() wine_path = self.get_path_for_version(default_version) if 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 is_installed(self, version=None, fallback=True): """Check if Wine is installed. If no version is passed, checks if any version of wine is available """ if version: return system.path_exists(self.get_executable(version, fallback)) return bool(get_installed_wine_versions()) @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_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_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 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): 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.sandbox(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 = {} 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 enabled_only: continue 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 if show_debug == "-all": env["DXVK_LOG_LEVEL"] = "none" env["WINEARCH"] = self.wine_arch env["WINE"] = self.get_executable() env["WINE_MONO_CACHE_DIR"] = os.path.join(WINE_DIR, self.read_version_from_config(), "mono") env["WINE_GECKO_CACHE_DIR"] = os.path.join(WINE_DIR, self.read_version_from_config(), "gecko") if is_gstreamer_build(self.get_executable()): path_64 = os.path.join(WINE_DIR, self.read_version_from_config(), "lib64/gstreamer-1.0/") path_32 = os.path.join(WINE_DIR, self.read_version_from_config(), "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 not ("WINEESYNC" in env and env["WINEESYNC"] == "1"): env["WINEESYNC"] = "1" if self.runner_config.get("esync") else "0" if not ("WINEFSYNC" in env and env["WINEFSYNC"] == "1"): env["WINEFSYNC"] = "1" if self.runner_config.get("fsync") else "0" 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") 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) # Proton support if "Proton" in self.read_version_from_config(): steam_dir = get_steam_dir() if steam_dir: # May be None for example if Proton-GE is used but Steam is not installed env["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = steam_dir env["STEAM_COMPAT_DATA_PATH"] = self.prefix_path env["STEAM_COMPAT_APP_ID"] = '0' env["SteamAppId"] = '0' if "SteamGameId" not in env: env["SteamGameId"] = "lutris-game" return env def get_runtime_env(self): """Return runtime environment variables with path to wine for Lutris builds""" wine_path = self.get_executable() wine_root = None if WINE_DIR: wine_root = os.path.dirname(os.path.dirname(wine_path)) for proton_path in get_proton_paths(): if proton_path in wine_path: wine_root = os.path.dirname(os.path.dirname(wine_path)) return runtime.get_env( version="Ubuntu-18.04", prefer_system_libs=self.system_config.get("prefer_system_libs", True), wine_path=wine_root, ) def get_pids(self, wine_path=None): """Return a list of pids of processes using the current wine exe.""" if wine_path: exe = wine_path else: exe = self.get_executable() if not exe.startswith("/"): exe = system.find_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") # 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 sandbox(self, wine_prefix): if self.runner_config.get("sandbox", True): wine_prefix.enable_desktop_integration_sandbox(desktop_dir=self.runner_config.get("sandbox_dir")) else: wine_prefix.restore_desktop_integration() 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") if using_dxvk: # Set this to 1 to enable access to more RAM for 32bit applications launch_info["env"]["WINE_LARGE_ADDRESS_AWARE"] = "1" if not game_exe or not system.path_exists(game_exe): return {"error": "FILE_NOT_FOUND", "file": game_exe} if launch_info["env"].get("WINEESYNC") == "1": limit_set = is_esync_limit_set() if not limit_set: raise EsyncLimitError(_("Your ESYNC limits are not set correctly.")) if launch_info["env"].get("WINEFSYNC") == "1": fsync_supported = is_fsync_supported() if not fsync_supported: raise FsyncUnsupportedError(_("Your kernel is not patched for fsync.")) command = [self.get_executable()] 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 force_stop_game(self, game): """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.""" self.run_winekill() @staticmethod def parse_wine_path(path, prefix_path=None): """Take a Windows path, return the corresponding Linux path.""" if not prefix_path: prefix_path = os.path.expanduser("~/.wine") path = path.replace("\\\\", "/").replace("\\", "/") if path[1] == ":": # absolute path drive = os.path.join(prefix_path, "dosdevices", path[:2].lower()) if os.path.islink(drive): # Try to resolve the path drive = os.readlink(drive) return os.path.join(drive, path[3:]) if path[0] == "/": # drive-relative path. C is as good a guess as any.. return os.path.join(prefix_path, "drive_c", path[1:]) # Relative path return path 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" if not self.game_exe or os.path.exists(pathtoicon) or not PEFILE_AVAILABLE: return False extractor = ExtractIcon(self.game_exe) groups = extractor.get_group_icons() 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 except Exception as err: logger.exception("Failed to extract exe icon: %s", err) return False lutris-0.5.14/lutris/runners/xemu.py000066400000000000000000000024041451435154700175020ustar00rootroot00000000000000from gettext import gettext as _ 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): return {"error": "FILE_NOT_FOUND", "file": iso} arguments += ["-dvd_path", iso] return {"command": arguments} lutris-0.5.14/lutris/runners/yuzu.py000066400000000000000000000064071451435154700175470ustar00rootroot00000000000000import filecmp import os from gettext import gettext as _ from shutil import copyfile 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): return {"error": "FILE_NOT_FOUND", "file": 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.14/lutris/runners/zdoom.py000066400000000000000000000133471451435154700176640ustar00rootroot00000000000000import 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", "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_chooser", "label": _("Save path"), "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.14/lutris/runtime.py000066400000000000000000000355751451435154700165320ustar00rootroot00000000000000"""Runtime handling module""" import concurrent.futures import os import time from gettext import gettext as _ from gi.repository import GLib from lutris import settings from lutris.api import download_runtime_versions, get_time_from_api_date, load_runtime_versions from lutris.util import http, jobs, system, update_cache from lutris.util.downloader import Downloader from lutris.util.extract import extract_archive from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.wine.d3d_extras import D3DExtrasManager from lutris.util.wine.dgvoodoo2 import dgvoodoo2Manager from lutris.util.wine.dxvk import DXVKManager from lutris.util.wine.dxvk_nvapi import DXVKNVAPIManager from lutris.util.wine.vkd3d import VKD3DManager from lutris.util.wine.wine import get_installed_wine_versions RUNTIME_DISABLED = os.environ.get("LUTRIS_RUNTIME", "").lower() in ("0", "off") DEFAULT_RUNTIME = "Ubuntu-18.04" DLL_MANAGERS = { "dxvk": DXVKManager, "vkd3d": VKD3DManager, "d3d_extras": D3DExtrasManager, "dgvoodoo2": dgvoodoo2Manager, "dxvk_nvapi": DXVKNVAPIManager, } class Runtime: """Class for manipulating runtime folders""" def __init__(self, name: str, updater) -> None: self.name: str = name self.updater = updater self.versioned: bool = False # Versioned runtimes keep 1 version per folder self.version: str = "" self.download_progress: float = 0 @property def local_runtime_path(self): """Return the local path for the runtime folder""" if not self.name: return None return os.path.join(settings.RUNTIME_DIR, self.name) def get_updated_at(self): """Return the modification date of the runtime folder""" if not system.path_exists(self.local_runtime_path): return None return time.gmtime(os.path.getmtime(self.local_runtime_path)) def set_updated_at(self): """Set the creation and modification time to now""" if not system.path_exists(self.local_runtime_path): logger.error("No local runtime path in %s", self.local_runtime_path) return os.utime(self.local_runtime_path) def should_update(self, remote_updated_at): """Determine if the current runtime should be updated""" if self.versioned: return not system.path_exists(os.path.join(settings.RUNTIME_DIR, self.name, self.version)) local_updated_at = self.get_updated_at() if not local_updated_at: logger.debug("Runtime %s is not available locally", self.name) return True if local_updated_at and local_updated_at >= remote_updated_at: return False logger.debug( "Runtime %s locally updated on %s, remote created on %s)", self.name, time.strftime("%c", local_updated_at), time.strftime("%c", remote_updated_at), ) return True def should_update_component(self, filename, remote_modified_at): """Should an individual component be updated?""" file_path = os.path.join(settings.RUNTIME_DIR, self.name, filename) if not system.path_exists(file_path): return True locally_modified_at = time.gmtime(os.path.getmtime(file_path)) if locally_modified_at >= remote_modified_at: return False return True def get_downloader(self, remote_runtime_info: dict) -> Downloader: """Return Downloader for this runtime""" url = remote_runtime_info["url"] self.versioned = remote_runtime_info["versioned"] if self.versioned: self.version = remote_runtime_info["version"] archive_path = os.path.join(settings.RUNTIME_DIR, os.path.basename(url)) return Downloader(url, archive_path, overwrite=True) def download(self, remote_runtime_info: dict): """Downloads a runtime locally""" remote_updated_at = get_time_from_api_date(remote_runtime_info["created_at"]) if not self.should_update(remote_updated_at): return None downloader = self.get_downloader(remote_runtime_info) downloader.start() GLib.timeout_add(100, self.check_download_progress, downloader) return downloader def download_component(self, component): """Download an individual file from a runtime item""" file_path = os.path.join(settings.RUNTIME_DIR, self.name, component["filename"]) try: http.download_file(component["url"], file_path) except http.HTTPError as ex: logger.error("Failed to download runtime component %s: %s", component, ex) return return file_path def get_runtime_components(self) -> list: """Fetch individual runtime files for a component""" request = http.Request(settings.RUNTIME_URL + "/" + self.name) try: response = request.get() except http.HTTPError as ex: logger.error("Failed to get components: %s", ex) return [] if not response.json: return [] return response.json.get("components", []) def download_components(self) -> None: """Download a runtime item by individual components. Used for icons only at the moment""" components = self.get_runtime_components() downloads = [] for component in components: modified_at = get_time_from_api_date(component["modified_at"]) if not self.should_update_component(component["filename"], modified_at): continue downloads.append(component) with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: future_downloads = { executor.submit(self.download_component, component): component["filename"] for component in downloads } for future in concurrent.futures.as_completed(future_downloads): filename = future_downloads[future] if not filename: logger.warning("Failed to get %s", future) def check_download_progress(self, downloader: Downloader): """Call download.check_progress(), return True if download finished.""" if downloader.state == downloader.ERROR: logger.error("Runtime update failed") return False self.download_progress = downloader.check_progress() if downloader.state == downloader.COMPLETED: self.on_downloaded(downloader.dest) return False return True def on_downloaded(self, path: str) -> bool: """Actions taken once a runtime is downloaded Arguments: path: local path to the runtime archive, or None on download failure """ if not path: return False stats = os.stat(path) if not stats.st_size: logger.error("Download failed: file %s is empty, Deleting file.", path) os.unlink(path) return False directory, _filename = os.path.split(path) dest_path = os.path.join(directory, self.name) if self.versioned: dest_path = os.path.join(dest_path, self.version) else: # Delete the existing runtime path system.remove_folder(dest_path) # Extract the runtime archive jobs.AsyncCall(extract_archive, self.on_extracted, path, dest_path, merge_single=True) return False def on_extracted(self, result: tuple, error: Exception) -> bool: """Callback method when a runtime has extracted""" if error: logger.error("Runtime update failed: %s", error) return False archive_path, _destination_path = result os.unlink(archive_path) self.set_updated_at() if self.name in DLL_MANAGERS: manager = DLL_MANAGERS[self.name]() manager.fetch_versions() return False class RuntimeUpdater: """Class handling the runtime updates""" status_updater = None update_functions = [] downloaders = {} status_text: str = "" def __init__(self, pci_ids: list = None, force: bool = False): self.force = force self.pci_ids = pci_ids or [] self.runtime_versions = {} if RUNTIME_DISABLED: logger.warning("Runtime disabled. Safety not guaranteed.") else: self.add_update("runtime", self._update_runtime, hours=12) self.add_update("runners", self._update_runners, hours=12) def add_update(self, key: str, update_function, hours): """__init__ calls this to register each update. This function only registers the update if it hasn't been tried in the last 'hours' hours. This is trakced in 'updates.json', and identified by 'key' in that file.""" last_call = update_cache.get_last_call(key) if self.force or not last_call or last_call > 3600 * hours: self.update_functions.append((key, update_function)) @property def has_updates(self) -> bool: """Returns True if there are any updates to perform.""" return len(self.update_functions) > 0 def load_runtime_versions(self) -> dict: """Load runtime versions from json file""" self.runtime_versions = load_runtime_versions() return self.runtime_versions def update_runtimes(self): """Performs all the registered updates.""" self.runtime_versions = download_runtime_versions(self.pci_ids) for key, func in self.update_functions: func() update_cache.write_date_to_cache(key) def _update_runners(self): """Update installed runners (only works for Wine at the moment)""" upstream_runners = self.runtime_versions.get("runners", {}) for name, upstream_runners in upstream_runners.items(): if name != "wine": continue upstream_runner = None for _runner in upstream_runners: if _runner["architecture"] == LINUX_SYSTEM.arch: upstream_runner = _runner if not upstream_runner: continue # This has the responsibility to update existing runners, not installing new ones runner_base_path = os.path.join(settings.RUNNER_DIR, name) if not system.path_exists(runner_base_path) or not os.listdir(runner_base_path): continue runner_path = os.path.join(settings.RUNNER_DIR, name, "-".join([upstream_runner["version"], upstream_runner["architecture"]])) if system.path_exists(runner_path): continue self.status_text = _(f"Updating {name}") archive_download_path = os.path.join(settings.CACHE_DIR, os.path.basename(upstream_runner["url"])) downloader = Downloader(upstream_runner["url"], archive_download_path) downloader.start() self.downloaders = {"wine": downloader} downloader.join() self.status_text = _(f"Extracting {name}") extract_archive(archive_download_path, runner_path) get_installed_wine_versions.cache_clear() def percentage_completed(self) -> float: if not self.downloaders: return 0 return sum(downloader.progress_fraction for downloader in self.downloaders.values()) / len(self.downloaders) def _update_runtime(self) -> None: """Launch the update process""" for name, remote_runtime in self.runtime_versions.get("runtimes", {}).items(): if remote_runtime["architecture"] == "x86_64" and not LINUX_SYSTEM.is_64_bit: logger.debug("Skipping runtime %s for %s", name, remote_runtime["architecture"]) continue runtime = Runtime(remote_runtime["name"], self) self.status_text = _(f"Updating {remote_runtime['name']}") if remote_runtime["url"]: downloader = runtime.download(remote_runtime) if downloader: self.downloaders[runtime] = downloader downloader.join() else: runtime.download_components() def get_env(version: str = None, prefer_system_libs: bool = False, wine_path: str = None) -> dict: """Return a dict containing LD_LIBRARY_PATH env var Params: version: Version of the runtime to use, such as "Ubuntu-18.04" or "legacy" prefer_system_libs: Whether to prioritize system libs over runtime libs wine_path: If you prioritize system libs, provide the path for a lutris wine build if one is being used. This allows Lutris to prioritize the wine libs over everything else. """ library_path = ":".join(get_paths(version=version, prefer_system_libs=prefer_system_libs, wine_path=wine_path)) env = {} if library_path: env["LD_LIBRARY_PATH"] = library_path network_tools_path = os.path.join(settings.RUNTIME_DIR, "network-tools") env["PATH"] = "%s:%s" % (network_tools_path, os.environ["PATH"]) return env def get_winelib_paths(wine_path): """Return wine libraries path for a Lutris wine build""" paths = [] # Prioritize libwine.so.1 for lutris builds for winelib_path in ("lib", "lib64"): winelib_fullpath = os.path.join(wine_path or "", winelib_path) if system.path_exists(winelib_fullpath): paths.append(winelib_fullpath) return paths def get_runtime_paths(version=None, prefer_system_libs=True, wine_path=None): """Return Lutris runtime paths""" version = version or DEFAULT_RUNTIME lutris_runtime_path = "%s-i686" % version runtime_paths = [ lutris_runtime_path, "steam/i386/lib/i386-linux-gnu", "steam/i386/lib", "steam/i386/usr/lib/i386-linux-gnu", "steam/i386/usr/lib", ] if LINUX_SYSTEM.is_64_bit: lutris_runtime_path = "%s-x86_64" % version runtime_paths += [ lutris_runtime_path, "steam/amd64/lib/x86_64-linux-gnu", "steam/amd64/lib", "steam/amd64/usr/lib/x86_64-linux-gnu", "steam/amd64/usr/lib", ] paths = [] if prefer_system_libs: if wine_path: paths += get_winelib_paths(wine_path) paths += list(LINUX_SYSTEM.iter_lib_folders()) # Then resolve absolute paths for the runtime paths += [os.path.join(settings.RUNTIME_DIR, path) for path in runtime_paths] return paths def get_paths(version=None, prefer_system_libs=True, wine_path=None): """Return a list of paths containing the runtime libraries.""" if not RUNTIME_DISABLED: paths = get_runtime_paths(version=version, prefer_system_libs=prefer_system_libs, wine_path=wine_path) else: paths = [] # Put existing LD_LIBRARY_PATH at the end if os.environ.get("LD_LIBRARY_PATH"): paths.append(os.environ["LD_LIBRARY_PATH"]) return paths lutris-0.5.14/lutris/scanners/000077500000000000000000000000001451435154700162725ustar00rootroot00000000000000lutris-0.5.14/lutris/scanners/__init__.py000066400000000000000000000000001451435154700203710ustar00rootroot00000000000000lutris-0.5.14/lutris/scanners/default_installers.py000066400000000000000000000110261451435154700225300ustar00rootroot00000000000000 DEFAULT_INSTALLERS = { "3do": { "runner": "libretro", "game": {"core": "opera", "main_file": "rom"}, }, "sms": { "runner": "libretro", "game": {"core": "genesis_plus_gx", "main_file": "rom"}, }, "gg": { "runner": "libretro", "game": {"core": "genesis_plus_gx", "main_file": "rom"}, }, "md": { "runner": "libretro", "game": {"core": "genesis_plus_gx", "main_file": "rom"}, }, "pico": { "runner": "libretro", "game": {"core": "picodrive", "main_file": "rom"}, }, "segacd": { "runner": "libretro", "game": {"core": "picodrive", "main_file": "rom"}, }, "saturn": { "runner": "libretro", "game": {"core": "yabause", "main_file": "rom"}, }, "dc": { "runner": "libretro", "game": {"core": "flycast", "main_file": "rom"}, }, "colecovision": { "runner": "mame", "game": {"main_file": "rom", "machine": "coleco", "device": "cart"} }, "atari800": { "runner": "atari800", "game": {"main_file": "rom", "machine": "xl"} }, "atari2600": { "runner": "stella", "game": {"main_file": "rom"} }, "lynx": { "runner": "libretro", "game": {"core": "handy", "main_file": "rom"}, }, "atari-st": { "runner": "hatari", "game": {"disk-a": "rom", } }, "jaguar": { "runner": "libretro", "game": {"core": "virtualjaguar", "main_file": "rom"}, }, "amiga": { "runner": "fsuae", "game": {"main_file": "rom"} }, "amiga-1200": { "runner": "fsuae", "game": {"main_file": "rom"}, "fsuae": {"model": "A1200"} }, "ds": { "runner": "libretro", "game": {"core": "desmume", "main_file": "rom"}, }, "gb": { "runner": "libretro", "game": {"core": "gambatte", "main_file": "rom"}, }, "gba": { "runner": "libretro", "game": {"core": "vba_next", "main_file": "rom"}, }, "nes": { "runner": "libretro", "game": {"core": "mesen", "main_file": "rom"}, }, "snes": { "runner": "libretro", "game": {"core": "snes9x", "main_file": "rom"}, }, "n64": { "runner": "libretro", "game": {"core": "mupen64plus_next", "main_file": "rom"}, }, "gamecube": { "runner": "dolphin", "game": {"main_file": "rom", "platform": "0"} }, "wii": { "runner": "dolphin", "game": {"main_file": "rom", "platform": "1"} }, "switch": { "runner": "yuzu", "game": {"main_file": "rom"} }, "ps1": { "runner": "libretro", "game": {"core": "mednafen_psx_hw", "main_file": "rom"}, }, "ps2": { "runner": "pcsx2", "game": {"main_file": "rom"} }, "ps3": { "runner": "rpcs3", "game": {"main_file": "rom"} }, "psp": { "runner": "libretro", "game": {"core": "ppsspp", "main_file": "rom"}, }, "cdi": { "runner": "mame", "game": {"main_file": "rom", "device": "cdrm", "machine": "cdimono1"} }, "msx": { "runner": "libretro", "game": {"core": "bluemsx", "main_file": "rom"}, }, "archimedes": { "runner": "mame", "game": {"main_file": "rom", "device": "flop1", "machine": "aa4000"} }, "bbc": { "runner": "mame", "game": {"main_file": "rom", "device": "flop1", "machine": "bbcbp128"} }, "electron": { "runner": "mame", "game": {"main_file": "rom", "device": "flop1", "machine": "electron"} }, "astrocade": { "runner": "mame", "game": {"main_file": "rom", "device": "cart", "machine": "astrocde"} }, "wonderswancolor": { "runner": "mame", "game": {"main_file": "rom", "device": "cart", "machine": "wscolor"} }, "wonderswan": { "runner": "mame", "game": {"main_file": "rom", "device": "cart", "machine": "wswan"} }, "cpc6128disk": { "runner": "mame", "game": {"main_file": "rom", "device": "flop1", "machine": "cpc6128p"} }, "gx4000": { "runner": "mame", "game": {"main_file": "rom", "device": "cart", "machine": "gx4000"} }, "apple2": { "runner": "mame", "game": {"main_file": "rom", "device": "flop1", "machine": "apple2gs"} }, "spectrumcass": { "runner": "mame", "game": {"main_file": "rom", "device": "cass", "machine": "spectrum"} } } lutris-0.5.14/lutris/scanners/lutris.py000066400000000000000000000157671451435154700202060ustar00rootroot00000000000000import json import os import time from functools import lru_cache from lutris import settings from lutris.api import get_api_games, get_game_installers from lutris.database.games import get_games from lutris.game import Game from lutris.installer.errors import MissingGameDependency from lutris.installer.interpreter import ScriptInterpreter from lutris.services.lutris import download_lutris_media from lutris.util.log import logger from lutris.util.strings import slugify GAME_PATH_CACHE_PATH = os.path.join(settings.CACHE_DIR, "game-paths.json") def get_game_slugs_and_folders(dirname): """Scan a directory for games previously installed with lutris""" folders = os.listdir(dirname) game_folders = {} for folder in folders: if not os.path.isdir(os.path.join(dirname, folder)): continue game_folders[slugify(folder)] = folder return game_folders def find_game_folder(dirname, api_game, slugs_map): if api_game["slug"] in slugs_map: game_folder = os.path.join(dirname, slugs_map[api_game["slug"]]) if os.path.exists(game_folder): return game_folder for alias in api_game["aliases"]: if alias["slug"] in slugs_map: game_folder = os.path.join(dirname, slugs_map[alias["slug"]]) if os.path.exists(game_folder): return game_folder def detect_game_from_installer(game_folder, installer): try: exe_path = installer["script"]["game"].get("exe") except KeyError: exe_path = installer["script"].get("exe") if not exe_path: return exe_path = exe_path.replace("$GAMEDIR/", "") full_path = os.path.join(game_folder, exe_path) if os.path.exists(full_path): return full_path def find_game(game_folder, api_game): installers = get_game_installers(api_game["slug"]) for installer in installers: full_path = detect_game_from_installer(game_folder, installer) if full_path: return full_path, installer return None, None def get_used_directories(): directories = set() for game in get_games(): if game['directory']: directories.add(game['directory']) return directories def install_game(installer, game_folder): interpreter = ScriptInterpreter(installer) interpreter.target_path = game_folder interpreter.installer.save() def scan_directory(dirname): slugs_map = get_game_slugs_and_folders(dirname) directories = get_used_directories() api_games = get_api_games(list(slugs_map.keys())) slugs_seen = set() slugs_installed = set() for api_game in api_games: if api_game["slug"] in slugs_seen: continue slugs_seen.add(api_game["slug"]) game_folder = find_game_folder(dirname, api_game, slugs_map) if game_folder in directories: slugs_installed.add(api_game["slug"]) continue full_path, installer = find_game(game_folder, api_game) if full_path: logger.info("Found %s in %s", api_game["name"], full_path) try: install_game(installer, game_folder) except MissingGameDependency as ex: logger.error("Skipped %s: %s", api_game["name"], ex) download_lutris_media(installer["game_slug"]) slugs_installed.add(api_game["slug"]) installed_map = {slug: folder for slug, folder in slugs_map.items() if slug in slugs_installed} missing_map = {slug: folder for slug, folder in slugs_map.items() if slug not in slugs_installed} return installed_map, missing_map def get_path_from_config(game): """Return the path of the main entry point for a game""" if not game.config: logger.warning("Game %s has no configuration", game) return "" game_config = game.config.game_config # Skip MAME roms referenced by their ID if game.runner_name == "mame": if "main_file" in game_config and "." not in game_config["main_file"]: return "" for key in ["exe", "main_file", "iso", "rom", "disk-a", "path", "files"]: if key in game_config: path = game_config[key] if key == "files": path = path[0] if not path.startswith("/"): path = os.path.join(game.directory, path) return path logger.warning("No path found in %s", game.config) return "" def get_game_paths(): game_paths = {} all_games = get_games(filters={'installed': 1}) for db_game in all_games: game = Game(db_game["id"]) if game.runner_name in ("steam", "web"): continue path = get_path_from_config(game) 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 = get_path_from_config(game) 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() def remove_from_path_cache(game): logger.debug("Removing %s from path cache", game) current_cache = read_path_cache() if str(game.id) not in current_cache: logger.warning("Game %s (id=%s) not in cache path", game, game.id) return del current_cache[str(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() @lru_cache() 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 get_missing_game_ids(): """Return a list of IDs for games that can't be found""" logger.debug("Checking for missing games") missing_ids = [] for game_id, path in get_path_cache().items(): if not os.path.exists(path): missing_ids.append(game_id) return missing_ids def is_game_missing(game_id): cache = get_path_cache() path = cache.get(str(game_id)) return path and not os.path.exists(path) lutris-0.5.14/lutris/scanners/retroarch.py000066400000000000000000000047741451435154700206510ustar00rootroot00000000000000import os from lutris.config import write_game_config from lutris.database.games import add_game, get_games from lutris.game import Game from lutris.util.log import logger from lutris.util.retroarch.core_config import RECOMMENDED_CORES from lutris.util.strings import slugify SCANNERS = { "mesen": "NES", "gambatte": "Gameboy / Gameboy Color", "snes": "Super Nintendo", "mupen64plus_next": "Nintendo 64", "picodrive": "Master System / Game Gear / Genesis / MegaCD / 32x", "opera": "3DO", } ROM_FLAGS = [ "USA", "Europe", "World", "Japan", "Japan, USA", "USA, Europe", "Proto", "SGB Enhanced", "Rev A", "V1.1", "F", "U", "E", "UE" "W", "M3" ] EXTRA_FLAGS = [ "!", "S" ] def clean_rom_name(name): """Remove known flags from ROM filename and apply formatting""" for flag in ROM_FLAGS: name = name.replace(" (%s)" % flag, "") for flag in EXTRA_FLAGS: name = name.replace("[%s]" % flag, "") if ", The" in name: name = "The %s" % name.replace(", The", "") name = name.strip() return name def scan_directory(dirname): """Add a directory of ROMs as Lutris games""" files = os.listdir(dirname) folder_extensions = {os.path.splitext(filename)[1] for filename in files} core_matches = {} for core, core_data in RECOMMENDED_CORES.items(): for ext in core_data.get("extensions", []): if ext in folder_extensions: core_matches[ext] = core added_games = [] for filename in files: name, ext = os.path.splitext(filename) if ext not in core_matches: continue logger.info("Importing '%s'", name) slug = slugify(name) core = core_matches[ext] config = { "game": { "core": core_matches[ext], "main_file": os.path.join(dirname, filename) } } installer_slug = "%s-libretro-%s" % (slug, core) existing_game = get_games(filters={"installer_slug": installer_slug}) if existing_game: game = Game(existing_game[0]["id"]) game.remove() configpath = write_game_config(slug, config) game_id = add_game( name=name, runner="libretro", slug=slug, directory=dirname, installed=1, installer_slug=installer_slug, configpath=configpath ) added_games.append(game_id) return added_games lutris-0.5.14/lutris/scanners/tosec.py000066400000000000000000000124501451435154700177630ustar00rootroot00000000000000import os from lutris import settings from lutris.util import http from lutris.util.extract import extract_archive from lutris.util.log import logger from lutris.util.system import get_md5_hash archive_formats = [".zip", ".7z", ".rar", ".gz"] save_formats = [".srm"] PLATFORM_PATTERNS = { "3DO": "3do", "Amiga CD32": "amiga-cd32", "Amiga": "amiga", "Master System": "sms", "Genesis": "md", "Game Gear": "gg", "Sega CD": "segacd", "Saturn": "saturn", "Dreamcast": "dc", "PICO": "pico", "ColecoVision": "colecovision", "Atari 8bit": "atari800", "Atari - 8-bit": "atari800", "Atari 2600": "atari2600", "Atari - 2600": "atari2600", "Atari Lynx": "lynx", "Atari ST": "atari-st", "Atari - ST": "atari-st", "Atari Jaguar": "jaguar", "Nintendo DS": "ds", "Super Nintendo Entertainment System": "snes", "Super Famicom": "snes", "Nintendo Famicom": "nes", "Nintendo Entertainment System": "nes", "Nintendo 64": "n64", "Game Boy Advance": "gba", "Game Boy": "gb", "GameCube": "gamecube", "Wii": "wii", "Switch": "switch", "PlayStation 2": "ps2", "PlayStation 3": "ps3", "PlayStation Vita": "psvita", "PlayStationPortable": "psp", "PlayStation Portable": "psp", "PlayStation": "ps1", "CD-i": "cdi", "MSX2": "msx", "Archimedes": "archimedes", "Acorn BBC": "bbc", "Acorn Electron": "electron", "Bally": "astrocade", "WonderSwan Color": "wonderswancolor", "WonderSwan": "wonderswan", "Amstrad CPC - Games - [DSK]": "cpc6128disk", "Amstrad CPC - Games - [CPR]": "gx4000", "ZX Spectrum - Games - [TZX]": "spectrumcass", "ZX Spectrum - Games - [TAP]": "spectrumcass", "Apple II": "apple2", } def search_tosec_by_md5(md5sum): """Retrieve a lutris bundle from the API""" if not md5sum: return [] url = settings.SITE_URL + "/api/tosec/games?md5=" + md5sum response = http.Request(url, headers={"Content-Type": "application/json"}) try: response.get() except http.HTTPError as ex: logger.error("Unable to get bundle from API: %s", ex) return None response_data = response.json return response_data["results"] def scan_folder(folder, extract_archives=False): roms = {} archives = [] saves = {} checksums = {} archive_contents = [] if extract_archives: for filename in os.listdir(folder): basename, ext = os.path.splitext(filename) if ext not in archive_formats: continue extract_archive( os.path.join(folder, filename), os.path.join(folder, basename), merge_single=False ) for archive_file in os.listdir(os.path.join(folder, basename)): archive_contents.append("%s/%s" % (basename, archive_file)) for filename in os.listdir(folder) + archive_contents: basename, ext = os.path.splitext(filename) if ext in archive_formats: archives.append(filename) continue if ext in save_formats: saves[basename] = filename continue if os.path.isdir(os.path.join(folder, filename)): continue md5sum = get_md5_hash(os.path.join(folder, filename)) roms[filename] = search_tosec_by_md5(md5sum) checksums[md5sum] = filename for rom, result in roms.items(): if not result: print("no result for %s" % rom) continue if len(result) > 1: print("More than 1 match for %s", rom) continue print("Found: %s" % result[0]["name"]) roms_matched = 0 renames = {} for game_rom in result[0]["roms"]: source_file = checksums[game_rom["md5"]] dest_file = game_rom["name"] renames[source_file] = dest_file roms_matched += 1 if roms_matched == len(result[0]["roms"]): for source, dest in renames.items(): base_name, _ext = os.path.splitext(source) dest_base_name, _ext = os.path.splitext(dest) if base_name in saves: save_file = saves[base_name] _base_name, ext = os.path.splitext(save_file) os.rename( os.path.join(folder, save_file), os.path.join(folder, dest_base_name + ext) ) try: os.rename( os.path.join(folder, source), os.path.join(folder, dest) ) except FileNotFoundError: logger.error("Failed to rename %s to %s", source, dest) def guess_platform(game): category = game["category"]["name"] for pattern, platform in PLATFORM_PATTERNS.items(): if pattern in category: return platform def clean_rom_name(name): in_parens = False good_index = 0 for i, c in enumerate(name[::-1], start=1): if c in (")", "]"): in_parens = True if in_parens: good_index = i if c in ("(", "]"): in_parens = False name = name[:len(name) - good_index].strip() if name.endswith(", The"): name = "The " + name[:-5] return name lutris-0.5.14/lutris/services/000077500000000000000000000000001451435154700163015ustar00rootroot00000000000000lutris-0.5.14/lutris/services/__init__.py000066400000000000000000000046741451435154700204250ustar00rootroot00000000000000"""Service package""" import os from lutris import settings from lutris.services.amazon import AmazonService from lutris.services.battlenet import BNET_ENABLED, BattleNetService from lutris.services.dolphin import DolphinService from lutris.services.ea_app import EAAppService from lutris.services.egs import EpicGamesStoreService from lutris.services.flathub import FlathubService from lutris.services.gog import GOGService from lutris.services.humblebundle import HumbleBundleService from lutris.services.itchio import ItchIoService from lutris.services.lutris import LutrisService from lutris.services.mame import MAMEService from lutris.services.origin import OriginService from lutris.services.scummvm import SCUMMVM_CONFIG_FILE, ScummvmService from lutris.services.steam import SteamService from lutris.services.steamwindows import SteamWindowsService from lutris.services.ubisoft import UbisoftConnectService from lutris.services.xdg import XDGService from lutris.util import system from lutris.util.dolphin.cache_reader import DOLPHIN_GAME_CACHE_FILE from lutris.util.linux import LINUX_SYSTEM DEFAULT_SERVICES = ["lutris", "gog", "egs", "ea_app", "ubisoft", "steam"] def get_services(): """Return a mapping of available services""" _services = { "lutris": LutrisService, "gog": GOGService, "humblebundle": HumbleBundleService, "egs": EpicGamesStoreService, "itchio": ItchIoService, "origin": OriginService, "ea_app": EAAppService, "ubisoft": UbisoftConnectService, "amazon": AmazonService, "flathub": FlathubService } if BNET_ENABLED: _services["battlenet"] = BattleNetService if not LINUX_SYSTEM.is_flatpak: _services["xdg"] = XDGService if LINUX_SYSTEM.has_steam: _services["steam"] = SteamService _services["steamwindows"] = SteamWindowsService if system.path_exists(DOLPHIN_GAME_CACHE_FILE): _services["dolphin"] = DolphinService if system.path_exists(SCUMMVM_CONFIG_FILE): _services["scummvm"] = ScummvmService return _services SERVICES = get_services() # Those services are not yet ready to be used WIP_SERVICES = { "mame": MAMEService, } if os.environ.get("LUTRIS_ENABLE_ALL_SERVICES"): SERVICES.update(WIP_SERVICES) def get_enabled_services(): return { key: _class for key, _class in SERVICES.items() if settings.read_setting(key, section="services").lower() == "true" } lutris-0.5.14/lutris/services/amazon.py000066400000000000000000000574151451435154700201540ustar00rootroot00000000000000"""Module for handling the Amazon service""" import base64 import hashlib import json import lzma import os import secrets import struct import time import uuid from collections import defaultdict from gettext import gettext as _ from urllib.parse import parse_qs, urlencode, urlparse import yaml from lutris import settings from lutris.exceptions import AuthenticationError, UnavailableGameError from lutris.installer import AUTO_WIN32_EXE from lutris.installer.installer_file import InstallerFile from lutris.installer.installer_file_collection import InstallerFileCollection from lutris.services.base import OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import system from lutris.util.amazon.sds_proto2 import CompressionAlgorithm, HashAlgorithm, Manifest, ManifestHeader from lutris.util.http import HTTPError, Request from lutris.util.log import logger from lutris.util.strings import slugify class AmazonBanner(ServiceMedia): """Game logo""" service = "amazon" size = (200, 112) dest_path = os.path.join(settings.CACHE_DIR, "amazon/banners") file_pattern = "%s.jpg" file_format = "jpeg" api_field = "image" url_pattern = "%s" def get_media_url(self, details): return details["product"]["productDetail"]["details"]["logoUrl"] class AmazonGame(ServiceGame): """Representation of a Amazon game""" service = "amazon" @classmethod def new_from_amazon_game(cls, amazon_game): """Return a Amazon game instance from the API info""" service_game = AmazonGame() service_game.appid = str(amazon_game["id"]) service_game.slug = slugify(amazon_game["product"]["title"]) service_game.name = amazon_game["product"]["title"] service_game.details = json.dumps(amazon_game) return service_game class AmazonService(OnlineService): """Service class for Amazon""" id = "amazon" name = _("Amazon Prime Gaming") icon = "amazon" has_extras = False drm_free = False medias = { "banner": AmazonBanner } default_format = "banner" login_window_width = 400 login_window_height = 710 marketplace_id = "ATVPDKIKX0DER" user_agent = "com.amazon.agslauncher.win/2.1.7437.6" amazon_api = "https://api.amazon.com" amazon_sds = "https://sds.amazon.com" amazon_gaming_graphql = "https://gaming.amazon.com/graphql" client_id = None serial = None verifier = None redirect_uri = "https://www.amazon.com/?" cookies_path = os.path.join(settings.CACHE_DIR, ".amazon.auth") user_path = os.path.join(settings.CACHE_DIR, ".amazon.user") cache_path = os.path.join(settings.CACHE_DIR, "amazon-library.json") locale = "en-US" @property def credential_files(self): return [self.user_path, self.cookies_path] @property def login_url(self): """Return authentication URL""" self.verifier = self.generate_code_verifier() challenge = self.generate_challange(self.verifier) self.serial = self.generate_device_serial() self.client_id = self.generate_client_id(self.serial) arguments = { "openid.ns": "http://specs.openid.net/auth/2.0", "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select", "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select", "openid.mode": "checkid_setup", "openid.oa2.scope": "device_auth_access", "openid.ns.oa2": "http://www.amazon.com/ap/ext/oauth/2", "openid.oa2.response_type": "code", "openid.oa2.code_challenge_method": "S256", "openid.oa2.client_id": f"device:{self.client_id}", "language": "en_US", "marketPlaceId": self.marketplace_id, "openid.return_to": "https://www.amazon.com", "openid.pape.max_auth_age": 0, "openid.assoc_handle": "amzn_sonic_games_launcher", "pageId": "amzn_sonic_games_launcher", "openid.oa2.code_challenge": challenge, } return "https://amazon.com/ap/signin?" + urlencode(arguments) def login_callback(self, url): """Get authentication token from Amazon""" if url.find("openid.oa2.authorization_code") > 0: logger.info("Got authorization code") # Parse auth code parsed = urlparse(url) query = parse_qs(parsed.query) auth_code = query["openid.oa2.authorization_code"][0] user_data = self.register_device(auth_code) user_data["token_obtain_time"] = time.time() self.save_user_data(user_data) self.emit("service-login") def is_connected(self): """Return whether the user is authenticated and if the service is available""" if not self.is_authenticated(): return False return self.check_connection() def load(self): """Load the user game library from the Amazon API""" if not self.is_connected(): logger.error("User not connected to Amazon") return games = [AmazonGame.new_from_amazon_game(game) for game in self.get_library()] for game in games: game.save() return games def save_user_data(self, user_data): """Save the user data file""" with open(self.user_path, "w", encoding='utf-8') as user_file: user_file.write(json.dumps(user_data)) def load_user_data(self): """Load the user data file""" user_data = None if not os.path.exists(self.user_path): raise AuthenticationError(_("No Amazon user data available, please log in again")) with open(self.user_path, "r", encoding='utf-8') as user_file: user_data = json.load(user_file) return user_data def generate_code_verifier(self) -> bytes: code_verifier = secrets.token_bytes(32) code_verifier = base64.urlsafe_b64encode(code_verifier).rstrip(b"=") logger.info("Generated code_verifier: %s", code_verifier) return code_verifier def generate_challange(self, code_verifier: bytes) -> bytes: challenge_hash = hashlib.sha256(code_verifier) challenge = base64.urlsafe_b64encode(challenge_hash.digest()).rstrip(b"=") logger.info("Generated challange: %s", challenge) return challenge def generate_device_serial(self) -> str: serial = uuid.UUID(int=uuid.getnode()).hex.upper() logger.info("Generated serial: %s", serial) return serial def generate_client_id(self, serial) -> str: serialEx = f"{serial}#A2UMVHOX7UP4V7" clientId = serialEx.encode("ascii") clientIdHex = clientId.hex() logger.info("Generated client_id: %s", clientIdHex) return clientIdHex def register_device(self, code): """Register current device and return the user data""" logger.info("Registerring a device. ID: %s", self.client_id) data = { "auth_data": { "authorization_code": code, "client_domain": "DeviceLegacy", "client_id": self.client_id, "code_algorithm": "SHA-256", "code_verifier": self.verifier.decode("utf-8"), "use_global_authentication": False, }, "registration_data": { "app_name": "AGSLauncher for Windows", "app_version": "1.0.0", "device_model": "Windows", "device_name": None, "device_serial": self.serial, "device_type": "A2UMVHOX7UP4V7", "domain": "Device", "os_version": "10.0.19044.0", }, "requested_extensions": ["customer_info", "device_info"], "requested_token_type": ["bearer", "mac_dms"], "user_context_map": {}, } url = f"{self.amazon_api}/auth/register" request = Request(url) try: request.post(json.dumps(data).encode()) except HTTPError as ex: logger.error("Failed http request %s", url) raise AuthenticationError(_("Unable to register device, please log in again")) from ex res_json = request.json logger.info("Successfully registered a device") user_data = res_json["response"]["success"] return user_data def is_token_expired(self): """Check if the stored token is expired""" user_data = self.load_user_data() token_obtain_time = user_data["token_obtain_time"] expires_in = user_data["tokens"]["bearer"]["expires_in"] if not token_obtain_time or not expires_in: raise AuthenticationError(_("Invalid token info found, please log in again")) return time.time() > token_obtain_time + int(expires_in) def refresh_token(self): """Refresh the token""" url = f"{self.amazon_api}/auth/token" logger.info("Refreshing token") user_data = self.load_user_data() headers = { "Accept": "application/json", "Accept-Language": "en_US", "User-Agent": self.user_agent, "Content-Type": "application/json", "charset": "utf-8", } refresh_token = user_data["tokens"]["bearer"]["refresh_token"] request_data = { "source_token": refresh_token, "source_token_type": "refresh_token", "requested_token_type": "access_token", "app_name": "AGSLauncher for Windows", "app_version": "1.0.0", } request = Request(url, headers=headers) try: request.post(json.dumps(request_data).encode()) except HTTPError as ex: logger.error("Failed http request %s", url) raise AuthenticationError(_("Unable to refresh token, please log in again")) from ex res_json = request.json user_data["tokens"]["bearer"]["access_token"] = res_json["access_token"] user_data["tokens"]["bearer"]["expires_in"] = res_json["expires_in"] user_data["token_obtain_time"] = time.time() self.save_user_data(user_data) def get_access_token(self): """Return the access token and refresh the session if required""" if self.is_token_expired(): self.refresh_token() user_data = self.load_user_data() access_token = user_data["tokens"]["bearer"]["access_token"] return access_token def check_connection(self): """Check if the connection with Amazon is available""" try: access_token = self.get_access_token() except Exception: return False headers = { "Accept": "application/json", "Accept-Language": "en_US", "User-Agent": self.user_agent, "Authorization": f"bearer {access_token}", } url = f"{self.amazon_api}/user/profile" request = Request(url, headers=headers) try: request.get() except HTTPError: # Do not raise exception here, should be managed from the caller logger.error("Failed http request %s", url) return False return True def get_library(self): """Return the user's library of Amazon games""" if system.path_exists(self.cache_path): logger.debug("Returning cached Amazon library") with open(self.cache_path, "r", encoding='utf-8') as amazon_cache: return json.load(amazon_cache) access_token = self.get_access_token() user_data = self.load_user_data() serial = user_data["extensions"]["device_info"]["device_serial_number"] games_by_asin = defaultdict(list) nextToken = None while True: request_data = self.get_sync_request_data(serial, nextToken) json_data = self.request_sds( "com.amazonaws.gearbox." "softwaredistribution.service.model." "SoftwareDistributionService.GetEntitlementsV2", access_token, request_data, ) if not json_data: return for game_json in json_data["entitlements"]: product = game_json["product"] asin = product["asin"] games_by_asin[asin].append(game_json) if "nextToken" not in json_data: break logger.info("Got next token in response, making next request") nextToken = json_data["nextToken"] # If Amazon gives is the same game with different ids we'll pick the # least ID. Probably we should just use ASIN as the ID, but since we didn't # do this in the first release of the Amazon integration, we'll maintain compatibility # by using the top level ID whenever we can. games = [sorted(gl, key=lambda g: g["id"])[0] for gl in games_by_asin.values()] with open(self.cache_path, "w", encoding='utf-8') as amazon_cache: json.dump(games, amazon_cache) return games def get_sync_request_data(self, serial, nextToken=None): request_data = { "Operation": "GetEntitlementsV2", "clientId": "Sonic", "syncPoint": None, "nextToken": nextToken, "maxResults": 50, "productIdFilter": None, "keyId": "d5dc8b8b-86c8-4fc4-ae93-18c0def5314d", "hardwareHash": hashlib.sha256(serial.encode()).hexdigest().upper(), } return request_data def request_sds(self, target, token, body): headers = { "X-Amz-Target": target, "x-amzn-token": token, "User-Agent": self.user_agent, "Content-Type": "application/json", "Content-Encoding": "amz-1.0", } url = f"{self.amazon_sds}/amazon/" request = Request(url, headers=headers) try: request.post(json.dumps(body).encode()) except HTTPError as ex: # Do not raise exception here, should be managed from the caller logger.error("Failed http request %s: %s", url, ex) return return request.json def get_game_manifest_info(self, game_id): """Get a game manifest information""" access_token = self.get_access_token() request_data = { "adgGoodId": game_id, "previousVersionId": None, "keyId": "d5dc8b8b-86c8-4fc4-ae93-18c0def5314d", "Operation": "GetDownloadManifestV3", } response = self.request_sds( "com.amazonaws.gearbox." "softwaredistribution.service.model." "SoftwareDistributionService.GetDownloadManifestV3", access_token, request_data, ) if not response: logger.error("There was an error getting game manifest: %s", game_id) raise UnavailableGameError(_( "Unable to get game manifest info")) return response def get_game_manifest(self, manifest_info): """Get a game manifest""" headers = { "User-Agent": self.user_agent, } url = manifest_info["downloadUrls"][0] request = Request(url, headers=headers) try: request.get() except HTTPError as ex: logger.error("Failed http request %s", url) raise UnavailableGameError(_( "Unable to get game manifest")) from ex content = request.content header_size = struct.unpack(">I", content[:4])[0] header = ManifestHeader() header.decode(content[4: 4 + header_size]) if header.compression.algorithm == CompressionAlgorithm.none: raw_manifest = content[4 + header_size:] elif header.compression.algorithm == CompressionAlgorithm.lzma: raw_manifest = lzma.decompress(content[4 + header_size:]) else: logger.error("Unknown compression algorithm found in manifest") raise UnavailableGameError(_( "Unknown compression algorithm found in manifest")) manifest = Manifest() manifest.decode(raw_manifest) return manifest def get_file_patch(self, access_token, game_id, version, file_hashes): request_data = { "Operation": "GetPatches", "versionId": version, "fileHashes": file_hashes, "deltaEncodings": ["FUEL_PATCH", "NONE"], "adgGoodId": game_id, } response = self.request_sds( "com.amazonaws.gearbox." "softwaredistribution.service.model." "SoftwareDistributionService.GetPatches", access_token, request_data, ) if not response: logger.error("There was an error getting patches: %s", game_id) raise UnavailableGameError(_( "Unable to get the patches of game, " "please check your Amazon credentials and internet connectivity"), game_id) return response def get_game_patches(self, game_id, version, file_list): """Get game files""" access_token = self.get_access_token() def get_batches(to_batch, batch_size): i = 0 while i < len(to_batch): yield to_batch[i:i + batch_size] i += batch_size batches = get_batches(file_list, 500) patches = [] for batch in batches: response = self.get_file_patch(access_token, game_id, version, batch) patches += response["patches"] return patches def structure_manifest_data(self, manifest): """Transform the manifest to more convenient data structures""" files = [] directories = [] hashes = [] hashpairs = [] for __, package in enumerate(manifest.packages): for __, file in enumerate(package.files): file_hash = file.hash.value.hex() hashes.append(file_hash) files.append({"path": file.path.decode().replace("\\", "/"), "size": file.size, "url": None}) hashpairs.append({ 'sourceHash': None, 'targetHash': { 'value': file_hash, 'algorithm': HashAlgorithm.get_name(file.hash.algorithm) } }) for __, directory in enumerate(package.dirs): if directory.path is not None: directories.append(directory.path.decode().replace("\\", "/")) file_dict = dict(zip(hashes, files)) return file_dict, directories, hashpairs def get_game_files(self, game_id): """Get the game file list""" manifest_info = self.get_game_manifest_info(game_id) manifest = self.get_game_manifest(manifest_info) file_dict, directories, hashpairs = self.structure_manifest_data(manifest) game_patches = self.get_game_patches(game_id, manifest_info["versionId"], hashpairs) for patch in game_patches: file_dict[patch["patchHash"]["value"]]["url"] = patch["downloadUrls"][0] return file_dict, directories def get_exe_and_game_args(self, fuel_url): """Get and parse the fuel.json file""" headers = { "User-Agent": self.user_agent, } request = Request(fuel_url, headers=headers) try: request.get() except HTTPError as ex: logger.error("Failed http request %s", fuel_url) raise UnavailableGameError(_( "Unable to get fuel.json file, please check your Amazon credentials")) from ex try: res_yaml_text = request.text res_json = yaml.safe_load(res_yaml_text) except Exception as ex: # Maybe it can be parsed as plain JSON. May as well try it. try: logger.exception("Unparesable yaml response from %s:\n%s", fuel_url, res_yaml_text) res_json = json.loads(res_yaml_text) except Exception: raise UnavailableGameError(_( "Invalid response from Amazon APIs")) from ex if res_json["Main"] is None or res_json["Main"]["Command"] is None: return None, None game_cmd = res_json["Main"]["Command"].replace("\\", "/") game_args = "" if "Args" in res_json["Main"] and res_json["Main"]["Args"]: for arg in res_json["Main"]["Args"]: game_args += arg if game_args == "" else " " + arg return game_cmd, game_args def get_game_cmd_line(self, fuel_url): """Get the executable path and the arguments for run the game""" game_cmd = None game_args = None if fuel_url is not None: game_cmd, game_args = self.get_exe_and_game_args(fuel_url) if game_cmd is None: game_cmd = AUTO_WIN32_EXE if game_args is None: game_args = "" return game_cmd, game_args def get_installer_files(self, installer, installer_file_id, selected_extras): try: file_dict, __ = self.get_game_files(installer.service_appid) except HTTPError as err: raise UnavailableGameError(_("Couldn't load the downloads for this game")) from err files = [] for file_hash, file in file_dict.items(): file_name = os.path.basename(file["path"]) files.append(InstallerFile(installer.game_slug, file_hash, { "url": file["url"], "filename": file_name, "size": file["size"] })) # return should be a list of files, so we return a list containing a InstallerFileCollection return [InstallerFileCollection(installer.game_slug, "amazongame", files)] def generate_installer(self, db_game): """Generate a installer for the Amazon game""" details = json.loads(db_game["details"]) game_id = details["id"] manifest_info = self.get_game_manifest_info(game_id) manifest = self.get_game_manifest(manifest_info) file_dict, directories, hashpairs = self.structure_manifest_data(manifest) installer = [ {"task": {"name": "create_prefix"}}, {"mkdir": "$GAMEDIR/drive_c/game"}, {"autosetup_amazon": {"files": file_dict, "directories": directories}}] # try to get fuel file that contain the main exe fuel_file = {k: v for k, v in file_dict.items() if "fuel.json" in v["path"]} hashpair = [hashpair for hashpair in hashpairs if hashpair["targetHash"]["value"] == list(fuel_file.keys())[0]] fuel_url = None if fuel_file: version = manifest_info["versionId"] access_token = self.get_access_token() response = self.get_file_patch(access_token, game_id, version, hashpair) patch = response["patches"][0] fuel_url = patch["downloadUrls"][0] game_cmd, game_args = self.get_game_cmd_line(fuel_url) logger.info("game cmd line: %s %s", game_cmd, game_args) return { "name": details["product"]["title"], "version": _("Amazon Prime Gaming"), "slug": slugify(details["product"]["title"]), "game_slug": slugify(details["product"]["title"]), "runner": "wine", "script": { "game": { "exe": f"$GAMEDIR/drive_c/game/{game_cmd}", "args": game_args, "prefix": "$GAMEDIR", "working_dir": "$GAMEDIR/drive_c/game" }, "system": {}, "files": [{"amazongame": "N/A:Select the installer from Amazon Games"}], "installer": installer } } lutris-0.5.14/lutris/services/base.py000066400000000000000000000340561451435154700175750ustar00rootroot00000000000000"""Generic service utilities""" import os import shutil from gettext import gettext as _ from gi.repository import Gio, GObject from lutris import api, settings from lutris.api import get_game_installers from lutris.config import write_game_config from lutris.database import sql from lutris.database.games import add_game, get_game_by_field, get_games from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.gui.dialogs import NoticeDialog from lutris.gui.dialogs.webconnect_dialog import DEFAULT_USER_AGENT, WebConnectDialog from lutris.gui.views.media_loader import download_media from lutris.gui.widgets.utils import BANNER_SIZE, ICON_SIZE from lutris.installer import get_installers from lutris.services.service_media import ServiceMedia from lutris.util import system from lutris.util.cookies import WebkitCookieJar from lutris.util.jobs import AsyncCall from lutris.util.log import logger PGA_DB = settings.PGA_DB class AuthTokenExpired(Exception): """Exception raised when a token is no longer valid""" class LutrisBanner(ServiceMedia): service = 'lutris' size = BANNER_SIZE dest_path = settings.BANNER_PATH file_pattern = "%s.jpg" file_format = "jpeg" api_field = 'banner_url' class LutrisIcon(LutrisBanner): size = ICON_SIZE dest_path = settings.ICON_PATH file_pattern = "lutris_%s.png" file_format = "png" api_field = 'icon_url' @property def custom_media_storage_size(self): return (128, 128) def update_desktop(self): system.update_desktop_icons() class LutrisCoverart(ServiceMedia): service = 'lutris' size = (264, 352) file_pattern = "%s.jpg" file_format = "jpeg" dest_path = settings.COVERART_PATH api_field = 'coverart' @property def config_ui_size(self): return (66, 88) class LutrisCoverartMedium(LutrisCoverart): size = (176, 234) class BaseService(GObject.Object): """Base class for local services""" id = NotImplemented _matcher = None has_extras = False name = NotImplemented description = "" icon = NotImplemented online = False local = False drm_free = False # DRM free games can be added to Lutris from an existing install client_installer = None # ID of a script needed to install the client used by the service scripts = {} # Mapping of Javascript snippets to handle redirections during auth medias = {} extra_medias = {} default_format = "icon" is_loading = False __gsignals__ = { "service-games-load": (GObject.SIGNAL_RUN_FIRST, None, ()), "service-games-loaded": (GObject.SIGNAL_RUN_FIRST, None, ()), "service-login": (GObject.SIGNAL_RUN_FIRST, None, ()), "service-logout": (GObject.SIGNAL_RUN_FIRST, None, ()), } @property def matcher(self): if self._matcher: return self._matcher return self.id def run(self): """Launch the game client""" launcher = self.get_launcher() if launcher: launcher.emit("game-launch") def is_launchable(self): if self.client_installer: return get_game_by_field(self.client_installer, "slug") return False def get_launcher(self): if not self.client_installer: return db_launcher = get_game_by_field(self.client_installer, "slug") if db_launcher: return Game(db_launcher["id"]) def is_launcher_installed(self): launcher = self.get_launcher() if not launcher: return False return launcher.is_installed def start_reload(self, reloaded_callback): """Refresh the service's games, asynchronously. This raises signals, but does so on the main thread- and runs the reload on a worker thread. It calls reloaded_callback when done, passing any error (or None on success)""" def do_reload(): if self.is_loading: logger.warning("'%s' games are already loading", self.name) return try: self.is_loading = True self.wipe_game_cache() self.load() self.load_icons() self.add_installed_games() finally: self.is_loading = False def reload_cb(_result, error): self.emit("service-games-loaded") reloaded_callback(error) self.emit("service-games-load") AsyncCall(do_reload, reload_cb) def load(self): logger.warning("Load method not implemented") def load_icons(self): """Download all game media from the service""" all_medias = self.medias.copy() all_medias.update(self.extra_medias) service_medias = [media_type() for media_type in all_medias.values()] # Download icons for service_media in service_medias: media_urls = service_media.get_media_urls() download_media(media_urls, service_media) # Process icons for service_media in service_medias: service_media.render() def wipe_game_cache(self): logger.debug("Deleting games from service-games for %s", self.id) sql.db_delete(PGA_DB, "service_games", "service", self.id) def get_update_installers(self, db_game): return [] def generate_installer(self, db_game): """Used to generate an installer from the data returned from the services""" return {} def match_game(self, service_game, api_game): """Match a service game to a lutris game referenced by its slug""" if not service_game: return sql.db_update( PGA_DB, "service_games", {"lutris_slug": api_game["slug"]}, conditions={"appid": service_game["appid"], "service": self.id} ) unmatched_lutris_games = get_games( searches={"installer_slug": self.matcher}, filters={"slug": api_game["slug"]}, excludes={"service": self.id} ) for game in unmatched_lutris_games: logger.debug("Updating unmatched game %s", game) sql.db_update( PGA_DB, "games", {"service": self.id, "service_id": service_game["appid"]}, conditions={"id": game["id"]} ) def match_games(self): """Matching of service games to lutris games""" service_games = { str(game["appid"]): game for game in ServiceGameCollection.get_for_service(self.id) } lutris_games = api.get_api_games(list(service_games.keys()), service=self.id) for lutris_game in lutris_games: for provider_game in lutris_game["provider_games"]: if provider_game["service"] != self.id: continue self.match_game(service_games.get(provider_game["slug"]), lutris_game) unmatched_service_games = get_games(searches={"installer_slug": self.matcher}, excludes={"service": self.id}) for lutris_game in api.get_api_games(game_slugs=[g["slug"] for g in unmatched_service_games]): for provider_game in lutris_game["provider_games"]: if provider_game["service"] != self.id: continue self.match_game(service_games.get(provider_game["slug"]), lutris_game) def match_existing_game(self, db_games, appid): """Checks if a game is already installed and populates the service info""" for _game in db_games: logger.debug("Matching %s with existing install: %s", appid, _game) game = Game(_game["id"]) game.appid = appid game.service = self.id game.save() service_game = ServiceGameCollection.get_game(self.id, appid) sql.db_update(PGA_DB, "service_games", {"lutris_slug": game.slug}, {"id": service_game["id"]}) return game def get_installers_from_api(self, appid): """Query the lutris API for an appid and get existing installers for the service""" lutris_games = api.get_api_games([appid], service=self.id) service_installers = [] if lutris_games: lutris_game = lutris_games[0] installers = get_game_installers(lutris_game["slug"]) for installer in installers: if self.matcher in installer["version"].lower(): service_installers.append(installer) return service_installers def install(self, db_game, update=False): """Install a service game, or starts the installer of the game. Args: db_game (dict or str): Database fields of the game to add, or (for Lutris service only the slug of the game.) Returns: str: The slug of the game that was installed, to be run. None if the game should not be run now. Many installers start from here, but continue running after this returns; they return None. """ appid = db_game["appid"] logger.debug("Installing %s from service %s", appid, self.id) # Local services (aka game libraries that don't require any type of online interaction) can # be added without going through an install dialog. if self.local: return self.simple_install(db_game) if update: service_installers = self.get_update_installers(db_game) else: service_installers = self.get_installers_from_api(appid) # Check if the game is not already installed for service_installer in service_installers: existing_game = self.match_existing_game( get_games(filters={"installer_slug": service_installer["slug"], "installed": "1"}), appid ) if existing_game: logger.debug("Found existing game, aborting install") return if update: installer = None else: installer = self.generate_installer(db_game) if installer: if service_installers: installer["version"] = installer["version"] + " (auto-generated)" service_installers.append(installer) if not service_installers: logger.error("No installer found for %s", db_game) return application = Gio.Application.get_default() application.show_installer_window(service_installers, service=self, appid=appid) def simple_install(self, db_game): """A simplified version of the install method, used when a game doesn't need any setup""" installer = self.generate_installer(db_game) configpath = write_game_config(db_game["slug"], installer["script"]) game_id = add_game( name=installer["name"], runner=installer["runner"], slug=installer["game_slug"], directory=self.get_game_directory(installer), installed=1, installer_slug=installer["slug"], configpath=configpath, service=self.id, service_id=db_game["appid"], ) return game_id def add_installed_games(self): """Services can implement this method to scan for locally installed games and add them to lutris. This runs on a worker thread, and must trigger UI actions - so no emitting signals here. """ def get_game_directory(self, _installer): """Specific services should implement this""" return "" def get_game_platforms(self, db_game): """Interprets the database record for this game from this service to extract its platform, or returns None if this is not available.""" return None class OnlineService(BaseService): """Base class for online gaming services""" online = True cookies_path = NotImplemented cache_path = NotImplemented requires_login_page = False login_window_width = 390 login_window_height = 500 login_user_agent = DEFAULT_USER_AGENT @property def credential_files(self): """Return a list of all files used for authentication""" return [self.cookies_path] def login(self, parent=None): if self.client_installer and not self.is_launcher_installed(): NoticeDialog( _("This service requires a game launcher. The following steps will install it.\n" "Once the client is installed, you can login to %s.") % self.name) application = Gio.Application.get_default() installers = get_installers(game_slug=self.client_installer) application.show_installer_window(installers) return logger.debug("Connecting to %s", self.name) dialog = WebConnectDialog(self, parent) dialog.run() def is_authenticated(self): """Return whether the service is authenticated""" return all(system.path_exists(path) for path in self.credential_files) def wipe_game_cache(self): """Wipe the game cache, allowing it to be reloaded""" if self.cache_path: logger.debug("Deleting %s cache %s", self.id, self.cache_path) if os.path.isdir(self.cache_path): shutil.rmtree(self.cache_path) elif system.path_exists(self.cache_path): os.remove(self.cache_path) super().wipe_game_cache() def logout(self): """Disconnect from the service by removing all credentials""" self.wipe_game_cache() for auth_file in self.credential_files: try: os.remove(auth_file) except OSError: logger.warning("Unable to remove %s", auth_file) logger.debug("logged out from %s", self.id) self.emit("service-logout") def load_cookies(self): """Load cookies from disk""" if not system.path_exists(self.cookies_path): logger.warning("No cookies found in %s, please authenticate first", self.cookies_path) return cookiejar = WebkitCookieJar(self.cookies_path) cookiejar.load() return cookiejar lutris-0.5.14/lutris/services/battlenet.py000066400000000000000000000226551451435154700206470ustar00rootroot00000000000000"""Battle.net service""" import json import os from gettext import gettext as _ from gi.repository import Gio from lutris import settings from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_game, get_game_by_field from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.services.base import BaseService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util.battlenet.definitions import ProductDbInfo try: from lutris.util.battlenet.product_db_pb2 import ProductDb BNET_ENABLED = True except (ImportError, TypeError): BNET_ENABLED = False from lutris.util.log import logger GAME_IDS = { 's1': ('s1', 'StarCraft', 'S1', 'starcraft-remastered'), 's2': ('s2', 'StarCraft II', 'S2', 'starcraft-ii'), 'wow': ('wow', 'World of Warcraft', 'WoW', 'world-of-warcraft'), 'wow_classic': ('wow_classic', 'World of Warcraft Classic', 'WoW_wow_classic', 'world-of-warcraft-classic'), 'pro': ('pro', 'Overwatch 2', 'Pro', 'overwatch-2'), 'w3': ('w3', 'Warcraft III', 'W3', 'warcraft-iii-reforged'), 'hsb': ('hsb', 'Hearthstone', 'WTCG', 'hearthstone'), 'hero': ('hero', 'Heroes of the Storm', 'Hero', 'heroes-of-the-storm'), 'd3cn': ('d3cn', '暗黑破壞神III', 'D3CN', 'diablo-iii'), 'd3': ('d3', 'Diablo III', 'D3', 'diablo-iii'), 'fenris': ('fenris', 'Diablo IV', 'Fen', 'diablo-iv'), 'viper': ('viper', 'Call of Duty: Black Ops 4', 'VIPR', 'call-of-duty-black-ops-4'), 'odin': ('odin', 'Call of Duty: Modern Warfare', 'ODIN', 'call-of-duty-modern-warfare'), 'lazarus': ('lazarus', 'Call of Duty: MW2 Campaign Remastered', 'LAZR', 'call-of-duty-modern-warfare-2-campaign-remastered'), 'zeus': ('zeus', 'Call of Duty: Black Ops Cold War', 'ZEUS', 'call-of-duty-black-ops-cold-war'), 'rtro': ('rtro', 'Blizzard Arcade Collection', 'RTRO', 'blizzard-arcade-collection'), 'wlby': ('wlby', 'Crash Bandicoot 4: It\'s About Time', 'WLBY', 'crash-bandicoot-4-its-about-time'), 'osi': ('osi', 'Diablo® II: Resurrected', 'OSI', 'diablo-2-ressurected'), 'fore': ('fore', 'Call of Duty: Vanguard', 'FORE', 'call-of-duty-vanguard'), 'd2': ('d2', 'Diablo® II', 'Diablo II', 'diablo-ii'), 'd2LOD': ('d2LOD', 'Diablo® II: Lord of Destruction®', 'Diablo II', 'diablo-ii-lord-of-destruction'), 'w3ROC': ('w3ROC', 'Warcraft® III: Reign of Chaos', 'Warcraft III', "warcraft-iii-reign-of-chaos"), 'w3tft': ('w3tft', 'Warcraft® III: The Frozen Throne®', 'Warcraft III', "warcraft-iii-the-frozen-throne"), 'sca': ('sca', 'StarCraft® Anthology', 'Starcraft', 'starcraft') } class BattleNetCover(ServiceMedia): service = 'battlenet' size = (176, 234) file_pattern = "%s.jpg" file_format = "jpeg" dest_path = os.path.join(settings.CACHE_DIR, "battlenet/coverart") api_field = 'coverart' class BattleNetGame(ServiceGame): """Game from Battle.net""" service = "battlenet" runner = "wine" installer_slug = "battlenet" @classmethod def create(cls, blizzard_game): """Create a service game from an entry from the Dolphin cache""" service_game = cls() service_game.name = blizzard_game[1] service_game.appid = blizzard_game[0] service_game.slug = blizzard_game[3] service_game.details = json.dumps({ "id": blizzard_game[0], "name": blizzard_game[1], "slug": blizzard_game[3], "product_code": blizzard_game[2], "coverart": "https://lutris.net/games/cover/%s.jpg" % blizzard_game[3] }) return service_game class BattleNetService(BaseService): """Service class for Battle.net""" id = "battlenet" name = _("Battle.net") icon = "battlenet" runner = "wine" medias = { "coverart": BattleNetCover } default_format = "coverart" client_installer = "battlenet" cookies_path = os.path.join(settings.CACHE_DIR, ".bnet.auth") cache_path = os.path.join(settings.CACHE_DIR, "bnet-library.json") redirect_uri = "https://lutris.net" @property def battlenet_config_path(self): return "" def load(self): games = [BattleNetGame.create(game) for game in GAME_IDS.values()] for game in games: game.save() return games def add_installed_games(self): """Scan an existing EGS install for games""" bnet_game = get_game_by_field(self.client_installer, "slug") if not bnet_game: raise RuntimeError("Battle.net is not installed in Lutris") bnet_prefix = bnet_game["directory"].split("drive_c")[0] parser = BlizzardProductDbParser(bnet_prefix) for game in parser.games: self.install_from_battlenet(bnet_game, game) def install_from_battlenet(self, bnet_game, game): app_id = game.ngdp logger.debug("Installing Battle.net game %s", app_id) service_game = ServiceGameCollection.get_game("battlenet", app_id) if not service_game: logger.error("Aborting install, %s is not present in the game library.", app_id) return lutris_game_id = service_game["slug"] + "-" + self.id existing_game = get_game_by_field(lutris_game_id, "installer_slug") if existing_game: return game_config = LutrisConfig(game_config_id=bnet_game["configpath"]).game_level game_config["game"]["args"] = '--exec="launch %s"' % game.ngdp configpath = write_game_config(lutris_game_id, game_config) game_id = add_game( name=service_game["name"], runner=bnet_game["runner"], slug=service_game["slug"], directory=bnet_game["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=app_id, platform="Windows" ) return game_id def generate_installer(self, db_game, egs_db_game): egs_game = Game(egs_db_game["id"]) egs_exe = egs_game.config.game_config["exe"] if not os.path.isabs(egs_exe): egs_exe = os.path.join(egs_game.config.game_config["prefix"], egs_exe) return { "name": db_game["name"], "version": self.name, "slug": db_game["slug"] + "-" + self.id, "game_slug": db_game["slug"], "runner": self.runner, "appid": db_game["appid"], "script": { "requires": self.client_installer, "game": { "args": '--exec="launch %s"' % db_game["appid"], }, "installer": [ {"task": { "name": "wineexec", "executable": egs_exe, "args": '--exec="install %s"' % db_game["appid"], "prefix": egs_game.config.game_config["prefix"], "description": ( "Battle.net will now open. Please launch " "the installation of %s then close Battle.net " "once the game has been downloaded." % db_game["name"] ) }} ] } } def install(self, db_game): bnet_game = get_game_by_field(self.client_installer, "slug") application = Gio.Application.get_default() application.show_installer_window( [self.generate_installer(db_game, bnet_game)], service=self, appid=db_game["appid"] ) class BlizzardProductDbParser: # Adapted from DatabaseParser in https://github.com/bartok765/galaxy_blizzard_plugin NOT_GAMES = ('bna', 'agent') PRODUCT_DB_PATH = "/drive_c/ProgramData/Battle.net/Agent/product.db" def __init__(self, prefix_path): self.data = self.load_product_db(prefix_path + self.PRODUCT_DB_PATH) self.products = {} self._region = '' self.parse() @property def region(self): return self._region @staticmethod def load_product_db(product_db_path): with open(product_db_path, 'rb') as f: pdb = f.read() return pdb @property def games(self): if self.products: return [v for k, v in self.products.items() if k not in self.NOT_GAMES] return [] def parse(self): self.products = {} database = ProductDb() database.ParseFromString(self.data) for product_install in database.product_installs: # pylint: disable=no-member # process region if product_install.product_code in ['agent', 'bna'] and not self.region: self._region = product_install.settings.play_region ngdp_code = product_install.product_code uninstall_tag = product_install.uid install_path = product_install.settings.install_path playable = product_install.cached_product_state.base_product_state.playable version = product_install.cached_product_state.base_product_state.current_version_str installed = product_install.cached_product_state.base_product_state.installed self.products[ngdp_code] = ProductDbInfo( uninstall_tag, ngdp_code, install_path, version, playable, installed ) lutris-0.5.14/lutris/services/dolphin.py000066400000000000000000000076111451435154700203150ustar00rootroot00000000000000import json import os from gettext import gettext as _ from PIL import Image from lutris import settings from lutris.runners.dolphin import PLATFORMS from lutris.services.base import BaseService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import system from lutris.util.dolphin.cache_reader import DOLPHIN_GAME_CACHE_FILE, DolphinCacheReader from lutris.util.strings import slugify class DolphinBanner(ServiceMedia): service = "dolphin" source = "local" size = (96, 32) file_pattern = "%s.png" file_format = "jpeg" dest_path = os.path.join(settings.CACHE_DIR, "dolphin/banners/small") class DolphinService(BaseService): id = "dolphin" icon = "dolphin" name = _("Dolphin") local = True medias = { "icon": DolphinBanner } def load(self): if not system.path_exists(DOLPHIN_GAME_CACHE_FILE): return cache_reader = DolphinCacheReader() dolphin_games = [DolphinGame.new_from_cache(game) for game in cache_reader.get_games()] for game in dolphin_games: game.save() return dolphin_games def generate_installer(self, db_game): details = json.loads(db_game["details"]) return { "name": db_game["name"], "version": "Dolphin", "slug": db_game["slug"], "game_slug": slugify(db_game["name"]), "runner": "dolphin", "script": { "game": { "main_file": details["path"], "platform": details["platform"] }, } } def get_game_directory(self, installer): """Pull install location from installer""" return os.path.dirname(installer["script"]["game"]["main_file"]) def get_game_platforms(self, db_game): if "details" in db_game: details = json.loads(db_game.get("details")) if details and details.get("platform"): platform_value = details["platform"] if platform_value.isdigit(): platform_number = int(details["platform"]) if 0 <= platform_number < len(PLATFORMS): platform = PLATFORMS[platform_number] return [platform] return [platform_value] return None class DolphinGame(ServiceGame): """Game for the Dolphin emulator""" service = "dolphin" runner = "dolphin" installer_slug = "dolphin" @classmethod def new_from_cache(cls, cache_entry): """Create a service game from an entry from the Dolphin cache""" name = cache_entry["internal_name"] or os.path.splitext(cache_entry["file_name"])[0] service_game = cls() service_game.name = name service_game.appid = str(cache_entry["game_id"]) service_game.slug = slugify(name) service_game.icon = service_game.get_banner(cache_entry) service_game.details = json.dumps({ "path": cache_entry["file_path"], "platform": cache_entry["platform"][:-1] }) return service_game @staticmethod def get_game_name(cache_entry): names = cache_entry["long_names"] name_index = 1 if len(names.keys()) > 1 else 0 return str(names[list(names.keys())[name_index]]) def get_banner(self, cache_entry): banner = DolphinBanner() banner_path = banner.get_media_path(self.appid) if os.path.exists(banner_path): return banner_path (width, height), data = cache_entry["volume_banner"] if data: img = Image.frombytes("RGB", (width, height), data, "raw", ("BGRX")) # 96x32 is a bit small, maybe 2x scale? # img.resize((width * 2, height * 2)) img.save(banner_path) return banner_path return "" lutris-0.5.14/lutris/services/ea_app.py000066400000000000000000000352751451435154700201140ustar00rootroot00000000000000"""EA App service.""" import json import os import random import ssl from gettext import gettext as _ from xml.etree import ElementTree import requests import urllib3 from gi.repository import Gio from lutris import settings from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_game, get_game_by_field from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.installer import get_installers from lutris.services.base import OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util.log import logger from lutris.util.strings import slugify SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION = 1 << 18 class EAAppGames: ea_games_location = "Program Files/EA Games" def __init__(self, prefix_path): self.prefix_path = prefix_path self.ea_games_path = os.path.join(self.prefix_path, 'drive_c', self.ea_games_location) def iter_installed_games(self): if not os.path.exists(self.ea_games_path): return for game_folder in os.listdir(self.ea_games_path): yield game_folder def get_installed_games_content_ids(self): installed_game_ids = [] for game_folder in self.iter_installed_games(): installer_data_path = os.path.join(self.ea_games_path, game_folder, "__Installer/installerdata.xml") if not os.path.exists(installer_data_path): logger.warning("No installerdata.xml for %s", game_folder) continue tree = ElementTree.parse(installer_data_path) nodes = tree.find("contentIDs").findall("contentID") if not nodes: logger.warning("Content ID not found for %s", game_folder) continue installed_game_ids.append([node.text for node in nodes]) return installed_game_ids class EAAppArtSmall(ServiceMedia): service = "ea_app" file_pattern = "%s.jpg" file_format = "jpeg" size = (63, 89) dest_path = os.path.join(settings.CACHE_DIR, "ea_app/pack-art-small") api_field = "packArtSmall" def get_media_url(self, details): return details["imageServer"] + details["i18n"][self.api_field] class EAAppArtMedium(EAAppArtSmall): size = (142, 200) dest_path = os.path.join(settings.CACHE_DIR, "ea_app/pack-art-medium") api_field = "packArtMedium" class EAAppArtLarge(EAAppArtSmall): size = (231, 326) dest_path = os.path.join(settings.CACHE_DIR, "ea_app/pack-art-large") api_field = "packArtLarge" class EAAppGame(ServiceGame): service = "ea_app" @classmethod def new_from_api(cls, offer): ea_game = EAAppGame() ea_game.appid = offer["contentId"] ea_game.slug = offer["gameNameFacetKey"] ea_game.name = offer["i18n"]["displayName"] ea_game.details = json.dumps(offer) return ea_game class LegacyRenegotiationHTTPAdapter(requests.adapters.HTTPAdapter): """Allow insecure SSL/TLS protocol renegotiation in an HTTP request. By default, OpenSSL v3 expects that servers support RFC 5746. Unfortunately, accounts.ea.com does not support this TLS extension (from 2010!), causing OpenSSL to refuse to connect. This `requests` HTTP Adapter configures OpenSSL to allow "unsafe legacy renegotiation", allowing EA Origin to connect. This is only intended as a temporary workaround, and should be removed as soon as accounts.ea.com is updated to support RFC 5746. Using this adapter will reduce the security of the connection. However, the impact should be relatively minimal this is only used to connect to EA services. See CVE-2009-3555 for more details. See #4235 for more information. """ def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): """Override the default PoolManager to allow insecure renegotiation.""" # Based off of the default function from `requests`. self._pool_connections = connections self._pool_maxsize = maxsize self._pool_block = block ssl_context = ssl.create_default_context() ssl_context.options |= SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION self.poolmanager = urllib3.PoolManager( num_pools=connections, maxsize=maxsize, block=block, strict=True, ssl_context=ssl_context, **pool_kwargs, ) class EAAppService(OnlineService): """Service class for EA App""" id = "ea_app" name = _("EA App") icon = "ea_app" client_installer = "ea-app" login_window_width = 460 login_window_height = 760 runner = "wine" online = True medias = { "packArtSmall": EAAppArtSmall, "packArtMedium": EAAppArtMedium, "packArtLarge": EAAppArtLarge, } default_format = "packArtMedium" cache_path = os.path.join(settings.CACHE_DIR, "ea_app/cache/") cookies_path = os.path.join(settings.CACHE_DIR, "ea_app/cookies") token_path = os.path.join(settings.CACHE_DIR, "ea_app/auth_token") origin_redirect_uri = "https://www.origin.com/views/login.html" login_url = "https://www.ea.com/login" redirect_uri = "https://www.ea.com/" origin_login_url = ( "https://accounts.ea.com/connect/auth" "?response_type=code&client_id=ORIGIN_SPA_ID&display=originXWeb/login" "&locale=en_US&release_type=prod" "&redirect_uri=%s" ) % origin_redirect_uri login_user_agent = "Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0 QtWebEngine/5.8.0" def __init__(self): super().__init__() self.session = requests.session() self.session.mount("https://", LegacyRenegotiationHTTPAdapter()) self.access_token = self.load_access_token() @property def api_url(self): return "https://api%s.origin.com" % random.randint(1, 4) def run(self): db_game = get_game_by_field(self.client_installer, "slug") game = Game(db_game["id"]) game.emit("game-launch") def is_launchable(self): return get_game_by_field(self.client_installer, "slug") def is_connected(self): return bool(self.access_token) def login_callback(self, url): self.fetch_access_token() self.emit("service-login") def fetch_access_token(self): token_data = self.get_access_token() if not token_data: raise RuntimeError("Failed to get access token") with open(self.token_path, "w", encoding='utf-8') as token_file: token_file.write(json.dumps(token_data, indent=2)) self.access_token = self.load_access_token() def load_access_token(self): if not os.path.exists(self.token_path): return "" with open(self.token_path, encoding="utf-8") as token_file: token_data = json.load(token_file) return token_data.get("access_token", "") def get_access_token(self): """Request an access token from EA""" response = self.session.get( "https://accounts.ea.com/connect/auth", params={ "client_id": "ORIGIN_JS_SDK", "response_type": "token", "redirect_uri": "nucleus:rest", "prompt": "none" }, cookies=self.load_cookies() ) response.raise_for_status() token_data = response.json() return token_data def _request_identity(self): response = self.session.get( "https://gateway.ea.com/proxy/identity/pids/me", cookies=self.load_cookies(), headers=self.get_auth_headers() ) return response.json() def get_identity(self): """Request the user info""" identity_data = self._request_identity() if identity_data.get('error') == "invalid_access_token": logger.warning("Refreshing EA access token") self.fetch_access_token() identity_data = self._request_identity() elif identity_data.get("error"): raise RuntimeError( "%s (Error code: %s)" % (identity_data["error"], identity_data["error_number"]) ) if 'error' in identity_data: raise RuntimeError(identity_data["error"]) try: user_id = identity_data["pid"]["pidId"] except KeyError: logger.error("Can't read user ID from %s", identity_data) raise persona_id_response = self.session.get( "{}/atom/users?userIds={}".format(self.api_url, user_id), headers=self.get_auth_headers() ) content = persona_id_response.text ea_account_info = ElementTree.fromstring(content) persona_id = ea_account_info.find("user").find("personaId").text user_name = ea_account_info.find("user").find("EAID").text return str(user_id), str(persona_id), str(user_name) def load(self): user_id, _persona_id, _user_name = self.get_identity() games = self.get_library(user_id) logger.info("Retrieved %s games from EA library", len(games)) ea_games = [] for game in games: ea_game = EAAppGame.new_from_api(game) ea_game.save() ea_games.append(ea_game) return ea_games def get_library(self, user_id): """Load EA library""" offers = [] for entitlement in self.get_entitlements(user_id): if entitlement["offerType"] != "basegame": continue offer_id = entitlement["offerId"] offer = self.get_offer(offer_id) offers.append(offer) return offers def get_offer(self, offer_id): """Load offer details from EA""" url = "{}/ecommerce2/public/supercat/{}/{}".format(self.api_url, offer_id, "en_US") response = self.session.get(url, headers=self.get_auth_headers()) return response.json() def get_entitlements(self, user_id): """Request the user's entitlements""" url = "%s/ecommerce2/consolidatedentitlements/%s?machine_hash=1" % ( self.api_url, user_id ) headers = self.get_auth_headers() headers["Accept"] = "application/vnd.origin.v3+json; x-cache/force-write" response = self.session.get(url, headers=headers) data = response.json() return data["entitlements"] def get_auth_headers(self): """Return headers needed to authenticate HTTP requests""" if not self.access_token: raise RuntimeError("User not authenticated to EA") return { "Authorization": "Bearer %s" % self.access_token, "AuthToken": self.access_token, "X-AuthToken": self.access_token } def add_installed_games(self): ea_app_game = get_game_by_field("ea-app", "slug") if not ea_app_game: logger.error("EA App is not installed") ea_app_prefix = ea_app_game["directory"].split("drive_c")[0] if not os.path.exists(os.path.join(ea_app_prefix, "drive_c")): logger.error("Invalid install of EA App at %s", ea_app_prefix) return ea_app_launcher = EAAppGames(ea_app_prefix) installed_games = 0 for content_ids in ea_app_launcher.get_installed_games_content_ids(): self.install_from_ea_app(ea_app_game, content_ids) installed_games += 1 logger.debug("Installed %s EA games", installed_games) def install_from_ea_app(self, ea_game, content_ids): offer_id = content_ids[0] logger.debug("Installing EA game %s", offer_id) service_game = ServiceGameCollection.get_game("ea_app", offer_id) if not service_game: logger.error("Aborting install, %s is not present in the game library.", offer_id) return lutris_game_id = slugify(service_game["name"]) + "-" + self.id existing_game = get_game_by_field(lutris_game_id, "installer_slug") if existing_game: return game_config = LutrisConfig(game_config_id=ea_game["configpath"]).game_level game_config["game"]["args"] = get_launch_arguments(",".join(content_ids)) configpath = write_game_config(lutris_game_id, game_config) game_id = add_game( name=service_game["name"], runner=ea_game["runner"], slug=slugify(service_game["name"]), directory=ea_game["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=offer_id, ) return game_id def generate_installer(self, db_game, ea_db_game): ea_game = Game(ea_db_game["id"]) ea_exe = ea_game.config.game_config["exe"] if not os.path.isabs(ea_exe): ea_exe = os.path.join(ea_game.config.game_config["prefix"], ea_exe) return { "name": db_game["name"], "version": self.name, "slug": slugify(db_game["name"]) + "-" + self.id, "game_slug": slugify(db_game["name"]), "runner": self.runner, "appid": db_game["appid"], "script": { "requires": self.client_installer, "game": { "args": get_launch_arguments(db_game["appid"]), }, "installer": [ {"task": { "name": "wineexec", "executable": ea_exe, "args": get_launch_arguments(db_game["appid"]), "prefix": ea_game.config.game_config["prefix"], "description": ( "EA App will now open and prompt you to install %s." % db_game["name"] ) }} ] } } def install(self, db_game): ea_app_game = get_game_by_field(self.client_installer, "slug") application = Gio.Application.get_default() if not ea_app_game or not ea_app_game["installed"]: logger.warning("Installing the EA App client") installers = get_installers(game_slug=self.client_installer) application.show_installer_window(installers) else: application.show_installer_window( [self.generate_installer(db_game, ea_app_game)], service=self, appid=db_game["appid"] ) def get_launch_arguments(content_id, action="launch"): """Return launch argument for EA games. download used to be a valid action but it doesn't seem like it's implemented in EA App.""" return f"origin2://game/{action}?offerIds={content_id}&autoDownload=1" lutris-0.5.14/lutris/services/egs.py000066400000000000000000000365771451435154700174530ustar00rootroot00000000000000"""Epic Games Store service""" import json import os from gettext import gettext as _ import requests from gi.repository import Gio from lutris import settings from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_game, get_game_by_field from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.gui.widgets.utils import Image, paste_overlay, thumbnail_image from lutris.installer import get_installers from lutris.services.base import AuthTokenExpired, OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import system from lutris.util.egs.egs_launcher import EGSLauncher from lutris.util.log import logger from lutris.util.strings import slugify EGS_GAME_ART_PATH = os.path.expanduser("~/.cache/lutris/egs/game_box") EGS_GAME_BOX_PATH = os.path.expanduser("~/.cache/lutris/egs/game_box_tall") EGS_LOGO_PATH = os.path.expanduser("~/.cache/lutris/egs/game_logo") EGS_BANNERS_PATH = os.path.expanduser("~/.cache/lutris/egs/banners") EGS_BOX_ART_PATH = os.path.expanduser("~/.cache/lutris/egs/boxart") BANNER_SIZE = (316, 178) BOX_ART_SIZE = (200, 267) class DieselGameMedia(ServiceMedia): service = "egs" remote_size = (200, 267) file_pattern = "%s.jpg" file_format = "jpeg" min_logo_x = 300 min_logo_y = 150 def _render_filename(self, filename): game_box_path = os.path.join(self.dest_path, filename) logo_path = os.path.join(EGS_LOGO_PATH, filename.replace(".jpg", ".png")) has_logo = os.path.exists(logo_path) thumb_image = Image.open(game_box_path) thumb_image = thumb_image.convert("RGBA") thumb_image = thumbnail_image(thumb_image, self.remote_size) if has_logo: logo_image = Image.open(logo_path) logo_image = logo_image.convert("RGBA") logo_width, logo_height = logo_image.size if logo_width > self.min_logo_x: logo_image = logo_image.resize((self.min_logo_x, int( logo_height * (self.min_logo_x / logo_width))), resample=Image.BICUBIC) elif logo_height > self.min_logo_y: logo_image = logo_image.resize( (int(logo_width * (self.min_logo_y / logo_height)), self.min_logo_y), resample=Image.BICUBIC) thumb_image = paste_overlay(thumb_image, logo_image) thumb_path = os.path.join(self.dest_path, filename) thumb_image = thumb_image.convert("RGB") thumb_image.save(thumb_path) def get_media_url(self, details): for image in details.get("keyImages", []): if image["type"] == self.api_field: return image["url"] + "?w=%s&resize=1&h=%s" % ( self.remote_size[0], self.remote_size[1] ) class DieselGameBoxTall(DieselGameMedia): """EGS tall game box""" size = (200, 267) remote_size = size min_logo_x = 100 min_logo_y = 100 dest_path = os.path.join(settings.CACHE_DIR, "egs/game_box_tall") api_field = "DieselGameBoxTall" def render(self): for filename in os.listdir(self.dest_path): self._render_filename(filename) class DieselGameBoxSmall(DieselGameBoxTall): size = (100, 133) remote_size = (200, 267) class DieselGameBox(DieselGameBoxTall): """EGS game box""" size = (316, 178) remote_size = size min_logo_x = 300 min_logo_y = 150 dest_path = os.path.join(settings.CACHE_DIR, "egs/game_box") api_field = "DieselGameBox" class DieselGameBannerSmall(DieselGameBox): size = (158, 89) remote_size = (316, 178) class DieselGameBoxLogo(DieselGameMedia): """EGS game box""" size = (200, 100) remote_size = size file_pattern = "%s.png" file_format = "png" visible = False dest_path = os.path.join(settings.CACHE_DIR, "egs/game_logo") api_field = "DieselGameBoxLogo" class EGSGame(ServiceGame): """Service game for Epic Games Store""" service = "egs" @classmethod def new_from_api(cls, egs_game): """Convert an EGS game to a service game""" service_game = cls() service_game.appid = egs_game["appName"] service_game.slug = slugify(egs_game["title"]) service_game.name = egs_game["title"] service_game.details = json.dumps(egs_game) return service_game class EpicGamesStoreService(OnlineService): """Service class for Epic Games Store""" id = "egs" name = _("Epic Games Store") login_window_width = 500 login_window_height = 850 icon = "egs" online = True runner = "wine" client_installer = "epic-games-store" medias = { "game_box_small": DieselGameBoxSmall, "game_banner_small": DieselGameBannerSmall, "game_box": DieselGameBox, "box_tall": DieselGameBoxTall, } extra_medias = { "logo": DieselGameBoxLogo, } default_format = "game_banner_small" requires_login_page = True cookies_path = os.path.join(settings.CACHE_DIR, ".egs.auth") token_path = os.path.join(settings.CACHE_DIR, ".egs.token") cache_path = os.path.join(settings.CACHE_DIR, "egs-library.json") login_url = ("https://www.epicgames.com/id/login?redirectUrl=" "https%3A//www.epicgames.com/id/api/redirect%3F" "clientId%3D34a02cf8f4414e29b15921876da36f9a%26responseType%3Dcode") redirect_uri = "https://www.epicgames.com/id/api/redirect" oauth_url = 'https://account-public-service-prod03.ol.epicgames.com' catalog_url = 'https://catalog-public-service-prod06.ol.epicgames.com' library_url = 'https://library-service.live.use1a.on.epicgames.com' user_agent = ( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' 'AppleWebKit/537.36 (KHTML, like Gecko) ' 'EpicGamesLauncher/11.0.1-14907503+++Portal+Release-Live ' 'UnrealEngine/4.23.0-14907503+++Portal+Release-Live ' 'Chrome/84.0.4147.38 Safari/537.36' ) def __init__(self): super().__init__() self.session = requests.session() self.session.headers['User-Agent'] = self.user_agent if os.path.exists(self.token_path): with open(self.token_path, encoding='utf-8') as token_file: self.session_data = json.loads(token_file.read()) else: self.session_data = {} @property def http_basic_auth(self): return requests.auth.HTTPBasicAuth( '34a02cf8f4414e29b15921876da36f9a', 'daafbccc737745039dffe53d94fc76cf' ) def run(self): egs = get_game_by_field(self.client_installer, "slug") egs_game = Game(egs["id"]) egs_game.emit("game-launch") def is_launchable(self): return get_game_by_field(self.client_installer, "slug") def is_connected(self): return self.is_authenticated() def login_callback(self, content): """Once the user logs in in a browser window, Epic redirects to a page containing a Session ID which we can use to finish the authentication. Store session ID and exchange token to auth file""" logger.debug("Login to EGS successful") logger.debug(content) content_json = json.loads(content.decode()) session_id = content_json["authorizationCode"] self.start_session(authorization_code=session_id) self.emit("service-login") def resume_session(self): self.session.headers['Authorization'] = 'bearer %s' % self.session_data["access_token"] response = self.session.get('%s/account/api/oauth/verify' % self.oauth_url) if response.status_code >= 500: response.raise_for_status() response_content = response.json() if 'errorMessage' in response_content: raise RuntimeError(response_content) return response_content def start_session(self, exchange_code=None, authorization_code=None): if exchange_code: params = { 'grant_type': 'exchange_code', 'exchange_code': exchange_code, 'token_type': 'eg1' } elif authorization_code: params = { 'grant_type': 'authorization_code', 'code': authorization_code, 'token_type': 'eg1' } else: params = { 'grant_type': 'refresh_token', 'refresh_token': self.session_data["refresh_token"], 'token_type': 'eg1' } response = self.session.post( 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token', data=params, auth=self.http_basic_auth ) if response.status_code >= 500: response.raise_for_status() response_content = response.json() if 'error' in response_content: raise RuntimeError(response_content) with open(self.token_path, "w", encoding='utf-8') as auth_file: auth_file.write(json.dumps(response_content, indent=2)) self.session_data = response_content def get_game_details(self, asset): namespace = asset["namespace"] catalog_item_id = asset["catalogItemId"] response = self.session.get( '%s/catalog/api/shared/namespace/%s/bulk/items' % (self.catalog_url, namespace), params={ "id": catalog_item_id, "includeDLCDetails": True, "includeMainGameDetails": True, "country": "US", "locale": "en" } ) response.raise_for_status() # Merge the details with the initial asset to keep 'appName' asset.update(response.json()[catalog_item_id]) return asset def get_library(self): self.resume_session() response = self.session.get( '%s/library/api/public/items' % self.library_url, params={'includeMetadata': 'true'} ) response.raise_for_status() resData = response.json() records = resData['records'] cursor = resData['responseMetadata'].get('nextCursor', None) while cursor: response = self.session.get( '%s/library/api/public/items' % self.library_url, params={'includeMetadata': 'true', 'cursor': cursor} ) response.raise_for_status() resData = response.json() records.extend(resData['records']) cursor = resData['responseMetadata'].get('nextCursor', None) games = [] for record in records: if record["namespace"] == "ue": continue game_details = self.get_game_details(record) games.append(game_details) return games def load(self): """Load the list of games""" try: library = self.get_library() except Exception as ex: # pylint=disable:broad-except logger.warning("EGS Token expired") raise AuthTokenExpired from ex egs_games = [] for game in library: egs_game = EGSGame.new_from_api(game) egs_game.save() egs_games.append(egs_game) return egs_games def install_from_egs(self, egs_game, manifest): """Create a new Lutris game based on an existing EGS install""" app_name = manifest["AppName"] logger.debug("Installing EGS game %s", app_name) service_game = ServiceGameCollection.get_game("egs", app_name) if not service_game: logger.error("Aborting install, %s is not present in the game library.", app_name) return lutris_game_id = slugify(service_game["name"]) + "-" + self.id existing_game = get_game_by_field(lutris_game_id, "installer_slug") if existing_game: return game_config = LutrisConfig(game_config_id=egs_game["configpath"]).game_level game_config["game"]["args"] = get_launch_arguments(app_name) configpath = write_game_config(lutris_game_id, game_config) game_id = add_game( name=service_game["name"], runner=egs_game["runner"], slug=slugify(service_game["name"]), directory=egs_game["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=app_name, ) return game_id def add_installed_games(self): """Scan an existing EGS install for games""" egs_game = get_game_by_field("epic-games-store", "slug") if not egs_game: logger.error("EGS is not installed in Lutris") return egs_prefix = egs_game["directory"].split("drive_c")[0] logger.info("EGS detected in %s", egs_prefix) if not system.path_exists(os.path.join(egs_prefix, "drive_c")): logger.error("Invalid install of EGS at %s", egs_prefix) return egs_launcher = EGSLauncher(egs_prefix) for manifest in egs_launcher.iter_manifests(): self.install_from_egs(egs_game, manifest) logger.debug("All EGS games imported") def generate_installer(self, db_game, egs_db_game): egs_game = Game(egs_db_game["id"]) egs_exe = egs_game.config.game_config["exe"] if not os.path.isabs(egs_exe): egs_exe = os.path.join(egs_game.config.game_config["prefix"], egs_exe) return { "name": db_game["name"], "version": self.name, "slug": slugify(db_game["name"]) + "-" + self.id, "game_slug": slugify(db_game["name"]), "runner": self.runner, "appid": db_game["appid"], "script": { "requires": self.client_installer, "game": { "args": get_launch_arguments(db_game["appid"]), }, "installer": [ {"task": { "name": "wineexec", "executable": egs_exe, "args": get_launch_arguments(db_game["appid"], "install"), "prefix": egs_game.config.game_config["prefix"], "description": ( "The Epic Game Store will now open. Please launch " "the installation of %s then close the EGS client " "once the game has been downloaded." % db_game["name"] ) }} ] } } def install(self, db_game): egs_game = get_game_by_field(self.client_installer, "slug") application = Gio.Application.get_default() if not egs_game or not egs_game["installed"]: logger.warning("EGS (%s) not installed", self.client_installer) installers = get_installers( game_slug=self.client_installer, ) application.show_installer_window(installers) else: application.show_installer_window( [self.generate_installer(db_game, egs_game)], service=self, appid=db_game["appid"] ) def get_launch_arguments(app_name, action="launch"): return ( "-opengl" " -SkipBuildPatchPrereq" " -com.epicgames.launcher://apps/%s?action=%s" ) % (app_name, action) lutris-0.5.14/lutris/services/flathub.py000066400000000000000000000174141451435154700203070ustar00rootroot00000000000000import json import os import shutil import subprocess from gettext import gettext as _ from pathlib import Path import requests from gi.repository import Gio from lutris import settings from lutris.services.base import BaseService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import system from lutris.util.log import logger from lutris.util.strings import slugify class FlathubBanner(ServiceMedia): """Standard size of a Flathub banner""" service = "flathub" size = (128, 128) dest_path = os.path.join(settings.CACHE_DIR, "flathub/banners") file_pattern = "%s.png" file_format = "png" url_field = 'iconDesktopUrl' def get_media_url(self, details): return details.get(self.url_field) class FlathubGame(ServiceGame): """Representation of a Flathub game""" service = "flathub" @classmethod def new_from_flathub_game(cls, flathub_game): """Return a Flathub game instance from the API info""" service_game = FlathubGame() service_game.appid = flathub_game["flatpakAppId"] service_game.slug = slugify(flathub_game["name"]) service_game.lutris_slug = slugify(flathub_game["name"]) service_game.name = flathub_game["name"] service_game.details = { "summary": flathub_game["summary"], "version": flathub_game["currentReleaseVersion"] } service_game.runner = "flatpak" service_game.details = json.dumps(flathub_game) return service_game class FlathubService(BaseService): """Service class for Flathub""" id = "flathub" name = _("Flathub") icon = "flathub" medias = { "banner": FlathubBanner } default_format = "banner" api_url = "https://flathub.org/api/v1/apps/category/Game" cache_path = os.path.join(settings.CACHE_DIR, "flathub-library.json") branch = "stable" arch = "x86_64" install_type = "system" # can be either system (default) or user install_locations = { "system": "var/lib/flatpak/app/", "user": f"{Path.home()}/.local/share/flatpak/app/" } runner = "flatpak" game_class = FlathubGame def wipe_game_cache(self): """Wipe the game cache, allowing it to be reloaded""" if system.path_exists(self.cache_path): logger.debug("Deleting %s cache %s", self.id, self.cache_path) os.remove(self.cache_path) super().wipe_game_cache() def get_flatpak_cmd(self): flatpak_abspath = shutil.which("flatpak") if flatpak_abspath: return [flatpak_abspath] flatpak_spawn_abspath = shutil.which("flatpak-spawn") if flatpak_spawn_abspath: return [flatpak_spawn_abspath, "--host", "flatpak"] raise RuntimeError("No flatpak or flatpak-spawn found") def load(self): """Load the available games from Flathub""" response = requests.get(self.api_url, timeout=5) entries = response.json() flathub_games = [] for game in entries: flathub_games.append(FlathubGame.new_from_flathub_game(game)) for game in flathub_games: game.save() return flathub_games def install(self, db_game): """Install a Flathub game""" app_id = db_game["appid"] logger.debug("Installing %s from service %s", app_id, self.id) # Check if Flathub repo is active on the system if not self.is_flathub_remote_active(): logger.error("Flathub is not configured on the system. Visit https://flatpak.org/setup/ for instructions.") return # Install the game service_installers = self.get_installers_from_api(app_id) if not service_installers: service_installers = [self.generate_installer(db_game)] application = Gio.Application.get_default() application.show_installer_window(service_installers, service=self, appid=app_id) def get_installed_apps(self): """Get list of installed Flathub apps""" try: flatpak_cmd = self.get_flatpak_cmd() process = subprocess.run(flatpak_cmd + ["list", "--app", "--columns=application"], capture_output=True, check=True, encoding="utf-8", text=True, timeout=5.0) return process.stdout.splitlines() or [] except (TimeoutError, subprocess.CalledProcessError) as err: logger.exception("Error occurred while getting installed flatpak apps: %s", err) return [] def is_flathub_remote_active(self): """Check if Flathub is configured and enabled as a flatpak repository""" remotes = self.get_active_remotes() if not remotes: logger.warning("Remotes not found, Flathub considered installed") return True for remote in remotes: if 'flathub' in remote.values(): return True return False def get_active_remotes(self): """Get a list of dictionaries containing name, title and url""" try: flatpak_cmd = self.get_flatpak_cmd() process = subprocess.run(flatpak_cmd + ["remotes", "--columns=name,title,url"], capture_output=True, check=True, encoding="utf-8", text=True, timeout=5.0) entries = [] for line in process.stdout.splitlines(): cols = line.split("\t") entries.append({ "name": cols[0].lower(), "title": cols[1].lower(), "url": cols[2] }) return entries except (TimeoutError, subprocess.CalledProcessError) as err: logger.exception("Error occurred while getting installed flatpak apps: %s", err) return [] def generate_installer(self, db_game): # TODO: Add options for user to select arch, branch and install_type flatpak_cmd = self.get_flatpak_cmd() return { "appid": db_game["appid"], "game_slug": slugify(db_game["name"]), "slug": slugify(db_game["name"]) + "-" + self.id, "name": db_game["name"], "version": "Flathub", "runner": self.runner, "script": { "game": { "appid": db_game["appid"], "arch": self.arch, "branch": self.branch, "install_type": self.install_type }, "system": { "disable_runtime": True }, "require-binaries": flatpak_cmd[0], "installer": [ { "execute": { "file": flatpak_cmd[0], "args": " ".join(flatpak_cmd[1:]) + f" install --app --noninteractive flathub " f"app/{db_game['appid']}/{self.arch}/{self.branch}", "disable_runtime": True } } ] } } def get_game_directory(self, _installer): install_type, application, arch, branch = (_installer["script"]["game"][key] for key in ("install_type", "application", "arch", "branch")) return os.path.join(self.install_locations[install_type], application, arch, branch) # def add_installed_games(self): # process = subprocess.run(["flatpak", "list", "--app", "--columns=application,arch,branch,installation,name"], # capture_output=True, check=True, encoding="utf-8", text=True) # for line in process.stdout.splitlines(): # cols = line.split("\t") lutris-0.5.14/lutris/services/gog.py000066400000000000000000000622601451435154700174350ustar00rootroot00000000000000"""Module for handling the GOG service""" import json import os import time from collections import defaultdict from gettext import gettext as _ from urllib.parse import parse_qsl, urlencode, urlparse from lxml import etree from lutris import settings from lutris.exceptions import AuthenticationError, UnavailableGameError from lutris.installer import AUTO_ELF_EXE, AUTO_WIN32_EXE from lutris.installer.installer_file import InstallerFile from lutris.installer.installer_file_collection import InstallerFileCollection from lutris.services.base import OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import i18n, system from lutris.util.http import HTTPError, Request, UnauthorizedAccess from lutris.util.log import logger from lutris.util.strings import human_size, slugify class GogSmallBanner(ServiceMedia): """Small size game logo""" service = "gog" size = (100, 60) dest_path = os.path.join(settings.CACHE_DIR, "gog/banners/small") file_pattern = "%s.jpg" file_format = "jpeg" api_field = "image" url_pattern = "https:%s_prof_game_100x60.jpg" class GogMediumBanner(GogSmallBanner): """Medium size game logo""" size = (196, 110) dest_path = os.path.join(settings.CACHE_DIR, "gog/banners/medium") url_pattern = "https:%s_196.jpg" class GogLargeBanner(GogSmallBanner): """Big size game logo""" size = (392, 220) dest_path = os.path.join(settings.CACHE_DIR, "gog/banners/large") url_pattern = "https:%s_392.jpg" class GOGGame(ServiceGame): """Representation of a GOG game""" service = "gog" @classmethod def new_from_gog_game(cls, gog_game): """Return a GOG game instance from the API info""" service_game = GOGGame() service_game.appid = str(gog_game["id"]) service_game.slug = gog_game["slug"] service_game.name = gog_game["title"] service_game.details = json.dumps(gog_game) return service_game class GOGService(OnlineService): """Service class for GOG""" id = "gog" name = _("GOG") icon = "gog" has_extras = True drm_free = True medias = { "banner_small": GogSmallBanner, "banner": GogMediumBanner, "banner_large": GogLargeBanner } default_format = "banner" embed_url = "https://embed.gog.com" api_url = "https://api.gog.com" client_id = "46899977096215655" client_secret = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9" redirect_uri = "https://embed.gog.com/on_login_success?origin=client" login_success_url = "https://www.gog.com/on_login_success" cookies_path = os.path.join(settings.CACHE_DIR, ".gog.auth") token_path = os.path.join(settings.CACHE_DIR, ".gog.token") cache_path = os.path.join(settings.CACHE_DIR, "gog-library.json") def __init__(self): super().__init__() gog_locales = { "en": "en-US", "de": "de-DE", "fr": "fr-FR", "pl": "pl-PL", "ru": "ru-RU", "zh": "zh-Hans", } self.locale = gog_locales.get(i18n.get_lang(), "en-US") @property def login_url(self): """Return authentication URL""" params = { "client_id": self.client_id, "layout": "client2", "redirect_uri": self.redirect_uri, "response_type": "code", } return "https://auth.gog.com/auth?" + urlencode(params) @property def credential_files(self): return [self.cookies_path, self.token_path] def is_connected(self): """Return whether the user is authenticated and if the service is available""" if not self.is_authenticated(): return False try: user_data = self.get_user_data() except UnauthorizedAccess: logger.warning("GOG token is invalid") return False return user_data and "username" in user_data def load(self): """Load the user game library from the GOG API""" if not self.is_connected(): logger.error("User not connected to GOG") return games = [GOGGame.new_from_gog_game(game) for game in self.get_library()] for game in games: game.save() self.match_games() return games def login_callback(self, url): return self.request_token(url) def request_token(self, url="", refresh_token=""): """Get authentication token from GOG""" if refresh_token: grant_type = "refresh_token" extra_params = {"refresh_token": refresh_token} else: grant_type = "authorization_code" parsed_url = urlparse(url) response_params = dict(parse_qsl(parsed_url.query)) if "code" not in response_params: logger.error("code not received from GOG") logger.error(response_params) return extra_params = { "code": response_params["code"], "redirect_uri": self.redirect_uri, } params = { "client_id": self.client_id, "client_secret": self.client_secret, "grant_type": grant_type, } params.update(extra_params) url = "https://auth.gog.com/token?" + urlencode(params) request = Request(url) try: request.get() except HTTPError as http_error: logger.error(http_error) logger.error("Failed to get token, check your GOG credentials.") logger.warning("Clearing existing credentials") self.logout() return token = request.json with open(self.token_path, "w", encoding='utf-8') as token_file: token_file.write(json.dumps(token)) if not refresh_token: self.emit("service-login") def load_token(self): """Load token from disk""" if not os.path.exists(self.token_path): raise AuthenticationError("No GOG token available") with open(self.token_path, encoding='utf-8') as token_file: token_content = json.loads(token_file.read()) return token_content def get_token_age(self): """Return age of token""" token_stat = os.stat(self.token_path) token_modified = token_stat.st_mtime return time.time() - token_modified def make_request(self, url): """Send a cookie authenticated HTTP request to GOG""" request = Request(url, cookies=self.load_cookies()) request.get() if request.content.startswith(b"<"): raise AuthenticationError("Token expired, please log in again") return request.json def make_api_request(self, url): """Send a token authenticated request to GOG""" try: token = self.load_token() except AuthenticationError: return if self.get_token_age() > 2600: self.request_token(refresh_token=token["refresh_token"]) token = self.load_token() if not token: logger.warning( "Request to %s cancelled because the GOG token could not be acquired", url, ) return headers = {"Authorization": "Bearer " + token["access_token"]} request = Request(url, headers=headers, cookies=self.load_cookies()) try: request.get() except HTTPError: logger.error( "Failed to request %s, check your GOG credentials", url, ) return return request.json def get_user_data(self): """Return GOG profile information""" url = "https://embed.gog.com/userData.json" return self.make_api_request(url) def get_library(self): """Return the user's library of GOG games""" if system.path_exists(self.cache_path): logger.debug("Returning cached GOG library") with open(self.cache_path, "r", encoding='utf-8') as gog_cache: return json.load(gog_cache) total_pages = 1 games = [] page = 1 while page <= total_pages: products_response = self.get_products_page(page=page) page += 1 total_pages = products_response["totalPages"] games += products_response["products"] with open(self.cache_path, "w", encoding='utf-8') as gog_cache: json.dump(games, gog_cache) return games def get_service_game(self, gog_game): return GOGGame.new_from_gog_game(gog_game) def get_products_page(self, page=1, search=None): """Return a single page of games""" if not self.is_authenticated(): raise AuthenticationError("User is not logged in") params = {"mediaType": "1"} if page: params["page"] = page if search: params["search"] = search url = self.embed_url + "/account/getFilteredProducts?" + urlencode(params) return self.make_request(url) def get_game_dlcs(self, product_id): """Return the list of DLC products for a game""" game_details = self.get_game_details(product_id) if not game_details["dlcs"]: return [] all_products_url = game_details["dlcs"]["expanded_all_products_url"] return self.make_api_request(all_products_url) def get_game_details(self, product_id): """Return game information for a given game""" if not product_id: raise ValueError("Missing product ID") logger.info("Getting game details for %s", product_id) url = "{}/products/{}?expand=downloads&locale={}".format(self.api_url, product_id, self.locale) return self.make_api_request(url) def get_download_info(self, downlink): """Return file download information""" logger.info("Getting download info for %s", downlink) try: response = self.make_api_request(downlink) except HTTPError as ex: logger.error("HTTP error: %s", ex) raise UnavailableGameError(_("The download of '%s' failed.") % downlink) from ex if not response: raise UnavailableGameError(_("The download of '%s' failed.") % downlink) for field in ("checksum", "downlink"): field_url = response[field] parsed = urlparse(field_url) query = dict(parse_qsl(parsed.query)) response[field + "_filename"] = os.path.basename(query.get("path") or parsed.path) return response def get_downloads(self, gogid): """Return all available downloads for a GOG ID""" if not gogid: logger.warning("Unable to get GOG data because no GOG ID is available") return {} gog_data = self.get_game_details(gogid) if not gog_data: logger.warning("Unable to get GOG data for game %s", gogid) return {} return gog_data["downloads"] def get_extras(self, gogid): """Return a list of bonus content available for a GOG ID and its DLCs""" logger.debug("Download extras for GOG ID %s and its DLCs", gogid) game = self.get_game_details(gogid) if not game: logger.warning("Unable to get GOG data for game %s", gogid) return [] dlcs = self.get_game_dlcs(gogid) products = [game, *dlcs] if dlcs else [game] all_extras = {} for product in products: extras = [ { "name": download.get("name", "").strip().capitalize(), "type": download.get("type", "").strip(), "total_size": download.get("total_size", 0), "id": str(download["id"]), } for download in product["downloads"].get("bonus_content") or [] ] if extras: all_extras[product.get("title", "").strip()] = extras return all_extras def get_installers(self, downloads, runner, language="en"): """Return available installers for a GOG game""" # Filter out Mac installers gog_installers = [installer for installer in downloads.get("installers", []) if installer["os"] != "mac"] available_platforms = {installer["os"] for installer in gog_installers} # If it's a Linux game, also filter out Windows games if "linux" in available_platforms: filter_os = "windows" if runner == "linux" else "linux" gog_installers = [installer for installer in gog_installers if installer["os"] != filter_os] return [ installer for installer in gog_installers if installer["language"] == self.determine_language_installer(gog_installers, language) ] def get_update_versions(self, gog_id): """Return updates available for a game, keyed by patch version""" games_detail = self.get_game_details(gog_id) patches = games_detail["downloads"]["patches"] if not patches: logger.info("No patches for %s", games_detail) return {} patch_versions = defaultdict(list) for patch in patches: patch_versions[patch["name"]].append(patch) return patch_versions def determine_language_installer(self, gog_installers, default_language="en"): """Return locale language string if available in gog_installers""" language = i18n.get_lang() gog_installers = [installer for installer in gog_installers if installer["language"] == language] if not gog_installers: language = default_language return language def query_download_links(self, download): """Convert files from the GOG API to a format compatible with lutris installers""" download_links = [] for game_file in download.get("files", []): downlink = game_file.get("downlink") if not downlink: logger.error("No download information for %s", game_file) continue download_info = self.get_download_info(downlink) for field in ('checksum', 'downlink'): download_links.append({ "name": download.get("name", ""), "os": download.get("os", ""), "type": download.get("type", ""), "total_size": download.get("total_size", 0), "id": str(game_file["id"]), "url": download_info[field], "filename": download_info[field + "_filename"] }) return download_links def get_extra_files(self, downloads, installer, selected_extras): extra_files = [] for extra in downloads["bonus_content"]: if str(extra["id"]) not in selected_extras: continue links = self.query_download_links(extra) for link in links: if link["filename"].endswith(".xml"): # GOG gives a link for checksum XML files for bonus content # but downloading them results in a 404 error. continue extra_files.append( InstallerFile(installer.game_slug, str(extra["id"]), { "url": link["url"], "filename": link["filename"], }) ) return extra_files def _get_installer_links(self, installer, downloads): """Return links to downloadable files from a list of downloads""" try: gog_installers = self.get_installers(downloads, installer.runner) if not gog_installers: return [] if len(gog_installers) > 1: logger.warning("More than 1 GOG installer found, picking first.") _installer = gog_installers[0] return self.query_download_links(_installer) except HTTPError as err: raise UnavailableGameError(_("Couldn't load the download links for this game")) from err def get_patch_files(self, installer, installer_file_id): logger.debug("Getting patches for %s", installer.version) downloads = self.get_downloads(installer.service_appid) links = [] for patch_file in downloads["patches"]: if "GOG " + patch_file["version"] == installer.version: links += self.query_download_links(patch_file) return self._format_links(installer, installer_file_id, links) def _format_links(self, installer, installer_file_id, links): _installer_files = defaultdict(dict) # keyed by filename for link in links: try: filename = link["filename"] except KeyError: logger.error("Invalid link: %s", link) raise if filename.lower().endswith(".xml"): if filename != installer_file_id: filename = filename[:-4] _installer_files[filename]["checksum_url"] = link["url"] continue _installer_files[filename]["id"] = link["id"] _installer_files[filename]["url"] = link["url"] _installer_files[filename]["filename"] = filename _installer_files[filename]["total_size"] = link["total_size"] files = [] file_id_provided = False # Only assign installer_file_id once for _file_id in _installer_files: installer_file = _installer_files[_file_id] if "url" not in installer_file: raise ValueError("Invalid installer file %s" % installer_file) filename = installer_file["filename"] if filename.lower().endswith((".exe", ".sh")) and not file_id_provided: file_id = installer_file_id file_id_provided = True else: file_id = _file_id files.append(InstallerFile(installer.game_slug, file_id, { "url": installer_file["url"], "filename": installer_file["filename"], "checksum_url": installer_file.get("checksum_url"), "total_size": installer_file["total_size"], "size": -1, })) if not file_id_provided: raise UnavailableGameError(_("Unable to determine correct file to launch installer")) return files def get_installer_files(self, installer, installer_file_id, selected_extras): try: downloads = self.get_downloads(installer.service_appid) except HTTPError as err: raise UnavailableGameError(_("Couldn't load the downloads for this game")) from err links = self._get_installer_links(installer, downloads) if links: files = [InstallerFileCollection(installer.game_slug, installer_file_id, self._format_links(installer, installer_file_id, links))] else: files = [] if selected_extras: for extra_file in self.get_extra_files(downloads, installer, selected_extras): files.append(extra_file) return files def read_file_checksum(self, file_path): """Return the MD5 checksum for a GOG file Requires a GOG XML file as input This has yet to be used. """ if not file_path.endswith(".xml"): raise ValueError("Pass a XML file to return the checksum") with open(file_path, encoding='utf-8') as checksum_file: checksum_content = checksum_file.read() root_elem = etree.fromstring(checksum_content) return (root_elem.attrib["name"], root_elem.attrib["md5"]) def generate_installer(self, db_game): details = json.loads(db_game["details"]) platforms = [platform.lower() for platform, is_supported in details["worksOn"].items() if is_supported] system_config = {} if "linux" in platforms: runner = "linux" game_config = {"exe": AUTO_ELF_EXE} script = [ {"extract": {"file": "goginstaller", "format": "zip", "dst": "$CACHE"}}, {"merge": {"src": "$CACHE/data/noarch", "dst": "$GAMEDIR"}}, ] else: runner = "wine" game_config = {"exe": AUTO_WIN32_EXE} script = [ {"autosetup_gog_game": "goginstaller"}, ] return { "name": db_game["name"], "version": "GOG", "slug": details["slug"], "game_slug": slugify(db_game["name"]), "runner": runner, "gogid": db_game["appid"], "script": { "game": game_config, "system": system_config, "files": [ {"goginstaller": "N/A:Select the installer from GOG"} ], "installer": script } } def get_games_owned(self): """Return IDs of games owned by user""" url = "{}/user/data/games".format(self.embed_url) return self.make_api_request(url) def get_dlc_installers(self, db_game): """Return all available DLC installers for game""" appid = db_game["service_id"] dlcs = self.get_game_dlcs(appid) installers = [] for dlc in dlcs: dlc_id = "gogdlc-%s" % dlc["slug"] # remove mac installers for now installfiles = [installer for installer in dlc["downloads"].get( "installers", []) if installer["os"] != "mac"] for file in installfiles: # supports linux if file["os"].lower() == "linux": runner = "linux" script = [{"extract": {"dst": "$CACHE/GOG", "file": dlc_id, "format": "zip"}}, {"merge": {"dst": "$GAMEDIR", "src": "$CACHE/GOG/data/noarch/"}}] else: runner = "wine" script = [{"task": {"name": "wineexec", "executable": dlc_id}}] installer = { "name": db_game["name"], # add runner in brackets - wrong installer can be run when this is not unique "version": f"{dlc['title']} ({runner})", "slug": dlc["slug"], "description": "DLC for %s" % db_game["name"], "game_slug": slugify(db_game["name"]), "runner": runner, "is_dlc": True, "dlcid": dlc["id"], "gogid": dlc["id"], "script": { "extends": db_game["installer_slug"], "files": [ {dlc_id: "N/A:Select the patch from GOG"} ], "installer": script } } installers.append(installer) return installers def get_dlc_installers_owned(self, db_game): """Return DLC installers for owned DLC""" owned = self.get_games_owned() installers = self.get_dlc_installers(db_game) installers = [installer for installer in installers if installer["dlcid"] in owned["owned"]] return installers def get_dlc_installers_runner(self, db_game, runner, only_owned=True): """Return DLC installers for requested runner only_owned=True only return installers for owned DLC (default)""" if only_owned: installers = self.get_dlc_installers_owned(db_game) else: installers = self.get_dlc_installers(db_game) # only handle linux & wine for now if runner != "linux": runner = "wine" installers = [installer for installer in installers if installer["runner"] == runner] return installers def get_update_installers(self, db_game): appid = db_game["service_id"] patch_versions = self.get_update_versions(appid) patch_installers = [] for version in patch_versions: patch = patch_versions[version] size = human_size(sum(part["total_size"] for part in patch)) patch_id = "gogpatch-%s" % slugify(patch[0]["version"]) installer = { "name": db_game["name"], "description": patch[0]["name"] + " " + size, "slug": db_game["installer_slug"], "game_slug": db_game["slug"], "version": "GOG " + patch[0]["version"], "runner": "wine", "script": { "extends": db_game["installer_slug"], "files": [ {patch_id: "N/A:Select the patch from GOG"} ], "installer": [ {"task": {"name": "wineexec", "executable": patch_id}} ] } } patch_installers.append(installer) return patch_installers def get_game_platforms(self, db_game): details = db_game.get("details") if details: worksOn = json.loads(details).get("worksOn") if worksOn is not None: return [name for name, works in worksOn.items() if works] return None lutris-0.5.14/lutris/services/humblebundle.py000066400000000000000000000355421451435154700213320ustar00rootroot00000000000000"""Manage Humble Bundle libraries""" import concurrent.futures import json import os from gettext import gettext as _ from gi.repository import Gtk from lutris import settings from lutris.exceptions import UnavailableGameError from lutris.gui.dialogs import HumbleBundleCookiesDialog, QuestionDialog from lutris.installer import AUTO_ELF_EXE, AUTO_WIN32_EXE from lutris.installer.installer_file import InstallerFile from lutris.services.base import OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import linux from lutris.util.http import HTTPError, Request from lutris.util.log import logger from lutris.util.strings import slugify class HumbleBundleIcon(ServiceMedia): """HumbleBundle icon""" service = "humblebundle" size = (70, 70) dest_path = os.path.join(settings.CACHE_DIR, "humblebundle/icons") file_pattern = "%s.png" file_format = "png" api_field = "icon" class HumbleSmallIcon(HumbleBundleIcon): size = (35, 35) class HumbleBigIcon(HumbleBundleIcon): size = (105, 105) class HumbleBundleGame(ServiceGame): """Service game for DRM free Humble Bundle games""" service = "humblebundle" @classmethod def new_from_humble_game(cls, humble_game): """Converts a game from the API to a service game usable by Lutris""" service_game = HumbleBundleGame() service_game.appid = humble_game["machine_name"] service_game.slug = humble_game["machine_name"] service_game.name = humble_game["human_name"] service_game.details = json.dumps(humble_game) return service_game class HumbleBundleService(OnlineService): """Service for Humble Bundle""" id = "humblebundle" _matcher = "humble" name = _("Humble Bundle") icon = "humblebundle" online = True drm_free = True medias = { "small_icon": HumbleSmallIcon, "icon": HumbleBundleIcon, "big_icon": HumbleBigIcon } default_format = "icon" api_url = "https://www.humblebundle.com/" login_url = "https://www.humblebundle.com/login?goto=/home/library" redirect_uri = "https://www.humblebundle.com/home/library" cookies_path = os.path.join(settings.CACHE_DIR, ".humblebundle.auth") token_path = os.path.join(settings.CACHE_DIR, ".humblebundle.token") cache_path = os.path.join(settings.CACHE_DIR, "humblebundle/library/") supported_platforms = ("linux", "windows") def login(self, parent=None): dialog = QuestionDialog({ "title": _("Workaround for Humble Bundle authentication"), "question": _("Humble Bundle is restricting API calls from software like Lutris and GameHub.\n" "Authentication to the service will likely fail.\n" "There is a workaround involving copying cookies " "from Firefox, do you want to do this instead?"), "parent": parent }) if dialog.result == Gtk.ResponseType.YES: dialog = HumbleBundleCookiesDialog() if dialog.cookies_content: with open(self.cookies_path, "w", encoding="utf-8") as cookies_file: cookies_file.write(dialog.cookies_content) self.login_callback(None) else: self.logout() else: return super().login(parent=parent) def login_callback(self, url): """Called after the user has logged in successfully""" self.emit("service-login") def is_connected(self): """This doesn't actually check if the authentication is valid like the GOG service does. """ return self.is_authenticated() def load(self): """Load the user's Humble Bundle library""" try: library = self.get_library() except ValueError as ex: raise RuntimeError("Failed to get Humble Bundle library. Try logging out and back-in.") from ex humble_games = [] seen = set() for game in library: if game["human_name"] in seen: continue humble_games.append(HumbleBundleGame.new_from_humble_game(game)) seen.add(game["human_name"]) for game in humble_games: game.save() return humble_games def make_api_request(self, url): """Make an authenticated request to the Humble API""" request = Request(url, cookies=self.load_cookies()) try: request.get() except HTTPError: logger.error( "Failed to request %s, check your Humble Bundle credentials", url, ) return return request.json def order_path(self, gamekey): """Return the local path for an order""" return os.path.join(self.cache_path, "%s.json" % gamekey) def get_order(self, gamekey): """Retrieve an order identitied by its key""" # logger.debug("Getting Humble Bundle order %s", gamekey) cache_filename = self.order_path(gamekey) if os.path.exists(cache_filename): with open(cache_filename, encoding='utf-8') as cache_file: return json.load(cache_file) response = self.make_api_request(self.api_url + "api/v1/order/%s?all_tpkds=true" % gamekey) os.makedirs(self.cache_path, exist_ok=True) with open(cache_filename, "w", encoding='utf-8') as cache_file: json.dump(response, cache_file) return response def get_library(self): """Return the games from the user's library""" games = [] for order in self.get_orders(): if not order: continue for product in order["subproducts"]: for download in product["downloads"]: if download["platform"] in self.supported_platforms: games.append(product) return games def get_gamekeys_from_local_orders(self): """Retrieve a list of orders from the cache.""" game_keys = [] if os.path.exists(self.cache_path): for order_file in os.listdir(self.cache_path): if not order_file.endswith(".json"): continue game_keys.append({"gamekey": order_file[:-5]}) return game_keys def get_orders(self): """Return all orders""" gamekeys = self.get_gamekeys_from_local_orders() orders = [] if not gamekeys: gamekeys = self.make_api_request(self.api_url + "api/v1/user/order") with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: future_orders = [ executor.submit(self.get_order, gamekey["gamekey"]) for gamekey in gamekeys ] for order in future_orders: orders.append(order.result()) logger.info("Loaded %s Humble Bundle orders", len(orders)) return orders @staticmethod def find_download_in_order(order, humbleid, platform): """Return the download information in an order for a give game""" for product in order["subproducts"]: if product["machine_name"] != humbleid: continue available_platforms = [d["platform"] for d in product["downloads"]] if platform not in available_platforms: logger.warning("Requested platform %s not available in available platforms: %s", platform, available_platforms) if "linux" in available_platforms: platform = "linux" elif "windows" in available_platforms: platform = "windows" else: platform = available_platforms[0] for download in product["downloads"]: if download["platform"] != platform: continue return { "product": order["product"], "gamekey": order["gamekey"], "created": order["created"], "download": download } def get_downloads(self, humbleid, platform): """Return the download information for a given game""" download_links = [] for order in self.get_orders(): download = self.find_download_in_order(order, humbleid, platform) if download: download_links.append(download) return download_links def get_installer_files(self, installer, installer_file_id, _selected_extras): """Replace the user provided file with download links from Humble Bundle""" try: link = get_humble_download_link(installer.service_appid, installer.runner) except Exception as ex: logger.exception("Failed to get Humble Bundle game: %s", ex) raise UnavailableGameError(_("The download URL for the game could not be determined.")) from ex if not link: raise UnavailableGameError(_("No game found on Humble Bundle")) filename = link.split("?")[0].split("/")[-1] return [ InstallerFile(installer.game_slug, installer_file_id, { "url": link, "filename": filename }) ] @staticmethod def get_filename_for_platform(downloads, platform): download = [d for d in downloads if d["platform"] == platform][0] url = pick_download_url_from_download_info(download) if not url: return return url.split("?")[0].split("/")[-1] @staticmethod def platform_has_downloads(downloads, platform): for download in downloads: if download["platform"] != platform: continue if len(download["download_struct"]) > 0: return True def generate_installer(self, db_game): details = json.loads(db_game["details"]) platforms = [download["platform"] for download in details["downloads"]] system_config = {} if "linux" in platforms and self.platform_has_downloads(details["downloads"], "linux"): runner = "linux" game_config = {"exe": AUTO_ELF_EXE} filename = self.get_filename_for_platform(details["downloads"], "linux") if filename.lower().endswith(".sh"): script = [ {"extract": {"file": "humblegame", "format": "zip", "dst": "$CACHE"}}, {"merge": {"src": "$CACHE/data/noarch", "dst": "$GAMEDIR", "optional": True}}, {"move": {"src": "$CACHE/data/noarch", "dst": "$CACHE/noarch", "optional": True}}, {"merge": {"src": "$CACHE/data/x86_64", "dst": "$GAMEDIR", "optional": True}}, {"move": {"src": "$CACHE/data/x86_64", "dst": "$CACHE/x86_64", "optional": True}}, {"merge": {"src": "$CACHE/data/x86", "dst": "$GAMEDIR", "optional": True}}, {"move": {"src": "$CACHE/data/x86", "dst": "$CACHE/x86", "optional": True}}, {"merge": {"src": "$CACHE/data/", "dst": "$GAMEDIR", "optional": True}}, ] elif filename.endswith("-bin") or filename.endswith("mojo.run"): script = [ {"extract": {"file": "humblegame", "format": "zip", "dst": "$CACHE"}}, {"merge": {"src": "$CACHE/data/", "dst": "$GAMEDIR"}}, ] elif filename.endswith(".air"): script = [ {"move": {"src": "humblegame", "dst": "$GAMEDIR"}}, ] else: script = [{"extract": {"file": "humblegame"}}] system_config = {"gamemode": 'false'} # Unity games crash with gamemode elif "windows" in platforms: runner = "wine" game_config = {"exe": AUTO_WIN32_EXE, "prefix": "$GAMEDIR"} filename = self.get_filename_for_platform(details["downloads"], "windows") if filename.lower().endswith(".zip"): script = [ {"task": {"name": "create_prefix", "prefix": "$GAMEDIR"}}, {"extract": {"file": "humblegame", "dst": "$GAMEDIR/drive_c/%s" % db_game["slug"]}} ] else: script = [ {"task": {"name": "wineexec", "executable": "humblegame"}} ] else: logger.warning("Unsupported platforms: %s", platforms) return {} return { "name": db_game["name"], "version": "Humble Bundle", "slug": details["machine_name"], "game_slug": slugify(db_game["name"]), "runner": runner, "humbleid": db_game["appid"], "script": { "game": game_config, "system": system_config, "files": [ {"humblegame": "N/A:Select the installer from Humble Bundle"} ], "installer": script } } def pick_download_url_from_download_info(download_info): """From a list of downloads in Humble Bundle, pick the most appropriate one for the installer. This needs a way to be explicitely filtered. """ if not download_info["download_struct"]: logger.warning("No downloads found") return def humble_sort(download): name = download["name"] if "rpm" in name: return -99 # Not supported as an extractor bonus = 1 if "deb" not in name: bonus = 2 if linux.LINUX_SYSTEM.is_64_bit: if "386" in name or "32" in name: return -1 else: if "64" in name: return -10 return 1 * bonus sorted_downloads = sorted(download_info["download_struct"], key=humble_sort, reverse=True) logger.debug("Humble bundle installers:") for download in sorted_downloads: logger.debug(download) return sorted_downloads[0]["url"]["web"] def get_humble_download_link(humbleid, runner): """Return a download link for a given humbleid and runner""" service = HumbleBundleService() platform = runner if runner != "wine" else "windows" downloads = service.get_downloads(humbleid, platform) if not downloads: logger.error("Game %s for %s not found in the Humble Bundle library", humbleid, platform) return logger.info("Found %s download for %s", len(downloads), humbleid) download = downloads[0] logger.info("Reloading order %s", download["product"]["human_name"]) os.remove(service.order_path(download["gamekey"])) order = service.get_order(download["gamekey"]) download_info = service.find_download_in_order(order, humbleid, platform) if download_info: return pick_download_url_from_download_info(download_info["download"]) logger.warning("Couldn't retrieve any downloads for %s", humbleid) lutris-0.5.14/lutris/services/itchio.py000066400000000000000000000525021451435154700201360ustar00rootroot00000000000000"""itch.io service""" import datetime import json import os from gettext import gettext as _ from urllib.parse import quote_plus, urlencode from lutris import settings from lutris.database import games as games_db from lutris.exceptions import UnavailableGameError from lutris.installer import AUTO_ELF_EXE, AUTO_WIN32_EXE from lutris.installer.installer_file import InstallerFile from lutris.services.base import OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import linux from lutris.util.downloader import Downloader from lutris.util.http import HTTPError, Request from lutris.util.log import logger from lutris.util.strings import slugify class ItchIoCover(ServiceMedia): """itch.io game cover""" service = "itchio" size = (315, 250) dest_path = os.path.join(settings.CACHE_DIR, "itchio/cover") file_pattern = "%s.png" file_format = "png" def get_media_url(self, details): """Extract cover from API""" # Animated (gif) covers have an extra field with a png version of the cover if "still_cover_url" in details: if details["still_cover_url"]: return details["still_cover_url"] if "cover_url" in details: if details["cover_url"]: return details["cover_url"] else: logger.warning("No field 'cover_url' in API game %s", details) return class ItchIoGame(ServiceGame): """itch.io Game""" service = "itchio" @classmethod def new(cls, igame): """Return a Itch.io game instance from the API info""" service_game = ItchIoGame() service_game.appid = str(igame["id"]) service_game.slug = slugify(igame["title"]) service_game.name = igame["title"] service_game.details = json.dumps(igame) return service_game class ItchIoGameTraits(): """Game Traits Helper Class""" def __init__(self, traits): self._traits = traits self.windows = bool("p_windows" in traits) self.linux = bool("p_linux" in traits) self.can_be_bought = bool("can_be_bought" in traits) self.has_demo = bool("has_demo" in traits) def has_supported_platform(self): return self.windows or self.linux class ItchIoService(OnlineService): """Service class for itch.io""" id = "itchio" # According to their branding, "itch.io" is supposed to be all lowercase name = _("itch.io") icon = "itchio" online = True drm_free = True has_extras = True medias = { "banner": ItchIoCover, } default_format = "banner" api_url = "https://api.itch.io" login_url = "https://itch.io/login" redirect_uri = "https://itch.io/my-feed" cookies_path = os.path.join(settings.CACHE_DIR, ".itchio.auth") cache_path = os.path.join(settings.CACHE_DIR, "itchio/api/") key_cache_file = os.path.join(cache_path, "profile/owned-keys.json") games_cache_path = os.path.join(cache_path, "games/") key_cache = {} supported_platforms = ("p_linux", "p_windows") extra_types = ( "soundtrack", "book", "video", "documentation", "mod", "audio_assets", "graphical_assets", "sourcecode", "other" ) def login_callback(self, url): """Called after the user has logged in successfully""" self.emit("service-login") def is_connected(self): """Check if service is connected and can call the API""" if not self.is_authenticated(): return False try: profile = self.fetch_profile() except HTTPError: logger.warning("Not connected to itch.io account.") return False return profile and "user" in profile def load(self): """Load the user's itch.io library""" if not self.is_connected(): logger.error("User not connected to itch.io") return library = self.get_games() games = [] seen = set() for game in library: if game["title"] in seen: continue _game = ItchIoGame.new(game) games.append(_game) _game.save() seen.add(game["title"]) return games def make_api_request(self, path, query=None): """Make API request""" url = "{}/{}".format(self.api_url, path) if query is not None and isinstance(query, dict): url += "?{}".format(urlencode(query, quote_via=quote_plus)) request = Request(url, cookies=self.load_cookies()) request.get() return request.json def fetch_profile(self): """Do API request to get users online profile""" return self.make_api_request("profile") def fetch_owned_keys(self, query=None): """Do API request to get games owned by user (paginated)""" return self.make_api_request("profile/owned-keys", query) def fetch_game(self, game_id): """Do API request to get game info""" return self.make_api_request(f"games/{game_id}") def fetch_uploads(self, game_id, dl_key): """Do API request to get downloadables of a game.""" query = None if dl_key is not None: query = {"download_key_id": dl_key} return self.make_api_request(f"games/{game_id}/uploads", query) def fetch_upload(self, upload, dl_key): """Do API request to get downloadable of a game""" query = None if dl_key is not None: query = {"download_key_id": dl_key} return self.make_api_request(f"uploads/{upload}", query) def fetch_build_patches(self, installed, target, dl_key): """Do API request to get game patches""" query = None if dl_key is not None: query = {"download_key_id": dl_key} return self.make_api_request(f"builds/{installed}/upgrade-paths/{target}", query) def get_download_link(self, upload_id, dl_key): """Create download link for installation""" url = "{}/{}".format(self.api_url, f"uploads/{upload_id}/download") if dl_key is not None: query = {"download_key_id": dl_key} url += "?{}".format(urlencode(query, quote_via=quote_plus)) return url def get_game_cache(self, appid): """Create basic cache key based on game slug and appid""" return os.path.join(self.games_cache_path, f"{appid}.json") def _cache_games(self, games): """Store information about owned keys in cache""" os.makedirs(self.games_cache_path, exist_ok=True) for game in games: filename = self.get_game_cache(game["id"]) key_path = os.path.join(self.games_cache_path, filename) with open(key_path, "w", encoding="utf-8") as cache_file: json.dump(game, cache_file) def get_owned_games(self, force_load=False): """Get all owned library keys from itch.io""" owned_keys = [] fresh_data = True if (not force_load) and os.path.exists(self.key_cache_file): with open(self.key_cache_file, "r", encoding="utf-8") as key_file: owned_keys = json.load(key_file) fresh_data = False else: query = {"page": 1} # Basic security; I'm pretty sure itch.io will block us before that tho safety = 65507 while safety: response = self.fetch_owned_keys(query) if isinstance(response["owned_keys"], list): owned_keys += response["owned_keys"] if len(response["owned_keys"]) == int(response["per_page"]): query["page"] += 1 else: break else: break safety -= 1 os.makedirs(os.path.join(self.cache_path, "profile/"), exist_ok=True) with open(self.key_cache_file, "w", encoding="utf-8") as key_file: json.dump(owned_keys, key_file) games = [] for key in owned_keys: game = key.get("game", {}) game["download_key_id"] = key["id"] games.append(game) if fresh_data: self._cache_games(games) return games def get_games(self): """Return games from the user's library""" games = self.get_owned_games() filtered_games = [] for game in games: traits = game.get("traits", {}) if any(platform in traits for platform in self.supported_platforms): filtered_games.append(game) return filtered_games def get_key(self, appid): """Retrieve cache information on a key""" game_filename = self.get_game_cache(appid) game = {} if os.path.exists(game_filename): with open(game_filename, "r", encoding="utf-8") as game_file: game = json.load(game_file) else: try: game = self.fetch_game(appid).get("game", {}) self._cache_games([game]) except HTTPError: return traits = game.get("traits", []) if "can_be_bought" not in traits: # If game can not be bought it can not have a key return if "download_key_id" in game: # Return cached key return game["download_key_id"] if not game.get("min_price", 0): # We have no key but the game can be played for free return # Reload whole key library to check if a key was added library = self.get_owned_games(True) game = next((x for x in library if x["id"] == appid), game) if "download_key_id" in game: return game["download_key_id"] return def get_extras(self, appid): """Return a list of bonus content for itch.io game.""" key = self.get_key(appid) try: uploads = self.fetch_uploads(appid, key) except HTTPError: return [] all_extras = {} extras = [] for upload in uploads["uploads"]: if upload["type"] not in self.extra_types: continue extras.append( { "name": upload.get("filename", "").strip().capitalize(), "type": upload.get("type", "").strip(), "total_size": upload.get("size", 0), "id": str(upload["id"]), } ) if len(extras) > 0: all_extras["Bonus Content"] = extras return all_extras def generate_installer(self, db_game): """Auto generate installer for itch.io game""" details = json.loads(db_game["details"]) if "p_linux" in details["traits"]: runner = "linux" game_config = {"exe": AUTO_ELF_EXE} script = [ {"extract": {"file": "itchupload", "dst": "$CACHE"}}, {"merge": {"src": "$CACHE", "dst": "$GAMEDIR"}}, ] elif "p_windows" in details["traits"]: runner = "wine" game_config = {"exe": AUTO_WIN32_EXE} script = [ {"task": {"name": "create_prefix"}}, {"install_or_extract": "itchupload"} ] else: logger.warning("No supported platforms found") return {} return { "name": db_game["name"], "version": "itch.io", "slug": db_game["slug"], "game_slug": db_game["slug"], "runner": runner, "itchid": db_game["appid"], "script": { "files": [ {"itchupload": "N/A:Select the installer from itch.io"} ], "game": game_config, "installer": script, } } def _check_update_with_db(self, db_game, key, upload=None): stamp = 0 if upload: uploads = [upload["upload"] if "upload" in upload else upload] else: uploads = self.fetch_uploads(db_game["service_id"], key) if "uploads" in uploads: uploads = uploads["uploads"] for _upload in uploads: # skip extras if _upload["type"] in self.extra_types: continue ts = self._rfc3999_to_timestamp(_upload["updated_at"]) if (not stamp) or (ts > stamp): stamp = ts if stamp: dbg = games_db.get_games_where( installed_at__lessthan=stamp, service=self.id, service_id=db_game["service_id"] ) return len(dbg) return False def get_update_installers(self, db_game): """Check for updates""" patch_installers = [] key = self.get_key(db_game["service_id"]) upload = None outdated = False patch_url = None info = {} info_filename = os.path.join(db_game["directory"], ".lutrisgame.json") if os.path.exists(info_filename): with open(info_filename, encoding="utf-8") as info_file: info = json.load(info_file) if "upload" in info: # TODO: Implement wharf patching # if "build" in info and info["build"]: # upload = self.fetch_upload(info["upload"], key) # patches = self.fetch_build_patches(info["build"], upload["build_id"], key) # patch_urls = [] # for build in patches["upgrade_path"]["builds"]: # patch_urls.append("builds/{}/download/patch/default".format(build["id"])) # else: # Do overinstall of upload / Full build url try: upload = self.fetch_upload(info["upload"], key) upload = upload["upload"] if "upload" in upload else upload patch_url = self.get_download_link(info["upload"], key) except HTTPError as error: if error.code == 400: # Bad request probably means the upload was removed logger.info("Upload %s for %s seems to be removed.", info["upload"], db_game["name"] ) outdated = True if upload: ts = self._rfc3999_to_timestamp(upload.get("updated_at", 0)) if int(info.get("date", 0)) >= ts: return info["date"] = int(datetime.datetime.now().timestamp()) # Skip time based checks if we already know it's outdated if not outdated: outdated = self._check_update_with_db(db_game, key, upload) if outdated: installer = { "version": "itch.io", "name": db_game["name"], "slug": db_game["installer_slug"], "game_slug": db_game["slug"], "runner": db_game["runner"], "script": { "extends": db_game["installer_slug"], "files": [], "installer": [ {"extract": {"file": "itchupload", "dst": "$CACHE"}}, ] } } if patch_url: installer["script"]["files"] = [ {"itchupload": { "url": patch_url, "filename": "update.zip", "downloader": Downloader(patch_url, None, overwrite=True, cookies=self.load_cookies()), }} ] else: installer["script"]["files"] = [ {"itchupload": "N/A:Select the installer from itch.io"} ] if db_game["runner"] == "linux": installer["script"]["installer"].append( {"merge": {"src": "$CACHE", "dst": "$GAMEDIR"}}, ) elif db_game["runner"] == "wine": installer["script"]["installer"].append( {"merge": {"src": "$CACHE", "dst": "$GAMEDIR/drive_c/%s" % db_game["slug"]}} ) if patch_url: installer["script"]["installer"].append( {"write_json": { "data": info, "file": info_filename, "merge": True }} ) patch_installers.append(installer) return patch_installers def get_dlc_installers_runner(self, db_game, runner, only_owned=True): """itch.io does currently not officially support dlc""" return [] def get_installer_files(self, installer, installer_file_id, selected_extras): """Replace the user provided file with download links from itch.io""" key = self.get_key(installer.service_appid) try: uploads = self.fetch_uploads(installer.service_appid, key) except HTTPError as ex: raise UnavailableGameError from ex filtered = [] extras = [] files = [] link = None filename = "setup.zip" file = next(_file.copy() for _file in installer.script_files if _file.id == installer_file_id) if not file.url.startswith("N/A"): link = file.url data = { "service": self.id, "appid": installer.service_appid, "slug": installer.game_slug, "runner": installer.runner, "date": int(datetime.datetime.now().timestamp()) } if not link or len(selected_extras) > 0: for upload in uploads["uploads"]: if selected_extras and (upload["type"] in self.extra_types): extras.append(upload) continue # default = games/tools ("executables") if upload["type"] == "default" and (installer.runner in ("linux", "wine")): is_linux = installer.runner == "linux" and "p_linux" in upload["traits"] is_windows = installer.runner == "wine" and "p_windows" in upload["traits"] is_demo = "demo" in upload["traits"] if not (is_linux or is_windows): continue upload["Weight"] = self.get_file_weight(upload["filename"], is_demo) if upload["Weight"] == 0xFF: continue filtered.append(upload) continue # TODO: Implement embedded types: flash, unity, java, html # I have not found keys for embdedded games # but people COULD write custom installers. # So far embedded games can be played directly on itch.io if len(filtered) > 0 and not link: filtered.sort(key=lambda upload: upload["Weight"]) # Lutris does not support installer selection upload = filtered[0] data["upload"] = str(upload["id"]) if "build_id" in upload: data["build"] = str(upload["build_id"]) link = self.get_download_link(upload["id"], key) filename = upload["filename"] if link: # Adding a file with some basic info for e.g. patching installer.script["installer"].append({"write_json": { "data": data, "file": "$GAMEDIR/.lutrisgame.json", "merge": False }}) files.append( InstallerFile(installer.game_slug, installer_file_id, { "url": link, "filename": filename or file.filename or "setup.zip", "downloader": Downloader(link, None, overwrite=True, cookies=self.load_cookies()), }) ) for extra in extras: if str(extra["id"]) not in selected_extras: continue link = self.get_download_link(extra["id"], key) files.append( InstallerFile(installer.game_slug, str(extra["id"]), { "url": link, "filename": extra["filename"], "downloader": Downloader(link, None, overwrite=True, cookies=self.load_cookies()), }) ) return files def get_patch_files(self, installer, installer_file_id): """Similar to get_installer_files but for patches""" # No really, it is the same! so we just call get_installer_files return self.get_installer_files(installer, installer_file_id, []) def get_file_weight(self, name, demo): if name.endswith(".rpm"): return 0xFF # Not supported as an extractor weight = 0x0 if name.endswith(".deb"): weight |= 0x01 if linux.LINUX_SYSTEM.is_64_bit: if "386" in name or "32" in name: weight |= 0x08 else: if "64" in name: weight |= 0x10 if demo: weight |= 0x40 return weight def _rfc3999_to_timestamp(self, _s): # Python does ootb not fully comply with RFC3999; Cut after seconds return datetime.datetime.fromisoformat(_s[:_s.rfind(".")]).timestamp() lutris-0.5.14/lutris/services/lutris.py000066400000000000000000000157471451435154700202130ustar00rootroot00000000000000import json import os import urllib.parse from gettext import gettext as _ from gi.repository import Gio from lutris import settings from lutris.api import get_api_games, get_game_installers, read_api_key from lutris.database.games import get_games from lutris.database.services import ServiceGameCollection from lutris.gui import dialogs from lutris.gui.views.media_loader import download_media from lutris.services.base import LutrisBanner, LutrisCoverart, LutrisCoverartMedium, LutrisIcon, OnlineService from lutris.services.service_game import ServiceGame from lutris.util import http from lutris.util.log import logger class LutrisGame(ServiceGame): """Service game created from the Lutris API""" service = "lutris" @classmethod def new_from_api(cls, api_payload): """Create an instance of LutrisGame from the API response""" service_game = LutrisGame() service_game.appid = api_payload['slug'] service_game.slug = api_payload['slug'] service_game.name = api_payload['name'] service_game.details = json.dumps(api_payload) return service_game class LutrisService(OnlineService): """Service for Lutris games""" id = "lutris" name = _("Lutris") icon = "lutris" online = True medias = { "icon": LutrisIcon, "banner": LutrisBanner, "coverart_med": LutrisCoverartMedium, "coverart_big": LutrisCoverart, } default_format = "banner" api_url = settings.SITE_URL + "/api" login_url = settings.SITE_URL + "/api/accounts/token" cache_path = os.path.join(settings.CACHE_DIR, "lutris") token_path = os.path.join(settings.CACHE_DIR, "auth-token") @property def credential_files(self): """Return a list of all files used for authentication""" return [self.token_path] def match_games(self): """Matching lutris games is much simpler... No API call needed.""" service_games = { str(game["appid"]): game for game in ServiceGameCollection.get_for_service(self.id) } for lutris_game in get_games(): self.match_game(service_games.get(lutris_game["slug"]), lutris_game) def is_connected(self): """Is the service connected?""" return self.is_authenticated() def login(self, parent=None): """Connect to Lutris""" login_dialog = dialogs.ClientLoginDialog(parent=parent) login_dialog.connect("connected", self.on_connect_success) def on_connect_success(self, _widget, _username): """Handles connection success""" self.emit("service-login") def get_library(self): """Return the remote library as a list of dicts.""" credentials = read_api_key() if not credentials: return [] url = settings.SITE_URL + "/api/games/library/%s" % urllib.parse.quote(credentials["username"]) request = http.Request(url, headers={"Authorization": "Token " + credentials["token"]}) try: response = request.get() except http.HTTPError as ex: logger.error("Unable to load library: %s", ex) return [] response_data = response.json if response_data: return response_data["games"] return [] def load(self): lutris_games = self.get_library() logger.debug("Loaded %s games from Lutris library", len(lutris_games)) for game in lutris_games: lutris_game = LutrisGame.new_from_api(game) lutris_game.save() logger.debug("Matching with already installed games") self.match_games() logger.debug("Lutris games loaded") return lutris_games def install(self, db_game): if isinstance(db_game, dict): slug = db_game["slug"] else: slug = db_game installers = get_game_installers(slug) if not installers: raise RuntimeError(_("Lutris has no installers for %s. Try using a different service instead.") % slug) application = Gio.Application.get_default() application.show_installer_window(installers) def get_game_platforms(self, db_game): details = db_game.get("details") if details: platforms = json.loads(details).get("platforms") if platforms is not None: return [p.get("name") for p in platforms] return None def download_lutris_media(slug): """Download all media types for a single lutris game""" url = settings.SITE_URL + "/api/games/%s" % slug request = http.Request(url) try: response = request.get() except http.HTTPError as ex: logger.debug("Unable to load %s: %s", slug, ex) return response_data = response.json icon_url = response_data.get("icon_url") if icon_url: download_media({slug: icon_url}, LutrisIcon()) banner_url = response_data.get("banner_url") if banner_url: download_media({slug: banner_url}, LutrisBanner()) cover_url = response_data.get("coverart") if cover_url: download_media({slug: cover_url}, LutrisCoverart()) def sync_media(): """Downlad all missing media""" banners_available = {fn.split(".")[0] for fn in os.listdir(settings.BANNER_PATH)} icons_available = { fn.split(".")[0].replace("lutris_", "") for fn in os.listdir(settings.ICON_PATH) if fn.startswith("lutris_") } covers_available = {fn.split(".")[0] for fn in os.listdir(settings.COVERART_PATH)} complete_games = banners_available.intersection(icons_available).intersection(covers_available) all_slugs = {game["slug"] for game in get_games()} slugs = all_slugs - complete_games if not slugs: return games = get_api_games(list(slugs)) alias_map = {} api_slugs = set() for game in games: api_slugs.add(game["slug"]) for alias in game["aliases"]: if alias["slug"] in slugs: alias_map[game["slug"]] = alias["slug"] alias_slugs = set(alias_map.values()) used_alias_slugs = alias_slugs - api_slugs for alias_slug in used_alias_slugs: for game in games: if alias_slug in [alias["slug"] for alias in game["aliases"]]: game["slug"] = alias_map[game["slug"]] continue banner_urls = { game["slug"]: game["banner_url"] for game in games if game["slug"] not in banners_available and game["banner_url"] } icon_urls = { game["slug"]: game["icon_url"] for game in games if game["slug"] not in icons_available and game["icon_url"] } cover_urls = { game["slug"]: game["coverart"] for game in games if game["slug"] not in covers_available and game["coverart"] } logger.debug( "Syncing %s banners, %s icons and %s covers", len(banner_urls), len(icon_urls), len(cover_urls) ) download_media(banner_urls, LutrisBanner()) download_media(icon_urls, LutrisIcon()) download_media(cover_urls, LutrisCoverart()) lutris-0.5.14/lutris/services/mame.py000066400000000000000000000003521451435154700175720ustar00rootroot00000000000000"""MAME service Not ready yet""" from gettext import gettext as _ from lutris.services.base import BaseService class MAMEService(BaseService): """Service class for MAME""" id = "mame" name = _("MAME") icon = "mame" lutris-0.5.14/lutris/services/origin.py000066400000000000000000000351301451435154700201440ustar00rootroot00000000000000"""EA Origin service.""" import json import os import random import ssl import urllib.parse from gettext import gettext as _ from xml.etree import ElementTree import requests import urllib3 from gi.repository import Gio from lutris import settings from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_game, get_game_by_field from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.installer import get_installers from lutris.services.base import OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util.log import logger from lutris.util.strings import slugify SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION = 1 << 18 class OriginLauncher: manifests_paths = "ProgramData/Origin/LocalContent" 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 directory in %s", manifests_path) return for game_folder in os.listdir(manifests_path): for manifest in os.listdir(os.path.join(manifests_path, game_folder)): if not manifest.endswith(".mfst"): continue with open(os.path.join(manifests_path, game_folder, manifest), encoding="utf-8") as manifest_file: manifest_content = manifest_file.read() parsed_url = urllib.parse.urlparse(manifest_content) parsed_data = dict(urllib.parse.parse_qsl(parsed_url.query)) yield parsed_data class OriginPackArtSmall(ServiceMedia): service = "origin" file_pattern = "%s.jpg" file_format = "jpeg" size = (63, 89) dest_path = os.path.join(settings.CACHE_DIR, "origin/pack-art-small") api_field = "packArtSmall" def get_media_url(self, details): return details["imageServer"] + details["i18n"][self.api_field] class OriginPackArtMedium(OriginPackArtSmall): size = (142, 200) dest_path = os.path.join(settings.CACHE_DIR, "origin/pack-art-medium") api_field = "packArtMedium" class OriginPackArtLarge(OriginPackArtSmall): size = (231, 326) dest_path = os.path.join(settings.CACHE_DIR, "origin/pack-art-large") api_field = "packArtLarge" class OriginGame(ServiceGame): service = "origin" @classmethod def new_from_api(cls, offer): origin_game = OriginGame() origin_game.appid = offer["offerId"] origin_game.slug = offer["gameNameFacetKey"] origin_game.name = offer["i18n"]["displayName"] origin_game.details = json.dumps(offer) return origin_game class LegacyRenegotiationHTTPAdapter(requests.adapters.HTTPAdapter): """Allow insecure SSL/TLS protocol renegotiation in an HTTP request. By default, OpenSSL v3 expects that servers support RFC 5746. Unfortunately, accounts.ea.com does not support this TLS extension (from 2010!), causing OpenSSL to refuse to connect. This `requests` HTTP Adapter configures OpenSSL to allow "unsafe legacy renegotiation", allowing EA Origin to connect. This is only intended as a temporary workaround, and should be removed as soon as accounts.ea.com is updated to support RFC 5746. Using this adapter will reduce the security of the connection. However, the impact should be relatively minimal this is only used to connect to EA services. See CVE-2009-3555 for more details. See #4235 for more information. """ def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs): """Override the default PoolManager to allow insecure renegotiation.""" # Based off of the default function from `requests`. self._pool_connections = connections self._pool_maxsize = maxsize self._pool_block = block ssl_context = ssl.create_default_context() ssl_context.options |= SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION self.poolmanager = urllib3.PoolManager( num_pools=connections, maxsize=maxsize, block=block, strict=True, ssl_context=ssl_context, **pool_kwargs, ) class OriginService(OnlineService): """Service class for EA Origin""" id = "origin" name = _("Origin") description = _("Deprecated, use EA App") icon = "origin" client_installer = "origin" runner = "wine" online = True medias = { "packArtSmall": OriginPackArtSmall, "packArtMedium": OriginPackArtMedium, "packArtLarge": OriginPackArtLarge, } default_format = "packArtMedium" cache_path = os.path.join(settings.CACHE_DIR, "origin/cache/") cookies_path = os.path.join(settings.CACHE_DIR, "origin/cookies") token_path = os.path.join(settings.CACHE_DIR, "origin/auth_token") redirect_uri = "https://www.origin.com/views/login.html" login_url = ( "https://accounts.ea.com/connect/auth" "?response_type=code&client_id=ORIGIN_SPA_ID&display=originXWeb/login" "&locale=en_US&release_type=prod" "&redirect_uri=%s" ) % redirect_uri login_user_agent = "Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0 QtWebEngine/5.8.0" def __init__(self): super().__init__() self.session = requests.session() self.session.mount("https://", LegacyRenegotiationHTTPAdapter()) self.access_token = self.load_access_token() @property def api_url(self): return "https://api%s.origin.com" % random.randint(1, 4) def run(self): db_game = get_game_by_field(self.client_installer, "slug") game = Game(db_game["id"]) game.emit("game-launch") def is_launchable(self): return get_game_by_field(self.client_installer, "slug") def is_connected(self): return bool(self.access_token) def login_callback(self, url): self.fetch_access_token() self.emit("service-login") def fetch_access_token(self): token_data = self.get_access_token() if not token_data: raise RuntimeError("Failed to get access token") with open(self.token_path, "w", encoding='utf-8') as token_file: token_file.write(json.dumps(token_data, indent=2)) self.access_token = self.load_access_token() def load_access_token(self): if not os.path.exists(self.token_path): return "" with open(self.token_path, encoding="utf-8") as token_file: token_data = json.load(token_file) return token_data.get("access_token", "") def get_access_token(self): """Request an access token from EA""" response = self.session.get( "https://accounts.ea.com/connect/auth", params={ "client_id": "ORIGIN_JS_SDK", "response_type": "token", "redirect_uri": "nucleus:rest", "prompt": "none" }, cookies=self.load_cookies() ) response.raise_for_status() token_data = response.json() return token_data def _request_identity(self): response = self.session.get( "https://gateway.ea.com/proxy/identity/pids/me", cookies=self.load_cookies(), headers=self.get_auth_headers() ) return response.json() def get_identity(self): """Request the user info""" identity_data = self._request_identity() if identity_data.get('error') == "invalid_access_token": logger.warning("Refreshing Origin access token") self.fetch_access_token() identity_data = self._request_identity() elif identity_data.get("error"): raise RuntimeError( "%s (Error code: %s)" % (identity_data["error"], identity_data["error_number"]) ) if 'error' in identity_data: raise RuntimeError(identity_data["error"]) try: user_id = identity_data["pid"]["pidId"] except KeyError: logger.error("Can't read user ID from %s", identity_data) raise persona_id_response = self.session.get( "{}/atom/users?userIds={}".format(self.api_url, user_id), headers=self.get_auth_headers() ) content = persona_id_response.text origin_account_info = ElementTree.fromstring(content) persona_id = origin_account_info.find("user").find("personaId").text user_name = origin_account_info.find("user").find("EAID").text return str(user_id), str(persona_id), str(user_name) def load(self): user_id, _persona_id, _user_name = self.get_identity() games = self.get_library(user_id) logger.info("Retrieved %s games from Origin library", len(games)) origin_games = [] for game in games: origin_game = OriginGame.new_from_api(game) origin_game.save() origin_games.append(origin_game) return origin_games def get_library(self, user_id): """Load Origin library""" offers = [] for entitlement in self.get_entitlements(user_id): if entitlement["offerType"] != "basegame": continue offer_id = entitlement["offerId"] offer = self.get_offer(offer_id) offers.append(offer) return offers def get_offer(self, offer_id): """Load offer details from Origin""" url = "{}/ecommerce2/public/supercat/{}/{}".format(self.api_url, offer_id, "en_US") response = self.session.get(url, headers=self.get_auth_headers()) return response.json() def get_entitlements(self, user_id): """Request the user's entitlements""" url = "%s/ecommerce2/consolidatedentitlements/%s?machine_hash=1" % ( self.api_url, user_id ) headers = self.get_auth_headers() headers["Accept"] = "application/vnd.origin.v3+json; x-cache/force-write" response = self.session.get(url, headers=headers) data = response.json() return data["entitlements"] def get_auth_headers(self): """Return headers needed to authenticate HTTP requests""" if not self.access_token: raise RuntimeError("User not authenticated to Origin") return { "Authorization": "Bearer %s" % self.access_token, "AuthToken": self.access_token, "X-AuthToken": self.access_token } def add_installed_games(self): origin_game = get_game_by_field("origin", "slug") if not origin_game: logger.error("Origin is not installed") origin_prefix = origin_game["directory"].split("drive_c")[0] if not os.path.exists(os.path.join(origin_prefix, "drive_c")): logger.error("Invalid install of Origin at %s", origin_prefix) return origin_launcher = OriginLauncher(origin_prefix) installed_games = 0 for manifest in origin_launcher.iter_manifests(): self.install_from_origin(origin_game, manifest) installed_games += 1 logger.debug("Installed %s Origin games", installed_games) def install_from_origin(self, origin_game, manifest): if "id" not in manifest: return offer_id = manifest["id"].split("@")[0] logger.debug("Installing Origin game %s", offer_id) service_game = ServiceGameCollection.get_game("origin", offer_id) if not service_game: logger.error("Aborting install, %s is not present in the game library.", offer_id) return lutris_game_id = slugify(service_game["name"]) + "-" + self.id existing_game = get_game_by_field(lutris_game_id, "installer_slug") if existing_game: return game_config = LutrisConfig(game_config_id=origin_game["configpath"]).game_level game_config["game"]["args"] = get_launch_arguments(manifest["id"]) configpath = write_game_config(lutris_game_id, game_config) game_id = add_game( name=service_game["name"], runner=origin_game["runner"], slug=slugify(service_game["name"]), directory=origin_game["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=offer_id, ) return game_id def generate_installer(self, db_game, origin_db_game): origin_game = Game(origin_db_game["id"]) origin_exe = origin_game.config.game_config["exe"] if not os.path.isabs(origin_exe): origin_exe = os.path.join(origin_game.config.game_config["prefix"], origin_exe) return { "name": db_game["name"], "version": self.name, "slug": slugify(db_game["name"]) + "-" + self.id, "game_slug": slugify(db_game["name"]), "runner": self.runner, "appid": db_game["appid"], "script": { "requires": self.client_installer, "game": { "args": get_launch_arguments(db_game["appid"]), }, "installer": [ {"task": { "name": "wineexec", "executable": origin_exe, "args": get_launch_arguments(db_game["appid"], "download"), "prefix": origin_game.config.game_config["prefix"], "description": ( "Origin will now open and install %s." % db_game["name"] ) }} ] } } def install(self, db_game): origin_game = get_game_by_field(self.client_installer, "slug") application = Gio.Application.get_default() if not origin_game or not origin_game["installed"]: logger.warning("Installing the Origin client") installers = get_installers(game_slug=self.client_installer) application.show_installer_window(installers) else: application.show_installer_window( [self.generate_installer(db_game, origin_game)], service=self, appid=db_game["appid"] ) def get_launch_arguments(offer_id, action="launch"): if action == "launch": return "origin2://game/launch?offerIds=%s&autoDownload=1" % offer_id if action == "download": return "origin2://game/download?offerId=%s" % offer_id lutris-0.5.14/lutris/services/scummvm.py000066400000000000000000000042011451435154700203370ustar00rootroot00000000000000import json import os import re from configparser import ConfigParser from gettext import gettext as _ from lutris import settings from lutris.services.base import BaseService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import system from lutris.util.strings import slugify SCUMMVM_CONFIG_FILE = os.path.join(os.path.expanduser("~/.config/scummvm"), "scummvm.ini") # Dummy banner. Maybe the download from lutris should be implemented at this place class ScummvmBanner(ServiceMedia): service = "scummvm" source = "local" size = (96, 32) file_pattern = "%s.png" file_format = "jpeg" dest_path = settings.CACHE_DIR class ScummvmService(BaseService): id = "scummvm" icon = "scummvm" name = _("ScummVM") local = True medias = { "icon": ScummvmBanner } def load(self): if not system.path_exists(SCUMMVM_CONFIG_FILE): return config = ConfigParser() config.read(SCUMMVM_CONFIG_FILE) config_sections = config.sections() for section in config_sections: if section == "scummvm": continue game = ScummvmGame() game.name = config[section]["description"] game.slug = slugify(re.split(r" \(.*\)$", game.name)[0]) game.appid = section game.runner = "scummvm" game.lutris_slug = game.slug game.details = json.dumps({ "path": config[section]["path"] }) game.save() def generate_installer(self, db_game): details = json.loads(db_game["details"]) return { "name": db_game["name"], "version": "ScummVM", "slug": db_game["slug"], "game_slug": slugify(db_game["lutris_slug"]), "runner": "scummvm", "script": { "game": { "game_id": db_game["appid"], "path": details["path"], } } } class ScummvmGame(ServiceGame): service = "scummvm" runner = "scummvm" lutris-0.5.14/lutris/services/service_game.py000066400000000000000000000027731451435154700213150ustar00rootroot00000000000000"""Service game module""" from lutris import settings from lutris.database import sql from lutris.database.services import ServiceGameCollection from lutris.services.service_media import ServiceMedia PGA_DB = settings.PGA_DB class ServiceGame: """Representation of a game from a 3rd party service""" service = NotImplemented installer_slug = NotImplemented medias = (ServiceMedia, ) def __init__(self): self.appid = None # External ID of the game on the 3rd party service self.game_id = None # Internal Lutris ID self.runner = None # Name of the runner self.name = None # Name self.slug = None # Game slug self.lutris_slug = None # Slug used by the lutris website self.logo = None # Game logo self.icon = None # Game icon self.details = None # Additional details for the game def save(self): """Save this game to database""" game_data = { "service": self.service, "appid": self.appid, "name": self.name, "slug": self.slug, "lutris_slug": self.lutris_slug, "icon": self.icon, "logo": self.logo, "details": str(self.details), } existing_game = ServiceGameCollection.get_game(self.service, self.appid) if existing_game: sql.db_update(PGA_DB, "service_games", game_data, {"id": existing_game["id"]}) else: sql.db_insert(PGA_DB, "service_games", game_data) lutris-0.5.14/lutris/services/service_media.py000066400000000000000000000066411451435154700214610ustar00rootroot00000000000000import json import os import random import time from lutris import settings from lutris.database.services import ServiceGameCollection from lutris.util import system from lutris.util.http import HTTPError, download_file from lutris.util.log import logger PGA_DB = settings.PGA_DB class ServiceMedia: """Information about the service's media format""" service = NotImplemented size = NotImplemented source = "remote" # set to local if the files don't need to be downloaded visible = True # This media should be displayed as an option in the UI small_size = None dest_path = None file_pattern = NotImplemented file_format = NotImplemented api_field = NotImplemented url_pattern = "%s" def __init__(self): if self.dest_path and not system.path_exists(self.dest_path): os.makedirs(self.dest_path) def get_filename(self, slug): return self.file_pattern % slug def get_media_path(self, slug): """Return the absolute path of a local media file""" return os.path.join(self.dest_path, self.get_filename(slug)) def exists(self, slug): """Whether the icon for the specified slug exists locally""" return system.path_exists(self.get_media_path(slug)) def get_media_url(self, details): if self.api_field not in details: logger.warning("No field '%s' in API game %s", self.api_field, details) return if not details[self.api_field]: return return self.url_pattern % details[self.api_field] def get_media_urls(self): """Return URLs for icons and logos from a service""" if self.source == "local": return {} service_games = ServiceGameCollection.get_for_service(self.service) medias = {} for game in service_games: if not game["details"]: continue details = json.loads(game["details"]) media_url = self.get_media_url(details) if not media_url: continue medias[game["slug"]] = media_url return medias def download(self, slug, url): """Downloads the banner if not present""" if not url: return cache_path = os.path.join(self.dest_path, self.get_filename(slug)) if system.path_exists(cache_path, exclude_empty=True): return if system.path_exists(cache_path): cache_stats = os.stat(cache_path) # Empty files have a life time between 1 and 2 weeks, retry them after if time.time() - cache_stats.st_mtime < 3600 * 24 * random.choice(range(7, 15)): return cache_path os.unlink(cache_path) try: return download_file(url, cache_path, raise_errors=True) except HTTPError as ex: logger.error("Failed to download %s: %s", url, ex) @property def custom_media_storage_size(self): """The size this media is stored in when customized; we accept whatever we get when we download the media, however.""" return self.size @property def config_ui_size(self): """The size this media should be shown at when in the configuration UI.""" return self.size def update_desktop(self): """Update the desktop, if this media type appears there. Most don't.""" def render(self): """Used if the media requires extra processing""" lutris-0.5.14/lutris/services/steam.py000066400000000000000000000204241451435154700177660ustar00rootroot00000000000000"""Steam service""" import json import os from collections import defaultdict from gettext import gettext as _ from gi.repository import Gio from lutris import settings from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_game, get_game_by_field, get_games from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.installer.installer_file import InstallerFile from lutris.services.base import BaseService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util.log import logger from lutris.util.steam.appmanifest import AppManifest, get_appmanifests from lutris.util.steam.config import get_active_steamid64, get_steam_library, get_steamapps_dirs from lutris.util.strings import slugify class SteamBanner(ServiceMedia): service = "steam" size = (184, 69) dest_path = os.path.join(settings.CACHE_DIR, "steam/banners") file_pattern = "%s.jpg" file_format = "jpeg" api_field = "appid" url_pattern = "http://cdn.akamai.steamstatic.com/steam/apps/%s/capsule_184x69.jpg" class SteamCover(ServiceMedia): service = "steam" size = (200, 300) dest_path = os.path.join(settings.CACHE_DIR, "steam/covers") file_pattern = "%s.jpg" file_format = "jpeg" api_field = "appid" url_pattern = "http://cdn.steamstatic.com/steam/apps/%s/library_600x900.jpg" class SteamBannerLarge(ServiceMedia): service = "steam" size = (460, 215) dest_path = os.path.join(settings.CACHE_DIR, "steam/header") file_pattern = "%s.jpg" file_format = "jpeg" api_field = "appid" url_pattern = "https://cdn.cloudflare.steamstatic.com/steam/apps/%s/header.jpg" class SteamGame(ServiceGame): """ServiceGame for Steam games""" service = "steam" installer_slug = "steam" runner = "steam" @classmethod def new_from_steam_game(cls, steam_game, game_id=None): """Return a Steam game instance from an AppManifest""" game = cls() game.appid = steam_game["appid"] game.game_id = steam_game["appid"] game.name = steam_game["name"] game.slug = slugify(steam_game["name"]) game.runner = cls.runner game.details = json.dumps(steam_game) return game class SteamService(BaseService): id = "steam" name = _("Steam") icon = "steam-client" medias = { "banner": SteamBanner, "banner_large": SteamBannerLarge, "cover": SteamCover, } default_format = "banner" runner = "steam" excluded_appids = [ "221410", # Steam for Linux "228980", # Steamworks Common Redistributables "1070560", # Steam Linux Runtime ] game_class = SteamGame def load(self): """Return importable Steam games""" steamid = get_active_steamid64() if not steamid: logger.error("Unable to find SteamID from Steam config") return steam_games = get_steam_library(steamid) if not steam_games: raise RuntimeError(_("Failed to load games. Check that your profile is set to public during the sync.")) for steam_game in steam_games: if steam_game["appid"] in self.excluded_appids: continue game = self.game_class.new_from_steam_game(steam_game) game.save() self.match_games() return steam_games def get_installer_files(self, installer, installer_file_id, _selected_extras): steam_uri = "$STEAM:%s:." appid = str(installer.script["game"]["appid"]) return [ InstallerFile(installer.game_slug, "steam_game", { "url": steam_uri % appid, "filename": appid }) ] def install_from_steam(self, manifest): """Create a new Lutris game based on an existing Steam install""" if not manifest.is_installed(): return appid = manifest.steamid if appid in self.excluded_appids: return service_game = ServiceGameCollection.get_game(self.id, appid) if not service_game: return lutris_game_id = "%s-%s" % (self.id, appid) existing_game = get_game_by_field(lutris_game_id, "installer_slug") if existing_game: return game_config = LutrisConfig().game_level game_config["game"]["appid"] = appid configpath = write_game_config(lutris_game_id, game_config) game_id = add_game( name=service_game["name"], runner="steam", slug=slugify(service_game["name"]), installed=1, installer_slug=lutris_game_id, configpath=configpath, platform="Linux", service=self.id, service_id=appid, ) return game_id @property def steamapps_paths(self): return get_steamapps_dirs() def add_installed_games(self): """Syncs installed Steam games with Lutris""" stats = {"installed": 0, "removed": 0, "deduped": 0, "paths": []} installed_appids = [] for steamapps_path in self.steamapps_paths: for appmanifest_file in get_appmanifests(steamapps_path): if steamapps_path not in stats["paths"]: stats["paths"].append(steamapps_path) app_manifest_path = os.path.join(steamapps_path, appmanifest_file) app_manifest = AppManifest(app_manifest_path) installed_appids.append(app_manifest.steamid) self.install_from_steam(app_manifest) stats["installed"] += 1 if stats["paths"]: logger.debug("%s Steam games detected and installed", stats["installed"]) logger.debug("Games found in: %s", ", ".join(stats["paths"])) else: logger.debug("No Steam folder found with games") db_games = get_games(filters={"runner": "steam"}) for db_game in db_games: steam_game = Game(db_game["id"]) try: appid = steam_game.config.game_level["game"]["appid"] except KeyError: logger.warning("Steam game %s has no AppID") continue if appid not in installed_appids: steam_game.remove(no_signal=True) stats["removed"] += 1 logger.debug("%s Steam games removed", stats["removed"]) db_appids = defaultdict(list) db_games = get_games(filters={"service": "steam"}) for db_game in db_games: db_appids[db_game["service_id"]].append(db_game["id"]) for appid in db_appids: game_ids = db_appids[appid] if len(game_ids) == 1: continue for game_id in game_ids: steam_game = Game(game_id) if not steam_game.playtime: # Unsafe to emit a signal from a worker thread! steam_game.remove(no_signal=True) steam_game.delete(no_signal=True) stats["deduped"] += 1 logger.debug("%s Steam games deduplicated", stats["deduped"]) def generate_installer(self, db_game): """Generate a basic Steam installer""" return { "name": db_game["name"], "version": self.name, "slug": slugify(db_game["name"]) + "-" + self.id, "game_slug": slugify(db_game["name"]), "runner": self.runner, "appid": db_game["appid"], "script": { "game": {"appid": db_game["appid"]} } } def install(self, db_game): appid = db_game["appid"] db_games = get_games(filters={"service_id": appid, "installed": "1", "service": self.id}) existing_game = self.match_existing_game(db_games, appid) if existing_game: logger.debug("Found steam game: %s", existing_game) game = Game(existing_game.id) game.save() return service_installers = self.get_installers_from_api(appid) if not service_installers: service_installers = [self.generate_installer(db_game)] application = Gio.Application.get_default() application.show_installer_window(service_installers, service=self, appid=appid) lutris-0.5.14/lutris/services/steamwindows.py000066400000000000000000000062331451435154700214030ustar00rootroot00000000000000import os from gettext import gettext as _ from gi.repository import Gio from lutris.database.games import get_game_by_field, get_games from lutris.game import Game from lutris.installer import get_installers from lutris.services.steam import SteamGame, SteamService from lutris.util import system from lutris.util.log import logger from lutris.util.strings import slugify STEAM_INSTALLER = "steam-wine" # Lutris installer used to setup the Steam client class SteamWindowsGame(SteamGame): service = "steamwindows" installer_slug = "steamwindows" runner = "wine" class SteamWindowsService(SteamService): id = "steamwindows" name = _("Steam for Windows") description = _("Use only for the rare games or mods requiring the Windows version of Steam") runner = "wine" game_class = SteamWindowsGame client_installer = "steam-wine" def generate_installer(self, db_game, steam_game): """Generate a basic Steam installer""" return { "name": db_game["name"], "version": self.name, "slug": slugify(db_game["name"]) + "-" + self.id, "game_slug": slugify(db_game["name"]), "runner": self.runner, "appid": db_game["appid"], "script": { "requires": self.client_installer, "game": { "exe": steam_game.config.game_config["exe"], "args": "-no-cef-sandbox -applaunch %s" % db_game["appid"], "prefix": steam_game.config.game_config["prefix"], } } } def get_steam(self): db_entry = get_game_by_field(self.client_installer, "installer_slug") if db_entry: return Game(db_entry["id"]) def install(self, db_game): steam_game = self.get_steam() if not steam_game: installers = get_installers( game_slug=self.client_installer, ) appid = None else: installers = [self.generate_installer(db_game, steam_game)] appid = db_game["appid"] db_games = get_games(filters={"service_id": appid, "installed": "1", "service": self.id}) existing_game = self.match_existing_game(db_games, appid) if existing_game: logger.debug("Found steam game: %s", existing_game) game = Game(existing_game.id) game.save() return application = Gio.Application.get_default() application.show_installer_window( installers, service=self, appid=appid ) @property def steamapps_paths(self): """Return steamapps paths""" steam_game = self.get_steam() if not steam_game: return [] dirs = [] steam_path = steam_game.config.game_config["exe"] steam_data_dir = os.path.dirname(steam_path) if steam_data_dir: main_dir = os.path.join(steam_data_dir, "steamapps") main_dir = system.fix_path_case(main_dir) if os.path.isdir(main_dir): dirs.append(os.path.abspath(main_dir)) return dirs lutris-0.5.14/lutris/services/ubisoft.py000066400000000000000000000262601451435154700203340ustar00rootroot00000000000000"""Ubisoft Connect service""" import json import os import shutil from gettext import gettext as _ from urllib.parse import unquote from gi.repository import Gio from lutris import settings from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_game, get_game_by_field, update_existing from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.installer import get_installers from lutris.services.base import OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util.log import logger from lutris.util.strings import slugify from lutris.util.ubisoft import consts from lutris.util.ubisoft.client import UbisoftConnectClient from lutris.util.ubisoft.helpers import get_ubisoft_registry from lutris.util.ubisoft.parser import UbisoftParser from lutris.util.wine.prefix import WinePrefixManager class UbisoftCover(ServiceMedia): """Ubisoft connect cover art""" service = "ubisoft" size = (160, 186) dest_path = os.path.join(settings.CACHE_DIR, "ubisoft/covers") file_pattern = "%s.jpg" file_format = "jpeg" api_field = "id" url_pattern = "https://ubiservices.cdn.ubi.com/%s/spaceCardAsset/boxArt_mobile.jpg?imwidth=320" def get_media_url(self, details): if self.api_field in details: return super().get_media_url(details) return details["thumbImage"] def download(self, slug, url): if url.startswith("http"): return super().download(slug, url) if not url.endswith(".jpg"): return ubi_game = get_game_by_field("ubisoft-connect", "slug") if not ubi_game: return base_dir = ubi_game["directory"] asset_file = os.path.join( base_dir, "drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher/cache/assets", url ) cache_path = os.path.join(self.dest_path, self.get_filename(slug)) if os.path.exists(asset_file): shutil.copy(asset_file, cache_path) else: logger.warning("No thumbnail in %s", asset_file) class UbisoftGame(ServiceGame): """Service game for games from Ubisoft connect""" service = "ubisoft" @classmethod def new_from_api(cls, payload): """Convert an Ubisoft game to a service game""" service_game = cls() service_game.appid = payload["spaceId"] or payload["installId"] service_game.slug = slugify(payload["name"]) service_game.name = payload["name"] service_game.details = json.dumps(payload) return service_game class UbisoftConnectService(OnlineService): """Service class for Ubisoft Connect""" id = "ubisoft" name = _("Ubisoft Connect") icon = "ubisoft" runner = "wine" client_installer = "ubisoft-connect" browser_size = (460, 690) login_window_width = 460 login_window_height = 690 cookies_path = os.path.join(settings.CACHE_DIR, "ubisoft/.auth") token_path = os.path.join(settings.CACHE_DIR, "ubisoft/.token") cache_path = os.path.join(settings.CACHE_DIR, "ubisoft/library/") login_url = consts.LOGIN_URL redirect_uri = "https://connect.ubisoft.com/change_domain/" scripts = { "https://connect.ubisoft.com/ready": ( 'window.location.replace("https://connect.ubisoft.com/change_domain/");' ), "https://connect.ubisoft.com/change_domain/": ( 'window.location.replace(localStorage.getItem("PRODloginData") +","+ ' 'localStorage.getItem("PRODrememberMe") +"," + localStorage.getItem("PRODlastProfile"));' ) } medias = { "cover": UbisoftCover, } default_format = "cover" def __init__(self): super().__init__() self.client = UbisoftConnectClient(self) def auth_lost(self): self.emit("service-logout") def login_callback(self, credentials): """Called after the user has logged in successfully""" url = credentials[len("https://connect.ubisoft.com/change_domain/"):] unquoted_url = unquote(url) storage_jsons = json.loads("[" + unquoted_url + "]") user_data = self.client.authorise_with_local_storage(storage_jsons) self.client.set_auth_lost_callback(self.auth_lost) self.emit("service-login") return (user_data['userId'], user_data['username']) def is_connected(self): return self.is_authenticated() def get_configurations(self): ubi_game = get_game_by_field("ubisoft-connect", "slug") if not ubi_game: return base_dir = ubi_game["directory"] configurations_path = os.path.join( base_dir, "drive_c/Program Files (x86)/Ubisoft/Ubisoft Game Launcher/" "cache/configuration/configurations" ) if not os.path.exists(configurations_path): return with open(configurations_path, "rb") as config_file: content = config_file.read() return content def load(self): self.client.authorise_with_stored_credentials(self.load_credentials()) response = self.client.get_club_titles() games = response['data']['viewer']['ownedGames'].get('nodes', []) ubi_games = [] for game in games: if "ownedPlatformGroups" in game: is_pc = False for platform_group in game["ownedPlatformGroups"]: for platform in platform_group: if platform["type"] == "PC": is_pc = True if not is_pc: continue ubi_game = UbisoftGame.new_from_api(game) ubi_game.save() ubi_games.append(ubi_game) configuration_data = self.get_configurations() config_parser = UbisoftParser() for game in config_parser.parse_games(configuration_data): ubi_game = UbisoftGame.new_from_api(game) ubi_game.save() ubi_games.append(ubi_game) return ubi_games def store_credentials(self, credentials): if not os.path.exists(os.path.dirname(self.token_path)): os.mkdir(os.path.dirname(self.token_path)) with open(self.token_path, "w", encoding='utf-8') as auth_file: auth_file.write(json.dumps(credentials, indent=2)) def load_credentials(self): with open(self.token_path, encoding="utf-8") as auth_file: credentials = json.load(auth_file) return credentials def install_from_ubisoft(self, ubisoft_connect, game): app_name = game["name"] lutris_game_id = slugify(game["name"]) + "-" + self.id existing_game = get_game_by_field(lutris_game_id, "installer_slug") if existing_game and existing_game["installed"] == 1: logger.debug("Ubisoft Connect game %s is already installed", app_name) return logger.debug("Installing Ubisoft Connect game %s", app_name) game_config = LutrisConfig(game_config_id=ubisoft_connect["configpath"]).game_level details = json.loads(game["details"]) launch_id = details.get("launchId") or details.get("installId") or details.get("spaceId") game_config["game"]["args"] = f"uplay://launch/{launch_id}" configpath = write_game_config(lutris_game_id, game_config) if existing_game: update_existing( id=existing_game["id"], name=game["name"], runner=self.runner, slug=slugify(game["name"]), directory=ubisoft_connect["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=game["appid"], ) return existing_game["id"] game_id = add_game( name=game["name"], runner=self.runner, slug=slugify(game["name"]), directory=ubisoft_connect["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=game["appid"], ) return game_id def add_installed_games(self): ubisoft_connect = get_game_by_field(self.client_installer, "slug") if not ubisoft_connect: logger.warning("Ubisoft Connect not installed") return prefix_path = ubisoft_connect["directory"].split("drive_c")[0] prefix = WinePrefixManager(prefix_path) for game in ServiceGameCollection.get_for_service(self.id): details = json.loads(game["details"]) install_path = get_ubisoft_registry(prefix, details.get("registryPath")) exe = get_ubisoft_registry(prefix, details.get("exe")) if install_path and exe: self.install_from_ubisoft(ubisoft_connect, game) def generate_installer(self, db_game, ubi_db_game): ubisoft_connect = Game(ubi_db_game["id"]) uc_exe = ubisoft_connect.config.game_config["exe"] if not os.path.isabs(uc_exe): uc_exe = os.path.join(ubisoft_connect.config.game_config["prefix"], uc_exe) details = json.loads(db_game["details"]) launch_id = details.get("launchId") or details.get("installId") or details.get("spaceId") install_id = details.get("installId") or details.get("launchId") or details.get("spaceId") return { "name": db_game["name"], "version": self.name, "slug": slugify(db_game["name"]) + "-" + self.id, "game_slug": slugify(db_game["name"]), "runner": self.runner, "appid": db_game["appid"], "script": { "requires": self.client_installer, "game": { "args": f"uplay://launch/{launch_id}", }, "installer": [ {"task": { "name": "wineexec", "executable": uc_exe, "args": f"uplay://install/{install_id}", "prefix": ubisoft_connect.config.game_config["prefix"], "description": ( "Ubisoft will now open and install %s. " "Close Ubisoft Connect to complete the install process." ) % db_game["name"] }} ] } } def install(self, db_game): """Install a game or Ubisoft Connect if not already installed""" ubisoft_connect = get_game_by_field(self.client_installer, "slug") application = Gio.Application.get_default() if not ubisoft_connect or not ubisoft_connect["installed"]: logger.warning("Ubisoft Connect (%s) not installed", self.client_installer) installers = get_installers(game_slug=self.client_installer) application.show_installer_window(installers) else: application.show_installer_window( [self.generate_installer(db_game, ubisoft_connect)], service=self, appid=db_game["appid"] ) lutris-0.5.14/lutris/services/xdg.py000066400000000000000000000125731451435154700174450ustar00rootroot00000000000000"""XDG applications service""" import json import os import re import shlex import subprocess from gettext import gettext as _ from gi.repository import Gio from lutris import settings from lutris.database.games import get_games_where from lutris.services.base import BaseService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import system from lutris.util.log import logger from lutris.util.strings import slugify def get_appid(app): """Get the appid for the game""" try: return os.path.splitext(app.get_id())[0] except UnicodeDecodeError: logger.exception( "Failed to read ID for app %s (non UTF-8 encoding). Reverting to executable name.", app, ) return app.get_executable() class XDGMedia(ServiceMedia): service = "xdg" source = "local" size = (64, 64) dest_path = os.path.join(settings.CACHE_DIR, "xdg/icons") file_pattern = "%s.png" file_format = "png" class XDGService(BaseService): id = "xdg" name = _("Local") icon = "linux" online = False local = True medias = { "icon": XDGMedia } ignored_games = ("lutris", ) ignored_executables = ("lutris", "steam") ignored_categories = ("Emulator", "Development", "Utility") @classmethod def iter_xdg_games(cls): """Iterates through XDG games only""" for app in Gio.AppInfo.get_all(): if cls._is_importable(app): yield app @property def lutris_games(self): """Iterates through Lutris games imported from XDG""" for game in get_games_where(runner=XDGGame.runner, installer_slug=XDGGame.installer_slug, installed=1): yield game @classmethod def _is_importable(cls, app): """Returns whether a XDG game is importable to Lutris""" appid = get_appid(app) executable = app.get_executable() or "" if any( [ app.get_nodisplay() or app.get_is_hidden(), # App is hidden not executable, # Check app has an executable appid.startswith("net.lutris"), # Skip lutris created shortcuts appid.lower() in map(str.lower, cls.ignored_games), # game blacklisted executable.lower() in cls.ignored_executables, # exe blacklisted ] ): return False # must be in Game category categories = app.get_categories() or "" categories = list(filter(None, categories.lower().split(";"))) if "game" not in categories: return False # contains a blacklisted category if bool([category for category in categories if category in map(str.lower, cls.ignored_categories)]): return False return True def match_games(self): """XDG games aren't on the lutris website""" return def load(self): """Return the list of games stored in the XDG menu.""" xdg_games = [XDGGame.new_from_xdg_app(app) for app in self.iter_xdg_games()] for game in xdg_games: game.save() return xdg_games def generate_installer(self, db_game): details = json.loads(db_game["details"]) return { "name": db_game["name"], "version": "XDG", "slug": db_game["slug"], "game_slug": slugify(db_game["name"]), "runner": "linux", "script": { "game": { "exe": details["exe"], "args": details["args"], }, "system": {"disable_runtime": True} } } def get_game_directory(self, installer): """Pull install location from installer""" return os.path.dirname(installer["script"]["game"]["exe"]) class XDGGame(ServiceGame): """XDG game (Linux game with a desktop launcher)""" service = "xdg" runner = "linux" installer_slug = "desktopapp" @staticmethod def get_app_icon(xdg_app): """Return the name of the icon for an XDG app if one if set""" icon = xdg_app.get_icon() if not icon: return "" return icon.to_string() @classmethod def new_from_xdg_app(cls, xdg_app): """Create a service game from a XDG entry""" service_game = cls() service_game.name = xdg_app.get_display_name() service_game.icon = cls.get_app_icon(xdg_app) service_game.appid = get_appid(xdg_app) service_game.slug = cls.get_slug(xdg_app) exe, args = cls.get_command_args(xdg_app) service_game.details = json.dumps({ "exe": exe, "args": args, }) return service_game @staticmethod def get_command_args(app): """Return a tuple with absolute command path and an argument string""" command = shlex.split(app.get_commandline()) # remove %U etc. and change %% to % in arguments args = list(map(lambda arg: re.sub("%[^%]", "", arg).replace("%%", "%"), command[1:])) exe = command[0] if not exe.startswith("/"): exe = system.find_executable(exe) return exe, subprocess.list2cmdline(args) @staticmethod def get_slug(xdg_app): """Get the slug from the game name""" return slugify(xdg_app.get_display_name()) or slugify(get_appid(xdg_app)) lutris-0.5.14/lutris/settings.py000066400000000000000000000104761451435154700167000ustar00rootroot00000000000000"""Internal settings.""" import json import os import sys from gettext import gettext as _ from gi.repository import GLib from lutris import __version__ from lutris.util.log import logger from lutris.util.settings import SettingsIO PROJECT = "Lutris" VERSION = __version__ COPYRIGHT = _("(c) 2009 Lutris Team") AUTHORS = [_("The Lutris team")] # Paths CONFIG_DIR = os.path.join(GLib.get_user_config_dir(), "lutris") CONFIG_FILE = os.path.join(CONFIG_DIR, "lutris.conf") sio = SettingsIO(CONFIG_FILE) DATA_DIR = os.path.join(GLib.get_user_data_dir(), "lutris") RUNNER_DIR = sio.read_setting("runner_dir") or os.path.join(DATA_DIR, "runners") RUNTIME_DIR = sio.read_setting("runtime_dir") or os.path.join(DATA_DIR, "runtime") CACHE_DIR = sio.read_setting("cache_dir") or os.path.join(GLib.get_user_cache_dir(), "lutris") GAME_CONFIG_DIR = os.path.join(CONFIG_DIR, "games") SHADER_CACHE_DIR = os.path.join(CACHE_DIR, "shaders") BANNER_PATH = os.path.join(CACHE_DIR, "banners") COVERART_PATH = os.path.join(CACHE_DIR, "coverart") RUNTIME_VERSIONS_PATH = os.path.join(CACHE_DIR, "versions.json") ICON_PATH = os.path.join(GLib.get_user_data_dir(), "icons", "hicolor", "128x128", "apps") if "nosetests" in sys.argv[0] or "nose2" in sys.argv[0] or "pytest" in sys.argv[0]: PGA_DB = "/tmp/pga.db" else: PGA_DB = sio.read_setting("pga_path") or os.path.join(DATA_DIR, "pga.db") SITE_URL = sio.read_setting("website") or "https://lutris.net" DRIVER_HOWTO_URL = "https://github.com/lutris/docs/blob/master/InstallingDrivers.md" INSTALLER_URL = SITE_URL + "/api/installers/%s" INSTALLER_REVISION_URL = SITE_URL + "/api/installers/game/%s/revisions/%s" GAME_URL = SITE_URL + "/games/%s/" RUNTIME_URL = SITE_URL + "/api/runtimes" STEAM_API_KEY = sio.read_setting("steam_api_key") or "34C9698CEB394AB4401D65927C6B3752" SHOW_MEDIA = os.environ.get("LUTRIS_HIDE_MEDIA") != "1" and sio.read_setting("hide_media") != 'True' DEFAULT_RESOLUTION_WIDTH = sio.read_setting("default_resolution_width", "1280") DEFAULT_RESOLUTION_HEIGHT = sio.read_setting("default_resolution_height", "720") read_setting = sio.read_setting write_setting = sio.write_setting def get_lutris_directory_settings(directory): """Reads the 'lutris.json' file in 'directory' and returns it as a (new) dictionary. The file is missing, unreadable, unparseable, or not a dict, this returns an empty dict instead.""" if directory: path = os.path.join(directory, "lutris.json") try: if os.path.isfile(path): with open(path, "r", encoding='utf-8') as f: json_data = json.load(f) if not isinstance(json_data, dict): logger.error("'%s' does not contain a dict, and will be ignored.", path) return json_data except Exception as ex: logger.exception("Failed to read '%s': %s", path, ex) return {} def set_lutris_directory_settings(directory, settings, merge=True): """Updates the 'lutris.json' file in the 'directory' given. If it does not exist, this method creates it. By default, if it does exist this merges the values of settings into it, but in a shallow way - only the top level entries are merged, not the content any of any sub-dictionaries. If 'merge' is False, this replaces the existing 'lutris.json', so that its settings are lost. This function provides no way to remove a key; you can store nulls instead if appropriate.\ If this 'lutris.json' file can't be updated (say, if 'settings' can't be represented) then this logs the errors, but then returns False.""" path = os.path.join(directory, "lutris.json") if merge and os.path.isfile(path): old_settings = get_lutris_directory_settings(directory) old_settings.update(settings) settings = old_settings # In case settings contains something that can't be made into JSON # we'll save to a temporary file and rename. temp_path = os.path.join(directory, "lutris.json.tmp") try: with open(temp_path, "w", encoding='utf-8') as f: f.write(json.dumps(settings, indent=2)) os.rename(temp_path, path) return True except Exception as ex: logger.exception("Could not update '%s': %s", path, ex) return False finally: if os.path.isfile(temp_path): os.unlink(temp_path) lutris-0.5.14/lutris/startup.py000066400000000000000000000144511451435154700165370ustar00rootroot00000000000000"""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.scanners.lutris import build_path_cache from lutris.services import DEFAULT_SERVICES from lutris.util.display import display_gpu_info, get_gpus_info from lutris.util.graphics import drivers, vkquery from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.system import create_folder, preload_vulkan_gpu_names from lutris.util.wine.dxvk import REQUIRED_VULKAN_API_VERSION def init_dirs(): """Creates Lutris directories""" directories = [ settings.CONFIG_DIR, os.path.join(settings.CONFIG_DIR, "runners"), os.path.join(settings.CONFIG_DIR, "games"), settings.DATA_DIR, os.path.join(settings.DATA_DIR, "covers"), settings.ICON_PATH, os.path.join(settings.CACHE_DIR, "banners"), os.path.join(settings.CACHE_DIR, "coverart"), os.path.join(settings.DATA_DIR, "runners"), os.path.join(settings.DATA_DIR, "lib"), settings.RUNTIME_DIR, settings.CACHE_DIR, settings.SHADER_CACHE_DIR, os.path.join(settings.CACHE_DIR, "installer"), os.path.join(settings.CACHE_DIR, "tmp"), ] for directory in directories: create_folder(directory) def get_drivers(): """Report on the currently running driver""" driver_info = {} if drivers.is_nvidia(): driver_info = drivers.get_nvidia_driver_info() # pylint: disable=logging-format-interpolation logger.info("Using {vendor} drivers {version} for {arch}".format(**driver_info["nvrm"])) gpus = drivers.get_nvidia_gpu_ids() for gpu_id in gpus: gpu_info = drivers.get_nvidia_gpu_info(gpu_id) logger.info("GPU: %s", gpu_info.get("Model")) elif LINUX_SYSTEM.glxinfo: # pylint: disable=no-member 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 } logger.info( "Running %s Mesa driver %s on %s", LINUX_SYSTEM.glxinfo.opengl_vendor, LINUX_SYSTEM.glxinfo.GLX_MESA_query_renderer.version, LINUX_SYSTEM.glxinfo.GLX_MESA_query_renderer.device, ) else: logger.warning("glxinfo is not available on your system, unable to detect driver version") return driver_info 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 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(): """Run all startup checks""" driver_info = get_drivers() gpus_info = get_gpus_info() for gpu_id, gpu_info in gpus_info.items(): display_gpu_info(gpu_id, gpu_info) check_libs() check_vulkan() check_gnome() preload_vulkan_gpu_names(len(gpus_info) > 1) fill_missing_platforms() build_path_cache() return { "drivers": driver_info, "gpus": gpus_info } 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.PGA_DB ) 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.14/lutris/style_manager.py000066400000000000000000000106051451435154700176640ustar00rootroot00000000000000import enum from gi.repository import Gio, GLib, GObject, Gtk from lutris import settings PORTAL_BUS_NAME = "org.freedesktop.portal.Desktop" PORTAL_OBJECT_PATH = "/org/freedesktop/portal/desktop" PORTAL_SETTINGS_INTERFACE = "org.freedesktop.portal.Settings" class ColorScheme(enum.Enum): NO_PREFERENCE = 0 # Default PREFER_DARK = 1 PREFER_LIGHT = 2 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 a system is set to prefer dark mode. """ _color_scheme = ColorScheme.NO_PREFERENCE _dbus_proxy = None _is_config_dark = False _is_dark = False _is_system_dark = False def __init__(self): super().__init__() self.gtksettings = Gtk.Settings.get_default() self.is_config_dark = settings.read_setting("dark_theme", default="false").lower() == "true" 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): 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") def _call_cb(self, obj, result): values = obj.call_finish(result) if values: value = values[0] self.color_scheme = self._read_value(value) else: raise RuntimeError("Could not read color-scheme") 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.color_scheme = self._read_value(value) def _read_value(self, value: int) -> ColorScheme: if value == 1: return ColorScheme.PREFER_DARK if value == 2: return ColorScheme.PREFER_LIGHT return ColorScheme.NO_PREFERENCE @property def is_system_dark(self) -> bool: return self._is_system_dark @is_system_dark.setter # type: ignore def is_system_dark(self, is_system_dark: bool) -> None: if self._is_system_dark == is_system_dark: return self._is_system_dark = is_system_dark self._set_is_dark(self._is_config_dark or is_system_dark) @property def is_config_dark(self) -> bool: 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._set_is_dark(is_config_dark or self._is_system_dark) @GObject.Property(type=bool, default=False, flags=GObject.ParamFlags.READABLE) def is_dark(self) -> bool: return self._is_dark def _set_is_dark(self, is_dark: bool) -> None: 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 ) @property def color_scheme(self) -> ColorScheme: return self._color_scheme @color_scheme.setter # type: ignore def color_scheme(self, color_scheme: ColorScheme) -> None: if self._color_scheme == color_scheme: return self._color_scheme = color_scheme self.is_system_dark = self.color_scheme == ColorScheme.PREFER_DARK lutris-0.5.14/lutris/sysoptions.py000066400000000000000000000545441451435154700172760ustar00rootroot00000000000000"""Options list for system config.""" import os from collections import OrderedDict from gettext import gettext as _ from lutris import runners from lutris.util import linux, system from lutris.util.display import DISPLAY_MANAGER, SCREEN_SAVER_INHIBITOR, USE_DRI_PRIME from lutris.util.system import get_vk_icd_file_sets, get_vulkan_gpu_name def get_resolution_choices(): """Return list of available resolutions as label, value tuples suitable for inclusion in drop-downs. """ resolutions = DISPLAY_MANAGER.get_resolutions() resolution_choices = list(zip(resolutions, resolutions)) resolution_choices.insert(0, (_("Keep current"), "off")) return resolution_choices def get_locale_choices(): """Return list of available locales as label, value tuples suitable for inclusion in drop-downs. """ locales = system.get_locale_list() # adds "(recommended)" string to utf8 locales locales_humanized = locales.copy() for index, locale in enumerate(locales_humanized): if "utf8" in locale: locales_humanized[index] += " " + _("(recommended)") locale_choices = list(zip(locales_humanized, locales)) locale_choices.insert(0, (_("System"), "")) return locale_choices def get_output_choices(): """Return list of outputs for drop-downs""" displays = DISPLAY_MANAGER.get_display_names() output_choices = list(zip(displays, displays)) output_choices.insert(0, (_("Off"), "off")) output_choices.insert(1, (_("Primary"), "primary")) return output_choices def get_output_list(): """Return a list of output with their index. This is used to indicate to SDL 1.2 which monitor to use. """ choices = [(_("Off"), "off")] displays = DISPLAY_MANAGER.get_display_names() for index, output in enumerate(displays): # Display name can't be used because they might not be in the right order # Using DISPLAYS to get the number of connected monitors choices.append((output, str(index))) return choices def get_optirun_choices(): """Return menu choices (label, value) for Optimus""" choices = [(_("Off"), "off")] if system.find_executable("primusrun"): choices.append(("primusrun", "primusrun")) if system.find_executable("optirun"): choices.append(("optirun/virtualgl", "optirun")) if system.find_executable("pvkrun"): choices.append(("primus vk", "pvkrun")) return choices def get_vk_icd_choices(): """Return available Vulkan ICD loaders""" # fallback in case any ICDs don't match a known type icd_file_sets = get_vk_icd_file_sets() intel_files = ":".join(icd_file_sets["intel"]) amdradv_files = ":".join(icd_file_sets["amdradv"]) nvidia_files = ":".join(icd_file_sets["nvidia"]) amdvlk_files = ":".join(icd_file_sets["amdvlk"]) amdvlkpro_files = ":".join(icd_file_sets["amdvlkpro"]) unknown_files = ":".join(icd_file_sets["unknown"]) # default choice should always be blank so the env var gets left as is # This ensures Lutris doesn't change the vulkan loader behavior unless you select # a specific ICD from the list, to avoid surprises choices = [("Unspecified", "")] if intel_files: choices.append(("Intel Open Source (MESA: ANV)", intel_files)) if amdradv_files: choices.append(("AMD RADV Open Source (MESA: RADV)", amdradv_files)) if nvidia_files: choices.append(("Nvidia Proprietary", nvidia_files)) if amdvlk_files: if not amdvlkpro_files: choices.append(("AMDVLK/AMDGPU-PRO Proprietary", amdvlk_files)) else: choices.append(("AMDVLK Open source", amdvlk_files)) if amdvlkpro_files: choices.append(("AMDGPU-PRO Proprietary", amdvlkpro_files)) if unknown_files: choices.append(("Unknown Vendor", unknown_files)) choices = [(prefix + ": " + get_vulkan_gpu_name(files, USE_DRI_PRIME), files) for prefix, files in choices] return choices system_options = [ # pylint: disable=invalid-name { "section": "Lutris", "option": "game_path", "type": "directory_chooser", "label": _("Default installation folder"), "default": os.path.expanduser("~/Games"), "scope": ["runner", "system"], "help": _("The default folder where you install your games.") }, { "section": "Lutris", "option": "disable_runtime", "type": "bool", "label": _("Disable Lutris Runtime"), "default": False, "help": _("The Lutris Runtime loads some libraries before running the " "game, which can cause some incompatibilities in some cases. " "Check this option to disable it."), }, { "section": "Lutris", "option": "prefer_system_libs", "type": "bool", "label": _("Prefer system libraries"), "default": True, "help": _("When the runtime is enabled, prioritize the system libraries" " over the provided ones."), }, { "section": "Gamescope", "option": "gamescope", "type": "bool", "label": _("Enable Gamescope"), "default": False, "condition": bool(system.find_executable("gamescope")) and linux.LINUX_SYSTEM.nvidia_gamescope_support(), "help": _("Use gamescope to draw the game window isolated from your desktop.\n" "Toggle fullscreen: Super + F"), }, { "section": "Gamescope", "option": "gamescope_force_grab_cursor", "type": "bool", "label": _("Relative Mouse Mode"), "advanced": True, "default": False, "condition": bool(system.find_executable("gamescope")), "help": _("Always use relative mouse mode instead of flipping\n" "dependent on cursor visibility"), }, { "section": "Gamescope", "option": "gamescope_output_res", "type": "choice_with_entry", "label": _("Output Resolution"), "choices": DISPLAY_MANAGER.get_resolutions, "advanced": True, "condition": bool(system.find_executable("gamescope")), "help": _("Set the resolution used by gamescope.\n" "Resizing the gamescope window will update these settings.\n" "\n" "Custom Resolutions: (width)x(height)"), }, { "section": "Gamescope", "option": "gamescope_game_res", "type": "choice_with_entry", "label": _("Game Resolution"), "advanced": True, "choices": DISPLAY_MANAGER.get_resolutions, "condition": bool(system.find_executable("gamescope")), "help": _("Set the maximum resolution used by the game.\n" "\n" "Custom Resolutions: (width)x(height)"), }, { "section": "Gamescope", "option": "gamescope_window_mode", "label": _("Window Mode"), "advanced": True, "type": "choice", "choices": ( (_("Fullscreen"), "-f"), (_("Windowed"), ""), (_("Borderless"), "-b"), ), "default": "-f", "condition": bool(system.find_executable("gamescope")), "help": _("Run gamescope in fullscreen, windowed or borderless mode\n" "Toggle fullscreen : Super + F"), }, { "section": "Gamescope", "option": "gamescope_fsr_sharpness", "label": _("FSR Level"), "advanced": True, "type": "string", "condition": bool(system.find_executable("gamescope")), "help": _("Use AMD FidelityFX™ Super Resolution 1.0 for upscaling.\n" "Upscaler sharpness from 0 (max) to 20 (min)."), }, { "section": "Gamescope", "option": "gamescope_fps_limiter", "label": _("FPS Limiter"), "advanced": True, "type": "string", "condition": bool(system.find_executable("gamescope")), "help": _("Set a frame-rate limit for gamescope specified in frames per second."), }, { "section": "Gamescope", "option": "gamescope_flags", "label": _("Custom Settings"), "advanced": True, "type": "string", "condition": bool(system.find_executable("gamescope")), "help": _("Set additional flags for gamescope (if available).\n" "See 'gamescope --help' for a full list of options."), }, { "section": "CPU", "option": "single_cpu", "type": "bool", "label": _("Restrict number of cores used"), "default": False, "help": _("Restrict the game to a maximum number of CPU cores."), }, { "section": "CPU", "option": "limit_cpu_count", "type": "string", "label": _("Restrict number of cores to"), "default": "1", "help": _("Maximum number of CPU cores to be used, if 'Restrict number of cores used' is turned on."), }, { "section": "CPU", "option": "gamemode", "type": "bool", "default": linux.LINUX_SYSTEM.gamemode_available(), "condition": linux.LINUX_SYSTEM.gamemode_available(), "label": _("Enable Feral GameMode"), "help": _("Request a set of optimisations be temporarily applied to the host OS"), }, { "section": "Display", "option": "mangohud", "type": "bool", "label": _("FPS counter (MangoHud)"), "default": False, "condition": bool(system.find_executable("mangohud")), "help": _("Display the game's FPS + other information. Requires MangoHud to be installed."), }, { "section": "Display", "option": "reset_desktop", "type": "bool", "label": _("Restore resolution on game exit"), "default": False, "help": _("Some games don't restore your screen resolution when \n" "closed or when they crash. This is when this option comes \n" "into play to save your bacon."), }, { "section": "Display", "option": "restore_gamma", "type": "bool", "default": False, "label": _("Restore gamma on game exit"), "advanced": True, "help": _("Some games don't correctly restores gamma on exit, making " "your display too bright. Select this option to correct it."), }, { "section": "Display", "option": "disable_compositor", "label": _("Disable desktop effects"), "type": "bool", "default": False, "advanced": True, "help": _("Disable desktop effects while game is running, " "reducing stuttering and increasing performance"), }, { "section": "Display", "option": "disable_screen_saver", "label": _("Disable screen saver"), "type": "bool", "default": SCREEN_SAVER_INHIBITOR is not None, "advanced": False, "condition": SCREEN_SAVER_INHIBITOR is not None, "help": _("Disable the screen saver while a game is running. " "Requires the screen saver's functionality " "to be exposed over DBus."), }, { "section": "Display", "option": "fps_limit", "type": "string", "size": "small", "label": _("FPS limit"), "condition": bool(system.find_executable("strangle")), "help": _("Limit the game's FPS using libstrangle"), }, { "section": "Display", "option": "sdl_video_fullscreen", "type": "choice", "label": _("SDL 1.2 Fullscreen Monitor"), "choices": get_output_list, "default": "off", "advanced": True, "help": _("Hint SDL 1.2 games to use a specific monitor when going " "fullscreen by setting the SDL_VIDEO_FULLSCREEN " "environment variable"), }, { "section": "Display", "option": "display", "type": "choice", "label": _("Turn off monitors except"), "choices": get_output_choices, "default": "off", "advanced": True, "help": _("Only keep the selected screen active while the game is " "running. \n" "This is useful if you have a dual-screen setup, and are \n" "having display issues when running a game in fullscreen."), }, { "section": "Display", "option": "resolution", "type": "choice", "label": _("Switch resolution to"), "choices": get_resolution_choices, "default": "off", "help": _("Switch to this screen resolution while the game is running."), }, { "section": "Audio", "option": "reset_pulse", "type": "bool", "label": _("Reset PulseAudio"), "default": False, "advanced": True, "condition": system.find_executable("pulseaudio"), "help": _("Restart PulseAudio before launching the game."), }, { "section": "Audio", "option": "pulse_latency", "type": "bool", "label": _("Reduce PulseAudio latency"), "default": False, "advanced": True, "condition": system.find_executable("pulseaudio") or system.find_executable("pipewire-pulse"), "help": _("Set the environment variable PULSE_LATENCY_MSEC=60 " "to improve audio quality on some games"), }, { "section": "Input", "option": "use_us_layout", "type": "bool", "label": _("Switch to US keyboard layout"), "default": False, "advanced": True, "help": _("Switch to US keyboard QWERTY layout while game is running"), }, { "section": "Input", "option": "antimicro_config", "type": "file", "label": _("AntiMicroX Profile"), "advanced": True, "help": _("Path to an AntiMicroX profile file"), }, { "section": "Input", "option": "sdl_gamecontrollerconfig", "type": "string", "label": _("SDL2 gamepad mapping"), "advanced": True, "help": _("SDL_GAMECONTROLLERCONFIG mapping string or path to a custom " "gamecontrollerdb.txt file containing mappings."), }, { "section": "Multi-GPU", "option": "prime", "type": "bool", "default": False, "condition": True, "label": _("Enable NVIDIA Prime Render Offload"), "help": _("If you have the latest NVIDIA driver and the properly patched xorg-server (see " "https://download.nvidia.com/XFree86/Linux-x86_64/435.17/README/primerenderoffload.html" "), you can launch a game on your NVIDIA GPU by toggling this switch. This will apply " "__NV_PRIME_RENDER_OFFLOAD=1 and " "__GLX_VENDOR_LIBRARY_NAME=nvidia environment variables.") }, { "section": "Multi-GPU", "option": "dri_prime", "type": "bool", "default": USE_DRI_PRIME, "condition": USE_DRI_PRIME, "label": _("Use discrete graphics"), "advanced": True, "help": _("If you have open source graphic drivers (Mesa), selecting this " "option will run the game with the 'DRI_PRIME=1' environment variable, " "activating your discrete graphic chip for high 3D " "performance."), }, { "section": "Multi-GPU", "option": "optimus", "type": "choice", "default": "off", "choices": get_optirun_choices, "label": _("Optimus launcher (NVIDIA Optimus laptops)"), "advanced": True, "help": _("If you have installed the primus or bumblebee packages, " "select what launcher will run the game with the command, " "activating your NVIDIA graphic chip for high 3D " "performance. primusrun normally has better performance, but" "optirun/virtualgl works better for more games." "Primus VK provide vulkan support under bumblebee."), }, { "section": "Multi-GPU", "option": "vk_icd", "type": "choice", # Default is "" which does not set the VK_ICD_FILENAMES env var # (Matches "Unspecified" in dropdown) "default": "", "choices": get_vk_icd_choices, "label": _("Vulkan ICD loader"), "advanced": True, "help": _("The ICD loader is a library that is placed between a Vulkan " "application and any number of Vulkan drivers, in order to support " "multiple drivers and the instance-level functionality that works " "across these drivers.") }, { "section": "Text based games", "option": "terminal", "label": _("CLI mode"), "type": "bool", "default": False, "advanced": True, "help": _("Enable a terminal for text-based games. " "Only useful for ASCII based games. May cause issues with graphical games."), }, { "section": "Text based games", "option": "terminal_app", "label": _("Text based games emulator"), "type": "choice_with_entry", "choices": linux.get_terminal_apps, "default": linux.get_default_terminal(), "advanced": True, "help": _("The terminal emulator used with the CLI mode. " "Choose from the list of detected terminal apps or enter " "the terminal's command or path."), }, { "section": "Game execution", "option": "env", "type": "mapping", "label": _("Environment variables"), "help": _("Environment variables loaded at run time"), }, { "section": "Game execution", "option": "locale", "type": "choice_with_search", "label": _("Locale"), "choices": ( get_locale_choices ), "default": "", "advanced": False, "help": _("Can be used to force certain locale for an app. Fixes encoding issues in legacy software."), }, { "section": "Game execution", "option": "prefix_command", "type": "string", "label": _("Command prefix"), "advanced": True, "help": _("Command line instructions to add in front of the game's " "execution command."), }, { "section": "Game execution", "option": "manual_command", "type": "file", "label": _("Manual script"), "advanced": True, "help": _("Script to execute from the game's contextual menu"), }, { "section": "Game execution", "option": "prelaunch_command", "type": "command_line", "label": _("Pre-launch script"), "advanced": True, "help": _("Script to execute before the game starts"), }, { "section": "Game execution", "option": "prelaunch_wait", "type": "bool", "label": _("Wait for pre-launch script completion"), "advanced": True, "default": False, "help": _("Run the game only once the pre-launch script has exited"), }, { "section": "Game execution", "option": "postexit_command", "type": "command_line", "label": _("Post-exit script"), "advanced": True, "help": _("Script to execute when the game exits"), }, { "section": "Game execution", "option": "include_processes", "type": "string", "label": _("Include processes"), "advanced": True, "help": _("What processes to include in process monitoring. " "This is to override the built-in exclude list.\n" "Space-separated list, processes including spaces " "can be wrapped in quotation marks."), }, { "section": "Game execution", "option": "exclude_processes", "type": "string", "label": _("Exclude processes"), "advanced": True, "help": _("What processes to exclude in process monitoring. " "For example background processes that stick around " "after the game has been closed.\n" "Space-separated list, processes including spaces " "can be wrapped in quotation marks."), }, { "section": "Game execution", "option": "killswitch", "type": "string", "label": _("Killswitch file"), "advanced": True, "help": _("Path to a file which will stop the game when deleted \n" "(usually /dev/input/js0 to stop the game on joystick " "unplugging)"), }, { "section": "Xephyr (Deprecated, use Gamescope)", "option": "xephyr", "label": _("Use Xephyr"), "type": "choice", "choices": ( (_("Off"), "off"), (_("8BPP (256 colors)"), "8bpp"), (_("16BPP (65536 colors)"), "16bpp"), (_("24BPP (16M colors)"), "24bpp"), ), "default": "off", "advanced": True, "help": _("Run program in Xephyr to support 8BPP and 16BPP color modes"), }, { "section": "Xephyr (Deprecated, use Gamescope)", "option": "xephyr_resolution", "type": "string", "label": _("Xephyr resolution"), "advanced": True, "help": _("Screen resolution of the Xephyr server"), }, { "section": "Xephyr (Deprecated, use Gamescope)", "option": "xephyr_fullscreen", "type": "bool", "label": _("Xephyr Fullscreen"), "default": True, "advanced": True, "help": _("Open Xephyr in fullscreen (at the desktop resolution)"), }, ] def with_runner_overrides(runner_slug): """Return system options updated with overrides from given runner.""" options = system_options try: runner = runners.import_runner(runner_slug) except runners.InvalidRunner: return options if not getattr(runner, "system_options_override"): runner = runner() if runner.system_options_override: opts_dict = OrderedDict((opt["option"], opt) for opt in options) for option in runner.system_options_override: key = option["option"] if opts_dict.get(key): opts_dict[key] = opts_dict[key].copy() opts_dict[key].update(option) else: opts_dict[key] = option options = list(opts_dict.values()) return options lutris-0.5.14/lutris/util/000077500000000000000000000000001451435154700154335ustar00rootroot00000000000000lutris-0.5.14/lutris/util/__init__.py000066400000000000000000000007001451435154700175410ustar00rootroot00000000000000""" Misc common functions """ 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 lutris-0.5.14/lutris/util/amazon/000077500000000000000000000000001451435154700167205ustar00rootroot00000000000000lutris-0.5.14/lutris/util/amazon/__init__.py000066400000000000000000000000001451435154700210170ustar00rootroot00000000000000lutris-0.5.14/lutris/util/amazon/protobuf_decoder.py000066400000000000000000000142561451435154700226270ustar00rootroot00000000000000import 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.14/lutris/util/amazon/sds_proto2.py000066400000000000000000000052771451435154700214030ustar00rootroot00000000000000from 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.14/lutris/util/audio.py000066400000000000000000000007621451435154700171130ustar00rootroot00000000000000"""Whatever it is we want to do with audio module""" # Standard Library import time # Lutris Modules from lutris.util import system from lutris.util.log import logger def reset_pulse(): """Reset pulseaudio.""" if not system.find_executable("pulseaudio"): logger.warning("PulseAudio not installed. Nothing to do.") return system.execute(["pulseaudio", "--kill"]) time.sleep(1) system.execute(["pulseaudio", "--start"]) logger.debug("PulseAudio restarted") lutris-0.5.14/lutris/util/battlenet/000077500000000000000000000000001451435154700174155ustar00rootroot00000000000000lutris-0.5.14/lutris/util/battlenet/__init__.py000066400000000000000000000002531451435154700215260ustar00rootroot00000000000000# Code in this package from the GOG Galaxy integration for Battle.net # https://github.com/bartok765/galaxy_blizzard_plugin # All credits go to bartok765 and contributors lutris-0.5.14/lutris/util/battlenet/definitions.py000066400000000000000000000126151451435154700223070ustar00rootroot00000000000000import dataclasses as dc import json from typing import List, Optional import requests class DataclassJSONEncoder(json.JSONEncoder): def default(self, o): if dc.is_dataclass(o): return dc.asdict(o) return super().default(o) @dc.dataclass class WebsiteAuthData(object): cookie_jar: requests.cookies.RequestsCookieJar access_token: str region: str @dc.dataclass(frozen=True) class BlizzardGame: uid: str name: str family: str @dc.dataclass(frozen=True) class ClassicGame(BlizzardGame): registry_path: Optional[str] = None registry_installation_key: Optional[str] = None exe: Optional[str] = None bundle_id: Optional[str] = None @dc.dataclass class RegionalGameInfo: uid: str try_for_free: bool @dc.dataclass class ConfigGameInfo(object): uid: str uninstall_tag: Optional[str] last_played: Optional[str] @dc.dataclass class ProductDbInfo(object): uninstall_tag: str ngdp: str = '' install_path: str = '' version: str = '' playable: bool = False installed: bool = False class Singleton(type): _instances = {} # type: ignore def __call__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) return cls._instances[cls] class _Blizzard(object, metaclass=Singleton): TITLE_ID_MAP = { 21297: RegionalGameInfo('s1', True), 21298: RegionalGameInfo('s2', True), 5730135: RegionalGameInfo('wow', True), 5272175: RegionalGameInfo('prometheus', False), 22323: RegionalGameInfo('w3', False), 1146311730: RegionalGameInfo('destiny2', False), 1465140039: RegionalGameInfo('hs_beta', True), 1214607983: RegionalGameInfo('heroes', True), 17459: RegionalGameInfo('diablo3', True), 4613486: RegionalGameInfo('fenris', True), 1447645266: RegionalGameInfo('viper', False), 1329875278: RegionalGameInfo('odin', True), 1279351378: RegionalGameInfo('lazarus', False), 1514493267: RegionalGameInfo('zeus', False), 1381257807: RegionalGameInfo('rtro', False), 1464615513: RegionalGameInfo('wlby', False), 5198665: RegionalGameInfo('osi', False), 1179603525: RegionalGameInfo('fore', False) } TITLE_ID_MAP_CN = { **TITLE_ID_MAP, 17459: RegionalGameInfo('d3cn', False) } BATTLENET_GAMES = [ BlizzardGame('s1', 'StarCraft', 'S1'), BlizzardGame('s2', 'StarCraft II', 'S2'), BlizzardGame('wow', 'World of Warcraft', 'WoW'), BlizzardGame('wow_classic', 'World of Warcraft Classic', 'WoW_wow_classic'), BlizzardGame('prometheus', 'Overwatch', 'Pro'), BlizzardGame('w3', 'Warcraft III', 'W3'), BlizzardGame('hs_beta', 'Hearthstone', 'WTCG'), BlizzardGame('heroes', 'Heroes of the Storm', 'Hero'), BlizzardGame('d3cn', '暗黑破壞神III', 'D3CN'), BlizzardGame('diablo3', 'Diablo III', 'D3'), BlizzardGame('fenris', 'Diablo IV', 'FEN'), BlizzardGame('viper', 'Call of Duty: Black Ops 4', 'VIPR'), BlizzardGame('odin', 'Call of Duty: Modern Warfare', 'ODIN'), BlizzardGame('lazarus', 'Call of Duty: MW2 Campaign Remastered', 'LAZR'), BlizzardGame('zeus', 'Call of Duty: Black Ops Cold War', 'ZEUS'), BlizzardGame('rtro', 'Blizzard Arcade Collection', 'RTRO'), BlizzardGame('wlby', 'Crash Bandicoot 4: It\'s About Time', 'WLBY'), BlizzardGame('osi', 'Diablo® II: Resurrected', 'OSI'), BlizzardGame('fore', 'Call of Duty: Vanguard', 'FORE') ] CLASSIC_GAMES = [ ClassicGame('d2', 'Diablo® II', 'Diablo II', 'Diablo II', 'DisplayIcon', "Game.exe", "com.blizzard.diabloii"), ClassicGame('d2LOD', 'Diablo® II: Lord of Destruction®', 'Diablo II'), # TODO exe and bundleid ClassicGame('w3ROC', 'Warcraft® III: Reign of Chaos', 'Warcraft III', 'Warcraft III', 'InstallLocation', 'Warcraft III.exe', 'com.blizzard.WarcraftIII'), ClassicGame('w3tft', 'Warcraft® III: The Frozen Throne®', 'Warcraft III', 'Warcraft III', 'InstallLocation', 'Warcraft III.exe', 'com.blizzard.WarcraftIII'), ClassicGame('sca', 'StarCraft® Anthology', 'Starcraft', 'StarCraft') # TODO exe and bundleid ] def __init__(self): self._games = { game.uid: game for game in self.BATTLENET_GAMES + self.CLASSIC_GAMES} def __getitem__(self, key: str) -> BlizzardGame: """ :param key: str uid (eg. "prometheus") :returns: game by `key` """ return self._games[key] def game_by_title_id(self, title_id: int, cn: bool) -> BlizzardGame: """ :param cn: flag if china game definitions should be search though :raises KeyError: when unknown title_id for given region """ if cn: regional_info = self.TITLE_ID_MAP_CN[title_id] else: regional_info = self.TITLE_ID_MAP[title_id] return self[regional_info.uid] def try_for_free_games(self, cn: bool) -> List[BlizzardGame]: """ :param cn: flag if china game definitions should be search though """ return [ self[info.uid] for info in (self.TITLE_ID_MAP_CN if cn else self.TITLE_ID_MAP).values() if info.try_for_free ] Blizzard = _Blizzard() lutris-0.5.14/lutris/util/battlenet/product_db_pb2.py000066400000000000000000001333251451435154700226660ustar00rootroot00000000000000# noqa pylint: disable=too-many-lines,use-dict-literal # Generated by the protocol buffer compiler. DO NOT EDIT! # source: product_db.proto import sys _b = (lambda x: x) if sys.version_info[0] < 3 else (lambda x: x.encode('latin1')) from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message from google.protobuf import reflection as _reflection from google.protobuf import symbol_database as _symbol_database from google.protobuf.internal import enum_type_wrapper # ~ from google.protobuf import descriptor_pb2 # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() DESCRIPTOR = _descriptor.FileDescriptor( name='product_db.proto', package='', syntax='proto3', serialized_pb=_b('\n\x10product_db.proto\"D\n\x0fLanguageSetting\x12\x10\n\x08language\x18\x01 \x01(\t\x12\x1f\n\x06option\x18\x02 \x01(\x0e\x32\x0f.LanguageOption\"\xdb\x02\n\x0cUserSettings\x12\x14\n\x0cinstall_path\x18\x01 \x01(\t\x12\x13\n\x0bplay_region\x18\x02 \x01(\t\x12)\n\x10\x64\x65sktop_shortcut\x18\x03 \x01(\x0e\x32\x0f.ShortcutOption\x12+\n\x12startmenu_shortcut\x18\x04 \x01(\x0e\x32\x0f.ShortcutOption\x12/\n\x11language_settings\x18\x05 \x01(\x0e\x32\x14.LanguageSettingType\x12\x1e\n\x16selected_text_language\x18\x06 \x01(\t\x12 \n\x18selected_speech_language\x18\x07 \x01(\t\x12#\n\tlanguages\x18\x08 \x03(\x0b\x32\x10.LanguageSetting\x12\x19\n\x11gfx_override_tags\x18\t \x01(\t\x12\x15\n\rversionbranch\x18\n \x01(\t\"Q\n\x10InstallHandshake\x12\x0f\n\x07product\x18\x01 \x01(\t\x12\x0b\n\x03uid\x18\x02 \x01(\t\x12\x1f\n\x08settings\x18\x03 \x01(\x0b\x32\r.UserSettings\"3\n\x0b\x42uildConfig\x12\x0e\n\x06region\x18\x01 \x01(\t\x12\x14\n\x0c\x62uild_config\x18\x02 \x01(\t\"\xf4\x02\n\x10\x42\x61seProductState\x12\x11\n\tinstalled\x18\x01 \x01(\x08\x12\x10\n\x08playable\x18\x02 \x01(\x08\x12\x17\n\x0fupdate_complete\x18\x03 \x01(\x08\x12%\n\x1d\x62\x61\x63kground_download_available\x18\x04 \x01(\x08\x12$\n\x1c\x62\x61\x63kground_download_complete\x18\x05 \x01(\x08\x12\x17\n\x0f\x63urrent_version\x18\x06 \x01(\t\x12\x1b\n\x13\x63urrent_version_str\x18\x07 \x01(\t\x12,\n\x16installed_build_config\x18\x08 \x03(\x0b\x32\x0c.BuildConfig\x12\x36\n background_download_build_config\x18\t \x03(\x0b\x32\x0c.BuildConfig\x12\x16\n\x0e\x64\x65\x63ryption_key\x18\n \x01(\t\x12!\n\x19\x63ompleted_install_actions\x18\x0b \x03(\t\"h\n\x10\x42\x61\x63kfillProgress\x12\x10\n\x08progress\x18\x01 \x01(\x01\x12\x1a\n\x12\x62\x61\x63kgrounddownload\x18\x02 \x01(\x08\x12\x0e\n\x06paused\x18\x03 \x01(\x08\x12\x16\n\x0e\x64ownload_limit\x18\x04 \x01(\x04\"\"\n\x0eRepairProgress\x12\x10\n\x08progress\x18\x01 \x01(\x01\"\x8b\x01\n\x0eUpdateProgress\x12\x1a\n\x12last_disc_set_used\x18\x01 \x01(\t\x12\x10\n\x08progress\x18\x02 \x01(\x01\x12\x14\n\x0c\x64isc_ignored\x18\x03 \x01(\x08\x12\x19\n\x11total_to_download\x18\x04 \x01(\x04\x12\x1a\n\x12\x64ownload_remaining\x18\x05 \x01(\x04\"\xc5\x01\n\x12\x43\x61\x63hedProductState\x12-\n\x12\x62\x61se_product_state\x18\x01 \x01(\x0b\x32\x11.BaseProductState\x12,\n\x11\x62\x61\x63kfill_progress\x18\x02 \x01(\x0b\x32\x11.BackfillProgress\x12(\n\x0frepair_progress\x18\x03 \x01(\x0b\x32\x0f.RepairProgress\x12(\n\x0fupdate_progress\x18\x04 \x01(\x0b\x32\x0f.UpdateProgress\"K\n\x11ProductOperations\x12$\n\x10\x61\x63tive_operation\x18\x01 \x01(\x0e\x32\n.Operation\x12\x10\n\x08priority\x18\x02 \x01(\x04\"\xb7\x01\n\x0eProductInstall\x12\x0b\n\x03uid\x18\x01 \x01(\t\x12\x14\n\x0cproduct_code\x18\x02 \x01(\t\x12\x1f\n\x08settings\x18\x03 \x01(\x0b\x32\r.UserSettings\x12\x31\n\x14\x63\x61\x63hed_product_state\x18\x04 \x01(\x0b\x32\x13.CachedProductState\x12.\n\x12product_operations\x18\x05 \x01(\x0b\x32\x12.ProductOperations\"O\n\rProductConfig\x12\x14\n\x0cproduct_code\x18\x01 \x01(\t\x12\x15\n\rmetadata_hash\x18\x02 \x01(\t\x12\x11\n\ttimestamp\x18\x03 \x01(\t\"?\n\rActiveProcess\x12\x14\n\x0cprocess_name\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\x05\x12\x0b\n\x03uri\x18\x03 \x03(\t\"B\n\x10\x44ownloadSettings\x12\x16\n\x0e\x64ownload_limit\x18\x01 \x01(\x05\x12\x16\n\x0e\x62\x61\x63kfill_limit\x18\x02 \x01(\x05\"\xe3\x01\n\tProductDb\x12)\n\x10product_installs\x18\x01 \x03(\x0b\x32\x0f.ProductInstall\x12*\n\x0f\x61\x63tive_installs\x18\x02 \x03(\x0b\x32\x11.InstallHandshake\x12(\n\x10\x61\x63tive_processes\x18\x03 \x03(\x0b\x32\x0e.ActiveProcess\x12\'\n\x0fproduct_configs\x18\x04 \x03(\x0b\x32\x0e.ProductConfig\x12,\n\x11\x64ownload_settings\x18\x05 \x01(\x0b\x32\x11.DownloadSettings*q\n\x0eLanguageOption\x12\x13\n\x0fLANGOPTION_NONE\x10\x00\x12\x13\n\x0fLANGOPTION_TEXT\x10\x01\x12\x15\n\x11LANGOPTION_SPEECH\x10\x02\x12\x1e\n\x1aLANGOPTION_TEXT_AND_SPEECH\x10\x03*u\n\x13LanguageSettingType\x12\x14\n\x10LANGSETTING_NONE\x10\x00\x12\x16\n\x12LANGSETTING_SINGLE\x10\x01\x12\x16\n\x12LANGSETTING_SIMPLE\x10\x02\x12\x18\n\x14LANGSETTING_ADVANCED\x10\x03*N\n\x0eShortcutOption\x12\x11\n\rSHORTCUT_NONE\x10\x00\x12\x11\n\rSHORTCUT_USER\x10\x01\x12\x16\n\x12SHORTCUT_ALL_USERS\x10\x02*P\n\tOperation\x12\r\n\tOP_UPDATE\x10\x00\x12\x0f\n\x0bOP_BACKFILL\x10\x01\x12\r\n\tOP_REPAIR\x10\x02\x12\x14\n\x07OP_NONE\x10\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01\x62\x06proto3') # noqa pylint: disable=line-too-long ) _sym_db.RegisterFileDescriptor(DESCRIPTOR) _LANGUAGEOPTION = _descriptor.EnumDescriptor( name='LanguageOption', full_name='LanguageOption', filename=None, file=DESCRIPTOR, values=[ _descriptor.EnumValueDescriptor( name='LANGOPTION_NONE', index=0, number=0, options=None, type=None), _descriptor.EnumValueDescriptor( name='LANGOPTION_TEXT', index=1, number=1, options=None, type=None), _descriptor.EnumValueDescriptor( name='LANGOPTION_SPEECH', index=2, number=2, options=None, type=None), _descriptor.EnumValueDescriptor( name='LANGOPTION_TEXT_AND_SPEECH', index=3, number=3, options=None, type=None), ], containing_type=None, options=None, serialized_start=2142, serialized_end=2255, ) _sym_db.RegisterEnumDescriptor(_LANGUAGEOPTION) LanguageOption = enum_type_wrapper.EnumTypeWrapper(_LANGUAGEOPTION) _LANGUAGESETTINGTYPE = _descriptor.EnumDescriptor( name='LanguageSettingType', full_name='LanguageSettingType', filename=None, file=DESCRIPTOR, values=[ _descriptor.EnumValueDescriptor( name='LANGSETTING_NONE', index=0, number=0, options=None, type=None), _descriptor.EnumValueDescriptor( name='LANGSETTING_SINGLE', index=1, number=1, options=None, type=None), _descriptor.EnumValueDescriptor( name='LANGSETTING_SIMPLE', index=2, number=2, options=None, type=None), _descriptor.EnumValueDescriptor( name='LANGSETTING_ADVANCED', index=3, number=3, options=None, type=None), ], containing_type=None, options=None, serialized_start=2257, serialized_end=2374, ) _sym_db.RegisterEnumDescriptor(_LANGUAGESETTINGTYPE) LanguageSettingType = enum_type_wrapper.EnumTypeWrapper(_LANGUAGESETTINGTYPE) _SHORTCUTOPTION = _descriptor.EnumDescriptor( name='ShortcutOption', full_name='ShortcutOption', filename=None, file=DESCRIPTOR, values=[ _descriptor.EnumValueDescriptor( name='SHORTCUT_NONE', index=0, number=0, options=None, type=None), _descriptor.EnumValueDescriptor( name='SHORTCUT_USER', index=1, number=1, options=None, type=None), _descriptor.EnumValueDescriptor( name='SHORTCUT_ALL_USERS', index=2, number=2, options=None, type=None), ], containing_type=None, options=None, serialized_start=2376, serialized_end=2454, ) _sym_db.RegisterEnumDescriptor(_SHORTCUTOPTION) ShortcutOption = enum_type_wrapper.EnumTypeWrapper(_SHORTCUTOPTION) _OPERATION = _descriptor.EnumDescriptor( name='Operation', full_name='Operation', filename=None, file=DESCRIPTOR, values=[ _descriptor.EnumValueDescriptor( name='OP_UPDATE', index=0, number=0, options=None, type=None), _descriptor.EnumValueDescriptor( name='OP_BACKFILL', index=1, number=1, options=None, type=None), _descriptor.EnumValueDescriptor( name='OP_REPAIR', index=2, number=2, options=None, type=None), _descriptor.EnumValueDescriptor( name='OP_NONE', index=3, number=-1, options=None, type=None), ], containing_type=None, options=None, serialized_start=2456, serialized_end=2536, ) _sym_db.RegisterEnumDescriptor(_OPERATION) Operation = enum_type_wrapper.EnumTypeWrapper(_OPERATION) LANGOPTION_NONE = 0 LANGOPTION_TEXT = 1 LANGOPTION_SPEECH = 2 LANGOPTION_TEXT_AND_SPEECH = 3 LANGSETTING_NONE = 0 LANGSETTING_SINGLE = 1 LANGSETTING_SIMPLE = 2 LANGSETTING_ADVANCED = 3 SHORTCUT_NONE = 0 SHORTCUT_USER = 1 SHORTCUT_ALL_USERS = 2 OP_UPDATE = 0 OP_BACKFILL = 1 OP_REPAIR = 2 OP_NONE = -1 _LANGUAGESETTING = _descriptor.Descriptor( name='LanguageSetting', full_name='LanguageSetting', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='language', full_name='LanguageSetting.language', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='option', full_name='LanguageSetting.option', index=1, number=2, type=14, cpp_type=8, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=20, serialized_end=88, ) _USERSETTINGS = _descriptor.Descriptor( name='UserSettings', full_name='UserSettings', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='install_path', full_name='UserSettings.install_path', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='play_region', full_name='UserSettings.play_region', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='desktop_shortcut', full_name='UserSettings.desktop_shortcut', index=2, number=3, type=14, cpp_type=8, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='startmenu_shortcut', full_name='UserSettings.startmenu_shortcut', index=3, number=4, type=14, cpp_type=8, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='language_settings', full_name='UserSettings.language_settings', index=4, number=5, type=14, cpp_type=8, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='selected_text_language', full_name='UserSettings.selected_text_language', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='selected_speech_language', full_name='UserSettings.selected_speech_language', index=6, number=7, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='languages', full_name='UserSettings.languages', index=7, number=8, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='gfx_override_tags', full_name='UserSettings.gfx_override_tags', index=8, number=9, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='versionbranch', full_name='UserSettings.versionbranch', index=9, number=10, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=91, serialized_end=438, ) _INSTALLHANDSHAKE = _descriptor.Descriptor( name='InstallHandshake', full_name='InstallHandshake', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='product', full_name='InstallHandshake.product', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='uid', full_name='InstallHandshake.uid', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='settings', full_name='InstallHandshake.settings', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=440, serialized_end=521, ) _BUILDCONFIG = _descriptor.Descriptor( name='BuildConfig', full_name='BuildConfig', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='region', full_name='BuildConfig.region', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='build_config', full_name='BuildConfig.build_config', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=523, serialized_end=574, ) _BASEPRODUCTSTATE = _descriptor.Descriptor( name='BaseProductState', full_name='BaseProductState', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='installed', full_name='BaseProductState.installed', index=0, number=1, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='playable', full_name='BaseProductState.playable', index=1, number=2, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='update_complete', full_name='BaseProductState.update_complete', index=2, number=3, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='background_download_available', full_name='BaseProductState.background_download_available', index=3, number=4, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='background_download_complete', full_name='BaseProductState.background_download_complete', index=4, number=5, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='current_version', full_name='BaseProductState.current_version', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='current_version_str', full_name='BaseProductState.current_version_str', index=6, number=7, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='installed_build_config', full_name='BaseProductState.installed_build_config', index=7, number=8, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='background_download_build_config', full_name='BaseProductState.background_download_build_config', index=8, number=9, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='decryption_key', full_name='BaseProductState.decryption_key', index=9, number=10, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='completed_install_actions', full_name='BaseProductState.completed_install_actions', index=10, number=11, type=9, cpp_type=9, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=577, serialized_end=949, ) _BACKFILLPROGRESS = _descriptor.Descriptor( name='BackfillProgress', full_name='BackfillProgress', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='progress', full_name='BackfillProgress.progress', index=0, number=1, type=1, cpp_type=5, label=1, has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='backgrounddownload', full_name='BackfillProgress.backgrounddownload', index=1, number=2, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='paused', full_name='BackfillProgress.paused', index=2, number=3, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='download_limit', full_name='BackfillProgress.download_limit', index=3, number=4, type=4, cpp_type=4, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=951, serialized_end=1055, ) _REPAIRPROGRESS = _descriptor.Descriptor( name='RepairProgress', full_name='RepairProgress', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='progress', full_name='RepairProgress.progress', index=0, number=1, type=1, cpp_type=5, label=1, has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=1057, serialized_end=1091, ) _UPDATEPROGRESS = _descriptor.Descriptor( name='UpdateProgress', full_name='UpdateProgress', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='last_disc_set_used', full_name='UpdateProgress.last_disc_set_used', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='progress', full_name='UpdateProgress.progress', index=1, number=2, type=1, cpp_type=5, label=1, has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='disc_ignored', full_name='UpdateProgress.disc_ignored', index=2, number=3, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='total_to_download', full_name='UpdateProgress.total_to_download', index=3, number=4, type=4, cpp_type=4, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='download_remaining', full_name='UpdateProgress.download_remaining', index=4, number=5, type=4, cpp_type=4, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=1094, serialized_end=1233, ) _CACHEDPRODUCTSTATE = _descriptor.Descriptor( name='CachedProductState', full_name='CachedProductState', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='base_product_state', full_name='CachedProductState.base_product_state', index=0, number=1, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='backfill_progress', full_name='CachedProductState.backfill_progress', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='repair_progress', full_name='CachedProductState.repair_progress', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='update_progress', full_name='CachedProductState.update_progress', index=3, number=4, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=1236, serialized_end=1433, ) _PRODUCTOPERATIONS = _descriptor.Descriptor( name='ProductOperations', full_name='ProductOperations', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='active_operation', full_name='ProductOperations.active_operation', index=0, number=1, type=14, cpp_type=8, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='priority', full_name='ProductOperations.priority', index=1, number=2, type=4, cpp_type=4, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=1435, serialized_end=1510, ) _PRODUCTINSTALL = _descriptor.Descriptor( name='ProductInstall', full_name='ProductInstall', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='uid', full_name='ProductInstall.uid', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='product_code', full_name='ProductInstall.product_code', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='settings', full_name='ProductInstall.settings', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='cached_product_state', full_name='ProductInstall.cached_product_state', index=3, number=4, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='product_operations', full_name='ProductInstall.product_operations', index=4, number=5, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=1513, serialized_end=1696, ) _PRODUCTCONFIG = _descriptor.Descriptor( name='ProductConfig', full_name='ProductConfig', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='product_code', full_name='ProductConfig.product_code', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='metadata_hash', full_name='ProductConfig.metadata_hash', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='timestamp', full_name='ProductConfig.timestamp', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=1698, serialized_end=1777, ) _ACTIVEPROCESS = _descriptor.Descriptor( name='ActiveProcess', full_name='ActiveProcess', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='process_name', full_name='ActiveProcess.process_name', index=0, number=1, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='pid', full_name='ActiveProcess.pid', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='uri', full_name='ActiveProcess.uri', index=2, number=3, type=9, cpp_type=9, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=1779, serialized_end=1842, ) _DOWNLOADSETTINGS = _descriptor.Descriptor( name='DownloadSettings', full_name='DownloadSettings', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='download_limit', full_name='DownloadSettings.download_limit', index=0, number=1, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='backfill_limit', full_name='DownloadSettings.backfill_limit', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=1844, serialized_end=1910, ) _PRODUCTDB = _descriptor.Descriptor( name='ProductDb', full_name='ProductDb', filename=None, file=DESCRIPTOR, containing_type=None, fields=[ _descriptor.FieldDescriptor( name='product_installs', full_name='ProductDb.product_installs', index=0, number=1, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='active_installs', full_name='ProductDb.active_installs', index=1, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='active_processes', full_name='ProductDb.active_processes', index=2, number=3, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='product_configs', full_name='ProductDb.product_configs', index=3, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), _descriptor.FieldDescriptor( name='download_settings', full_name='ProductDb.download_settings', index=4, number=5, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None), ], extensions=[ ], nested_types=[], enum_types=[ ], options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=1913, serialized_end=2140, ) _LANGUAGESETTING.fields_by_name['option'].enum_type = _LANGUAGEOPTION _USERSETTINGS.fields_by_name['desktop_shortcut'].enum_type = _SHORTCUTOPTION _USERSETTINGS.fields_by_name['startmenu_shortcut'].enum_type = _SHORTCUTOPTION _USERSETTINGS.fields_by_name['language_settings'].enum_type = _LANGUAGESETTINGTYPE _USERSETTINGS.fields_by_name['languages'].message_type = _LANGUAGESETTING _INSTALLHANDSHAKE.fields_by_name['settings'].message_type = _USERSETTINGS _BASEPRODUCTSTATE.fields_by_name['installed_build_config'].message_type = _BUILDCONFIG _BASEPRODUCTSTATE.fields_by_name['background_download_build_config'].message_type = _BUILDCONFIG _CACHEDPRODUCTSTATE.fields_by_name['base_product_state'].message_type = _BASEPRODUCTSTATE _CACHEDPRODUCTSTATE.fields_by_name['backfill_progress'].message_type = _BACKFILLPROGRESS _CACHEDPRODUCTSTATE.fields_by_name['repair_progress'].message_type = _REPAIRPROGRESS _CACHEDPRODUCTSTATE.fields_by_name['update_progress'].message_type = _UPDATEPROGRESS _PRODUCTOPERATIONS.fields_by_name['active_operation'].enum_type = _OPERATION _PRODUCTINSTALL.fields_by_name['settings'].message_type = _USERSETTINGS _PRODUCTINSTALL.fields_by_name['cached_product_state'].message_type = _CACHEDPRODUCTSTATE _PRODUCTINSTALL.fields_by_name['product_operations'].message_type = _PRODUCTOPERATIONS _PRODUCTDB.fields_by_name['product_installs'].message_type = _PRODUCTINSTALL _PRODUCTDB.fields_by_name['active_installs'].message_type = _INSTALLHANDSHAKE _PRODUCTDB.fields_by_name['active_processes'].message_type = _ACTIVEPROCESS _PRODUCTDB.fields_by_name['product_configs'].message_type = _PRODUCTCONFIG _PRODUCTDB.fields_by_name['download_settings'].message_type = _DOWNLOADSETTINGS DESCRIPTOR.message_types_by_name['LanguageSetting'] = _LANGUAGESETTING DESCRIPTOR.message_types_by_name['UserSettings'] = _USERSETTINGS DESCRIPTOR.message_types_by_name['InstallHandshake'] = _INSTALLHANDSHAKE DESCRIPTOR.message_types_by_name['BuildConfig'] = _BUILDCONFIG DESCRIPTOR.message_types_by_name['BaseProductState'] = _BASEPRODUCTSTATE DESCRIPTOR.message_types_by_name['BackfillProgress'] = _BACKFILLPROGRESS DESCRIPTOR.message_types_by_name['RepairProgress'] = _REPAIRPROGRESS DESCRIPTOR.message_types_by_name['UpdateProgress'] = _UPDATEPROGRESS DESCRIPTOR.message_types_by_name['CachedProductState'] = _CACHEDPRODUCTSTATE DESCRIPTOR.message_types_by_name['ProductOperations'] = _PRODUCTOPERATIONS DESCRIPTOR.message_types_by_name['ProductInstall'] = _PRODUCTINSTALL DESCRIPTOR.message_types_by_name['ProductConfig'] = _PRODUCTCONFIG DESCRIPTOR.message_types_by_name['ActiveProcess'] = _ACTIVEPROCESS DESCRIPTOR.message_types_by_name['DownloadSettings'] = _DOWNLOADSETTINGS DESCRIPTOR.message_types_by_name['ProductDb'] = _PRODUCTDB DESCRIPTOR.enum_types_by_name['LanguageOption'] = _LANGUAGEOPTION DESCRIPTOR.enum_types_by_name['LanguageSettingType'] = _LANGUAGESETTINGTYPE DESCRIPTOR.enum_types_by_name['ShortcutOption'] = _SHORTCUTOPTION DESCRIPTOR.enum_types_by_name['Operation'] = _OPERATION LanguageSetting = _reflection.GeneratedProtocolMessageType('LanguageSetting', (_message.Message,), dict( DESCRIPTOR=_LANGUAGESETTING, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:LanguageSetting) )) _sym_db.RegisterMessage(LanguageSetting) UserSettings = _reflection.GeneratedProtocolMessageType('UserSettings', (_message.Message,), dict( DESCRIPTOR=_USERSETTINGS, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:UserSettings) )) _sym_db.RegisterMessage(UserSettings) InstallHandshake = _reflection.GeneratedProtocolMessageType('InstallHandshake', (_message.Message,), dict( DESCRIPTOR=_INSTALLHANDSHAKE, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:InstallHandshake) )) _sym_db.RegisterMessage(InstallHandshake) BuildConfig = _reflection.GeneratedProtocolMessageType('BuildConfig', (_message.Message,), dict( DESCRIPTOR=_BUILDCONFIG, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:BuildConfig) )) _sym_db.RegisterMessage(BuildConfig) BaseProductState = _reflection.GeneratedProtocolMessageType('BaseProductState', (_message.Message,), dict( DESCRIPTOR=_BASEPRODUCTSTATE, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:BaseProductState) )) _sym_db.RegisterMessage(BaseProductState) BackfillProgress = _reflection.GeneratedProtocolMessageType('BackfillProgress', (_message.Message,), dict( DESCRIPTOR=_BACKFILLPROGRESS, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:BackfillProgress) )) _sym_db.RegisterMessage(BackfillProgress) RepairProgress = _reflection.GeneratedProtocolMessageType('RepairProgress', (_message.Message,), dict( DESCRIPTOR=_REPAIRPROGRESS, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:RepairProgress) )) _sym_db.RegisterMessage(RepairProgress) UpdateProgress = _reflection.GeneratedProtocolMessageType('UpdateProgress', (_message.Message,), dict( DESCRIPTOR=_UPDATEPROGRESS, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:UpdateProgress) )) _sym_db.RegisterMessage(UpdateProgress) CachedProductState = _reflection.GeneratedProtocolMessageType('CachedProductState', (_message.Message,), dict( DESCRIPTOR=_CACHEDPRODUCTSTATE, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:CachedProductState) )) _sym_db.RegisterMessage(CachedProductState) ProductOperations = _reflection.GeneratedProtocolMessageType('ProductOperations', (_message.Message,), dict( DESCRIPTOR=_PRODUCTOPERATIONS, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:ProductOperations) )) _sym_db.RegisterMessage(ProductOperations) ProductInstall = _reflection.GeneratedProtocolMessageType('ProductInstall', (_message.Message,), dict( DESCRIPTOR=_PRODUCTINSTALL, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:ProductInstall) )) _sym_db.RegisterMessage(ProductInstall) ProductConfig = _reflection.GeneratedProtocolMessageType('ProductConfig', (_message.Message,), dict( DESCRIPTOR=_PRODUCTCONFIG, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:ProductConfig) )) _sym_db.RegisterMessage(ProductConfig) ActiveProcess = _reflection.GeneratedProtocolMessageType('ActiveProcess', (_message.Message,), dict( DESCRIPTOR=_ACTIVEPROCESS, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:ActiveProcess) )) _sym_db.RegisterMessage(ActiveProcess) DownloadSettings = _reflection.GeneratedProtocolMessageType('DownloadSettings', (_message.Message,), dict( DESCRIPTOR=_DOWNLOADSETTINGS, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:DownloadSettings) )) _sym_db.RegisterMessage(DownloadSettings) ProductDb = _reflection.GeneratedProtocolMessageType('ProductDb', (_message.Message,), dict( DESCRIPTOR=_PRODUCTDB, __module__='product_db_pb2' # @@protoc_insertion_point(class_scope:ProductDb) )) _sym_db.RegisterMessage(ProductDb) # @@protoc_insertion_point(module_scope) lutris-0.5.14/lutris/util/cookies.py000066400000000000000000000052451451435154700174470ustar00rootroot00000000000000import time from http.cookiejar import Cookie, MozillaCookieJar class WebkitCookieJar(MozillaCookieJar): """Subclass of MozillaCookieJar for compatibility with cookies coming from Webkit2. This disables the magic_re header which is not present and adds compatibility with HttpOnly cookies (See http://bugs.python.org/issue2190) """ def _really_load(self, f, filename, ignore_discard, ignore_expires): # pylint: disable=too-many-locals now = time.time() try: while 1: line = f.readline() if line == "": break # last field may be absent, so keep any trailing tab if line.endswith("\n"): line = line[:-1] sline = line.strip() # support HttpOnly cookies (as stored by curl or old Firefox). if sline.startswith("#HttpOnly_"): line = sline[10:] elif sline.startswith("#") or sline == "": continue domain, domain_specified, path, secure, expires, name, value, *_extra = line.split("\t") secure = secure == "TRUE" domain_specified = domain_specified == "TRUE" if name == "": # cookies.txt regards 'Set-Cookie: foo' as a cookie # with no name, whereas http.cookiejar regards it as a # cookie with no value. name = value value = None initial_dot = domain.startswith(".") assert domain_specified == initial_dot discard = False if expires == "": expires = None discard = True # assume path_specified is false c = Cookie( 0, name, value, None, False, domain, domain_specified, initial_dot, path, False, secure, expires, discard, None, None, {}, ) if not ignore_discard and c.discard: continue if not ignore_expires and c.is_expired(now): continue self.set_cookie(c) except OSError: raise except Exception as err: raise OSError("invalid Netscape format cookies file %r: %r" % (filename, line)) from err lutris-0.5.14/lutris/util/datapath.py000066400000000000000000000020471451435154700175760ustar00rootroot00000000000000"""Utility to get the path of Lutris assets""" # Standard Library import os import sys # Lutris Modules from lutris.util import system def get(): """Return the path for the resources.""" launch_path = os.path.realpath(sys.path[0]) if launch_path.startswith("/usr/local"): data_path = "/usr/local/share/lutris" elif launch_path.startswith("/usr"): data_path = "/usr/share/lutris" elif system.path_exists(os.path.normpath(os.path.join(sys.path[0], "share"))): data_path = os.path.normpath(os.path.join(sys.path[0], "share/lutris")) elif system.path_exists(os.path.normpath(os.path.join(launch_path, "../../share/lutris"))): data_path = os.path.normpath(os.path.join(launch_path, "../../share/lutris")) else: import lutris lutris_module = lutris.__file__ data_path = os.path.join(os.path.dirname(os.path.dirname(lutris_module)), "share/lutris") if not system.path_exists(data_path): raise IOError("data_path can't be found at : %s" % data_path) return data_path lutris-0.5.14/lutris/util/discord/000077500000000000000000000000001451435154700170625ustar00rootroot00000000000000lutris-0.5.14/lutris/util/discord/__init__.py000066400000000000000000000000561451435154700211740ustar00rootroot00000000000000__all__ = ['client'] from .rpc import client lutris-0.5.14/lutris/util/discord/base.py000066400000000000000000000010251451435154700203440ustar00rootroot00000000000000""" Discord Rich Presence Base Objects """ 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.14/lutris/util/discord/client.py000066400000000000000000000017271451435154700207210ustar00rootroot00000000000000from 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.14/lutris/util/discord/rpc.py000066400000000000000000000010341451435154700202160ustar00rootroot00000000000000""" Discord Rich Presence Loader This will enable DiscordRichPresenceClient if pypresence is installed. Otherwise, it will provide a dummy client that does nothing """ 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.14/lutris/util/display.py000066400000000000000000000355361451435154700174660ustar00rootroot00000000000000"""Module to deal with various aspects of displays""" # isort:skip_file import enum import os import subprocess 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 system from lutris.settings import DEFAULT_RESOLUTION_HEIGHT, DEFAULT_RESOLUTION_WIDTH from lutris.util.graphics import drivers from lutris.util.graphics.displayconfig import MutterDisplayManager from lutris.util.graphics.xrandr import LegacyDisplayManager, change_resolution, get_outputs from lutris.util.log import logger class NoScreenDetected(Exception): """Raise this when unable to detect screens""" 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 def restore_gamma(): """Restores gamma to a normal level.""" xgamma_path = system.find_executable("xgamma") try: subprocess.Popen([xgamma_path, "-gamma", "1.0"]) # pylint: disable=consider-using-with except (FileNotFoundError, TypeError): logger.warning("xgamma is not available on your system") except PermissionError: logger.warning("you do not have permission to call xgamma") def has_graphic_adapter_description(match_text): """Returns True if a graphics adapter is found with 'match_text' in its description.""" for adapter in _get_graphics_adapters(): if match_text in adapter[1]: return True return False 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_gpus()} 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 _get_graphics_adapters(): """Return the list of graphics cards available on a system Returns: list: list of tuples containing PCI ID and description of the display controller """ lspci_path = system.find_executable("lspci") dev_subclasses = ["VGA", "XGA", "3D controller", "Display controller"] if not lspci_path: logger.warning("lspci is not available. List of graphics cards not available") return [] return [ (pci_id, device_desc.split(": ")[1]) for pci_id, device_desc in [ line.split(maxsplit=1) for line in system.execute(lspci_path, timeout=3).split("\n") if any(subclass in line for subclass in dev_subclasses) ] ] class DisplayManager: """Get display and resolution using GnomeDesktop""" def __init__(self): if not LIB_GNOME_DESKTOP_AVAILABLE: logger.warning("libgnomedesktop unavailable") return screen = Gdk.Screen.get_default() if not screen: raise NoScreenDetected 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") resolutions = ['%dx%d' % (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: return DisplayManager() except (GLib.Error, NoScreenDetected): pass return LegacyDisplayManager() DISPLAY_MANAGER = get_display_manager() USE_DRI_PRIME = len(list(drivers.get_gpus())) > 1 class DesktopEnvironment(enum.Enum): """Enum of desktop environments.""" PLASMA = 0 MATE = 1 XFCE = 2 DEEPIN = 3 UNKNOWN = 999 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(): """Checks whether compositing is currently disabled or enabled. Returns True for enabled, False for disabled, and None if unknown. """ desktop_environment = get_desktop_environment() if desktop_environment is DesktopEnvironment.PLASMA: return _get_command_output( "qdbus", "org.kde.KWin", "/Compositor", "org.kde.kwin.Compositing.active" ) == b"true\n" if desktop_environment is DesktopEnvironment.MATE: return _get_command_output("gsettings", "get org.mate.Marco.general", "compositing-manager") == b"true\n" if desktop_environment is DesktopEnvironment.XFCE: return _get_command_output( "xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing" ) == b"true\n" if desktop_environment is DesktopEnvironment.DEEPIN: return _get_command_output( "dbus-send", "--session", "--dest=com.deepin.WMSwitcher", "--type=method_call", "--print-reply=literal", "/com/deepin/WMSwitcher", "com.deepin.WMSwitcher.CurrentWM" ) == b"deepin wm\n" return None # 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 = [] def _get_compositor_commands(): """Returns the commands to enable/disable compositing on the current desktop environment as a 2-tuple. """ start_compositor = None stop_compositor = None desktop_environment = get_desktop_environment() if desktop_environment is DesktopEnvironment.PLASMA: stop_compositor = ("qdbus", "org.kde.KWin", "/Compositor", "org.kde.kwin.Compositing.suspend") start_compositor = ("qdbus", "org.kde.KWin", "/Compositor", "org.kde.kwin.Compositing.resume") elif desktop_environment is DesktopEnvironment.MATE: stop_compositor = ("gsettings", "set org.mate.Marco.general", "compositing-manager", "false") start_compositor = ("gsettings", "set org.mate.Marco.general", "compositing-manager", "true") elif desktop_environment is DesktopEnvironment.XFCE: stop_compositor = ("xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing", "--set=false") start_compositor = ("xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing", "--set=true") elif desktop_environment is DesktopEnvironment.DEEPIN: start_compositor = ( "dbus-send", "--session", "--dest=com.deepin.WMSwitcher", "--type=method_call", "/com/deepin/WMSwitcher", "com.deepin.WMSwitcher.RequestSwitchWM", ) stop_compositor = start_compositor return start_compositor, stop_compositor def _run_command(*command): """Random _run_command lost in the middle of the project, are you lost little _run_command? """ try: return subprocess.Popen( # pylint: disable=consider-using-with command, stdin=subprocess.DEVNULL, close_fds=True ) except FileNotFoundError: logger.error("Oh no") def disable_compositing(): """Disable compositing if not already disabled.""" compositing_enabled = is_compositing_enabled() if compositing_enabled is None: compositing_enabled = True if any(_COMPOSITING_DISABLED_STACK): compositing_enabled = False _COMPOSITING_DISABLED_STACK.append(compositing_enabled) if not compositing_enabled: return _, stop_compositor = _get_compositor_commands() if stop_compositor: _run_command(*stop_compositor) 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, _ = _get_compositor_commands() if start_compositor: _run_command(*start_compositor) 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.14/lutris/util/dolphin.py000066400000000000000000000024361451435154700174470ustar00rootroot00000000000000# 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.14/lutris/util/dolphin/000077500000000000000000000000001451435154700170705ustar00rootroot00000000000000lutris-0.5.14/lutris/util/dolphin/__init__.py000066400000000000000000000000001451435154700211670ustar00rootroot00000000000000lutris-0.5.14/lutris/util/dolphin/cache_reader.py000066400000000000000000000110671451435154700220340ustar00rootroot00000000000000"""Reads the Dolphin game database, stored in a binary format""" import os from lutris.util.log import logger DOLPHIN_GAME_CACHE_FILE = os.path.expanduser("~/.cache/dolphin-emu/gamelist.cache") SUPPORTED_CACHE_VERSION = 24 def get_hex_string(string): """Return the hexadecimal representation of a string""" return " ".join("{:02x}".format(c) for c in string) def get_word_len(string): """Return the length of a string as specified in the Dolphin format""" return int("0x" + "".join("{:02x}".format(c) for c in string[::-1]), 0) # https://github.com/dolphin-emu/dolphin/blob/90a994f93780ef8a7cccfc02e00576692e0f2839/Source/Core/UICommon/GameFile.h#L140 # https://github.com/dolphin-emu/dolphin/blob/90a994f93780ef8a7cccfc02e00576692e0f2839/Source/Core/UICommon/GameFile.cpp#L318 class DolphinCacheReader: header_size = 20 structure = { 'valid': 'b', 'file_path': 's', 'file_name': 's', 'file_size': 8, 'volume_size': 8, 'volume_size_type': 4, 'is_datel_disc': 'b', 'is_nkit': 'b', 'short_names': 'a', 'long_names': 'a', 'short_makers': 'a', 'long_makers': 'a', 'descriptions': 'a', 'internal_name': 's', 'game_id': 's', 'gametdb_id': 's', 'title_id': 8, 'maker_id': 's', 'region': 4, 'country': 4, 'platform': 1, 'platform_': 3, 'blob_type': 4, 'block_size': 8, 'compression_method': 's', 'revision': 2, 'disc_number': 1, 'apploader_date': 's', 'custom_name': 's', 'custom_description': 's', 'custom_maker': 's', 'volume_banner': 'i', 'custom_banner': 'i', 'default_cover': 'c', 'custom_cover': 'c', } def __init__(self): self.offset = 0 with open(DOLPHIN_GAME_CACHE_FILE, "rb") as dolphin_cache_file: self.cache_content = dolphin_cache_file.read() cache_version = get_word_len(self.cache_content[:4]) if cache_version != SUPPORTED_CACHE_VERSION: logger.warning("Dolphin cache version expected %s but found %s", SUPPORTED_CACHE_VERSION, cache_version) def get_game(self): game = {} for key, i in self.structure.items(): if i == 's': game[key] = self.get_string() elif i == 'b': game[key] = self.get_boolean() elif i == 'a': game[key] = self.get_array() elif i == 'i': game[key] = self.get_image() elif i == 'c': game[key] = self.get_cover() else: game[key] = self.get_raw(i) return game def get_games(self): self.offset += self.header_size games = [] while self.offset < len(self.cache_content): try: games.append(self.get_game()) except Exception as ex: logger.error("Failed to read Dolphin database: %s", ex) return games def get_boolean(self): res = bool(get_word_len(self.cache_content[self.offset:self.offset + 1])) self.offset += 1 return res def get_array(self): array_len = get_word_len(self.cache_content[self.offset:self.offset + 4]) self.offset += 4 array = {} for _i in range(array_len): array_key = self.get_raw(4) array[array_key] = self.get_string() return array def get_image(self): data_len = get_word_len(self.cache_content[self.offset:self.offset + 4]) self.offset += 4 res = self.cache_content[self.offset:self.offset + data_len * 4] # vector self.offset += data_len * 4 width = get_word_len(self.cache_content[self.offset:self.offset + 4]) self.offset += 4 height = get_word_len(self.cache_content[self.offset:self.offset + 4]) self.offset += 4 return (width, height), res def get_cover(self): array_len = get_word_len(self.cache_content[self.offset:self.offset + 4]) self.offset += 4 return self.get_raw(array_len) def get_raw(self, word_len): res = get_hex_string(self.cache_content[self.offset:self.offset + word_len]) self.offset += word_len return res def get_string(self): word_len = get_word_len(self.cache_content[self.offset:self.offset + 4]) self.offset += 4 string = self.cache_content[self.offset:self.offset + word_len] self.offset += word_len return string.decode('utf8') lutris-0.5.14/lutris/util/downloader.py000066400000000000000000000200031451435154700201360ustar00rootroot00000000000000import os import threading import time 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=None, cookies=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.speed = 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.speed = 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=1024): 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.speed, 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 (speed, average speed) tuple.""" elapsed_time = get_time() - self.last_check_time chunk_size = self.downloaded_size - self.last_size speed = chunk_size / elapsed_time or 1 self.last_speeds.append(speed) # Average speed if get_time() - self.speed_check_time < 1: # Minimum delay return self.speed, self.average_speed while len(self.last_speeds) > 20: self.last_speeds.pop(0) if len(self.last_speeds) > 7: # Skim 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 speed, 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.14/lutris/util/egs/000077500000000000000000000000001451435154700162115ustar00rootroot00000000000000lutris-0.5.14/lutris/util/egs/__init__.py000066400000000000000000000000001451435154700203100ustar00rootroot00000000000000lutris-0.5.14/lutris/util/egs/egs_launcher.py000066400000000000000000000017311451435154700212240ustar00rootroot00000000000000"""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.14/lutris/util/extract.py000066400000000000000000000246751451435154700174750ustar00rootroot00000000000000import gzip import os import shutil import subprocess import tarfile import uuid import zlib from lutris import settings from lutris.util import system from lutris.util.log import logger class ExtractFailure(Exception): """Exception raised when and archive fails to extract""" def random_id(): """Return a random ID""" return str(uuid.uuid4())[:8] def is_7zip_supported(path, extractor): supported_extractors = ( "7z", "xz", "bzip2", "gzip", "tar", "zip", "ar", "arj", "cab", "chm", "cpio", "cramfs", "dmg", "ext", "fat", "gpt", "hfs", "ihex", "iso", "lzh", "lzma", "mbr", "msi", "nsis", "ntfs", "qcow2", "rar", "rpm", "squashfs", "udf", "uefi", "vdi", "vhd", "vmdk", "wim", "xar", "z", "auto", ) if extractor: return extractor.lower() in supported_extractors _base, ext = os.path.splitext(path) if ext: ext = ext.lstrip(".").lower() return ext in supported_extractors def guess_extractor(path): """Guess what extractor should be used from a file name""" if path.endswith(".tar"): extractor = "tar" elif path.endswith((".tar.gz", ".tgz")): extractor = "tgz" elif path.endswith((".tar.xz", ".txz", ".tar.lzma")): extractor = "txz" elif path.endswith((".tar.bz2", ".tbz2", ".tbz")): extractor = "tbz2" elif path.endswith((".tar.zst", ".tzst")): extractor = "tzst" elif path.endswith(".gz"): extractor = "gzip" elif path.endswith(".exe"): extractor = "exe" elif path.endswith(".deb"): extractor = "deb" elif path.endswith(".AppImage"): extractor = "AppImage" else: extractor = None return extractor def get_archive_opener(extractor): """Return the archive opener and optional mode for an extractor""" mode = None if extractor == "tar": opener, mode = tarfile.open, "r:" elif extractor == "tgz": opener, mode = tarfile.open, "r:gz" elif extractor == "txz": opener, mode = tarfile.open, "r:xz" elif extractor in ("tbz2", "bz2"): # bz2 is used for .tar.bz2 in some installer scripts opener, mode = tarfile.open, "r:bz2" elif extractor == "tzst": opener, mode = tarfile.open, "r:zst" # Note: not supported by tarfile yet elif extractor == "gzip": opener = "gz" elif extractor == "gog": opener = "innoextract" elif extractor == "exe": opener = "exe" elif extractor == "deb": opener = "deb" elif extractor == "AppImage": opener = "AppImage" else: opener = "7zip" return opener, mode def extract_archive(path: str, to_directory: str = ".", merge_single: bool = True, extractor=None): path = os.path.abspath(path) logger.debug("Extracting %s to %s", path, to_directory) if extractor is None: extractor = guess_extractor(path) opener, mode = get_archive_opener(extractor) temp_path = temp_dir = os.path.join(to_directory, ".extract-%s" % random_id()) try: _do_extract(path, temp_path, opener, mode, extractor) except (OSError, zlib.error, tarfile.ReadError, EOFError) as ex: logger.error("Extraction failed: %s", ex) raise ExtractFailure(str(ex)) from ex if merge_single: extracted = os.listdir(temp_path) if len(extracted) == 1: temp_path = os.path.join(temp_path, extracted[0]) if os.path.isfile(temp_path): destination_path = os.path.join(to_directory, extracted[0]) if os.path.isfile(destination_path): logger.warning("Overwrite existing file %s", destination_path) os.remove(destination_path) if os.path.isdir(destination_path): os.rename(destination_path, destination_path + random_id()) shutil.move(temp_path, to_directory) os.removedirs(temp_dir) else: for archive_file in os.listdir(temp_path): source_path = os.path.join(temp_path, archive_file) destination_path = os.path.join(to_directory, archive_file) # logger.debug("Moving extracted files from %s to %s", source_path, destination_path) if system.path_exists(destination_path): logger.warning("Overwrite existing path %s", destination_path) if os.path.isfile(destination_path): os.remove(destination_path) shutil.move(source_path, destination_path) elif os.path.isdir(destination_path): try: system.merge_folders(source_path, destination_path) except OSError as ex: logger.error( "Failed to merge to destination %s: %s", destination_path, ex, ) raise ExtractFailure(str(ex)) from ex else: shutil.move(source_path, destination_path) system.remove_folder(temp_dir) logger.debug("Finished extracting %s to %s", path, to_directory) return path, to_directory def _do_extract(archive, dest, opener, mode=None, extractor=None): if opener == "gz": decompress_gz(archive, dest) elif opener == "7zip": extract_7zip(archive, dest, archive_type=extractor) elif opener == "exe": extract_exe(archive, dest) elif opener == "innoextract": extract_gog(archive, dest) elif opener == "deb": extract_deb(archive, dest) elif opener == "AppImage": extract_AppImage(archive, dest) else: handler = opener(archive, mode) handler.extractall(dest) handler.close() def extract_exe(path, dest): if check_inno_exe(path): decompress_gog(path, dest) else: # use 7za to check if exe is an archive _7zip_path = os.path.join(settings.RUNTIME_DIR, "p7zip/7za") if not system.path_exists(_7zip_path): _7zip_path = system.find_executable("7za") if not system.path_exists(_7zip_path): raise OSError("7zip is not found in the lutris runtime or on the system") command = [_7zip_path, "t", path] return_code = subprocess.call(command) if return_code == 0: extract_7zip(path, dest) else: raise RuntimeError("specified exe is not an archive or GOG setup file") def extract_deb(archive, dest): """Extract the contents of a deb file to a destination folder""" extract_7zip(archive, dest, archive_type="ar") debian_folder = os.path.join(dest, "debian") os.makedirs(debian_folder) control_file_exts = [".gz", ".xz", ".zst", ""] for extension in control_file_exts: control_tar_path = os.path.join(dest, "control.tar{}".format(extension)) if os.path.exists(control_tar_path): shutil.move(control_tar_path, debian_folder) break data_file_exts = [".gz", ".xz", ".zst", ".bz2", ".lzma", ""] for extension in data_file_exts: data_tar_path = os.path.join(dest, "data.tar{}".format(extension)) if os.path.exists(data_tar_path): extract_archive(data_tar_path, dest) os.remove(data_tar_path) break def extract_AppImage(path, dest): """This is really here to prevent 7-zip from extracting the AppImage; we want to just use this sort of file as-is.""" system.create_folder(dest) shutil.copy(path, dest) def extract_gog(path, dest): if check_inno_exe(path): decompress_gog(path, dest) else: raise RuntimeError("specified exe is not a GOG setup file") def get_innoextract_path(): """Return the path where innoextract is installed""" inno_dirs = [path for path in os.listdir(settings.RUNTIME_DIR) if path.startswith("innoextract")] if inno_dirs: inno_path = os.path.join(settings.RUNTIME_DIR, inno_dirs[0], "innoextract") else: inno_path = system.find_executable("innoextract") if inno_path: logger.warning("innoextract not available in the runtime folder, using some random version") if system.path_exists(inno_path): return inno_path def check_inno_exe(path): """Check if a path in a compatible innosetup archive""" _innoextract_path = get_innoextract_path() if not _innoextract_path: logger.warning("Innoextract not found, can't determine type of archive %s", path) return False command = [_innoextract_path, "-i", path] return_code = subprocess.call(command) return return_code == 0 def get_innoextract_list(file_path): """Return the list of files contained in a GOG archive""" output = system.read_process_output([get_innoextract_path(), "-lmq", file_path]) return [line[3:] for line in output.split("\n") if line] def decompress_gog(file_path, destination_path): innoextract_path = get_innoextract_path() if not innoextract_path: raise OSError("innoextract is not found in the lutris runtime or on the system") system.create_folder(destination_path) # innoextract cannot do mkdir -p return_code = subprocess.call([innoextract_path, "-m", "-g", "-d", destination_path, "-e", file_path]) if return_code != 0: raise RuntimeError("innoextract failed to extract GOG setup file") def decompress_gz(file_path, dest_path): """Decompress a gzip file.""" if dest_path: dest_filename = os.path.join(dest_path, os.path.basename(file_path[:-3])) else: dest_filename = file_path[:-3] os.makedirs(os.path.dirname(dest_filename), exist_ok=True) with open(dest_filename, "wb") as dest_file: gzipped_file = gzip.open(file_path, "rb") dest_file.write(gzipped_file.read()) gzipped_file.close() return dest_path def extract_7zip(path, dest, archive_type=None): _7zip_path = os.path.join(settings.RUNTIME_DIR, "p7zip/7z") if not system.path_exists(_7zip_path): _7zip_path = system.find_executable("7z") if not system.path_exists(_7zip_path): raise OSError("7zip is not found in the lutris runtime or on the system") command = [_7zip_path, "x", path, "-o{}".format(dest), "-aoa"] if archive_type and archive_type != "auto": command.append("-t{}".format(archive_type)) subprocess.call(command) lutris-0.5.14/lutris/util/fileio.py000066400000000000000000000047031451435154700172600ustar00rootroot00000000000000# 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