pax_global_header00006660000000000000000000000064146056201050014511gustar00rootroot0000000000000052 comment=d3fd93dfe6d4017592c3296ae04d4d23e4dba98d lutris-0.5.17/000077500000000000000000000000001460562010500131255ustar00rootroot00000000000000lutris-0.5.17/.editorconfig000066400000000000000000000005741460562010500156100ustar00rootroot00000000000000# 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.17/.github/000077500000000000000000000000001460562010500144655ustar00rootroot00000000000000lutris-0.5.17/.github/FUNDING.yml000066400000000000000000000001041460562010500162750ustar00rootroot00000000000000patreon: lutris liberapay: Lutris custom: https://lutris.net/donate lutris-0.5.17/.github/ISSUE_TEMPLATE/000077500000000000000000000000001460562010500166505ustar00rootroot00000000000000lutris-0.5.17/.github/ISSUE_TEMPLATE/bug_report_form.yml000066400000000000000000000073331460562010500225740ustar00rootroot00000000000000name: Bug Report description: File a bug report labels: ["needs triage"] body: - type: markdown id: importantnotice attributes: value: | ### Important notice **This repository is not a place for requesting support with a game, runner or an installer.** *DO NOT REPORT BUGS FOR OLD LUTRIS VERSIONS. MAKE SURE YOU UP TO DATE AND ARE USING THE LATEST VERSION AVAILABLE. *BUGS REPORTS SUBMITTED ON OLD VERSIONS WILL BE CLOSED WITHOUT FURTHER CONSIDERATION. *If you have issues installing a game or launching it*, make sure you follow our essential guides on [graphics driver installation](https://github.com/lutris/docs/blob/master/InstallingDrivers.md) and [dependencies for the wine runner](https://github.com/lutris/docs/blob/master/WineDependencies.md). *If you followed the guides and the issues persist*, try asking for help in our official [Lutris Discord Server](https://discord.com/invite/Pnt5CuY). *If you think there is a legitimate issue with our Lutris Wine builds*, open an issue in our [Lutris Wine repository](https://github.com/lutris/wine) instead. *If you think Lutris is missing an important feature*, try opening a *Feature Request* instead. - type: textarea id: description attributes: label: "Bug description" description: "A clear and detailed description of what the bug is. placeholder: "Tell us about your problem with Lutris in a clear and detailed way" validations: required: true - type: textarea id: howtoreproduce attributes: label: How to Reproduce description: "Steps to reproduce the behavior and what should be observed in the end." placeholder: "Tell us step by step how we can replicate your problem and what we should see in the end" value: | Steps to reproduce the behavior: 1. Go to '....' 2. Click on '....' 3. Do '....' 4. See '....' validations: required: true - type: textarea id: expected-behavior attributes: label: "Expected behavior" description: "A clear and detailed description of what you think should happen." placeholder: "Tell us what you expected Lutris to do" validations: required: true - type: textarea id: logs attributes: label: Log output description: "Close Lutris, launch it in the terminal using the command `lutris -d`, reproduce your issue, then paste all of the output from the terminal here. Do not shortnen/prune the output." placeholder: "Full output from the `lutris -d` command after reproducing the issue" render: shell validations: required: true - type: textarea id: sysinfo attributes: label: "System Information" description: "An auto-generated summary about system configuration from the Lutris settings." placeholder: "Paste it in this text form" render: shell validations: required: true - type: markdown id: systemsummarygif attributes: value: | ***You can obtain a quick system summary from within Lutris like this:*** ![Peek 2021-11-12 05-09-2](https://user-images.githubusercontent.com/10602045/142093883-fb1169f2-28ab-4382-8e54-d7de9c96243e.gif) - type: textarea id: media attributes: label: Media (optional) description: "Screenshots or a Peek recorded GIF that showcases the problem." placeholder: If applicable, click on this form to activate it, then attach a GIF or a screenshot of the issue here by selecting or drag-and-dropping it - type: markdown id: peekinfo attributes: value: | ***Learn more about Peek and how it can record a GIF of your desktop here: https://github.com/phw/peek*** lutris-0.5.17/.github/scripts/000077500000000000000000000000001460562010500161545ustar00rootroot00000000000000lutris-0.5.17/.github/scripts/build-ubuntu-22.04.sh000077500000000000000000000026121460562010500215760ustar00rootroot00000000000000#!/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.17/.github/scripts/build-ubuntu-generic.sh000077500000000000000000000047331460562010500225530ustar00rootroot00000000000000#!/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.17/.github/scripts/build-ubuntu.sh000077500000000000000000000137301460562010500211360ustar00rootroot00000000000000#!/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.17/.github/scripts/install-ubuntu-generic.sh000077500000000000000000000004001460562010500231050ustar00rootroot00000000000000#!/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.17/.github/scripts/install-ubuntu.sh000077500000000000000000000032541460562010500215050ustar00rootroot00000000000000#!/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.17/.github/workflows/000077500000000000000000000000001460562010500165225ustar00rootroot00000000000000lutris-0.5.17/.github/workflows/publish-lutris-ppa.yml000066400000000000000000000033161460562010500230140ustar00rootroot00000000000000# 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.17/.github/workflows/publish-ppa.yml000066400000000000000000000045271460562010500215010ustar00rootroot00000000000000# 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.17/.github/workflows/static.yml000066400000000000000000000024121460562010500205330ustar00rootroot00000000000000on: [push, pull_request] name: Static Analysis jobs: mypy-checker: name: Mypy runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Print current dir run: pwd - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install Ubuntu dependencies run: | sudo apt update sudo apt-get install libdbus-1-dev pkg-config libgirepository1.0-dev - name: Install Python dependencies run: | python -m pip install --upgrade pip make req-python make dev - name: Run mypy analysis run: | mypy --version mypy . ruff-checker: name: Ruff runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" - name: Install Python dependencies run: | python -m pip install --upgrade pip make dev - name: Check code style run: ruff --version ruff check . - name: Check format continue-on-error: true # TODO remove this line when format is applied run: ruff format . --check lutris-0.5.17/.gitignore000066400000000000000000000005141460562010500151150ustar00rootroot00000000000000nbproject 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.17/.mypy_baseline000066400000000000000000000000011460562010500157550ustar00rootroot00000000000000 lutris-0.5.17/AUTHORS000066400000000000000000000032721460562010500142010ustar00rootroot00000000000000Copyright (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.17/CONTRIBUTING.md000066400000000000000000000154751460562010500153720ustar00rootroot00000000000000Contributing to Lutris ====================== Finding features to work on --------------------------- If you are looking for issues to work on, have a look at the [milestones](https://github.com/lutris/lutris/milestones) and see which one is the closest to release then look at the tickets targeted at this release. Don't forget that Lutris is not only a desktop client, there are also a lot of issues to work on [on the website](https://github.com/lutris/website/issues) and also in the [build scripts repository](https://github.com/lutris/buildbot) where you can submit bash scripts for various open source games and engines we do not already have. Another area where users can help is [confirming some issues](https://github.com/lutris/lutris/issues?q=is%3Aissue+is%3Aopen+label%3A%22can%27t+reproduce%22+) that can't be reproduced on the developers setup. Please make sure that you're able to reproduce an issue before attempting to fix it. Note that Lutris is not a playground or a toy project. One cannot submit new features that aren't on the roadmap and submit a pull request for them without agreeing on a design first with the development team. Please get in touch with the developers before writing any code, so that you don't waste your efforts on something that isn't going to be merged. Make sure to post all the relevant information in a ticket or on the pull request. New features must at all times have a valid use case based on an actual game, be very specific about why you are implementing a feature otherwise it will get rejected. Avoid adding options in the GUI or introducing new installer directives for things that can be automated. Lutris focuses heavily on automation and on doing the right thing by default. Only introduce new options when absolutely necessary. Contributors are welcome to suggest architectural changes or better code design if they feel like the current implementation should be improved but please take note that we're trying to stay as lean as possible. Requests introducing complex architectural changes for the sake of "modularity", "Unix pureness" or subjective aspects might not be received warmly. There are no current plans for any rewrite in another language. Once again, make sure to discuss any change with a core developer before writing a large amount of code. Keeping your pull requests as small as a possible is the best way to have them reviewed and merged quickly. Running Lutris from Git ----------------------- Running Lutris from a local git repository is easy, it only requires cloning the repository and executing Lutris from there. git clone https://github.com/lutris/lutris cd lutris ./bin/lutris -d Make sure you have all necessary dependencies installed. It is recommended that you keep a copy of the stable version installed with your package manager to ensure that all dependencies are available. If you are working on newly written code that might introduce new dependencies, check in the package configuration files for new packages to install. Debian based distros will have their dependencies listed in `debian/control` and RPM based ones in `lutris.spec`. The PyGOject introspection libraries are not regular python packages, for that reason, using a virtualenv for development is heavily discouraged. Make sure to always use PyGOject from your distribution's package manager. Also install the necessary GObject bindings as described in the INSTALL file. Set up your development environment ----------------------------------- To ensure you have the proper tools installed run `make dev` This will install all necessary python to allow testing and validating your code. This project includes .editorconfig so you're good to go if you're using any editor/IDE that supports this. Otherwise make sure to configure your max line length to 120, indent style to space and always end files with an empty new line. Formatting your code -------------------- To ensure getting your contributions getting merged faster and to avoid other developers from going back and fixing your code, please make sure your code passes style checks by running `make sc` and fixing any reported issues before submitting your code. This runs a series of tools to apply PEP 8 coding style conventions, sorting and grouping imports and checking for formatting issues and other code smells. You can help fix formatting issues or other code smells by having a look at the CodeFactor page: https://www.codefactor.io/repository/github/lutris/lutris Writing tests ------------- If your patch does not require interactions with a GUI or external processes, please consider adding unit tests for your code. Have a look at the existing test suite in the `tests` folder to see what kind of features are tested. Running tests ------------- Be sure to test your changes thoroughly, never submit changes without running the code. Also run the test suite and check that nothing broke. You can run the test suite by typing `make test` in the source directory. QAing your changes ------------------ It is very important that any of your changes be tested manually, especially if you didn't add unit tests. Even trivial changes should be tested as they could potentially introduce breaking changes from a simple oversight. Submitting your changes ----------------------- Make a new git branch based of `master` in most cases. Send a pull request through GitHub describing what issue the patch solves. If the PR is related to and existing bug report, you can add `(Closes #nnnn)` or `(Fixes #nnnn)` to your PR title or message, where `nnnn` is the ticket number you're fixing. If you contribute to Lutris on a somewhat regular basis, be sure to add yourself to the AUTHORS file! Developer resources ------------------- Lutris uses Python 3 and GObject / Gtk+ 3 as its core stack, here are some links to some resources that can help you familiarize yourself with the project's code base. * [Python 3 documentation](https://docs.python.org/3/) * [PyGObject documentation](https://pygobject.readthedocs.io/en/latest/) * [Python Gtk 3 tutorial](https://python-gtk-3-tutorial.readthedocs.io/en/latest/objects.html) Project structure ----------------- [root]-+ Config files and READMEs | +-[bin] Main lutris executable script +-[debian] Debian / Ubuntu packaging configuration +-[docs] User documentation +-[lutris]-+ Source folder | | | +-[gui] Gtk UI code | +-[installer] Install script interpreter | +-[migrations] Migration scripts for user side changes | +-[runners] Runner code, detailing launch options and settings | +-[services] External services (Steam, GOG, ...) | +-[util] Generic utilities | +-[po] Translation files +-[share] Lutris resources like icons, ui files, scripts +-[tests] Unit tests lutris-0.5.17/INSTALL.rst000066400000000000000000000056031460562010500147710ustar00rootroot00000000000000Installing 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.8 * 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 Run Lutris ----------- If you installed Lutris using a package, you can launch the program by typing ``lutris`` at the command line. And if you want to run Lutris from the source tree, type ``./bin/lutris`` lutris-0.5.17/LICENSE000066400000000000000000001043741460562010500141430ustar00rootroot00000000000000 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.17/MANIFEST.in000066400000000000000000000002211460562010500146560ustar00rootroot00000000000000recursive-include lutris *.py include bin/lutris include LICENSE include AUTHORS include MANIFEST.in include README.rst graft debian prune tests lutris-0.5.17/Makefile000066400000000000000000000061071460562010500145710ustar00rootroot00000000000000VERSION=`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 req-python: pip3 install PyYAML lxml requests Pillow setproctitle python-magic distro dbus-python types-requests \ types-PyYAML evdev PyGObject pypresence protobuf moddb dev: pip3 install ruff==0.3.5 mypy==1.8.0 mypy-baseline nose2 # ============ # Style checks # ============ style: ruff format . --check format: ruff check --select I --fix ruff format . # =============== # Static analysis # =============== check: ruff_lint mypy ruff_lint: ruff 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.17/README.rst000066400000000000000000000132371460562010500146220ustar00rootroot00000000000000****** 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 * Mastodon: https://fosstodon.org/@lutris .. |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.17/bin/000077500000000000000000000000001460562010500136755ustar00rootroot00000000000000lutris-0.5.17/bin/lutris000077500000000000000000000044431460562010500151520ustar00rootroot00000000000000#!/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. if os.environ.get("LUTRIS_ALLOW_LOCAL_PYTHON_PACKAGES") != "1": 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 if "WEBKIT_DISABLE_DMABUF_RENDERER" not in os.environ: os.environ["WEBKIT_DISABLE_DMABUF_RENDERER"] = "1" 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.17/debian/000077500000000000000000000000001460562010500143475ustar00rootroot00000000000000lutris-0.5.17/debian/changelog000066400000000000000000001561211460562010500162270ustar00rootroot00000000000000lutris (0.5.17) jammy; urgency=medium * Fix critical bug preventing completion of installs if the script specifies a wine version * Fix critical bug preventing Steam library sync * Fix critical bug preventing game or runner uninstall in Flatpak * Support for library sync to lutris.net, this allows to sync games, play time and categories to multiple devices. * Remove "Lutris" service view; with library sync the "Games" view replaces it. * Torturous and sadistic options for multi-GPUs that were half broken and understood by no one have been replaced by a simple GPU selector. * EXPERIMENTAL support for umu, which allows running games with Proton and Pressure Vessel. Using Proton in Lutris without umu is no longer possible. * Better and sensible sorting for games (sorting by playtime or last played no longer needs to be reversed) * Support the "Categories" command when you select multiple games * Notification bar when your Lutris is no longer supported * Improved error dialog. * Add Vita3k runner (thanks @ItsAllAboutTheCode) * Add Supermodel runner * WUA files are now supported in Cemu * "Show Hidden Games" now displays the hidden games in a separate view, and re-hides them as soon as you leave it. * Support transparent PNG files for custom banner and cover-art * Images are now downloaded for manually added games. * Deprecate 'exe', 'main_file' or 'iso' placed at the root of the script, all lutris.net installers have been updated accordingly. * Deprecate libstrangle and xgamma support. * Deprecate DXVK state cache feature (it was never used and is no longer relevant to DXVK 2) -- Mathieu Comandon Wed, 03 Apr 2024 13:49:49 -0700 lutris (0.5.16) jammy; urgency=medium * Fix bug that prevented installers to complete * Better handling of Steam configurations for the Steam account picker * Load game library in a background thread -- Mathieu Comandon Mon, 15 Jan 2024 16:10:35 -0800 lutris (0.5.15) jammy; urgency=medium * Fix some crashes happening when using Wayland and a high DPI gaming mouse * Fix crash when opening the system preferences tab for a game * Reduced the locales list to a predefined one (let us know if you need yours added) * Fix Lutris not expanding "~" in paths * Download runtime components from the main window, the "updating runtime" dialog appearing before Lutris opens has been removed * Add the ability to open a location in your file browser from file picker widgets * Add the ability to select, remove, or stop multiple games in the Lutris window * Redesigned 'Uninstall Game' dialog now completely removes games by default * Fix the export / import feature * Show an animation when a game is launched * Add the ability to disable Wine auto-updates at the expense of losing support * Add playtime editing in the game preferences * Move game files, runners to the trash instead of deleting them they are uninstalled * Add "Updates" tab in Preferences control and check for updates and correct missing media in the 'Games' view. * Add "Storage" tab in Preferences to control game and installer cache location * Expand "System" tab in Preferences with more system information but less brown. * Add "Run Task Manager" command for Wine games * Add two new, smaller banner sizes for itch.io games. * Ignore Wine virtual desktop setting when using Wine-GE/Proton to avoid crash * Ignore MangoHUD setting when launching Steam to avoid crash * Sync Steam playtimes with the Lutris library -- Mathieu Comandon Sun, 07 Jan 2024 17:29:46 -0800 lutris (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.17/debian/clean000066400000000000000000000000121460562010500153450ustar00rootroot00000000000000builddir/ lutris-0.5.17/debian/control000066400000000000000000000031451460562010500157550ustar00rootroot00000000000000Source: 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, Recommends: python3-evdev, python3-protobuf, gvfs-backends, libwine-development | libwine, winetricks, fluidsynth, gamescope, gamemode, xdg-desktop-portal, xdg-desktop-portal-gtk | xdg-desktop-portal-kde, 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.17/debian/copyright000066400000000000000000000012351460562010500163030ustar00rootroot00000000000000Format: 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.17/debian/rules000077500000000000000000000003651460562010500154330ustar00rootroot00000000000000#!/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.17/debian/source/000077500000000000000000000000001460562010500156475ustar00rootroot00000000000000lutris-0.5.17/debian/source/format000066400000000000000000000000151460562010500170560ustar00rootroot000000000000003.0 (native) lutris-0.5.17/docs/000077500000000000000000000000001460562010500140555ustar00rootroot00000000000000lutris-0.5.17/docs/installers.rst000066400000000000000000000776001460562010500170010ustar00rootroot00000000000000================== 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`` ``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 disable esync. (``esync: false``) ``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. ``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``) ``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``) 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.17/docs/moderating-installers.rst000066400000000000000000000042621460562010500211220ustar00rootroot00000000000000============================ Moderating Lutris installers ============================ Every Lutris installer must receive approval from a moderator before being public. Here are some guidelines on how to accept or reject installer submissions. Base guidelines =============== You should be comfortable with the syntax of Lutris install scripts and ideally have written a few scripts. You do not have to test the games for which you validate the script for. Valid submissions ================= Those cases are usually valid and can be accepted. - Version upgrade: The version number of a downloaded file is increased. - GOG installers with fixes, installers that differ enough from the default Lutris generated one - Steam installers for games without a Steam ID in the Lutris DB (usually free games) - Fixes to existing installers that make sense or provide a good reason for the fix - Simplification of installers. Removing Winetricks commands, removing the pinned wine version. Non ideal submissions but are most likely to get approved anyway ================================================================ - Installers that install a launcher like EGS or EA App for a single game, bypassing the Lutris integrations. - Scripts that reimplement existing Lutris features with Shell commands. - Windows games using a win32 prefix: With modern Wine, all games should work with a 64bit prefix Invalid submissions =================== - Unmodified content. While we have code to prevent this, some still manage to get through. - Suspicious URL changes. If a file changes to a different domain, make sure the new URL is referenced by the game's authors. - Version downgrade: Unless given very good reasons, downgrade in versions are rejected. - Runner changes: Submissions changing the runner to another one are almost always invalid. - Mentions of disabling the Lutris runtime: We make Lutris with the intention of having the runtime work. If it doesn't that's an user problem or something that needs to be fixed in the runtime. - Submissions for "Apes VS Helium". This games somehow attract kids who have no clue what Lutris installers are. - Submissions for "League of Legends". Most submissions should be rejected. lutris-0.5.17/docs/steam.rst000066400000000000000000000012461460562010500157230ustar00rootroot00000000000000 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.17/lutris.spec000066400000000000000000000123251460562010500153260ustar00rootroot00000000000000%global appid net.lutris.Lutris Name: lutris Version: 0.5.17 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.17/lutris/000077500000000000000000000000001460562010500144475ustar00rootroot00000000000000lutris-0.5.17/lutris/__init__.py000066400000000000000000000000621460562010500165560ustar00rootroot00000000000000"""Main Lutris package""" __version__ = "0.5.17" lutris-0.5.17/lutris/api.py000066400000000000000000000451241460562010500156000ustar00rootroot00000000000000"""Functions to interact with the Lutris REST API""" import json import os import re import socket import time import urllib.error import urllib.parse import urllib.request from collections import OrderedDict from datetime import datetime from gettext import gettext as _ from typing import Any, Dict, Optional, Tuple import requests from lutris import settings from lutris.gui.widgets import NotificationSource from lutris.util import cache_single, http, system from lutris.util.graphics.gpu import get_gpus_info from lutris.util.http import HTTPError, Request from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.strings import time_ago 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 get_runtime_versions_date() -> float: return os.path.getmtime(settings.RUNTIME_VERSIONS_PATH) def get_runtime_versions_date_time_ago() -> str: try: return time_ago(get_runtime_versions_date()) except FileNotFoundError: return _("never") def check_stale_runtime_versions() -> bool: """Check if the runtime needs to be updated""" try: modified_at = get_runtime_versions_date() should_update_at = modified_at + 6 * 60 * 60 logger.debug( "Modified at %s, will update after %s", datetime.fromtimestamp(modified_at).strftime("%c"), datetime.fromtimestamp(should_update_at).strftime("%c"), ) return should_update_at < time.time() except FileNotFoundError: return True def download_runtime_versions() -> Dict[str, Any]: """Queries runtime + runners + current client versions and stores the result in a file; the mdate of this file is used to decide when it is stale and should be replaced.""" gpus_info = get_gpus_info() pci_ids = [" ".join([gpu["PCI_ID"], gpu["PCI_SUBSYS_ID"]]) for gpu in gpus_info.values()] 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) get_default_wine_runner_version_info.cache_clear() return response.json def get_runtime_versions() -> Dict[str, Any]: """Load runtime versions from the json file that is created at startup if it is missing or stale.""" 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} LUTRIS_ACCOUNT_CONNECTED = NotificationSource() LUTRIS_ACCOUNT_DISCONNECTED = NotificationSource() 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)) account_info = fetch_user_info() if not account_info: logger.warning("Unable to fetch user info") else: with open(USER_INFO_FILE_PATH, "w", encoding="utf-8") as token_file: json.dump(account_info, token_file, indent=2) LUTRIS_ACCOUNT_CONNECTED.fire() 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) LUTRIS_ACCOUNT_DISCONNECTED.fire() def fetch_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() return response.json def read_user_info(): if not os.path.exists(USER_INFO_FILE_PATH): return {} with open(USER_INFO_FILE_PATH, encoding="utf-8") as user_info_file: user_info = json.load(user_info_file) return user_info 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 [] return runner_info.get("versions") or [] def format_runner_version(version_info: Dict[str, str]) -> str: version = version_info.get("version") or "" arch = version_info.get("architecture") return format_version_architecture(version, arch) def normalize_version_architecture(version_name: str) -> str: """Ensures the version name has an architecture on the end ofit; adds the system's architecture if it is missing.""" base_version, arch = parse_version_architecture(version_name) return format_version_architecture(base_version, arch) if base_version else "" def format_version_architecture(base_version: str, arch: Optional[str] = None) -> str: """Assembles a version with architecture from the version and architecture.""" if not base_version: return "" # A gross hack, since runner versions could be used with non-Wine runners, # but it so happens we don't. 'GE-Proton' versions arbitrarily do not have # an architecture on them - they are always 64-bit. if base_version.startswith("GE-Proton"): return base_version if arch: return "{}-{}".format(base_version, arch) return base_version def parse_version_architecture(version_name: str) -> Tuple[str, str]: """Splits a version that ends with an architecture into the plain version and architecture, as a tuple. If the version has no architecture, this provides the system's architecture instead.""" if version_name: if version_name.endswith("-i386") or version_name.endswith("-x86_64"): version, arch = version_name.rsplit("-", 1) return version, arch return version_name, LINUX_SYSTEM.arch def get_runner_version_from_cache(runner_name, version): # Prefer to provide the info from our local cache if we can; if this can't find # an unambiguous result, we'll fall back on the API which should know what the default is. version, _arch = parse_version_architecture(version or "") runtime_versions = get_runtime_versions() if runtime_versions: try: runner_versions = runtime_versions["runners"][runner_name] runner_versions = [r for r in runner_versions if r["architecture"] in (LINUX_SYSTEM.arch, "all")] if version: runner_versions = [r for r in runner_versions if r["version"] == version] if len(runner_versions) == 1: return runner_versions[0] except KeyError: pass return None def iter_get_from_api_candidates(versions: list, version: Optional[str], arch: Optional[str]): """A generator yielding possible version infos, or None for those that are available; we pick the first non-None value yielded.""" def select_info(predicate=None, accept_ambiguous=False): candidates = [v for v in versions if predicate(v)] if candidates and (accept_ambiguous or len(candidates) == 1): return candidates[0] return None if version: yield select_info(lambda v: v["architecture"] == arch and v["version"] == version) else: yield select_info(lambda v: v["architecture"] == arch and v["default"]) # Try various fallbacks to get some version - we prefer the default version # or a version with the right architecture. yield select_info(lambda v: v["architecture"] == arch and v["default"]) yield select_info(lambda v: v["architecture"] == arch) # 64-bit system can use 32-bit version, if it's the default. if LINUX_SYSTEM.is_64_bit: yield select_info(lambda v: v["default"]) yield select_info(lambda v: v["architecture"] == arch, accept_ambiguous=True) def get_runner_version_from_api(runner_name: str, version: Optional[str]): version, arch = parse_version_architecture(version or "") versions = download_runner_versions(runner_name) for candidate in iter_get_from_api_candidates(versions, version, arch): if candidate: if not version: return candidate if version == candidate.get("version") and arch == candidate.get("architecture"): return candidate return None def get_default_runner_version_info(runner_name: str, version: Optional[str] = None) -> Dict[str, str]: """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, an empty dict if the data can't be retrieved. If a pseudo-version is accepted, may be a dict containing only the version itself. """ return get_runner_version_from_cache(runner_name, version) or get_runner_version_from_api(runner_name, version) @cache_single def get_default_wine_runner_version_info(): """Just returns the runner info for the default Wine, but with caching. This is just a little optimization.""" return get_default_runner_version_info("wine") 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 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 as ex: logger.warning("Unable to parse url %s", url) raise ValueError("Invalid lutris url %s" % url) from ex if parsed_url.scheme != "lutris": raise ValueError("Invalid lutris url %s" % url) url_path = parsed_url.path if not url_path: raise ValueError("Invalid lutris url %s" % url) # 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), safe="") 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.17/lutris/cache.py000066400000000000000000000032011460562010500160600ustar00rootroot00000000000000"""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(): """Returns the directory under which Lutris caches install files. This can be specified by the user, but defaults to a location in ~/.cache.""" cache_path = settings.read_setting("pga_cache_path") if cache_path: cache_path = os.path.expanduser(cache_path) if os.path.isdir(cache_path): return cache_path return settings.INSTALLER_CACHE_DIR def has_custom_cache_path() -> bool: """True if the user has selected a custom cache location, in which case we keep the files there, rather than removing them during installation.""" cache_path = settings.read_setting("pga_cache_path") if not cache_path: return False if not os.path.isdir(cache_path): logger.warning("Cache path %s does not exist", cache_path) return False return True def save_custom_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("File %s is 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.17/lutris/config.py000066400000000000000000000237171460562010500163000ustar00rootroot00000000000000"""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 InvalidRunnerError, import_runner from lutris.util.log import logger from lutris.util.system import path_exists from lutris.util.yaml import read_yaml_from_file, write_yaml_to_file def make_game_config_id(game_slug: str) -> str: """Return an unique config id to avoid clashes between multiple games""" return "{}-{}".format(game_slug, int(time.time())) def write_game_config(game_slug: str, config: dict): """Writes a game config to disk""" configpath = make_game_config_id(game_slug) logger.debug("Writing game config to %s", configpath) config_filename = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % configpath) write_yaml_to_file(config, config_filename) return configpath def duplicate_game_config(game_slug: str, source_config_id: str): """Copies an existing configuration file, giving it a new id that this function returns.""" new_config_id = make_game_config_id(game_slug) src_path = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % source_config_id) dest_path = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % new_config_id) copyfile(src_path, dest_path) return new_config_id class LutrisConfig: """Class where all the configuration handling happens. Description =========== Lutris' configuration uses a cascading mechanism where each higher, more specific level overrides the lower ones The levels are (highest to lowest): `game`, `runner` and `system`. Each level has its own set of options (config section), available to and overridden by upper levels: ``` level | Config sections -------|---------------------- game | system, runner, game runner | system, runner system | system ``` Example: if requesting runner options at game level, their returned value will be from the game level config if it's set at this level; if not it will be the value from runner level if available; and if not, the default value set in the runner's module, or None. The config levels are stored in separate YAML format text files. Usage ===== The config level will be auto set depending on what you pass to __init__: - For game level, pass game_config_id and optionally runner_slug (better perfs) - For runner level, pass runner_slug - For system level, pass nothing If need be, you can pass the level manually. To read, use the config sections dicts: game_config, runner_config and system_config. To write, modify the relevant `raw_*_config` section dict, then run `save()`. """ def __init__(self, runner_slug: str = None, game_config_id: str = None, level: str = None): 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.RUNNERS_CONFIG_DIR, "%s.yml" % self.runner_slug) @property def game_config_path(self): if not self.game_config_id: return None return os.path.join(settings.CONFIG_DIR, "games/%s.yml" % self.game_config_id) def initialize_config(self): """Init and load config files""" self.game_level = {"system": {}, self.runner_slug: {}, "game": {}} self.runner_level = {"system": {}, self.runner_slug: {}} self.system_level = {"system": {}} self.game_level.update(read_yaml_from_file(self.game_config_path)) self.runner_level.update(read_yaml_from_file(self.runner_config_path)) self.system_level.update(read_yaml_from_file(self.system_config_path)) self.update_cascaded_config() self.update_raw_config() def update_cascaded_config(self): if self.system_level.get("system") is None: self.system_level["system"] = {} self.system_config.clear() self.system_config.update(self.get_defaults("system")) self.system_config.update(self.system_level.get("system")) if self.level in ["runner", "game"] and self.runner_slug: if self.runner_level.get(self.runner_slug) is None: self.runner_level[self.runner_slug] = {} if self.runner_level.get("system") is None: self.runner_level["system"] = {} self.runner_config.clear() self.runner_config.update(self.get_defaults("runner")) self.runner_config.update(self.runner_level.get(self.runner_slug)) self.merge_to_system_config(self.runner_level.get("system")) if self.level == "game" and self.runner_slug: if self.game_level.get("game") is None: self.game_level["game"] = {} if self.game_level.get(self.runner_slug) is None: self.game_level[self.runner_slug] = {} if self.game_level.get("system") is None: self.game_level["system"] = {} self.game_config.clear() self.game_config.update(self.get_defaults("game")) self.game_config.update(self.game_level.get("game")) self.runner_config.update(self.game_level.get(self.runner_slug)) self.merge_to_system_config(self.game_level.get("system")) def merge_to_system_config(self, config): """Merge a configuration to the system configuration""" if not config: return existing_env = None if self.system_config.get("env") and "env" in config: existing_env = self.system_config["env"] self.system_config.update(config) if existing_env: self.system_config["env"] = existing_env self.system_config["env"].update(config["env"]) def update_raw_config(self): # Select the right level of config if self.level == "game": raw_config = self.game_level elif self.level == "runner": raw_config = self.runner_level else: raw_config = self.system_level # Load config sections self.raw_system_config = raw_config["system"] if self.level in ["runner", "game"]: self.raw_runner_config = raw_config[self.runner_slug] if self.level == "game": self.raw_game_config = raw_config["game"] self.raw_config = raw_config def remove(self): """Delete the configuration file from disk.""" if not path_exists(self.game_config_path): logger.debug("No config file at %s", self.game_config_path) return os.remove(self.game_config_path) logger.debug("Removed config %s", self.game_config_path) def save(self): """Save configuration file according to its type""" if self.level == "system": config = self.system_level config_path = self.system_config_path elif self.level == "runner": config = self.runner_level config_path = self.runner_config_path elif self.level == "game": config = self.game_level config_path = self.game_config_path else: raise ValueError("Invalid config level '%s'" % self.level) # Remove keys with no values from config before saving config = {key: value for key, value in config.items() if value} logger.debug("Saving %s config to %s", self, config_path) write_yaml_to_file(config, config_path) self.initialize_config() def get_defaults(self, options_type): """Return a dict of options' default value.""" options_dict = self.options_as_dict(options_type) defaults = {} for option, params in options_dict.items(): if "default" in params: default = params["default"] if callable(default): try: default = default() except Exception as ex: logger.exception("Unable to generate a default for '%s': %s", option, ex) continue defaults[option] = default return defaults def options_as_dict(self, options_type: str) -> dict: """Convert the option list to a dict with option name as keys""" if options_type == "system": options = ( sysoptions.with_runner_overrides(self.runner_slug) if self.runner_slug else sysoptions.system_options ) else: if not self.runner_slug: return {} attribute_name = options_type + "_options" try: runner = import_runner(self.runner_slug) except InvalidRunnerError: options = {} else: if not getattr(runner, attribute_name): runner = runner() options = getattr(runner, attribute_name) return dict((opt["option"], opt) for opt in options) lutris-0.5.17/lutris/database/000077500000000000000000000000001460562010500162135ustar00rootroot00000000000000lutris-0.5.17/lutris/database/__init__.py000066400000000000000000000000001460562010500203120ustar00rootroot00000000000000lutris-0.5.17/lutris/database/categories.py000066400000000000000000000104161460562010500207140ustar00rootroot00000000000000import re from collections import defaultdict from itertools import repeat 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.DB_PATH, "categories") def get_all_games_categories(): games_categories = defaultdict(list) for row in sql.db_select(settings.DB_PATH, "games_categories"): games_categories[row["game_id"]].append(row["category_id"]) return games_categories def get_category(name): """Return a category by name""" categories = sql.db_select(settings.DB_PATH, "categories", condition=("name", name)) if categories: return categories[0] def get_game_ids_for_categories(included_category_names=None, excluded_category_names=None): """Get the ids of games in database.""" filters = [] parameters = [] if included_category_names: # Query that finds games in the included categories query = ( "SELECT games.id FROM games " "INNER JOIN games_categories ON games.id = games_categories.game_id " "INNER JOIN categories ON categories.id = games_categories.category_id" ) filters.append("categories.name IN (%s)" % ", ".join(repeat("?", len(included_category_names)))) parameters.extend(included_category_names) else: # Or, if you listed none, we fall back to all games query = "SELECT games.id FROM games" if excluded_category_names: # Sub-query to exclude the excluded categories, if any. exclude_filter = ( "NOT EXISTS(SELECT * FROM games_categories AS gc " "INNER JOIN categories AS c ON gc.category_id = c.id " "WHERE gc.game_id = games.id " "AND c.name IN (%s))" % ", ".join(repeat("?", len(excluded_category_names))) ) filters.append(exclude_filter) parameters.extend(excluded_category_names) if filters: query += " WHERE %s" % " AND ".join(filters) return [game["id"] for game in sql.db_query(settings.DB_PATH, query, tuple(parameters))] def get_categories_in_game(game_id): """Get the categories of a game in database.""" query = ( "SELECT categories.name FROM categories " "JOIN games_categories ON categories.id = games_categories.category_id " "JOIN games ON games.id = games_categories.game_id " "WHERE games.id=?" ) return [category["name"] for category in sql.db_query(settings.DB_PATH, query, (game_id,))] def add_category(category_name): """Add a category to the database""" return sql.db_insert(settings.DB_PATH, "categories", {"name": category_name}) def add_game_to_category(game_id, category_id): """Add a category to a game""" return sql.db_insert(settings.DB_PATH, "games_categories", {"game_id": game_id, "category_id": category_id}) def remove_category_from_game(game_id, category_id): """Remove a category from a game""" query = "DELETE FROM games_categories WHERE category_id=? AND game_id=?" with sql.db_cursor(settings.DB_PATH) as cursor: sql.cursor_execute(cursor, query, (category_id, game_id)) def remove_unused_categories(): """Remove all categories that have no games associated with them""" 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.DB_PATH, query) for category in empty_categories: if category["name"] == "favorite": continue query = "DELETE FROM categories WHERE categories.id=?" with sql.db_cursor(settings.DB_PATH) as cursor: sql.cursor_execute(cursor, query, (category["id"],)) lutris-0.5.17/lutris/database/games.py000066400000000000000000000203441460562010500176640ustar00rootroot00000000000000import math import time from itertools import chain from lutris import settings from lutris.database import sql from lutris.util.log import logger from lutris.util.strings import slugify _SERVICE_CACHE = {} _SERVICE_CACHE_ACCESSED = False # Keep time of last access to have a self degrading cache def get_games(searches=None, filters=None, excludes=None, sorts=None): return sql.filtered_query( settings.DB_PATH, "games", searches=searches, filters=filters, excludes=excludes, sorts=sorts ) def get_games_where(**conditions): """ Query games table based on conditions Args: conditions (dict): named arguments with each field matches its desired value. Special values for field names can be used: __lessthan will return rows where `field` is less than the value __isnull will return rows where `field` is NULL if the value is True __not will invert the condition using `!=` instead of `=` __in will match rows for every value of `value`, which should be an iterable Returns: list: Rows matching the query """ query = "select * from games" condition_fields = [] condition_values = [] for field, value in conditions.items(): field, *extra_conditions = field.split("__") if extra_conditions: extra_condition = extra_conditions[0] if extra_condition == "lessthan": condition_fields.append("{} < ?".format(field)) condition_values.append(value) if extra_condition == "isnull": condition_fields.append("{} is {} null".format(field, "" if value else "not")) if extra_condition == "not": condition_fields.append("{} != ?".format(field)) condition_values.append(value) if extra_condition == "in": if not hasattr(value, "__iter__"): raise ValueError("Value should be an iterable (%s given)" % value) if len(value) > 999: raise ValueError("SQLite limited to a maximum of 999 parameters.") if value: condition_fields.append("{} in ({})".format(field, ", ".join("?" * len(value)) or "")) condition_values = list(chain(condition_values, value)) else: condition_fields.append("{} = ?".format(field)) condition_values.append(value) condition = " AND ".join(condition_fields) if condition: query = " WHERE ".join((query, condition)) else: # Inspect and document why we should return # an empty list when no condition is present. return [] return sql.db_query(settings.DB_PATH, query, tuple(condition_values)) def get_games_by_ids(game_ids): # sqlite limits the number of query parameters to 999, to # bypass that limitation, divide the query in chunks size = 999 return list( chain.from_iterable( [ get_games_where(id__in=list(game_ids)[page * size : page * size + size]) for page in range(math.ceil(len(game_ids) / size)) ] ) ) def get_game_for_service(service, appid): if service == "lutris": return get_game_by_field(appid, field="slug") existing_games = get_games(filters={"service_id": appid, "service": service}) if existing_games: return existing_games[0] def get_all_installed_game_for_service(service): if service == "lutris": db_games = get_games(filters={"installed": 1}) return {g["slug"]: g for g in db_games} db_games = get_games(filters={"service": service, "installed": 1}) return {g["service_id"]: g for g in db_games} def get_service_games(service): """Return the list of all installed games for a service""" global _SERVICE_CACHE_ACCESSED previous_cache_accessed = _SERVICE_CACHE_ACCESSED or 0 _SERVICE_CACHE_ACCESSED = time.time() if service not in _SERVICE_CACHE or _SERVICE_CACHE_ACCESSED - previous_cache_accessed > 1: if service == "lutris": _SERVICE_CACHE[service] = [game["slug"] for game in get_games(filters={"installed": "1"})] else: _SERVICE_CACHE[service] = [ game["service_id"] for game in get_games(filters={"service": service, "installed": "1"}) ] return _SERVICE_CACHE[service] def get_game_by_field(value, field="slug"): """Query a game based on a database field""" if field not in ("slug", "installer_slug", "id", "configpath", "name"): raise ValueError("Can't query by field '%s'" % field) game_result = sql.db_select(settings.DB_PATH, "games", condition=(field, value)) if game_result: return game_result[0] return {} def get_games_by_runner(runner): """Return all games using a specific runner""" return sql.db_select(settings.DB_PATH, "games", condition=("runner", runner)) def get_games_by_slug(slug): """Return all games using a specific slug""" return sql.db_select(settings.DB_PATH, "games", condition=("slug", slug)) def add_game(**game_data): """Add a game to the database.""" game_data["installed_at"] = int(time.time()) if "slug" not in game_data: game_data["slug"] = slugify(game_data["name"]) return sql.db_insert(settings.DB_PATH, "games", game_data) def add_games_bulk(games): """ Add a list of games to the database. The dicts must have an identical set of keys. Args: games (list): list of games in dict format Returns: list: List of inserted game ids """ return [sql.db_insert(settings.DB_PATH, "games", game) for game in games] def add_or_update(**params): """Add a game to the database or update an existing one If an 'id' is provided in the parameters then it will try to match it, otherwise it will try matching by slug, creating one when possible. """ game_id = update_existing(**params) if game_id: return game_id return add_game(**params) def update_existing(**params): """Updates a game, but do not add one. If the game exists, this returns its ID; if not it returns None and makes no changes.""" game_id = get_matching_game(params) if game_id: params["id"] = game_id sql.db_update(settings.DB_PATH, "games", params, {"id": game_id}) return game_id return None def get_matching_game(params): """Tries to match given parameters with an existing game""" # Always match by ID if provided if params.get("id"): game = get_game_by_field(params["id"], "id") if game: return game["id"] logger.warning("Game ID %s provided but couldn't be matched", params["id"]) slug = params.get("slug") or slugify(params.get("name")) if not slug: raise ValueError("Can't add or update without an identifier") for game in get_games_by_slug(slug): if game["installed"]: if game["configpath"] == params.get("configpath"): return game["id"] else: if game["runner"] == params.get("runner") or not all([params.get("runner"), game["runner"]]): return game["id"] return None def delete_game(game_id): """Delete a game from the PGA.""" sql.db_delete(settings.DB_PATH, "games", "id", game_id) def get_used_runners(): """Return a list of the runners in use by installed games.""" with sql.db_cursor(settings.DB_PATH) as cursor: query = "select distinct runner from games where runner is not null order by runner" rows = cursor.execute(query) results = rows.fetchall() return [result[0] for result in results if result[0]] def get_used_platforms(): """Return a list of platforms currently in use""" with sql.db_cursor(settings.DB_PATH) as cursor: query = ( "select distinct platform from games " "where platform is not null and platform is not '' order by platform" ) rows = cursor.execute(query) results = rows.fetchall() return [result[0] for result in results if result[0]] def get_game_count(param, value): res = sql.db_select(settings.DB_PATH, "games", fields=("COUNT(id)",), condition=(param, value)) if res: return res[0]["COUNT(id)"] lutris-0.5.17/lutris/database/schema.py000066400000000000000000000110411460562010500200220ustar00rootroot00000000000000from lutris import settings from lutris.database import sql from lutris.util.log import logger DATABASE = { "games": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "name", "type": "TEXT"}, { "name": "sortname", "type": "TEXT", }, {"name": "slug", "type": "TEXT"}, {"name": "installer_slug", "type": "TEXT"}, {"name": "parent_slug", "type": "TEXT"}, {"name": "platform", "type": "TEXT"}, {"name": "runner", "type": "TEXT"}, {"name": "executable", "type": "TEXT"}, {"name": "directory", "type": "TEXT"}, {"name": "updated", "type": "DATETIME"}, {"name": "lastplayed", "type": "INTEGER"}, {"name": "installed", "type": "INTEGER"}, {"name": "installed_at", "type": "INTEGER"}, {"name": "year", "type": "INTEGER"}, {"name": "configpath", "type": "TEXT"}, {"name": "has_custom_banner", "type": "INTEGER"}, {"name": "has_custom_icon", "type": "INTEGER"}, {"name": "has_custom_coverart_big", "type": "INTEGER"}, {"name": "playtime", "type": "REAL"}, {"name": "service", "type": "TEXT"}, {"name": "service_id", "type": "TEXT"}, { "name": "discord_id", "type": "TEXT", }, ], "service_games": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "service", "type": "TEXT"}, {"name": "appid", "type": "TEXT"}, {"name": "name", "type": "TEXT"}, {"name": "slug", "type": "TEXT"}, {"name": "icon", "type": "TEXT"}, {"name": "logo", "type": "TEXT"}, {"name": "url", "type": "TEXT"}, {"name": "details", "type": "TEXT"}, {"name": "lutris_slug", "type": "TEXT"}, ], "sources": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "uri", "type": "TEXT UNIQUE"}, ], "categories": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "name", "type": "TEXT", "unique": True}, ], "games_categories": [ {"name": "game_id", "type": "INTEGER", "indexed": False}, {"name": "category_id", "type": "INTEGER", "indexed": False}, ], } def get_schema(tablename): """ Fields: - position - name - type - not null - default - indexed """ tables = [] query = "pragma table_info('%s')" % tablename with sql.db_cursor(settings.DB_PATH) as cursor: for row in cursor.execute(query).fetchall(): field = { "name": row[1], "type": row[2], "not_null": row[3], "default": row[4], "indexed": row[5], } tables.append(field) return tables def field_to_string(name="", type="", indexed=False, unique=False): # pylint: disable=redefined-builtin """Converts a python based table definition to it's SQL statement""" field_query = "%s %s" % (name, type) if indexed: field_query += " PRIMARY KEY" if unique: field_query += " UNIQUE" return field_query def create_table(name, schema): """Creates a new table in the database""" fields = ", ".join([field_to_string(**f) for f in schema]) query = "CREATE TABLE IF NOT EXISTS %s (%s)" % (name, fields) logger.debug("[Query] %s", query) with sql.db_cursor(settings.DB_PATH) as cursor: cursor.execute(query) def migrate(table, schema): """Compare a database table with the reference model and make necessary changes This is very basic and only the needed features have been implemented (adding columns) Args: table (str): Name of the table to migrate schema (dict): Reference schema for the table Returns: list: The list of column names that have been added """ existing_schema = get_schema(table) migrated_fields = [] if existing_schema: columns = [col["name"] for col in existing_schema] for field in schema: if field["name"] not in columns: logger.info("Migrating %s field %s", table, field["name"]) migrated_fields.append(field["name"]) sql.add_field(settings.DB_PATH, table, field) else: create_table(table, schema) return migrated_fields def syncdb(): """Update the database to the current version, making necessary changes for backwards compatibility.""" for table_name, table_data in DATABASE.items(): migrate(table_name, table_data) lutris-0.5.17/lutris/database/services.py000066400000000000000000000022401460562010500204060ustar00rootroot00000000000000from lutris import settings from lutris.database import sql from lutris.util.log import logger class ServiceGameCollection: @classmethod def get_service_games(cls, searches=None, filters=None, excludes=None, sorts=None): return sql.filtered_query( settings.DB_PATH, "service_games", searches=searches, filters=filters, excludes=excludes, sorts=sorts ) @classmethod def get_for_service(cls, service): if not service: raise ValueError("No service provided") return sql.filtered_query(settings.DB_PATH, "service_games", filters={"service": service}) @classmethod def get_game(cls, service, appid): """Return a single game referred by its appid""" if not service: raise ValueError("No service provided") if not appid: raise ValueError("No appid provided") results = sql.filtered_query(settings.DB_PATH, "service_games", filters={"service": service, "appid": appid}) if not results: return if len(results) > 1: logger.warning("More than one game found for %s on %s", appid, service) return results[0] lutris-0.5.17/lutris/database/sources.py000066400000000000000000000030411460562010500202460ustar00rootroot00000000000000import os from lutris import settings from lutris.database import sql from lutris.util import system from lutris.util.log import logger def add_source(uri): sql.db_insert(settings.DB_PATH, "sources", {"uri": uri}) def delete_source(uri): sql.db_delete(settings.DB_PATH, "sources", "uri", uri) def read_sources(): with sql.db_cursor(settings.DB_PATH) as cursor: rows = cursor.execute("select uri from sources") results = rows.fetchall() return [row[0] for row in results] def write_sources(sources): db_sources = read_sources() for uri in db_sources: if uri not in sources: sql.db_delete(settings.DB_PATH, "sources", "uri", uri) for uri in sources: if uri not in db_sources: sql.db_insert(settings.DB_PATH, "sources", {"uri": uri}) def check_for_file(game, file_id): for source in read_sources(): if source.startswith("file://"): source = source[7:] else: protocol = source[:7] logger.warning("PGA source protocol %s not implemented", protocol) continue if not system.path_exists(source): logger.info("PGA source %s unavailable", source) continue game_dir = os.path.join(source, game) if not system.path_exists(game_dir): continue for game_file in os.listdir(game_dir): game_base, _ext = os.path.splitext(game_file) if game_base == file_id: return os.path.join(game_dir, game_file) return False lutris-0.5.17/lutris/database/sql.py000066400000000000000000000117101460562010500173640ustar00rootroot00000000000000import 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 try: return cursor.execute(query, params) finally: DB_LOCK.release() def db_insert(db_path, table, fields): columns = ", ".join(list(fields.keys())) placeholders = ("?, " * len(fields))[:-2] field_values = tuple(fields.values()) with db_cursor(db_path) as cursor: cursor_execute( cursor, "insert into {0}({1}) values ({2})".format(table, columns, placeholders), field_values, ) inserted_id = cursor.lastrowid return inserted_id def db_update(db_path, table, updated_fields, conditions): """Update `table` with the values given in the dict `values` on the condition given with the `row` tuple. """ columns = "=?, ".join(list(updated_fields.keys())) + "=?" field_values = tuple(updated_fields.values()) condition_field = " AND ".join(["%s=?" % field for field in conditions]) condition_value = tuple(conditions.values()) with db_cursor(db_path) as cursor: query = "UPDATE {0} SET {1} WHERE {2}".format(table, columns, condition_field) result = cursor_execute(cursor, query, field_values + condition_value) return result def db_delete(db_path, table, field, value): with db_cursor(db_path) as cursor: cursor_execute(cursor, "delete from {0} where {1}=?".format(table, field), (value,)) def db_select(db_path, table, fields=None, condition=None): if fields: columns = ", ".join(fields) else: columns = "*" with db_cursor(db_path) as cursor: query = "SELECT {} FROM {}" if condition: condition_field, condition_value = condition if isinstance(condition_value, (list, tuple, set)): condition_value = tuple(condition_value) placeholders = ", ".join("?" * len(condition_value)) where_condition = " where {} in (" + placeholders + ")" else: condition_value = (condition_value,) where_condition = " where {}=?" query = query + where_condition query = query.format(columns, table, condition_field) params = condition_value else: query = query.format(columns, table) params = () cursor_execute(cursor, query, params) rows = cursor.fetchall() column_names = [column[0] for column in cursor.description] results = [] for row in rows: row_data = {} for index, column in enumerate(column_names): row_data[column] = row[index] results.append(row_data) return results def db_query(db_path, query, params=()): with db_cursor(db_path) as cursor: cursor_execute(cursor, query, params) rows = cursor.fetchall() column_names = [column[0] for column in cursor.description] results = [] for row in rows: row_data = {} for index, column in enumerate(column_names): row_data[column] = row[index] results.append(row_data) return results def add_field(db_path, tablename, field): query = "ALTER TABLE %s ADD COLUMN %s %s" % ( tablename, field["name"], field["type"], ) with db_cursor(db_path) as cursor: cursor.execute(query) def filtered_query(db_path, table, searches=None, filters=None, excludes=None, sorts=None): query = "select * from %s" % table params = [] sql_filters = [] for field in searches or {}: sql_filters.append("%s LIKE ?" % field) params.append("%" + searches[field] + "%") for field in filters or {}: if filters[field] is not None: # but 0 or False are okay! sql_filters.append("%s = ?" % field) params.append(filters[field]) for field in excludes or {}: if excludes[field]: sql_filters.append("%s IS NOT ?" % field) params.append(excludes[field]) if sql_filters: query += " WHERE " + " AND ".join(sql_filters) if sorts: query += " ORDER BY %s" % ", ".join(["%s %s" % (sort[0], sort[1]) for sort in sorts]) else: query += " ORDER BY slug ASC" return db_query(db_path, query, tuple(params)) lutris-0.5.17/lutris/exception_backstops.py000066400000000000000000000176471460562010500211070ustar00rootroot00000000000000import inspect from functools import wraps from gettext import gettext as _ from typing import Any, Callable, Dict, Iterable, Type, TypeVar from gi.repository import Gio, GLib, GObject, Gtk from lutris.gui.dialogs import ErrorDialog from lutris.util.log import logger 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.stop_game() 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.stop_game() game.signal_error(ex) return game_stop_result return wrapper return inner_decorator _error_handlers: Dict[Type[BaseException], Callable[[BaseException, Gtk.Window], Any]] = {} TError = TypeVar("TError", bound=BaseException) def register_error_handler(error_class: Type[TError], handler: Callable[[TError, Gtk.Window], Any]) -> None: """Records a function to call to handle errors of a particular class or its subclasses. The function is given the error and a parent window, and can display a modal dialog.""" _error_handlers[error_class] = handler def get_error_handler(error_class: Type[TError]) -> Callable[[TError, Gtk.Window], Any]: """Returns the register error handler for an exception class. If none is registered, this returns a default handler that shows an ErrorDialog.""" if not isinstance(error_class, type): if isinstance(error_class, BaseException): logger.debug("An error was passed where an error class should be passed.") error_class = type(error_class) else: raise ValueError(f"'{error_class}' was passed to get_error_handler, but an error class is required here.") if error_class in _error_handlers: return _error_handlers[error_class] for base_class in inspect.getmro(error_class): if base_class in _error_handlers: return _error_handlers[base_class] return lambda e, p: ErrorDialog(e, parent=p) def _get_error_parent(error_objects: Iterable) -> Gtk.Window: """Obtains a top-level window to use as the parent of an error, by examining s list of objects. Any that are None are skipped; we call get_toplevel() on each object that has this method, and return the first non-None result. If this fails, we turn to the application's main window instead.""" for error_object in error_objects: if not error_object: continue if error_object and hasattr(error_object, "get_toplevel"): toplevel = error_object.get_toplevel() if toplevel: return toplevel application = Gio.Application.get_default() return application.window if application else None def _create_error_wrapper( handler: Callable, handler_name: str, error_result: Any, error_method_name: str, connected_object: Any = None ): """Wraps a handler function in an error handler that will log and then report any exceptions, then return the 'error_result'.""" handler_object = handler.__self__ if hasattr(handler, "__self__") else None def error_wrapper(*args, **kwargs): try: return handler(*args, **kwargs) except Exception as ex: logger.exception("Error handling %s: %s", handler_name, ex) if handler_object and hasattr(handler_object, error_method_name): error_method = getattr(handler_object, error_method_name) error_method(ex) else: error_handler = get_error_handler(type(ex)) error_handler(ex, _get_error_parent([handler_object, connected_object])) return error_result return error_wrapper def init_exception_backstops(): """This function is called once only, during startup, and replaces ("swizzles") a bunch of callback setup functions in GLib. The callbacks are all wrapped with error handlers that log the error and report it. This is important to do because PyGObject will straight up crash if an exception escapes these handlers; it's better to tell the user and try to survive. You can provide certain methods to provide error handling, but if you do not you get an ErrorDialog. Put these handling methods on the same object as the callback method itself. We take care of these methods: GObject.Object.connect (via on_signal_error(self, error)) GObject.add_emission_hook (via on_emission_hook_error(self, error)) GLib.idle_add (via on_idle_error(self, error)) GLib.timeout_add (via on_timeout_error(self, error)) Idle and timeout handlers will be disconnected if this happens to avoid repeated error reports, but signals and emission hooks will remain connected. """ def _error_handling_connect(self: Gtk.Widget, signal_spec: str, handler, *args, **kwargs): error_wrapper = _create_error_wrapper( handler, f"signal '{signal_spec}'", error_result=None, error_method_name="on_signal_error", connected_object=self, ) return _original_connect(self, signal_spec, error_wrapper, *args, **kwargs) def _error_handling_add_emission_hook(emitting_type, signal_spec, handler, *args, **kwargs): error_wrapper = _create_error_wrapper( handler, f"emission hook '{emitting_type}.{signal_spec}'", error_result=True, # stay attached error_method_name="on_emission_hook_error", ) return _original_add_emission_hook(emitting_type, signal_spec, error_wrapper, *args, **kwargs) def _error_handling_idle_add(handler, *args, **kwargs): error_wrapper = _create_error_wrapper( handler, "idle function", error_result=False, # stop calling idle func error_method_name="on_idle_error", ) return _original_idle_add(error_wrapper, *args, **kwargs) def _error_handling_timeout_add(interval, handler, *args, **kwargs): error_wrapper = _create_error_wrapper( handler, "timeout function", error_result=False, # stop calling timeout fund error_method_name="on_timeout_error", ) return _original_timeout_add(interval, error_wrapper, *args, **kwargs) def _handle_keyerror(error: KeyError, parent: Gtk.Window) -> None: message = _("The key '%s' could not be found.") % error.args[0] ErrorDialog(message, parent=parent) _original_connect = Gtk.Widget.connect GObject.Object.connect = _error_handling_connect _original_add_emission_hook = GObject.add_emission_hook GObject.add_emission_hook = _error_handling_add_emission_hook _original_idle_add = GLib.idle_add GLib.idle_add = _error_handling_idle_add _original_timeout_add = GLib.timeout_add GLib.timeout_add = _error_handling_timeout_add register_error_handler(KeyError, _handle_keyerror) lutris-0.5.17/lutris/exceptions.py000066400000000000000000000076001460562010500172050ustar00rootroot00000000000000"""Exception handling module""" from gettext import gettext as _ class LutrisError(Exception): """Base exception for Lutris related errors""" def __init__(self, message, *args, **kwarg): super().__init__(message, *args, **kwarg) self.message = message class MisconfigurationError(LutrisError): """Raised for incorrect configuration or installation, like incorrect or missing settings, missing components, that sort of thing. This has subclasses that are less vague.""" class DirectoryNotFoundError(MisconfigurationError): """Raise this error if a directory that is required is not present.""" def __init__(self, message=None, directory=None, *args, **kwarg): if not message and directory: message = _("The directory {} could not be found").format(directory) super().__init__(message, *args, **kwarg) self.directory = directory class GameConfigError(MisconfigurationError): """Throw this error when the game configuration prevents the game from running properly.""" class MissingBiosError(GameConfigError): """Throw this error when the game requires a BIOS, but none is configured.""" def __init__(self, message=None, *args, **kwarg): super().__init__(message or _("A bios file is required to run this game"), *args, **kwarg) class AuthenticationError(LutrisError): """Raised when authentication to a service fails""" class UnavailableGameError(LutrisError): """Raised when a game is unavailable from a service""" class UnavailableLibrariesError(MisconfigurationError): 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 UnavailableRunnerError(MisconfigurationError): """Raised when a runner is not installed or not installed fully.""" class UnspecifiedVersionError(MisconfigurationError): """Raised when a version number must be specified, but was not.""" class MissingExecutableError(MisconfigurationError): """Raised when a program can't be located.""" class MissingMediaError(LutrisError): """Raised when an image file could not be found.""" def __init__(self, message=None, filename=None, *args, **kwargs): if not message and filename: message = _("The file {} could not be found").format(filename) super().__init__(message, *args, **kwargs) self.filename = filename class MissingGameExecutableError(MissingExecutableError): """Raise when a game's executable can't be found is not specified.""" def __init__(self, message=None, filename=None, *args, **kwargs): if not message: if filename: message = _("The file {} could not be found").format(filename) else: message = _("This game has no executable set. The install process didn't finish properly.") super().__init__(message, *args, **kwargs) self.filename = filename class InvalidGameMoveError(LutrisError): """Raised when a game can't be moved as desired; we may have to just set the location.""" class EsyncLimitError(Exception): """Raised when the ESYNC limit is not set correctly.""" def __init__(self, message=None, *args, **kwarg): if not message: message = _("Your ESYNC limits are not set correctly.") super().__init__(message, *args, **kwarg) class FsyncUnsupportedError(Exception): """Raised when FSYNC is enabled, but is not supported by the kernel.""" def __init__(self, message=None, *args, **kwarg): if not message: message = _("Your kernel is not patched for fsync." " Please get a patched kernel to use fsync.") super().__init__(message, *args, **kwarg) lutris-0.5.17/lutris/game.py000066400000000000000000001316171460562010500157430ustar00rootroot00000000000000"""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 typing import cast from gi.repository import Gio, GLib, GObject, Gtk from lutris import settings 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.exception_backstops import watch_game_errors from lutris.exceptions import GameConfigError, InvalidGameMoveError, MissingExecutableError from lutris.installer import InstallationKind from lutris.monitored_command import MonitoredCommand from lutris.runner_interpreter import export_bash_script, get_launch_parameters from lutris.runners import import_runner, is_valid_runner_name from lutris.runners.runner import Runner from lutris.util import discord, extract, jobs, linux, strings, system, xdgshortcuts from lutris.util.display import DISPLAY_MANAGER, SCREEN_SAVER_INHIBITOR, disable_compositing, enable_compositing from lutris.util.graphics.xephyr import get_xephyr_command from lutris.util.graphics.xrandr import turn_off_except from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import LOG_BUFFERS, logger from lutris.util.process import Process from lutris.util.steam.shortcut import remove_shortcut as remove_steam_shortcut from lutris.util.system import fix_path_case from lutris.util.timer import Timer from lutris.util.wine import proton 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-start": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-started": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-stopped": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-updated": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-installed": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, game_id: str = None): super().__init__() self._id = str(game_id) if game_id else None # 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.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 = service.get_installed_slug(db_game) game.runner_name = service.get_installed_runner_name(db_game) 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 id(self) -> str: if not self._id: logger.error("The game '%s' has no ID, it is not stored in the database.", self.name) return cast(str, self._id) @property def is_db_stored(self) -> bool: """True if this Game has an ID, which means it is saved in the database.""" return bool(self._id) @property def is_updatable(self): """Return whether the game can be upgraded""" return self.is_installed and self.service in ["gog", "itchio"] 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 not self.is_db_stored: 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""" if not self.is_db_stored: return 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") @property def is_favorite(self) -> bool: """Return whether the game is in the user's favorites""" return "favorite" in self.get_categories() def mark_as_favorite(self, is_favorite: bool) -> None: """Place the game in the favorite's category, or remove it. This change is applied at once, and does not need to be saved.""" if self.is_favorite != bool(is_favorite): if is_favorite: self.add_category("favorite") else: self.remove_category("favorite") @property def is_hidden(self) -> bool: """Return whether the game is in the user's favorites""" return ".hidden" in self.get_categories() def mark_as_hidden(self, is_hidden: bool) -> None: """Place the game in the hidden category, or remove it. This change is applied at once, and does not need to be saved.""" if self.is_hidden != bool(is_hidden): if is_hidden: self.add_category(".hidden") else: self.remove_category(".hidden") @property def log_buffer(self): """Access the log buffer object, creating it if necessary""" _log_buffer = LOG_BUFFERS.get(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[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) 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 os.path.expanduser(self.directory) # expanduser just in case! if self.has_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) -> str: return self._runner_name @runner_name.setter def runner_name(self, value: str) -> None: self._runner_name = value or "" if self._runner and self._runner.name != value: self._runner = None @property def has_runner(self) -> bool: return bool(self._runner_name and is_valid_runner_name(self._runner_name)) @property def runner(self) -> Runner: if not self.has_runner: raise GameConfigError(_("Invalid game configuration: Missing runner")) if not self._runner: runner_class = import_runner(self.runner_name) self._runner = runner_class(self.config) return cast(Runner, self._runner) @runner.setter def runner(self, value: Runner) -> None: 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 install(self, launch_ui_delegate): """Request installation of a game""" if not self.slug: raise ValueError("Invalid game passed: %s" % self) if not self.service or self.service == "lutris": application = Gio.Application.get_default() application.show_lutris_installer_window(game_slug=self.slug) return service = launch_ui_delegate.get_service(self.service) db_game = service.get_service_db_game(self) if not db_game: logger.error("Can't find %s for %s", self.name, service.name) return 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(launch_ui_delegate) def install_updates(self, install_ui_delegate): service = install_ui_delegate.get_service(self.service) db_game = games_db.get_game_by_field(self.id, "id") def on_installers_ready(installers, error): if error: raise error # bounce errors off the backstop if not installers: raise RuntimeError(_("No updates found")) application = Gio.Application.get_default() application.show_installer_window( installers, service, self.appid, installation_kind=InstallationKind.UPDATE ) jobs.AsyncCall(service.get_update_installers, on_installers_ready, db_game) return True def install_dlc(self, install_ui_delegate): service = install_ui_delegate.get_service(self.service) db_game = games_db.get_game_by_field(self.id, "id") def on_installers_ready(installers, error): if error: raise error # bounce errors off the backstop if not installers: raise RuntimeError(_("No DLC found")) application = Gio.Application.get_default() application.show_installer_window(installers, service, self.appid, installation_kind=InstallationKind.DLC) jobs.AsyncCall(service.get_dlc_installers_runner, on_installers_ready, db_game, db_game["runner"]) return True def uninstall(self, delete_files: bool = False) -> None: """Uninstall a game, but do not remove it from the library. Params: delete_files (bool): Delete the game files """ sql.db_update(settings.DB_PATH, "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.has_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 self.id in LOG_BUFFERS: # Reset game logs on removal log_buffer = LOG_BUFFERS[self.id] log_buffer.delete(log_buffer.get_start_iter(), log_buffer.get_end_iter()) def delete(self) -> None: """Delete a game from the library; must be uninstalled first.""" if self.is_installed: raise RuntimeError(_("Uninstall the game before deleting")) games_db.delete_game(self.id) self._id = None def set_platform_from_runner(self): """Set the game's platform from the runner""" if not self.has_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, no_signal=False): """ 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, "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 = str(games_db.add_or_update(**game_data)) if not no_signal: 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.has_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.can_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""" if LINUX_SYSTEM.is_flatpak(): antimicro_command = ["flatpak-spawn", "--host", "antimicrox"] else: try: antimicro_command = [system.find_required_executable("antimicrox")] except MissingExecutableError as ex: raise GameConfigError( _("Unable to find Antimicrox, install it or disable the Antimicrox option") ) from ex logger.info("Starting Antimicro") antimicro_command += ["--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.can_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. """ gameplay_info = self.runner.play() 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 def get_path_from_config(self): """Return the path of the main entry point for a game""" if not self.config: logger.warning("%s has no configuration", self) return "" game_config = self.config.game_config # Skip MAME roms referenced by their ID if self.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 path: path = os.path.expanduser(path) if not path.startswith("/"): path = os.path.join(self.directory, path) # The Wine runner fixes case mismatches automatically, # sort of like Windows, so we need to do the same. if self.runner_name == "wine": path = fix_path_case(path) return path logger.warning("No path found in %s", self.config) return "" def get_store_name(self) -> str: store = self.service if not store: return "" if self.service == "humblebundle": return "humble" return store @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) if env.get("WINEARCH") == "win32" and "umu" in " ".join(command): raise RuntimeError("Proton is not compatible with 32bit prefixes") env["GAMEID"] = proton.get_game_id(self) env["STORE"] = self.get_store_name() # Some environment variables for the use of custom pre-launch and post-exit scripts. env["GAME_NAME"] = self.name if self.directory: env["GAME_DIRECTORY"] = self.directory 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"] # 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. if self.id in LOG_BUFFERS: # Reset game logs on each launch log_buffer = LOG_BUFFERS[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 launch 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 def force_stop_game(): self.runner.force_stop_game(self) return not self.get_stop_pids() def force_stop_game_cb(all_dead, error): if error: self.signal_error(error) elif all_dead: self.stop_game() else: self.force_kill_delayed() jobs.AsyncCall(force_stop_game, force_stop_game_cb) def force_kill_delayed(self, death_watch_seconds=5, death_watch_interval_seconds=0.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; kill them if they do not.""" 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 # Once we get past the time limit, starting killing! self.kill_processes(signal.SIGKILL) def death_watch_cb(_result, error): """Called after the death watch to more firmly kill any survivors.""" if error: self.signal_error(error) # 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: if self.game_thread.game_process.poll() is None: 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_folder = self.resolve_game_path() folder_pids = set() 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: folder_pids.add(pid) uuid_pids = set(pid for pid in new_pids if Process(pid).environ.get("LUTRIS_GAME_UUID") == self.game_uuid) return folder_pids & uuid_pids 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 %d 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-stopped") 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() # 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_lines = strings.lookup_strings_in_text(error, self.game_thread.stdout) if error_lines: raise RuntimeError(_("Error: Missing shared library.\n\n%s") % error_lines[0]) if self.game_thread.return_code == 1: # Error Wine version conflict error = "maybe the wrong wineserver" if strings.lookup_strings_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, no_signal=False): logger.info("Moving %s to %s", self, new_location) new_config = "" old_location = self.directory target_directory = self._get_move_target_directory(new_location) if new_location.startswith(old_location): raise InvalidGameMoveError( _("Lutris can't move '%s' to a location inside of itself, '%s'.") % (old_location, new_location) ) self.directory = target_directory self.save(no_signal=no_signal) 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("Initial location %s does not exist, files may have already been moved.") 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 set_location(self, new_location): target_directory = self._get_move_target_directory(new_location) self.directory = target_directory self.save() return target_directory def _get_move_target_directory(self, new_location): old_location = self.directory if old_location and os.path.exists(old_location): game_directory = os.path.basename(old_location) return os.path.join(new_location, game_directory) return new_location 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 not db_game: logger.error("Game %s not found", slug) return 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.tar.xz" % slug) command = ["tar", "cJf", archive_path, os.path.basename(game_path)] system.execute(command, cwd=os.path.dirname(game_path)) logger.info("%s exported to %s", slug, archive_path) 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) logger.info("Importing %s to %s", file_path, dest_dir) if not os.path.isdir(dest_dir): os.makedirs(dest_dir) original_file_list = set(os.listdir(dest_dir)) extract.extract_archive(file_path, dest_dir, merge_single=False) 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) try: game_config = [f for f in os.listdir(game_dir) if f.endswith(".lutris")][0] except IndexError: logger.error("No Lutris configuration file found in archive") return 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_game( 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"], service=lutris_config["service"], service_id=lutris_config["service_id"], ) print("Added game with ID %s" % game_id) lutris-0.5.17/lutris/game_actions.py000066400000000000000000000474221460562010500174630ustar00rootroot00000000000000"""Handle game specific actions""" # Standard Library # pylint: disable=too-many-public-methods import os from gettext import gettext as _ from typing import List from gi.repository import Gio, Gtk from lutris.config import duplicate_game_config from lutris.database.games import add_game, get_game_by_field from lutris.game import Game from lutris.gui import dialogs from lutris.gui.config.add_game_dialog import AddGameDialog from lutris.gui.config.edit_game import EditGameConfigDialog from lutris.gui.config.edit_game_categories import EditGameCategoriesDialog from lutris.gui.dialogs import InputDialog from lutris.gui.dialogs.log import LogWindow from lutris.gui.dialogs.uninstall_dialog import UninstallDialog from lutris.gui.widgets.utils import open_uri from lutris.monitored_command import MonitoredCommand from lutris.services.lutris import download_lutris_media from lutris.util import xdgshortcuts from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.steam import shortcut as steam_shortcut from lutris.util.strings import gtk_safe, slugify from lutris.util.system import path_exists class GameActions: """These classes provide a set of action to apply to a game or list of games, and can be used to populate menus. The base class handles the no-games case, for which there are no actions. But it also includes the code for actions that are shared between the subclasses. It also has methods for actions that are invokes externally by the GameBar.""" def __init__(self, window: Gtk.Window, application=None): self.application = application or Gio.Application.get_default() self.window = window # also used as a LaunchUIDelegate and InstallUIDelegate def get_games(self): """Return the list of games that the actions apply to.""" return [] def get_game_actions(self): """Return a list of game actions and their callbacks, Each item is a tuple of two strs and a callable, the action ID, it's human-readable name, and a callback to invoke to perform it. Menu separators are represented hre as (None, "-", None). """ return [] def get_displayed_entries(self): """Return a dictionary of flags indicating which actions are visible; the keys are the action ids from get_game_actions(), and the values are booleans indicating the action's visibility.""" return {} @property def is_game_launchable(self): for game in self.get_games(): if game.is_installed and not self.is_game_running: return True return False def on_game_launch(self, *_args): """Launch a game""" @property def is_game_running(self): for game in self.get_games(): if game.is_db_stored and self.application.is_game_running_by_id(game.id): return True return False def on_game_stop(self, *_args): """Stops the game""" games = self.get_running_games() for game in games: game.force_stop() def get_running_games(self): running_games = [] for game in self.get_games(): if game and game.is_db_stored: ids = self.application.get_running_game_ids() for game_id in ids: if str(game_id) == game.id: running_games.append(game) return running_games @property def is_installable(self): for game in self.get_games(): if not game.is_installed: return True return False def on_install_clicked(self, *_args): """Install a game""" # Install the currently selected game in the UI for game in self.get_games(): if not game.slug: game_id = game.id if game.is_db_stored else game.name raise RuntimeError("No game to install: %s" % game_id) game.install(launch_ui_delegate=self.window) def on_add_favorite_game(self, _widget): """Add to favorite Games list""" for game in self.get_games(): game.mark_as_favorite(True) def on_delete_favorite_game(self, _widget): """delete from favorites""" for game in self.get_games(): game.mark_as_favorite(False) def on_hide_game(self, _widget): """Add a game to the list of hidden games""" for game in self.get_games(): game.mark_as_hidden(True) def on_unhide_game(self, _widget): """Removes a game from the list of hidden games""" for game in self.get_games(): game.mark_as_hidden(False) def on_locate_installed_game(self, *_args): """Show the user a dialog to import an existing install to a DRM free service Params: games ([Game]): List of Game instances without a database ID, populated with fields the service can provides """ for game in self.get_games(): AddGameDialog(self.window, game=game, runner=game.runner_name) def on_view_game(self, _widget): """Callback to open a game on lutris.net""" for game in self.get_games(): open_uri("https://lutris.net/games/%s" % game.slug.replace("_", "-")) @property def is_game_removable(self): for game in self.get_games(): if game.is_installed or game.is_db_stored: return True return False def on_remove_game(self, *_args): """Callback that present the uninstall dialog to the user""" game_ids = [g.id for g in self.get_games() if g.is_installed or g.is_db_stored] application = Gio.Application.get_default() dlg = application.show_window(UninstallDialog, parent=self.window) dlg.add_games(game_ids) def on_edit_game_categories(self, _widget): """Edit game categories""" games = self.get_games() if len(games) == 1: # Individual games get individual separate windows self.application.show_window(EditGameCategoriesDialog, game=games[0], parent=self.window) else: def add_games(window): window.add_games(self.get_games()) # Multi-select means a common categories window for all of them; we can wind # up adding games to it if it's already open self.application.show_window(EditGameCategoriesDialog, update_function=add_games, parent=self.window) class MultiGameActions(GameActions): """This actions class handles actions on multiple games together, and only iof they are 'db stored' games, not service games. This supports a subset of the actions of SingleGameActions.""" def __init__(self, games: List[Game], window: Gtk.Window, application=None): super().__init__(window, application) self.games = games def get_games(self): return self.games def get_game_actions(self): return [ ("stop", _("Stop"), self.on_game_stop), (None, "-", None), ("category", _("Categories"), self.on_edit_game_categories), ("favorite", _("Add to favorites"), self.on_add_favorite_game), ("deletefavorite", _("Remove from favorites"), self.on_delete_favorite_game), ("hide", _("Hide game from library"), self.on_hide_game), ("unhide", _("Unhide game from library"), self.on_unhide_game), (None, "-", None), ("remove", _("Remove"), self.on_remove_game), ] def get_displayed_entries(self): return { "stop": self.is_game_running, "category": True, "favorite": any(g for g in self.games if not g.is_favorite), "deletefavorite": any(g for g in self.games if g.is_favorite), "hide": any(g for g in self.games if g.is_installed and not g.is_hidden), "unhide": any(g for g in self.games if g.is_hidden), "remove": self.is_game_removable, } class SingleGameActions(GameActions): """This actions class handles actions on a single game, which is a 'db stored' game, not a service game. This provides the largest selection of actions, including many that are unique to it.""" def __init__(self, game: Game, window: Gtk.Window, application=None): super().__init__(window, application) self.game = game def get_games(self): return [self.game] def get_game_actions(self): return [ ("play", _("Play"), self.on_game_launch), ("stop", _("Stop"), self.on_game_stop), ("execute-script", _("Execute script"), self.on_execute_script_clicked), ("show_logs", _("Show logs"), self.on_show_logs), (None, "-", None), ("configure", _("Configure"), self.on_edit_game_configuration), ("category", _("Categories"), self.on_edit_game_categories), ("browse", _("Browse files"), self.on_browse_files), ("favorite", _("Add to favorites"), self.on_add_favorite_game), ("deletefavorite", _("Remove from favorites"), self.on_delete_favorite_game), ("hide", _("Hide game from library"), self.on_hide_game), ("unhide", _("Unhide game from library"), self.on_unhide_game), (None, "-", None), ("install", _("Install"), self.on_install_clicked), ("install_more", _("Install another version"), self.on_install_clicked), ("install_dlcs", "Install DLCs", self.on_install_dlc_clicked), ("update", _("Install updates"), self.on_update_clicked), ("add", _("Locate installed game"), self.on_locate_installed_game), ("desktop-shortcut", _("Create desktop shortcut"), self.on_create_desktop_shortcut), ("rm-desktop-shortcut", _("Delete desktop shortcut"), self.on_remove_desktop_shortcut), ("menu-shortcut", _("Create application menu shortcut"), self.on_create_menu_shortcut), ("rm-menu-shortcut", _("Delete application menu shortcut"), self.on_remove_menu_shortcut), ("steam-shortcut", _("Create steam shortcut"), self.on_create_steam_shortcut), ("rm-steam-shortcut", _("Delete steam shortcut"), self.on_remove_steam_shortcut), ("view", _("View on Lutris.net"), self.on_view_game), ("duplicate", _("Duplicate"), self.on_game_duplicate), (None, "-", None), ("remove", _("Remove"), self.on_remove_game), ] def get_displayed_entries(self): """Return a dictionary of actions that should be shown for a game""" game = self.game if steam_shortcut.vdf_file_exists(): has_steam_shortcut = steam_shortcut.shortcut_exists(game) is_steam_game = steam_shortcut.is_steam_game(game) else: has_steam_shortcut = False is_steam_game = False return { "duplicate": game.is_installed, "install": self.is_installable, "add": not game.is_installed, "play": self.is_game_launchable, "update": game.is_updatable, "install_dlcs": game.is_updatable, "stop": self.is_game_running, "configure": bool(game.is_installed), "browse": game.is_installed and game.runner_name != "browser", "show_logs": game.is_installed, "category": True, "favorite": not game.is_favorite, "deletefavorite": game.is_favorite, "install_more": not game.service and game.is_installed, "execute-script": bool( game.is_installed and game.has_runner and game.runner.system_config.get("manual_command") ), "desktop-shortcut": (game.is_installed and not xdgshortcuts.desktop_launcher_exists(game.slug, game.id)), "menu-shortcut": (game.is_installed and not xdgshortcuts.menu_launcher_exists(game.slug, game.id)), "steam-shortcut": (game.is_installed and not has_steam_shortcut and not is_steam_game), "rm-desktop-shortcut": bool(game.is_installed and xdgshortcuts.desktop_launcher_exists(game.slug, game.id)), "rm-menu-shortcut": bool(game.is_installed and xdgshortcuts.menu_launcher_exists(game.slug, game.id)), "rm-steam-shortcut": bool(game.is_installed and has_steam_shortcut and not is_steam_game), "remove": self.is_game_removable, "view": True, "hide": game.is_installed and not game.is_hidden, "unhide": game.is_hidden, } def on_game_launch(self, *_args): """Launch a game""" game = self.game if game.is_installed and game.is_db_stored: if not self.application.is_game_running_by_id(game.id): game.launch(launch_ui_delegate=self.window) def on_execute_script_clicked(self, _widget): """Execute the game's associated script""" game = self.game manual_command = game.runner.system_config.get("manual_command") if path_exists(manual_command): MonitoredCommand( [manual_command], include_processes=[os.path.basename(manual_command)], cwd=game.directory, ).start() logger.info("Running %s in the background", manual_command) def on_show_logs(self, _widget): """Display game log""" game = self.game _buffer = game.log_buffer if not _buffer: logger.info("No log for game %s", game) return LogWindow(game=game, buffer=_buffer, application=self.application) def on_edit_game_configuration(self, _widget): """Edit game preferences""" self.application.show_window(EditGameConfigDialog, game=self.game, parent=self.window) def on_browse_files(self, _widget): """Callback to open a game folder in the file browser""" path = self.game.get_browse_dir() if not path: dialogs.NoticeDialog(_("This game has no installation directory")) elif path_exists(path): open_uri("file://%s" % path) else: dialogs.NoticeDialog(_("Can't open %s \nThe folder doesn't exist.") % path) def on_install_dlc_clicked(self, _widget): self.game.install_dlc(install_ui_delegate=self.window) def on_update_clicked(self, _widget): self.game.install_updates(install_ui_delegate=self.window) def on_create_menu_shortcut(self, *_args): """Add the selected game to the system's Games menu.""" game = self.game launch_config_name = self._select_game_launch_config_name(game) if launch_config_name is not None: xdgshortcuts.create_launcher(game.slug, game.id, game.name, launch_config_name, menu=True) def on_create_steam_shortcut(self, *_args): """Add the selected game to steam as a nonsteam-game.""" game = self.game launch_config_name = self._select_game_launch_config_name(game) if launch_config_name is not None: steam_shortcut.create_shortcut(game, launch_config_name) def on_create_desktop_shortcut(self, *_args): """Create a desktop launcher for the selected game.""" game = self.game launch_config_name = self._select_game_launch_config_name(game) if launch_config_name is not None: xdgshortcuts.create_launcher(game.slug, game.id, game.name, launch_config_name, desktop=True) def on_remove_menu_shortcut(self, *_args): """Remove an XDG menu shortcut""" game = self.game xdgshortcuts.remove_launcher(game.slug, game.id, menu=True) def on_remove_steam_shortcut(self, *_args): """Remove the selected game from list of non-steam apps.""" steam_shortcut.remove_shortcut(self.game) def on_remove_desktop_shortcut(self, *_args): """Remove a .desktop shortcut""" game = self.game xdgshortcuts.remove_launcher(game.slug, game.id, desktop=True) def on_game_duplicate(self, _widget): game = self.game duplicate_game_dialog = InputDialog( { "parent": self.window, "question": _( "Do you wish to duplicate %s?\nThe configuration will be duplicated, " "but the games files will not be duplicated.\n" "Please enter the new name for the copy:" ) % gtk_safe(game.name), "title": _("Duplicate game?"), "initial_value": game.name, } ) result = duplicate_game_dialog.run() if result != Gtk.ResponseType.OK: duplicate_game_dialog.destroy() return new_name = duplicate_game_dialog.user_value old_config_id = game.game_config_id if old_config_id: new_config_id = duplicate_game_config(game.slug, old_config_id) else: new_config_id = None duplicate_game_dialog.destroy() db_game = get_game_by_field(game.id, "id") db_game["name"] = new_name db_game["slug"] = slugify(new_name) if new_name != game.name else game.slug db_game["lastplayed"] = None db_game["playtime"] = 0.0 db_game["configpath"] = new_config_id db_game.pop("id") # Disconnect duplicate from service- there should be at most 1 database game for a service game. db_game.pop("service", None) db_game.pop("service_id", None) game_id = add_game(**db_game) new_game = Game(game_id) new_game.save() # Download in the background; we'll update the LutrisWindow when this # completes, no need to wait for it. AsyncCall(download_lutris_media, None, db_game["slug"]) def _select_game_launch_config_name(self, game): game_config = game.config.game_level.get("game", {}) configs = game_config.get("launch_configs") if not configs: return "" # use primary configuration dlg = dialogs.LaunchConfigSelectDialog(game, configs, title=_("Select shortcut target"), parent=self.window) if not dlg.confirmed: return None # no error here- the user cancelled out config_index = dlg.config_index return configs[config_index - 1]["name"] if config_index > 0 else "" class ServiceGameActions(GameActions): """This actions class supports a single service game, which has an idiosyncratic set of actions.""" def __init__(self, game: Game, window: Gtk.Window, application=None): super().__init__(window, application) self.game = game def get_games(self): return [self.game] def get_game_actions(self): return [ ("install", _("Install"), self.on_install_clicked), ("add", _("Locate installed game"), self.on_locate_installed_game), ("view", _("View on Lutris.net"), self.on_view_game), ] def get_displayed_entries(self): """Return a dictionary of actions that should be shown for a game""" return {"install": self.is_installable, "add": self.is_installable, "view": True} def get_game_actions(games: List[Game], window: Gtk.Window, application=None) -> GameActions: """Creates a GameActions instance (which may be a subclass) for the list of games given. If it can't figure out a suitable class, it falls back to the base GameActions class, which provides no actions.""" if games: if len(games) == 1: game = games[0] if game.is_db_stored: return SingleGameActions(game, window, application) if game.service: return ServiceGameActions(game, window, application) elif all(g.is_db_stored for g in games): return MultiGameActions(games, window) # If given no games, or the games are not of a kind we can handle, # the base class acts as an empty set of actions. return GameActions(window, application) lutris-0.5.17/lutris/gui/000077500000000000000000000000001460562010500152335ustar00rootroot00000000000000lutris-0.5.17/lutris/gui/__init__.py000066400000000000000000000000311460562010500173360ustar00rootroot00000000000000"""Lutris GUI package""" lutris-0.5.17/lutris/gui/addgameswindow.py000066400000000000000000000712731460562010500206140ustar00rootroot00000000000000import os from gettext import gettext as _ from gi.repository import Gio, GLib, Gtk from lutris import api, sysoptions 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.install_from_setup_game_slug_entry.connect( "focus-out-event", self.on_install_from_setup_game_slug_entry_focus_out ) 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() def on_back_clicked(self, _widget): self.stack.navigate_back() def on_navigate_home(self, _accel_group, _window, _keyval, _modifier): self.stack.navigate_home() def on_cancel_clicked(self, _widget): self.destroy() # 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() 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() 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) 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) 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(_("Showing %s results") % count) else: self.search_result_label.set_markup(_("%s results, only displaying first {count}") % total_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() def _on_game_selected(self, listbox, row): game_slug = row.api_info["slug"] application = Gio.Application.get_default() application.show_lutris_installer_window(game_slug=game_slug) 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) 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 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 on_install_from_setup_game_slug_entry_focus_out(self, *args): slug = slugify(self.install_from_setup_game_slug_entry.get_text()) self.install_from_setup_game_slug_entry.set_text(slug) 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")) 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() 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) 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 = slugify(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")) 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")) 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.17/lutris/gui/application.py000066400000000000000000001163551460562010500201230ustar00rootroot00000000000000# 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 _ from typing import List import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import Gio, GLib, GObject, Gtk from lutris import settings from lutris.api import get_runners, parse_installer_url from lutris.database import games as games_db from lutris.database.services import ServiceGameCollection from lutris.exception_backstops import init_exception_backstops from lutris.game import Game, export_game, import_game from lutris.gui.config.preferences_dialog import PreferencesDialog from lutris.gui.dialogs import ErrorDialog, InstallOrPlayDialog, NoticeDialog from lutris.gui.dialogs.delegates import CommandLineUIDelegate, InstallUIDelegate, LaunchUIDelegate from lutris.gui.dialogs.issue import IssueReportWindow from lutris.gui.installerwindow import InstallationKind, InstallerWindow from lutris.gui.widgets.status_icon import LutrisStatusIcon from lutris.installer import get_installers from lutris.migrations import migrate from lutris.monitored_command import exec_command from lutris.runners import InvalidRunnerError, RunnerInstallationError, get_runner_names, import_runner from lutris.services import get_enabled_services from lutris.startup import init_lutris, run_all_checks from lutris.style_manager import StyleManager from lutris.util import datapath, log, system from lutris.util.http import HTTPError, Request from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.savesync import save_check, show_save_stats, upload_save from lutris.util.steam.appmanifest import AppManifest, get_appmanifests from lutris.util.steam.config import get_steamapps_dirs from .lutriswindow import LutrisWindow LUTRIS_EXPERIMENTAL_FEATURES_ENABLED = os.environ.get("LUTRIS_EXPERIMENTAL_FEATURES_ENABLED") == "1" class Application(Gtk.Application): def __init__(self): super().__init__( application_id="net.lutris.Lutris", flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, register_session=True, ) # Prepare the backstop logic just before the first emission hook (or connection) is # established; this will apply to all connections from this point forward. init_exception_backstops() GObject.add_emission_hook(Game, "game-start", self.on_game_start) GObject.add_emission_hook(Game, "game-stopped", self.on_game_stopped) GObject.add_emission_hook(PreferencesDialog, "settings-changed", self.on_settings_changed) 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 = [] 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, ) if LUTRIS_EXPERIMENTAL_FEATURES_ENABLED: self.add_main_option( "save-stats", 0, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Show statistics about a game saves"), None, ) self.add_main_option( "save-upload", 0, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Upload saves"), None, ) self.add_main_option( "save-check", 0, GLib.OptionFlags.NONE, GLib.OptionArg.STRING, _("Verify status of save syncing"), 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 start_runtime_updates(self) -> None: if os.environ.get("LUTRIS_SKIP_INIT"): logger.debug("Skipping initialization") else: self.window.start_runtime_updates(self.force_updates) 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 kwargs["game"].id return str(kwargs) def show_window(self, window_class, /, update_function=None, **kwargs): """Instantiate a window keeping 1 instance max Params: window_class (Gtk.Window): class to create the instance from update_function (Callable): Function to initialize or update the window (if possible before being shown) 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() window_inst = self.app_windows[window_key] if update_function: update_function(window_inst) return window_inst 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)) if update_function: update_function(window_inst) 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 show_lutris_installer_window(self, game_slug): def on_installers_ready(installers, error): if error: ErrorDialog(error, parent=self.window) elif installers: self.show_installer_window(installers) else: ErrorDialog(_("No installer available."), parent=self.window) AsyncCall(get_installers, on_installers_ready, game_slug=game_slug) 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() migrate() 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 if LUTRIS_EXPERIMENTAL_FEATURES_ENABLED: def get_game_match(slug): # First look for an exact match games = games_db.get_games_by_slug(slug) if not games: # Then look for matches games = games_db.get_games(searches={"slug": slug}) if len(games) > 1: self._print( command_line, "Multiple games matching %s: %s" % (slug, ",".join(game["slug"] for game in games)), ) return if not games: self._print(command_line, "No matching game for %s" % slug) return return Game(games[0]["id"]) if options.contains("save-stats"): game = get_game_match(options.lookup_value("save-stats").get_string()) if game: show_save_stats(game, output_format="json" if options.contains("json") else "text") return 0 if options.contains("save-upload"): game = get_game_match(options.lookup_value("save-upload").get_string()) if game: upload_save(game) return 0 if options.contains("save-check"): game = get_game_match(options.lookup_value("save-check").get_string()) if game: save_check(game) 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: # 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() self.start_runtime_updates() # If the Lutris GUI is started by itself, don't quit it when a game stops self.quit_on_game_exit = False return 0 def on_settings_changed(self, dialog, state, setting_key): if setting_key == "dark_theme": self.style_manager.is_config_dark = state elif setting_key == "show_tray_icon" and self.window: if self.window.get_visible(): self.set_tray_icon() return 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 def on_game_stopped(self, game): """Callback to quit Lutris is last game stops while the window is hidden.""" running_game_ids = [g.id for g in self._running_games] if game.id in running_game_ids: logger.debug("Removing %s from running IDs", game.id) try: del self._running_games[running_game_ids.index(game.id)] except ValueError: pass elif running_game_ids: logger.warning("%s not in %s", game.id, running_game_ids) else: logger.debug("Game has already been removed from running IDs?") if settings.read_bool_setting("hide_client_on_game_start") and not self.quit_on_game_exit: self.window.show() # Show launcher window elif not self.window.is_visible(): if not self.has_running_games: if self.quit_on_game_exit or not self.has_tray_icon(): self.quit() return True def get_launch_ui_delegate(self): return self.launch_ui_delegate def get_running_games(self) -> List[Game]: # This method reflects games that have stopped even if the 'game-stopped' signal # has not been handled yet; that handler will still clean up the list though. return [g for g in self._running_games if g.state != g.STATE_STOPPED] @property def has_running_games(self): return bool(self.get_running_games()) def get_running_game_ids(self) -> List[str]: """Returns the ids of the games presently running.""" return [game.id for game in self.get_running_games()] def is_game_running_by_id(self, game_id: str) -> bool: """True if the ID is the ID of a game that is running.""" return bool(game_id and str(game_id) in self.get_running_game_ids()) def get_game_by_id(self, game_id: str) -> Game: """Returns the game with the ID given; if it's running this is the running game instance, and if not it's a fresh copy off the database.""" for game in self.get_running_games(): if game.id == str(game_id): return game return Game(game_id) @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) 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, "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"], "appid": game["appid"], "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 (InvalidRunnerError, 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 (InvalidRunnerError, 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 InvalidRunnerError: 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.17/lutris/gui/config/000077500000000000000000000000001460562010500165005ustar00rootroot00000000000000lutris-0.5.17/lutris/gui/config/__init__.py000066400000000000000000000000471460562010500206120ustar00rootroot00000000000000DIALOG_WIDTH = 845 DIALOG_HEIGHT = 600 lutris-0.5.17/lutris/gui/config/accounts_box.py000066400000000000000000000203211460562010500215370ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris import settings from lutris.api import disconnect, read_user_info from lutris.gui.config.base_config_box import BaseConfigBox from lutris.gui.config.updates_box import UpdateButtonBox from lutris.gui.dialogs import ClientLoginDialog, QuestionDialog from lutris.services.lutris import sync_media from lutris.util.jobs import AsyncCall from lutris.util.library_sync import ( LOCAL_LIBRARY_SYNCED, LOCAL_LIBRARY_SYNCING, LibrarySyncer, is_local_library_syncing, ) from lutris.util.steam.config import STEAM_ACCOUNT_SETTING, get_steam_users from lutris.util.strings import time_ago class AccountsBox(BaseConfigBox): def __init__(self): super().__init__() self.add(self.get_section_label(_("Lutris"))) frame = Gtk.Frame(visible=True, shadow_type=Gtk.ShadowType.ETCHED_IN) frame.get_style_context().add_class("info-frame") self.bullshit_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True) self.pack_start(frame, False, False, 0) self.lutris_options = self.get_lutris_options() self.bullshit_box.add(self.lutris_options) frame.add(self.bullshit_box) self.library_syncing_source_id = None self.library_synced_source_id = None self.sync_box = UpdateButtonBox(self.get_sync_box_label(), _("Sync Again"), clicked=self.on_sync_again_clicked) self.connect("realize", self.on_realize) self.connect("unrealize", self.on_unrealize) self.sync_frame = self._get_framed_options_list_box([self.sync_box]) self.sync_frame.set_visible(settings.read_bool_setting("library_sync_enabled")) self.pack_start(self.sync_frame, False, False, 0) 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.") ) ) self.frame = Gtk.Frame(visible=True, shadow_type=Gtk.ShadowType.ETCHED_IN) self.frame.get_style_context().add_class("info-frame") self.pack_start(self.frame, False, False, 0) self.accounts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, visible=True) self.frame.add(self.accounts_box) def on_realize(self, _widget): self.library_syncing_source_id = LOCAL_LIBRARY_SYNCING.register(self.on_local_library_syncing) self.library_synced_source_id = LOCAL_LIBRARY_SYNCED.register(self.on_local_library_synced) if is_local_library_syncing(): self.on_local_library_syncing() def on_unrealize(self, _widget): # The destroy signal never fires for this sub-widget, so we use # realize/unrealize for this instead. LOCAL_LIBRARY_SYNCING.unregister(self.library_syncing_source_id) LOCAL_LIBRARY_SYNCED.unregister(self.library_synced_source_id) def space_widget(self, widget, top=16, bottom=16): widget.set_margin_top(top) widget.set_margin_start(16) widget.set_margin_end(16) widget.set_margin_bottom(bottom) return widget def get_user_box(self): user_info = read_user_info() user_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, visible=True) label = Gtk.Label(visible=True) label.set_alignment(0, 0.5) if user_info: label.set_markup(_("Connected as %s") % user_info["username"]) else: label.set_markup(_("Not connected")) self.space_widget(label) user_box.pack_start(label, True, True, 0) if user_info: button_text = _("Logout") button_handler = self.on_logout_clicked else: button_text = _("Login") button_handler = self.on_login_clicked button = Gtk.Button(button_text, visible=True) button.connect("clicked", button_handler) self.space_widget(button) user_box.pack_start(button, False, False, 0) return user_box def get_lutris_options(self): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, visible=True) box.add(self.get_user_box()) sync_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6, visible=True) sync_label = Gtk.Label(_("Keep your game library synced with Lutris.net"), visible=True) sync_switch = Gtk.Switch(visible=True) sync_switch.set_active(settings.read_bool_setting("library_sync_enabled")) sync_switch.connect("state-set", self.on_sync_state_set) sync_box.pack_start(sync_label, False, False, 0) sync_box.pack_end(sync_switch, False, False, 0) self.space_widget(sync_box, bottom=0) box.add(sync_box) label = Gtk.Label(visible=True) label.set_alignment(0, 0.5) label.set_markup( _( "This will send play time, last played, runner, platform \n" "and store information to the lutris website so you can \n" "sync this data on multiple devices" ) ) self.space_widget(label, top=0) box.add(label) return box def populate_steam_accounts(self): main_radio_button = None active_steam_account = settings.read_setting(STEAM_ACCOUNT_SETTING) steam_users = get_steam_users() for account in steam_users: steamid64 = account["steamid64"] name = account.get("PersonaName") or f"#{steamid64}" radio_button = Gtk.RadioButton.new_with_label_from_widget(main_radio_button, name) self.space_widget(radio_button) radio_button.show() radio_button.set_active(active_steam_account == steamid64) radio_button.connect("toggled", self.on_steam_account_toggled, steamid64) self.accounts_box.pack_start(radio_button, True, True, 0) if not main_radio_button: main_radio_button = radio_button if not steam_users: self.accounts_box.pack_start( self.space_widget(Gtk.Label(_("No Steam account found"), visible=True)), True, True, 0, ) def rebuild_lutris_options(self): self.bullshit_box.remove(self.lutris_options) self.lutris_options.destroy() self.lutris_options = self.get_lutris_options() self.bullshit_box.add(self.lutris_options) def on_logout_clicked(self, _widget): disconnect() self.rebuild_lutris_options() def on_login_clicked(self, _widget): login_dialog = ClientLoginDialog(parent=self.get_toplevel()) login_dialog.connect("connected", self.on_connect_response) def on_connect_response(self, _dialog, bliblu): self.rebuild_lutris_options() def on_sync_again_clicked(self, _button): AsyncCall(LibrarySyncer().sync_local_library, None, force=True) def on_local_library_syncing(self): self.sync_box.show_running_markup(_("Syncing library...")) def on_local_library_synced(self): self.sync_box.show_completion_markup(self.get_sync_box_label(), "") AsyncCall(sync_media, None) def get_sync_box_label(self): synced_at = settings.read_setting("last_library_sync_at") if synced_at: return _("Last synced %s.") % time_ago(int(synced_at)) return "" def on_steam_account_toggled(self, radio_button, steamid64): """Handler for switching the active Steam account.""" settings.write_setting(STEAM_ACCOUNT_SETTING, steamid64) def on_sync_state_set(self, switch, state): if not settings.read_setting("last_library_sync_at"): sync_warn_dialog = QuestionDialog( { "title": _("Synchronize library?"), "question": _("Enable library sync and run a full sync with lutris.net?"), } ) if sync_warn_dialog.result == Gtk.ResponseType.YES: AsyncCall(LibrarySyncer().sync_local_library, None) else: return self.on_setting_change(switch, state, "library_sync_enabled") self.sync_frame.set_visible(state) lutris-0.5.17/lutris/gui/config/add_game_dialog.py000066400000000000000000000014601460562010500221130ustar00rootroot00000000000000from gettext import gettext as _ from lutris.config import LutrisConfig from lutris.gui.config.game_common import GameDialogCommon class AddGameDialog(GameDialogCommon): """Add game dialog class.""" def __init__(self, parent, game=None, runner=None): super().__init__(_("Add a new game"), config_level="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() self.name_entry.grab_focus() self.show_all() lutris-0.5.17/lutris/gui/config/base_config_box.py000066400000000000000000000111001460562010500221520ustar00rootroot00000000000000from typing import Callable from gi.repository import Gtk from lutris import settings from lutris.gui.config.boxes import UnderslungMessageBox from lutris.gui.widgets.common import VBox class BaseConfigBox(VBox): settings_accelerators = {} def __init__(self): super().__init__(visible=True, spacing=12) 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_section_label(self, text: str) -> Gtk.Label: label = Gtk.Label(visible=True) label.set_markup("%s" % text) label.set_alignment(0, 0.5) return label def get_description_label(self, text: str) -> Gtk.Label: label = Gtk.Label(visible=True) label.set_markup("%s" % text) label.set_line_wrap(True) label.set_alignment(0, 0.5) return label def _get_framed_options_list_box(self, items): frame = Gtk.Frame(visible=True, shadow_type=Gtk.ShadowType.ETCHED_IN) list_box = Gtk.ListBox(visible=True, selection_mode=Gtk.SelectionMode.NONE) frame.add(list_box) for item in items: list_box.add(Gtk.ListBoxRow(child=item, visible=True, activatable=False)) return frame def get_setting_box( self, setting_key: str, label: str, default: bool = False, warning_markup: str = None, warning_condition: Callable[[bool], bool] = None, extra_widget: Gtk.Widget = None, ) -> Gtk.Box: setting_value = settings.read_bool_setting(setting_key, default=default) if not warning_markup and not extra_widget: box = self._get_inner_settings_box(setting_key, setting_value, label) else: if warning_markup: def update_warning(active): visible = warning_condition(active) if bool(warning_condition) else active warning_box.show_markup(warning_markup if visible else None) warning_box = UnderslungMessageBox("dialog-warning", margin_left=0, margin_right=0, margin_bottom=0) update_warning(setting_value) inner_box = self._get_inner_settings_box( setting_key, setting_value, label, margin=0, when_setting_changed=update_warning ) else: warning_box = None inner_box = self._get_inner_settings_box( setting_key, setting_value, label, margin=0, ) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, visible=True) box.pack_start(inner_box, False, False, 0) if warning_box: box.pack_start(warning_box, False, False, 0) if extra_widget: box.pack_start(extra_widget, False, False, 0) box.set_margin_top(12) box.set_margin_bottom(12) box.set_margin_left(12) box.set_margin_right(12) return box def _get_inner_settings_box( self, setting_key: str, setting_value: bool, label: str, margin: int = 12, when_setting_changed: Callable[[bool], None] = None, ): checkbox = Gtk.Switch(visible=True, valign=Gtk.Align.CENTER) checkbox.set_active(setting_value) checkbox.connect("state-set", self.on_setting_change, setting_key, when_setting_changed) 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) return self.get_listed_widget_box(label, checkbox, margin=margin) def get_listed_widget_box(self, label: str, widget: Gtk.Widget, margin: int = 12) -> Gtk.Box: box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12, margin=margin, visible=True) label = Gtk.Label(label, visible=True, wrap=True) label.set_alignment(0, 0.5) box.pack_start(label, True, True, 0) box.pack_end(widget, False, False, 0) return box def on_setting_change( self, _widget, state: bool, setting_key: str, when_setting_changed: Callable[[bool], None] = None ) -> None: """Save a setting when an option is toggled""" settings.write_setting(setting_key, state) self.get_toplevel().emit("settings-changed", state, setting_key) if when_setting_changed: when_setting_changed(state) lutris-0.5.17/lutris/gui/config/boxes.py000066400000000000000000001041131460562010500201720ustar00rootroot00000000000000"""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 InvalidRunnerError, 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, config_level, game=None): super().__init__() self.options = [] self.config_level = config_level 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) 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: option = option.copy() # we will mutate this, so let's not alter the original try: if "scope" in option: if self.config_level 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") if callable(default): default = 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) hbox.pack_start(self.wrapper, True, True, 0) # Grey out option if condition unmet if "condition" in option and not option["condition"]: self.wrapper.set_sensitive(False) 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, self.wrapper) 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, default) self.tooltip_default = "Enabled" if default else "Disabled" elif option_type == "range": self.generate_range(option_key, option["min"], option["max"], option["label"], value, default) 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, default, option_size) elif option_type == "directory_chooser": self.generate_directory_chooser(option, value, default) elif option_type == "file": self.generate_file_chooser(option, value, default) elif option_type == "command_line": self.generate_file_chooser(option, value, default, shell_quoting=True) elif option_type == "multiple": self.generate_multiple_file_chooser(option_key, option["label"], value, default) elif option_type == "label": self.generate_label(option["label"]) elif option_type == "mapping": self.generate_editable_grid(option_key, label=option["label"], value=value, default=default) 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, default=None): """Generate a checkbox.""" label = Label(option["label"]) self.wrapper.pack_start(label, False, False, 0) switch = Gtk.Switch() if value is None: switch.set_active(default) else: 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, default=None, option_size=None): """Generate an entry box.""" label = Label(label) self.wrapper.pack_start(label, False, False, 0) entry = Gtk.Entry() entry.set_text(value or default or "") 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): expanded, tooltip_default = self._expand_combobox_choices(choices, default) for choice in expanded: liststore.append(choice) if tooltip_default: self.tooltip_default = tooltip_default @staticmethod def _expand_combobox_choices(choices, default): expanded = [] tooltip_default = None for choice in choices: if isinstance(choice, str): choice = (choice, choice) if choice[1] == default: tooltip_default = choice[0] choice = (_("%s (default)") % choice[0], choice[1]) expanded.append(choice) return expanded, tooltip_default # 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) # 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) expanded, _tooltip_default = self._expand_combobox_choices(choices, default) if value in [v for _k, v in expanded]: combobox.set_active_id(value) elif has_entry: for ch in combobox.get_children(): if isinstance(ch, Gtk.Entry): ch.set_text(value or "") break 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, default=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) elif default: spin_button.set_value(default) 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, path=None, default_path=None, shell_quoting=False): """Generate a file chooser button to select a file.""" option_name = option["option"] label = Label(option["label"]) chooser_default_path = option.get("default_path") or (self.runner.default_path if self.runner else "") warn_if_non_writable_parent = bool(option.get("warn_if_non_writable_parent")) if not path: path = default_path file_chooser = FileChooserEntry( title=_("Select file"), action=Gtk.FileChooserAction.OPEN, warn_if_non_writable_parent=warn_if_non_writable_parent, text=path, default_path=chooser_default_path, shell_quoting=shell_quoting, ) if "default_path" in option: chooser_default_path = self.lutris_config.system_config.get(option["default_path"]) if chooser_default_path and os.path.exists(chooser_default_path): file_chooser.entry.set_text(chooser_default_path) if path: # If path is relative, complete with game dir if not os.path.isabs(path): path = os.path.expanduser(path) if not os.path.isabs(path): if self.game and self.game.directory: path = os.path.join(self.game.directory, path) file_chooser.entry.set_text(path) 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, default_path=None): """Generate a file chooser button to select a directory.""" label = Label(option["label"]) option_name = option["option"] warn_if_non_writable_parent = bool(option.get("warn_if_non_writable_parent")) if not path: path = default_path chooser_default_path = None if not path and self.game and self.game.has_runner: chooser_default_path = self.game.runner.working_dir directory_chooser = FileChooserEntry( title=_("Select folder"), action=Gtk.FileChooserAction.SELECT_FOLDER, warn_if_non_writable_parent=warn_if_non_writable_parent, text=path, default_path=chooser_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, default=None): """Adds an editable grid widget""" value = value or default 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, default=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 not value: value = default 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, None) self.lutris_config.update_cascaded_config() reset_value = self.config.get(option_key) if current_value == reset_value: return default = option.get("default") if callable(default): default = default() # 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, 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") class GameBox(ConfigBox): config_section = "game" def __init__(self, config_level, lutris_config, game): ConfigBox.__init__(self, config_level, 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, config_level, lutris_config, game=None): ConfigBox.__init__(self, config_level, game) self.lutris_config = lutris_config try: self.runner = import_runner(self.lutris_config.runner_slug)() except InvalidRunnerError: 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 SystemConfigBox(ConfigBox): config_section = "system" def __init__(self, config_level, lutris_config): ConfigBox.__init__(self, config_level) 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 UnderslungMessageBox(Gtk.Box): """A box to display a message with an icon inside the configuration dialog.""" def __init__(self, icon_name, margin_left=18, margin_right=18, margin_bottom=6): super().__init__( spacing=6, visible=False, margin_left=margin_left, margin_right=margin_right, margin_bottom=margin_bottom, no_show_all=True, ) 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 show_markup(self, markup): """Displays the markup given, and shows this box. If markup is empty or None, this hides the box instead. Returns the new visibility.""" visible = bool(markup) if markup: self.label.set_markup(str(markup)) self.set_visible(visible) return visible class ConfigMessageBox(UnderslungMessageBox): def __init__(self, warning, option_key, icon_name, **kwargs): self.warning = warning self.option_key = option_key super().__init__(icon_name, **kwargs) if not callable(warning): text = gtk_safe(warning) if text: self.label.set_markup(str(text)) 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) return self.show_markup(text) 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, wrapper): super().__init__(error, option_key, icon_name="dialog-error") self.wrapper = wrapper def update_warning(self, config): visible = super().update_warning(config) self.wrapper.set_sensitive(not visible) return visible lutris-0.5.17/lutris/gui/config/edit_category_games.py000066400000000000000000000123471460562010500230570ustar00rootroot00000000000000# 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.game import Game from lutris.gui.dialogs import QuestionDialog, SavableModelessDialog from lutris.util.strings import get_natural_sort_key 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 = sorted( [Game(x["id"]) for x in games_db.get_games()], key=lambda g: (g.is_installed, get_natural_sort_key(g.name)) ) self.category_games = [Game(x) for x in categories_db.get_game_ids_for_categories([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 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() 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() lutris-0.5.17/lutris/gui/config/edit_game.py000066400000000000000000000010651460562010500207720ustar00rootroot00000000000000from gettext import gettext as _ from lutris.gui.config.game_common import GameDialogCommon class EditGameConfigDialog(GameDialogCommon): """Game config edit dialog.""" def __init__(self, parent, game): super().__init__(_("Configure %s") % game.name, config_level="game", parent=parent) self.game = game self.lutris_config = game.config self.slug = game.slug self.initial_slug = game.slug self.runner_name = game.runner_name self.build_notebook() self.build_tabs() self.show_all() lutris-0.5.17/lutris/gui/config/edit_game_categories.py000066400000000000000000000154751460562010500232110ustar00rootroot00000000000000# pylint: disable=no-member from gettext import gettext as _ from typing import Sequence from gi.repository import Gtk from lutris.database import categories as categories_db from lutris.database.categories import is_reserved_category from lutris.game import Game from lutris.gui.dialogs import QuestionDialog, SavableModelessDialog from lutris.util.strings import get_natural_sort_key class EditGameCategoriesDialog(SavableModelessDialog): """Game category edit dialog.""" def __init__(self, game=None, parent=None): title = game.name if game else _("Categories") super().__init__(title, parent=parent, border_width=10) self.set_default_size(350, 250) self.category_checkboxes = {} self.games = [] self.categories = sorted( [c["name"] for c in categories_db.get_categories() if not is_reserved_category(c["name"])], key=lambda c: get_natural_sort_key(c), ) self.checkbox_grid = Gtk.Grid() 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) if game: self.add_games([game]) self.vbox.show_all() def add_games(self, games: Sequence[Game]) -> None: """Adds games to the dialog; this is intended to be used when the dialog is for multiple games, and can be used more than once to accumulate games.""" def mark_category_checkbox(checkbox, included): # Checks or unchecks a textbox- but after the first game, this will # compare against the current state and go to 'inconsistent' rather than # reversing the checkbox. if len(self.games) == 0: checkbox.set_active(included) elif not checkbox.get_inconsistent() and checkbox.get_active() != included: checkbox.set_active(False) checkbox.set_inconsistent(True) def add_game(game): # Adds a single game to the dialog, and checks or unchecks # boxes as appropriate. categories = categories_db.get_categories_in_game(game.id) other_checkboxes = set(self.category_checkboxes.values()) for category in categories: category_checkbox = self.category_checkboxes.get(category) if category_checkbox: other_checkboxes.discard(category_checkbox) mark_category_checkbox(category_checkbox, included=True) for category_checkbox in other_checkboxes: mark_category_checkbox(category_checkbox, included=False) self.games.append(game) existing_game_ids = set(game.id for game in self.games) for g in games: if g.id not in existing_game_ids: add_game(g) if len(self.games) > 1: subtitle = _("%d games") % len(self.games) header_bar = self.get_header_bar() if header_bar: header_bar.set_subtitle(subtitle) def _create_category_checkboxes(self): """Constructs a frame containing checkboxes for all known (non-special) categories.""" frame = Gtk.Frame() scrolledwindow = Gtk.ScrolledWindow() for category in self.categories: label = category checkbutton = Gtk.CheckButton(label) checkbutton.connect("toggled", self.on_checkbutton_toggled) self.checkbox_grid.attach_next_to(checkbutton, None, Gtk.PositionType.BOTTOM, 3, 1) self.category_checkboxes[category] = checkbutton scrolledwindow.add(self.checkbox_grid) frame.add(scrolledwindow) return frame def _create_add_category(self): """Creates a box that carries the controls to add a new category.""" def on_add_category(*_args): category = categories_db.strip_category_name(category_entry.get_text()) if not categories_db.is_reserved_category(category) and category not in self.category_checkboxes: category_entry.set_text("") checkbutton = Gtk.CheckButton(category, visible=True, active=True) self.category_checkboxes[category] = checkbutton self.checkbox_grid.attach_next_to(checkbutton, None, Gtk.PositionType.TOP, 3, 1) categories_db.add_category(category) hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) category_entry = Gtk.Entry() category_entry.connect("activate", on_add_category) 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_end(button, False, False, 0) return hbox @staticmethod def on_checkbutton_toggled(checkbutton): # If the user toggles a checkbox, it is no longer inconsistent. checkbutton.set_inconsistent(False) def on_save(self, _button): """Save category changes and destroy widget.""" changes = [] for game in self.games: for category_checkbox in self.category_checkboxes.values(): removed_categories = set() added_categories = set() if not category_checkbox.get_inconsistent(): label = category_checkbox.get_label() game_categories = categories_db.get_categories_in_game(game.id) if label in 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: changes.append((game, added_categories, removed_categories)) if changes and len(self.games) > 1: if len(changes) == 1: question = _("You are updating the categories on 1 game. Are you sure you want to change it?") else: question = _( "You are updating the categories on %d games. Are you sure you want to change them?" ) % len(changes) dlg = QuestionDialog( { "parent": self, "question": question, "title": _("Changing Categories"), } ) if dlg.result != Gtk.ResponseType.YES: return for game, added_categories, removed_categories in changes: game.update_game_categories(added_categories, removed_categories) self.destroy() lutris-0.5.17/lutris/gui/config/game_common.py000066400000000000000000001023141460562010500213340ustar00rootroot00000000000000"""Shared config dialog stuff""" # pylint: disable=not-an-iterable import os.path 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.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, SystemConfigBox, UnderslungMessageBox 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 MEDIA_CACHE_INVALIDATED, get_image_file_extension from lutris.runners import import_runner from lutris.services.lutris import LutrisBanner, LutrisCoverart, LutrisIcon, download_lutris_media from lutris.services.service_media import resolve_media_path from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.strings import gtk_safe, parse_playtime, 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, config_level, parent=None): super().__init__(title, parent=parent, border_width=0) self.config_level = config_level 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.runner_warning_box = None self.timer_id = None self.game = None self.saved = None self.slug = None self.initial_slug = None self.slug_entry = None self.directory_entry = None self.year_entry = None self.playtime_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): """Build tabs (for game and runner levels)""" self.timer_id = None if self.config_level == "game": self._build_info_tab() self._build_game_tab() self._build_runner_tab() self._build_system_tab() 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 self.runner_warning_box = RunnerMessageBox() info_box.pack_start(self.runner_warning_box, False, False, 6) # Runner self.runner_warning_box.update_warning(self.runner_name) info_box.pack_start(self._get_year_box(), False, False, 6) # Year info_box.pack_start(self._get_playtime_box(), False, False, 6) # Playtime 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.VBox(spacing=12, margin_right=12, margin_left=12) slug_entry_box = Gtk.Box(spacing=12, margin_right=0, margin_left=0) slug_label = Label() slug_label.set_markup(_("Identifier\n(Internal ID: %s)") % self.game.id) slug_entry_box.pack_start(slug_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_entry_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_entry_box.pack_start(self.slug_change_button, False, False, 0) slug_box.pack_start(slug_entry_box, True, True, 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 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 _get_playtime_box(self): box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Label(_("Playtime")) box.pack_start(label, False, False, 0) self.playtime_entry = Gtk.Entry() if self.game: self.playtime_entry.set_text(self.game.formatted_playtime) box.pack_start(self.playtime_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 = resolve_media_path(service_media.get_possible_media_paths(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 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() def on_slug_entry_activate(self, _widget): self.change_game_slug() def change_game_slug(self): slug = self.slug_entry.get_text() self.slug = slug self.slug_entry.set_sensitive(False) self.slug_change_button.set_label(_("Change")) AsyncCall(download_lutris_media, self.refresh_all_images_cb, self.slug) def refresh_all_images_cb(self, _result, _error): for image_type, image_button in self.image_buttons.items(): self._set_image(image_type, image_button) 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() 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.has_runner and len(game.runner.game_options) > 8 def has_advanced(game): if game.has_runner: 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.config_level, 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.config_level, 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): if self.runner_name: self.runner_box = self._build_options_tab( _("Runner options"), lambda: RunnerBox(self.config_level, self.lutris_config) ) else: self._build_missing_options_tab(self.no_runner_label, _("Runner options")) def _build_system_tab(self): self.system_box = self._build_options_tab( _("System options"), lambda: SystemConfigBox(self.config_level, 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): if self.system_box: self.system_box.filter = value if self.runner_box: self.runner_box.filter = value if self.game_box: self.game_box.filter = value 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.runner_warning_box.update_warning(self.runner_name) 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() self._build_system_tab() 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 playtime_text = self.playtime_entry.get_text() if playtime_text and playtime_text != self.game.formatted_playtime: try: parse_playtime(playtime_text) except ValueError as ex: ErrorDialog(ex, 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 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 self.slug != self.initial_slug: AsyncCall(download_lutris_media, None, self.slug) if not self.game: self.game = Game() year = None if self.year_entry.get_text(): year = int(self.year_entry.get_text()) playtime = None playtime_text = self.playtime_entry.get_text() if playtime_text and playtime_text != self.game.formatted_playtime: playtime = parse_playtime(playtime_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 if playtime: self.game.playtime = playtime 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 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: image_path = dialog.get_filename() self.save_custom_media(image_type, image_path) dialog.destroy() def on_custom_image_reset_clicked(self, _widget, image_type): self.refresh_image(image_type) def save_custom_media(self, image_type, image_path): slug = self.slug or self.game.slug service_media = self.service_medias[image_type] self.game.custom_images.add(image_type) dest_paths = service_media.get_possible_media_paths(slug) if image_path not in dest_paths: ext = get_image_file_extension(image_path) if ext: for candidate in dest_paths: if candidate.casefold().endswith(ext): self._save_copied_media_to(candidate, image_type, image_path) return self._save_transcoded_media_to(dest_paths[0], image_type, image_path) def _save_copied_media_to(self, dest_path, image_type, image_path): """Copies a media file to the dest_path, but trashes the existing media for the game first. When complete, this updates the button indicated by image_type as well.""" slug = self.slug or self.game.slug service_media = self.service_medias[image_type] def on_trashed(): AsyncCall(copy_image, self.image_refreshed_cb) def copy_image(): shutil.copy(image_path, dest_path, follow_symlinks=True) MEDIA_CACHE_INVALIDATED.fire() return image_type service_media.trash_media(slug, completion_function=on_trashed) def _save_transcoded_media_to(self, dest_path, image_type, image_path): """Transcode an image, copying it to a new path and selecting the file type based on the file extension of dest_path. Trashes all media for the current game too. Runs in the background, and when complete updates the button indicated by image_type.""" slug = self.slug or self.game.slug service_media = self.service_medias[image_type] file_format = {".jpg": "jpeg", ".png": "png"}[get_image_file_extension(dest_path)] # 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 temp_file = dest_path + ".tmp" def transcode(): 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(temp_file, file_format, ["quality"], ["100"]) service_media.trash_media(slug, completion_function=on_trashed) def transcode_cb(_result, error): if error: raise error def on_trashed(): os.rename(temp_file, dest_path) MEDIA_CACHE_INVALIDATED.fire() self.image_refreshed_cb(image_type) AsyncCall(transcode, transcode_cb) def refresh_image(self, image_type): slug = self.slug or self.game.slug service_media = self.service_medias[image_type] self.game.custom_images.discard(image_type) def on_trashed(): AsyncCall(download, self.image_refreshed_cb) def download(): download_lutris_media(slug) return image_type service_media.trash_media(slug, completion_function=on_trashed) def refresh_image_cb(self, image_type, error): return image_type def image_refreshed_cb(self, image_type, _error=None): if image_type: self._set_image(image_type, self.image_buttons[image_type]) service_media = self.service_medias[image_type] service_media.run_system_update_desktop_icons() class RunnerMessageBox(UnderslungMessageBox): def __init__(self): super().__init__(margin_left=12, margin_right=12, icon_name="dialog-warning") def update_warning(self, runner_name): try: if runner_name: runner_class = import_runner(runner_name) runner = runner_class() warning = runner.runner_warning if warning: self.show_markup(warning) return self.show_markup(None) except Exception as ex: self.show_message(gtk_safe(ex)) lutris-0.5.17/lutris/gui/config/preferences_box.py000066400000000000000000000032321460562010500222230ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris.gui.config.base_config_box import BaseConfigBox from lutris.gui.widgets.status_icon import supports_status_icon 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"} settings_availability = {"show_tray_icon": supports_status_icon} 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, 0) for setting_key, label in self.settings_options.items(): available = setting_key not in self.settings_availability or self.settings_availability[setting_key]() if available: 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) lutris-0.5.17/lutris/gui/config/preferences_dialog.py000066400000000000000000000126521460562010500227000ustar00rootroot00000000000000"""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 SystemConfigBox from lutris.gui.config.game_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.storage_box import StorageBox from lutris.gui.config.sysinfo_box import SystemBox from lutris.gui.config.updates_box import UpdatesBox class PreferencesDialog(GameDialogCommon): __gsignals__ = { "settings-changed": (GObject.SIGNAL_RUN_LAST, None, (bool, str)), } def __init__(self, parent=None): super().__init__(_("Lutris settings"), config_level="system", 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("updates-stack", _("Updates"), "system-software-install-symbolic")) sidebar.add(self.get_sidebar_button("sysinfo-stack", _("System"), "computer-symbolic")) sidebar.add(self.get_sidebar_button("storage-stack", _("Storage"), "drive-harddisk-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") services_box = ServicesBox() self.page_generators["services-stack"] = services_box.populate_services self.stack.add_named(self.build_scrolled_window(services_box), "services-stack") accounts_box = AccountsBox() self.page_generators["accounts-stack"] = accounts_box.populate_steam_accounts self.stack.add_named(self.build_scrolled_window(accounts_box), "accounts-stack") self.stack.add_named(self.build_scrolled_window(UpdatesBox()), "updates-stack") sysinfo_box = SystemBox() self.page_generators["sysinfo-stack"] = sysinfo_box.populate self.stack.add_named(self.build_scrolled_window(sysinfo_box), "sysinfo-stack") self.stack.add_named(self.build_scrolled_window(StorageBox()), "storage-stack") self.system_box = SystemConfigBox(self.config_level, 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.17/lutris/gui/config/runner.py000066400000000000000000000020371460562010500203650ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import GObject from lutris.config import LutrisConfig from lutris.gui.config.game_common import GameDialogCommon from lutris.runners import get_runner_human_name class RunnerConfigDialog(GameDialogCommon): """Runner config edit dialog.""" __gsignals__ = { "runner-updated": (GObject.SIGNAL_RUN_FIRST, None, (str,)), } def __init__(self, runner, parent=None): super().__init__(_("Configure %s") % runner.human_name, config_level="runner", 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() 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.emit("runner-updated", self.runner_name) self.destroy() lutris-0.5.17/lutris/gui/config/runner_box.py000066400000000000000000000127471460562010500212460ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import GObject, Gtk from lutris import runners from lutris.gui.config.runner import RunnerConfigDialog from lutris.gui.dialogs import 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.set_sensitive(not self.runner.is_installed(flatpak_allowed=False)) _button.show() return _button 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) def on_install_clicked(self, widget): """Install a runner.""" logger.debug("Install of %s requested", self.runner) self.runner.install(self.get_toplevel()) if self.runner.is_installed(): self.emit("runner-installed") else: raise RuntimeError("Runner failed to install") def on_configure_clicked(self, widget): window = self.get_toplevel() application = window.get_application() application.show_window(RunnerConfigDialog, runner=self.runner, parent=window) 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: def on_runner_uninstalled(): self.emit("runner-removed") self.runner.uninstall(on_runner_uninstalled) 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()) 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()) lutris-0.5.17/lutris/gui/config/runners_box.py000066400000000000000000000050671460562010500214260ustar00rootroot00000000000000"""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, 0) 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, 0) 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.17/lutris/gui/config/services_box.py000066400000000000000000000055401460562010500215510ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import 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, 0) 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, wrap=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.17/lutris/gui/config/storage_box.py000066400000000000000000000063061460562010500213730ustar00rootroot00000000000000import os from gettext import gettext as _ from gi.repository import Gtk from lutris.cache import get_cache_path, has_custom_cache_path, save_custom_cache_path from lutris.config import LutrisConfig from lutris.gui.config.base_config_box import BaseConfigBox from lutris.gui.widgets.common import FileChooserEntry, Label from lutris.runners.runner import Runner class StorageBox(BaseConfigBox): def __init__(self): super().__init__() self.add(self.get_section_label(_("Paths"))) path_widgets = self.get_path_widgets() self.pack_start(self._get_framed_options_list_box(path_widgets), False, False, 0) def get_path_widgets(self): widgets = [] base_runner = Runner() path_settings = [ { "name": _("Game library"), "setting": "game_path", "default": os.path.expanduser("~/Games"), "value": base_runner.default_path, "help": _("The default folder where you install your games."), }, { "name": "Installer cache", "setting": "pga_cache_path", "default": "", "value": get_cache_path() if has_custom_cache_path() else "", "help": _( "If provided, files downloaded during game installs will be kept there\n" "\nOtherwise, all downloaded files are discarded." ), }, ] for path_setting in path_settings: widgets.append(self.get_directory_chooser(path_setting)) return widgets def get_directory_chooser(self, path_setting): label = Label() label.set_markup("%s" % path_setting["name"]) wrapper = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, visible=True) wrapper.set_margin_top(16) default_path = path_setting["default"] directory_chooser = FileChooserEntry( title=_("Select folder"), action=Gtk.FileChooserAction.SELECT_FOLDER, warn_if_non_writable_parent=True, text=path_setting["value"], default_path=default_path, ) directory_chooser.connect("changed", self.on_file_chooser_changed, path_setting) wrapper.pack_start(label, False, False, 0) wrapper.pack_start(directory_chooser, True, True, 0) if path_setting["help"]: help_wrapper = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4, visible=True) help_wrapper.add(wrapper) help_label = Label() help_label.set_markup("%s" % path_setting["help"]) help_wrapper.add(help_label) wrapper = help_wrapper wrapper.set_margin_start(16) wrapper.set_margin_end(16) wrapper.set_margin_bottom(16) return wrapper def on_file_chooser_changed(self, entry, setting): text = entry.get_text() if setting["setting"] == "pga_cache_path": save_custom_cache_path(text) elif setting["setting"] == "game_path": lutris_config = LutrisConfig() lutris_config.raw_system_config["game_path"] = text lutris_config.save() lutris-0.5.17/lutris/gui/config/sysinfo_box.py000066400000000000000000000121211460562010500214110ustar00rootroot00000000000000from gettext import gettext as _ from typing import Dict, Iterable, List from gi.repository import Gdk, Gtk from lutris.gui.config.base_config_box import BaseConfigBox from lutris.util import linux, system from lutris.util.linux import gather_system_info_dict from lutris.util.strings import gtk_safe from lutris.util.wine.wine import is_esync_limit_set, is_fsync_supported, is_installed_systemwide class SystemBox(BaseConfigBox): features_definitions = [ { "name": _("Vulkan support"), "callable": linux.LINUX_SYSTEM.is_vulkan_supported, }, { "name": _("Esync support"), "callable": is_esync_limit_set, }, { "name": _("Fsync support"), "callable": is_fsync_supported, }, { "name": _("Wine installed"), "callable": is_installed_systemwide, }, {"name": _("Gamescope"), "callable": system.can_find_executable, "args": ("gamescope",)}, {"name": _("Mangohud"), "callable": system.can_find_executable, "args": ("mangohud",)}, {"name": _("Gamemode"), "callable": linux.LINUX_SYSTEM.gamemode_available}, {"name": _("Steam"), "callable": linux.LINUX_SYSTEM.has_steam}, {"name": _("In Flatpak"), "callable": linux.LINUX_SYSTEM.is_flatpak}, ] def __init__(self): super().__init__() self.pack_start(self.get_section_label(_("System information")), False, False, 0) self.scrolled_window = Gtk.ScrolledWindow(visible=True) self.scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) sysinfo_frame = Gtk.Frame(visible=True) sysinfo_frame.get_style_context().add_class("info-frame") sysinfo_frame.add(self.scrolled_window) self.pack_start(sysinfo_frame, True, True, 0) button_copy = Gtk.Button(_("Copy to Clipboard"), halign=Gtk.Align.START, visible=True) button_copy.connect("clicked", self.on_copy_clicked) self.pack_start(button_copy, False, False, 0) def populate(self): items = self.get_items() self.scrolled_window.add(self.get_grid(items)) def get_items(self) -> list: """Assembles a list of items to display; most items are name-value tuples giving various bits of information, section headers appear also, as plain strings.""" features = self.get_features() items = [(f["name"], f["available_text"]) for f in features] system_info_readable = gather_system_info_dict() for section, dictionary in system_info_readable.items(): items.append(section) items.extend(dictionary.items()) return items @staticmethod def get_grid(items: Iterable) -> Gtk.Grid: """Constructs a Gtk.Grid containing labels for each item given; each item may be a name-value tuple, producing two labels, or just a string, giving one that covers two columns; this later is used for section headers.""" grid = Gtk.Grid(visible=True, row_spacing=6, margin=16) row = 0 for item in items: if isinstance(item, str): header_label = Gtk.Label(visible=True, xalign=0, yalign=0, margin_top=16) header_label.set_markup("[%s]" % gtk_safe(item)) if row == 0: grid.set_margin_top(0) grid.attach(header_label, 0, row, 2, 1) else: name, text = item name_label = Gtk.Label(name + ":", visible=True, xalign=0, yalign=0, margin_right=30) grid.attach(name_label, 0, row, 1, 1) markup_label = Gtk.Label(visible=True, xalign=0, selectable=True) markup_label.set_markup("%s" % gtk_safe(text)) grid.attach(markup_label, 1, row, 1, 1) row += 1 return grid @staticmethod def get_text(items: Iterable) -> str: """Constructs text for the clipboard, given the same items as get_grid() takess""" lines = [] for item in items: if isinstance(item, str): lines.append(f"[{item}]") else: name, text = item lines.append(f"{name}: {text}") return "\n".join(lines) def get_features(self) -> List[Dict[str, str]]: """Provides a list of features that may be present in your system; each is given as a dict, which hase 'name' and 'available_text' keys.""" yes = _("YES") no = _("NO") def eval_feature(feature): result = feature.copy() func = feature["callable"] args = feature.get("args", ()) result["availability"] = bool(func(*args)) result["available_text"] = yes if result["availability"] else no return result return [eval_feature(f) for f in self.features_definitions] def on_copy_clicked(self, _widget) -> None: items = self.get_items() text = self.get_text(items) clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) clipboard.set_text(text.strip(), -1) lutris-0.5.17/lutris/gui/config/updates_box.py000066400000000000000000000323011460562010500213660ustar00rootroot00000000000000import os from gettext import gettext as _ from typing import Callable from gi.repository import Gio, Gtk from lutris import settings from lutris.api import get_default_wine_runner_version_info, get_runtime_versions_date_time_ago from lutris.gui.config.base_config_box import BaseConfigBox from lutris.gui.dialogs import NoticeDialog from lutris.runtime import RuntimeUpdater from lutris.services.lutris import sync_media from lutris.settings import UPDATE_CHANNEL_STABLE, UPDATE_CHANNEL_UMU, UPDATE_CHANNEL_UNSUPPORTED from lutris.util import system from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.strings import gtk_safe from lutris.util.wine.wine import WINE_DIR LUTRIS_EXPERIMENTAL_FEATURES_ENABLED = os.environ.get("LUTRIS_EXPERIMENTAL_FEATURES_ENABLED") == "1" class UpdatesBox(BaseConfigBox): def __init__(self): super().__init__() self.add(self.get_section_label(_("Wine update channel"))) update_channel_radio_buttons = self.get_update_channel_radio_buttons() update_label_text, update_button_text = self.get_wine_update_texts() self.update_runners_box = UpdateButtonBox( update_label_text, update_button_text, clicked=self.on_runners_update_clicked ) self.pack_start(self._get_framed_options_list_box(update_channel_radio_buttons), False, False, 0) self.pack_start(self._get_framed_options_list_box([self.update_runners_box]), False, False, 0) self.add(self.get_section_label(_("Runtime updates"))) self.add(self.get_description_label(_("Runtime components include DXVK, VKD3D and Winetricks."))) self.update_runtime_box = UpdateButtonBox("", _("Check for Updates"), clicked=self.on_runtime_update_clicked) update_runtime_box = self.get_setting_box( "auto_update_runtime", _("Automatically Update the Lutris runtime"), default=True, extra_widget=self.update_runtime_box, ) self.pack_start(self._get_framed_options_list_box([update_runtime_box]), False, False, 0) self.add(self.get_section_label(_("Media updates"))) self.update_media_box = UpdateButtonBox("", _("Download Missing Media"), clicked=self.on_download_media_clicked) self.pack_start(self._get_framed_options_list_box([self.update_media_box]), False, False, 0) def get_update_channel_radio_buttons(self): update_channel = settings.read_setting("wine-update-channel", UPDATE_CHANNEL_STABLE) markup = _( "Stable:\n" "Wine-GE updates are downloaded automatically and the latest version " "is always used unless overridden in the settings.\n" "\n" "This allows us to keep track of regressions more efficiently and provide " "fixes more reliably." ) stable_channel_radio_button = self._get_radio_button( markup, active=update_channel == UPDATE_CHANNEL_STABLE, group=None ) markup = _( "Umu:\n" "Enables the use of Valve's Proton outside of Steam and uses a custom version of Proton which will " "automatically apply fixes for games.\n" "\n" "Please note that this feature is experimental." ) umu_channel_radio_button = self._get_radio_button( markup, active=update_channel == UPDATE_CHANNEL_UMU, group=stable_channel_radio_button ) markup = _( "Self-maintained:\n" "Wine updates are no longer delivered automatically and you have full responsibility " "of your Wine versions.\n" "\n" "Please note that this mode is fully unsupported. In order to submit issues on Github " "or ask for help on Discord, switch back to the Stable channel." ) unsupported_channel_radio_button = self._get_radio_button( markup, active=update_channel == UPDATE_CHANNEL_UNSUPPORTED, group=stable_channel_radio_button ) # Safer to connect these after the active property has been initialized on all radio buttons stable_channel_radio_button.connect("toggled", self.on_update_channel_toggled, UPDATE_CHANNEL_STABLE) umu_channel_radio_button.connect("toggled", self.on_update_channel_toggled, UPDATE_CHANNEL_UMU) unsupported_channel_radio_button.connect("toggled", self.on_update_channel_toggled, UPDATE_CHANNEL_UNSUPPORTED) if LUTRIS_EXPERIMENTAL_FEATURES_ENABLED: return (stable_channel_radio_button, umu_channel_radio_button, unsupported_channel_radio_button) return (stable_channel_radio_button, unsupported_channel_radio_button) def get_wine_update_texts(self): wine_version_info = get_default_wine_runner_version_info() wine_version = f"{wine_version_info['version']}-{wine_version_info['architecture']}" if system.path_exists(os.path.join(settings.RUNNER_DIR, "wine", wine_version)): update_label_text = _("Your wine version is up to date. Using: %s\n" "Last checked %s.") % ( wine_version_info["version"], get_runtime_versions_date_time_ago(), ) update_button_text = _("Check Again") elif not system.path_exists(os.path.join(settings.RUNNER_DIR, "wine")): update_label_text = ( _("You don't have any Wine version installed.\n" "We recommend %s") % wine_version_info["version"] ) update_button_text = _("Download %s") % wine_version_info["version"] else: update_label_text = ( _("You don't have the recommended Wine version: %s") % wine_version_info["version"] ) update_button_text = _("Download %s") % wine_version_info["version"] return update_label_text, update_button_text def apply_wine_update_texts(self, completion_markup: str = "") -> None: label_markup, _button_label = self.get_wine_update_texts() self.update_runners_box.show_completion_markup(label_markup, completion_markup) def _get_radio_button(self, label_markup, active, group, margin=12): radio_button = Gtk.RadioButton.new_from_widget(group) radio_button.set_active(active) radio_button.set_margin_left(margin) radio_button.set_margin_right(margin) radio_button.set_margin_top(margin) radio_button.set_margin_bottom(margin) radio_button.set_visible(True) radio_button.set_label("") # creates Gtk.Label child label = radio_button.get_child() label.set_markup(label_markup) label.set_margin_left(6) label.props.wrap = True return radio_button def on_download_media_clicked(self, _widget): self.update_media_box.show_running_markup(_("Checking for missing media...")) AsyncCall(sync_media, self.on_media_updated) def on_media_updated(self, result, error): if error: self.update_media_box.show_error(error) elif not result: self.update_media_box.show_completion_markup("", _("Nothing to update")) elif any(result.values()): update_text = _("Updated: ") names = { ("banners", False): _("banner"), ("icons", False): _("icon"), ("covers", False): _("cover"), ("banners", True): _("banners"), ("icons", True): _("icons"), ("covers", True): _("covers"), } for key, value in result.items(): if value: if not update_text.endswith(": "): update_text += ", " update_text += f"{value} {names[(key, value > 1)]}" self.update_media_box.show_completion_markup("", update_text) else: self.update_media_box.show_completion_markup("", _("No new media found.")) def _get_main_window(self): application = Gio.Application.get_default() if not application or not application.window: logger.error("No application or window found, how does this happen?") return return application.window def on_runners_update_clicked(self, _widget): window = self._get_main_window() if not window: return # Create runner dir if missing, to enable installing runner updates at all. if not system.path_exists(WINE_DIR): os.mkdir(WINE_DIR) updater = RuntimeUpdater() updater.update_runtime = False updater.update_runners = True component_updaters = [u for u in updater.create_component_updaters() if u.name == "wine"] if component_updaters: def on_complete(_result): self.apply_wine_update_texts() started = window.install_runtime_component_updates( component_updaters, updater, completion_function=on_complete, error_function=self.update_runners_box.show_error, ) if started: self.update_runners_box.show_running_markup(_("Downloading...")) else: NoticeDialog(_("Updates are already being downloaded and installed."), parent=self.get_toplevel()) else: self.apply_wine_update_texts(_("No updates are required at this time.")) def on_runtime_update_clicked(self, _widget): def get_updater(): updater = RuntimeUpdater() updater.update_runtime = True updater.update_runners = False return updater self._trigger_updates(get_updater, self.update_runtime_box) def _trigger_updates(self, updater_factory: Callable, update_box: "UpdateButtonBox") -> None: window = self._get_main_window() if not window: return updater = updater_factory() component_updaters = updater.create_component_updaters() if component_updaters: def on_complete(_result): # the 'icons' updater always shows as updated even when it's not component_names = [updater.name for updater in component_updaters if updater.name != "icons"] if len(component_names) == 1: update_box.show_completion_markup("", _("%s has been updated.") % component_names[0]) else: update_box.show_completion_markup("", _("%s have been updated.") % ", ".join(component_names)) started = window.install_runtime_component_updates( component_updaters, updater, completion_function=on_complete, error_function=update_box.show_error ) if started: update_box.show_running_markup(_("Checking for updates...")) else: NoticeDialog(_("Updates are already being downloaded and installed."), parent=self.get_toplevel()) else: update_box.show_completion_markup("", _("No updates are required at this time.")) def on_update_channel_toggled(self, checkbox, value): """Update setting when update channel is toggled""" if not checkbox.get_active(): return last_setting = settings.read_setting("wine-update-channel", UPDATE_CHANNEL_STABLE) if last_setting != UPDATE_CHANNEL_UNSUPPORTED and value == UPDATE_CHANNEL_UNSUPPORTED: NoticeDialog( _("Without the Wine-GE updates enabled, we can no longer provide support on Github and Discord."), parent=self.get_toplevel(), ) settings.write_setting("wine-update-channel", value) class UpdateButtonBox(Gtk.Box): """A box containing a button to start updating something, with methods to show a result when done.""" def __init__(self, label: str, button_label: str, clicked: Callable[[Gtk.Widget], None]): super().__init__(orientation=Gtk.Orientation.HORIZONTAL, margin=12, spacing=6, visible=True) self.label = Gtk.Label(visible=True, xalign=0) self.label.set_markup(label) self.pack_start(self.label, True, True, 0) self.button = Gtk.Button(button_label, visible=True) self.button.connect("clicked", clicked) self.pack_end(self.button, False, False, 0) self.spinner = Gtk.Spinner() self.pack_end(self.spinner, False, False, 0) self.result_label = Gtk.Label() self.pack_end(self.result_label, False, False, 0) def show_running_markup(self, markup: str) -> None: self.button.hide() self.result_label.set_markup(markup) self.result_label.show() self.spinner.show() self.spinner.start() def show_completion_markup(self, label_markup: str, completion_markup: str) -> None: self.button.hide() self.result_label.show() self.spinner.stop() self.spinner.hide() self.label.set_markup(label_markup) self.result_label.set_markup(completion_markup) def show_error(self, error: Exception) -> None: self.button.hide() self.result_label.show() self.spinner.stop() self.spinner.hide() self.result_label.set_markup("Error:%s" % gtk_safe(str(error))) lutris-0.5.17/lutris/gui/dialogs/000077500000000000000000000000001460562010500166555ustar00rootroot00000000000000lutris-0.5.17/lutris/gui/dialogs/__init__.py000066400000000000000000000650011460562010500207700ustar00rootroot00000000000000"""Commonly used dialogs""" import os import traceback from gettext import gettext as _ from typing import Callable, Union 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.exceptions import InvalidGameMoveError 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): """A base class for dialogs that provides handling for the response signal; you can override its on_response() methods, but that method will record the response for you via 'response_type' or 'confirmed' and destory this dialog if it isn't NONE.""" def __init__( self, title: str = None, parent: Gtk.Widget = None, flags: Gtk.DialogFlags = 0, buttons: Gtk.ButtonsType = None, **kwargs, ): super().__init__(title, parent, flags, buttons, **kwargs) self._response_type = Gtk.ResponseType.NONE self.connect("response", self.on_response) @property def response_type(self) -> Gtk.ResponseType: """The response type of the response that occurred; initially this is NONE. Use the GTK response() method to artificially generate a response, rather than setting this.""" return self._response_type @property def confirmed(self) -> bool: """True if 'response_type' is OK or YES.""" return self.response_type in (Gtk.ResponseType.OK, Gtk.ResponseType.YES) def on_response(self, _dialog, response: Gtk.ResponseType) -> None: """Handles the dialog response; you can override this but by default this records the response for 'response_type'.""" self._response_type = response def destroy_at_idle(self, condition: Callable = None): """Adds as idle task to destroy this window at idle time; it can do so conditionally if you provide a callable to check, but it checks only once. You can still explicitly destroy the dialog after calling this. This is used to ensure destruction of ModalDialog after run().""" def idle_destroy(): nonlocal idle_source_id idle_source_id = None if not condition or condition(): self.destroy() return False def on_destroy(*_args): nonlocal idle_source_id self.disconnect(on_destroy_id) if idle_source_id: GLib.source_remove(idle_source_id) idle_source_id = None self.hide() idle_source_id = GLib.idle_add(idle_destroy) on_destroy_id = self.connect("destroy", on_destroy) def add_styled_button(self, button_text: str, response_id: Gtk.ResponseType, css_class: str): 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: str, response_id: Gtk.ResponseType, css_class: str = "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. Unlike plain Gtk.Dialog, these destroy themselves (at idle-time) after you call run(), even if you forget to. They aren't meant to be reused.""" def __init__( self, title: str = None, parent: Gtk.Widget = None, flags: Gtk.DialogFlags = 0, buttons: Gtk.ButtonsType = None, **kwargs, ): super().__init__(title, parent, flags | Gtk.DialogFlags.MODAL, buttons, **kwargs) self.set_destroy_with_parent(True) def on_response(self, dialog, response: Gtk.ResponseType) -> None: super().on_response(dialog, response) # Model dialogs do return from run() in response from respose() but the # dialog is visible and locks out its parent. So we hide it. Watch out- # self.destroy() changes the run() result to NONE. if response != Gtk.ResponseType.NONE: self.hide() self.destroy_at_idle(condition=lambda: not self.get_visible()) 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: str = None, parent: Gtk.Widget = None, flags: Gtk.DialogFlags = 0, buttons: Gtk.ButtonsType = 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 def on_response(self, dialog, response: Gtk.ResponseType) -> None: super().on_response(dialog, response) # Modal dialogs self-destruct, but modeless ones must commit # suicide more explicitly. if response != Gtk.ResponseType.NONE: self.destroy() 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: str, parent: Gtk.Widget = 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) 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_markup: str, secondary: str = None, parent: Gtk.Window = None): super().__init__(message_type=Gtk.MessageType.INFO, buttons=Gtk.ButtonsType.OK, parent=parent) self.set_markup(message_markup) 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_markup: str, secondary: str = None, parent: Gtk.Window = None): super().__init__(message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK_CANCEL, parent=parent) self.set_markup(message_markup) 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: Union[str, BaseException], message_markup: str = None, secondary: str = None, parent: Gtk.Window = None, ): super().__init__(message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, parent=parent) if isinstance(error, BaseException): if secondary: # Some errors contain < and > and look like markup, but aren't- # we'll need to protect the message dialog against this. To use markup, # you must pass the message itself directly. message_markup = message_markup or gtk_safe(str(error)) elif not message_markup: message_markup = "%s" % _("Lutris has encountered an error") secondary = str(error) elif not message_markup: message_markup = gtk_safe(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_markup[: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) if isinstance(error, BaseException): content_area = self.get_content_area() spacing = content_area.get_spacing() content_area.set_spacing(0) details_expander = self.get_details_expander(error) details_expander.set_margin_top(spacing) content_area.pack_end(details_expander, False, False, 0) action_area = self.get_action_area() copy_button = Gtk.Button(_("Copy to Clipboard"), visible=True) action_area.pack_start(copy_button, False, True, 0) action_area.set_child_secondary(copy_button, True) copy_button.connect("clicked", self.on_copy_clicked, error) self.run() self.destroy() def on_copy_clicked(self, _button, error: BaseException): details = self.format_error(error) clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) clipboard.set_text(details, -1) def get_details_expander(self, error: BaseException) -> Gtk.Widget: details = self.format_error(error, include_message=False) expander = Gtk.Expander.new(_("Error details")) details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) details_box.pack_start(Gtk.Separator(margin_top=6), False, False, 0) details_textview = Gtk.TextView(editable=False) details_textview.get_buffer().set_text(details) details_scrolledwindow = Gtk.ScrolledWindow(width_request=800, height_request=400) details_scrolledwindow.add(details_textview) details_box.pack_start(details_scrolledwindow, False, False, 0) expander.add(details_box) expander.show_all() return expander @staticmethod def format_error(error: BaseException, include_message: bool = True): formatted = traceback.format_exception(type(error), error, error.__traceback__) if include_message: formatted = [str(error), ""] + formatted return "\n".join(formatted).strip() 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.set_default_response(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, activates_default=True) self.entry.connect("changed", self.on_entry_changed) self.get_content_area().pack_start(self.entry, True, True, 12) self.entry.set_text(dialog_settings.get("initial_value") or "") 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 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.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): if response == Gtk.ResponseType.CANCEL: self.action = None super().on_response(_widget, response) 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.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) 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() 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.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() 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) self.progress_source_id = GLib.timeout_add(125, self.show_progress) self.connect("destroy", self.on_destroy) self.show_all() def on_destroy(self, _dialog): GLib.source_remove(self.progress_source_id) def move(self): AsyncCall(self._move_game, self._move_game_cb) def show_progress(self): self.progress.pulse() return True def _move_game(self): # not safe fire a signal from a thread- it will surely hit GTK and may crash self.new_directory = self.game.move(self.destination, no_signal=True) def _move_game_cb(self, _result, error): if error and isinstance(error, InvalidGameMoveError): secondary = _( "Do you want to change the game location anyway? No files can be moved, " "and the game configuration may need to be adjusted." ) dlg = WarningDialog(message_markup=error, secondary=secondary, parent=self) if dlg.result == Gtk.ResponseType.OK: self.new_directory = self.game.set_location(self.destination) self.on_game_moved(None, None) else: self.destroy() return self.on_game_moved(_result, error) def on_game_moved(self, _result, error): if error: ErrorDialog(error, parent=self) self.game.emit("game-updated") # because we could not fire this on the thread 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.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, dialog, 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) super().on_response(dialog, response) lutris-0.5.17/lutris/gui/dialogs/cache.py000066400000000000000000000043741460562010500203020ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris.cache import get_cache_path, has_custom_cache_path, save_custom_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() if has_custom_cache_path() else "" 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_custom_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, warn_if_non_writable_parent=True, 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.17/lutris/gui/dialogs/delegates.py000066400000000000000000000175351460562010500211770ustar00rootroot00000000000000from 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.services import get_enabled_services from lutris.util.downloader import Downloader class Delegate: def get_service(self, service_id): """Returns a new service object by its id. This seems dumb, but it is a work-around for Python's circular import limitations.""" return get_enabled_services()[service_id]() class LaunchUIDelegate(Delegate): """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(Delegate): """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 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.17/lutris/gui/dialogs/download.py000066400000000000000000000026711460562010500210440ustar00rootroot00000000000000from 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.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) def download_cancelled(self, _widget): self.response(Gtk.ResponseType.CANCEL) def on_response(self, dialog, response): if response in (Gtk.ResponseType.DELETE_EVENT, Gtk.ResponseType.CANCEL): self.dialog_progress_box.downloader.cancel() super().on_response(dialog, response) lutris-0.5.17/lutris/gui/dialogs/game_import.py000066400000000000000000000246361460562010500215450ustar00rootroot00000000000000from collections import OrderedDict from copy import deepcopy from gettext import gettext as _ from gi.repository import Gio, 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.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.path_cache import get_path_cache 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.show_all() self.search_call = AsyncCall(self.search_checksums, self.search_result_finished) def on_response(self, dialog, response: Gtk.ResponseType) -> None: if response in (Gtk.ResponseType.CLOSE, Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): if self.search_call: self.search_call.stop_request.set() return # don't actually close the dialog super().on_response(dialog, response) 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): # We can't use this window as the delegate because we # are destroying it. application = Gio.Application.get_default() game.launch(application.launch_ui_delegate) 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.17/lutris/gui/dialogs/issue.py000066400000000000000000000062631460562010500203660ustar00rootroot00000000000000"""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=lambda *x: self.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.17/lutris/gui/dialogs/log.py000066400000000000000000000045251460562010500200160ustar00rootroot00000000000000"""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.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.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.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_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) lutris-0.5.17/lutris/gui/dialogs/runner_install.py000066400000000000000000000424011460562010500222670ustar00rootroot00000000000000"""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.api import format_runner_version, parse_version_architecture 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 def get_runner_path(runner_directory, version, arch): """Return the local path where the runner is/will be installed""" info = {"version": version, "architecture": arch} return os.path.join(runner_directory, format_runner_version(info)) def get_installed_versions(runner_directory): """List versions available locally""" if not os.path.exists(runner_directory): return set() return {parse_version_architecture(p) for p in os.listdir(runner_directory)} def get_usage_stats(runner_name): """Return the usage for each version""" runner_games = get_games_by_runner(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 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.ok_button = self.add_default_button(Gtk.STOCK_OK, Gtk.ResponseType.OK) self.runner_name = runner.name self.runner_directory = runner.directory self.runner_info = {} self.runner_store = [] 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() jobs.AsyncCall(self.fetch_runner_versions, self.runner_fetch_cb, self.runner_name, self.runner_directory) @staticmethod def fetch_runner_versions(runner_name, runner_directory): runner_info = api.get_runners(runner_name) runner_info["runner_name"] = runner_name runner_info["runner_directory"] = runner_directory remote_versions = {(v["version"], v["architecture"]) for v in runner_info["versions"]} local_versions = get_installed_versions(runner_directory) for local_version in local_versions - remote_versions: runner_info["versions"].append( { "version": local_version[0], "architecture": local_version[1], "url": "", } ) return runner_info, RunnerInstallDialog.fetch_runner_store(runner_info) @staticmethod def fetch_runner_store(runner_info): """Return a list populated with the runner versions""" runner_store = [] runner_name = runner_info["runner_name"] runner_directory = runner_info["runner_directory"] version_usage = get_usage_stats(runner_name) ordered = sorted(runner_info["versions"], key=RunnerInstallDialog.get_version_sort_key) for version_info in reversed(ordered): is_installed = os.path.exists( get_runner_path(runner_directory, version_info["version"], version_info["architecture"]) ) games_using = version_usage.get("%(version)s-%(architecture)s" % version_info) runner_store.append( { "version": version_info["version"], "architecture": version_info["architecture"], "url": version_info["url"], "is_installed": is_installed, "progress": 0, "game_count": len(games_using) if games_using else 0, } ) return runner_store def runner_fetch_cb(self, result, error): """Clear the box and display versions from runner_info""" if error: logger.error(error) ErrorDialog(_("Unable to get runner versions: %s") % error, parent=self) return self.runner_info, self.runner_store = result if not self.runner_info: ErrorDialog(_("Unable to get runner versions from lutris.net"), parent=self) 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 = {} 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() self.populate_listboxrows() def populate_listboxrows(self): for runner in self.runner_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["is_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["version"]) 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["architecture"]) 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["is_installed"]: # Check if there are apps installed, if so, show the view apps button app_count = runner["game_count"] 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) self.ok_button.set_sensitive(not self.installing) runner = row.runner icon = row.icon icon.set_visible(runner["is_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["version"] 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["is_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["version"], runner["architecture"]) dialog = ShowAppsDialog(_("Wine version usage"), self.get_toplevel(), self.runner_name, runner_version) dialog.run() @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_dest_path(self, runner): """Return temporary path where the runners should be downloaded to""" return os.path.join(settings.CACHE_DIR, os.path.basename(runner["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["version"]].cancel() self.uninstall_runner(row) runner["progress"] = 0 self.installing.pop(runner["version"]) self.update_listboxrow(row) 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["version"] arch = runner["architecture"] runner_path = get_runner_path(self.runner_directory, version, arch) def on_complete(): runner["is_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_error(error): ErrorDialog(error, parent=self) system.remove_folder(runner_path, completion_function=on_complete, error_function=on_error) 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["url"] version = runner["version"] if not url: ErrorDialog(_("Version %s is not longer available") % version, parent=self) return downloader = Downloader(url, dest_path, overwrite=True) GLib.timeout_add(100, self.get_progress, downloader, row) self.installing[version] = 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["progress"] = percent_downloaded row.install_progress.set_fraction(percent_downloaded / 100) else: runner["progress"] = 1 row.install_progress.pulse() row.install_progress.set_text = _("Downloading…") if downloader.state == downloader.COMPLETED: runner["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["is_installed"] def on_runner_downloaded(self, row): """Handler called when a runner version is downloaded""" runner = row.runner version = runner["version"] architecture = runner["architecture"] logger.debug("Runner %s for %s has finished downloading", version, architecture) src = self.get_dest_path(runner) dst = get_runner_path(self.runner_directory, 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["progress"] = 0 runner["is_installed"] = True self.installing.pop(runner["version"]) 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_response(self, dialog, response: Gtk.ResponseType) -> None: if self.installing: return super().on_response(dialog, response) lutris-0.5.17/lutris/gui/dialogs/uninstall_dialog.py000066400000000000000000000432651460562010500225710ustar00rootroot00000000000000# pylint: disable=no-member import os from gettext import gettext as _ from typing import Callable, Iterable, List from gi.repository import GObject, Gtk from lutris import settings from lutris.database.games import get_game_by_field, get_games from lutris.game import Game from lutris.gui.dialogs import QuestionDialog from lutris.gui.widgets.gi_composites import GtkTemplate from lutris.util import datapath from lutris.util.jobs import AsyncCall from lutris.util.library_sync import LibrarySyncer from lutris.util.log import logger from lutris.util.path_cache import remove_from_path_cache from lutris.util.strings import get_natural_sort_key, gtk_safe, human_size from lutris.util.system import get_disk_size, is_removeable @GtkTemplate(ui=os.path.join(datapath.get(), "ui", "uninstall-dialog.ui")) class UninstallDialog(Gtk.Dialog): """A dialog to uninstall and remove games. It lists the games and offers checkboxes to delete the game files, and to remove from the library.""" __gtype_name__ = "UninstallDialog" header_bar: Gtk.HeaderBar = GtkTemplate.Child() message_label: Gtk.Label = GtkTemplate.Child() uninstall_game_list: Gtk.ListBox = GtkTemplate.Child() cancel_button: Gtk.Button = GtkTemplate.Child() uninstall_button: Gtk.Button = GtkTemplate.Child() delete_all_files_checkbox: Gtk.CheckButton = GtkTemplate.Child() remove_all_games_checkbox: Gtk.CheckButton = GtkTemplate.Child() def __init__(self, parent: Gtk.Window, **kwargs): super().__init__(parent=parent, **kwargs) self.parent = parent self._setting_all_checkboxes = False self.games: List[Game] = [] self.any_shared = False self.any_protected = False self.init_template() self.show_all() def add_games(self, game_ids: Iterable[str]) -> None: new_game_ids = set(game_ids) - set(g.id for g in self.games) new_games = [Game(game_id) for game_id in new_game_ids] new_games.sort(key=lambda g: get_natural_sort_key(g.name)) self.games += new_games new_rows = [] for game in new_games: row = GameRemovalRow(game) self.uninstall_game_list.add(row) new_rows.append(row) self.update_deletability() self.update_folder_sizes(new_games) self.update_subtitle() self.update_message() self.update_all_checkboxes() self.update_uninstall_button() self.uninstall_game_list.show_all() # Defer the connection until all checkboxes are updated for row in new_rows: row.connect("row-updated", self.on_row_updated) def update_deletability(self) -> None: """Updates the can_delete_files property on each row; adding new rows can set this on existing rows (they might no longer violate the 'can't delete shared directory' rule). This also sets flags that are used by later update methods, so this must be called first.""" self.any_shared = False self.any_protected = False def is_shared(directory: str) -> bool: dir_users = set(str(g["id"]) for g in get_games(filters={"directory": directory, "installed": 1})) for g in self.games: dir_users.discard(g.id) return bool(dir_users) for row in self.uninstall_game_list.get_children(): game = row.game if game.is_installed and game.directory: if game.config and is_removeable(game.directory, game.config.system_config): shared_dir = is_shared(game.directory) self.any_shared = self.any_shared or shared_dir row.can_delete_files = not shared_dir else: row.can_delete_files = False self.any_protected = True else: row.can_delete_files = False def update_folder_sizes(self, new_games: List[Game]) -> None: """Starts fetching folder sizes for new games added to the dialog; we only do this for the games given in 'new_games', however.""" folders_to_size = [] folders_seen = set() for row in self.uninstall_game_list.get_children(): game = row.game if game in new_games and game.is_installed and game.directory: if game.directory not in folders_seen: folders_seen.add(game.directory) folders_to_size.append(game.directory) row.show_folder_size_spinner() if folders_to_size: AsyncCall( self._get_next_folder_size, self._get_next_folder_size_cb, folders_to_size, ) def update_subtitle(self) -> None: """Updates the dialog subtitle according to what games are being removed.""" to_uninstall = [g for g in self.games if g.is_installed] to_remove = [g for g in self.games if not g.is_installed] if len(to_uninstall) == 1 and not to_remove: subtitle = _("Uninstall %s") % gtk_safe(to_uninstall[0].name) elif len(to_remove) == 1 and not to_uninstall: subtitle = _("Remove %s") % gtk_safe(to_remove[0].name) elif not to_remove: subtitle = _("Uninstall %d games") % len(to_uninstall) elif not to_uninstall: subtitle = _("Remove %d games") % len(to_remove) else: subtitle = _("Uninstall %d games and remove %d games") % ( len(to_uninstall), len(to_remove), ) self.header_bar.set_subtitle(subtitle) def update_message(self) -> None: """Updates the message label at the top of the dialog.""" to_uninstall = [g for g in self.games if g.is_installed] messages = [] if to_uninstall: messages.append(_("After you uninstall these games, you won't be able play them in Lutris.")) messages.append( _( "Uninstalled games that you remove from the library will no longer appear in the " "'Games' view, but those that remain will retain their playtime data." ) ) else: messages.append(_("After you remove these games, they will no longer " "appear in the 'Games' view.")) if self.any_shared: messages.append( _( "Some of the game directories cannot be removed because they are shared " "with other games that you are not removing." ) ) if self.any_protected: messages.append(_("Some of the game directories cannot be removed because they are protected.")) if messages: self.message_label.set_markup("\n\n".join(messages)) self.message_label.show() else: self.message_label.hide() def on_row_updated(self, row) -> None: directory = row.game.directory if directory and row.can_delete_files: for r in self.uninstall_game_list.get_children(): if row != r and r.game.directory == directory and r.can_delete_files: r.delete_files = row.delete_files self.update_all_checkboxes() def update_all_checkboxes(self) -> None: """Sets the state of the checkboxes at the button that are used to control all settings together. While we are actually updating these checkboxes en-mass, this method does nothing at all.""" def update(checkbox, is_candidate, is_set): set_count = 0 unset_count = 0 for row in self.uninstall_game_list.get_children(): if is_candidate(row): if is_set(row): set_count += 1 else: unset_count += 1 checkbox.set_active(set_count > 0) checkbox.set_inconsistent(set_count > 0 and unset_count > 0) checkbox.set_visible((set_count + unset_count) > 1 and (set_count > 0 or unset_count > 0)) if not self._setting_all_checkboxes: self._setting_all_checkboxes = True try: update( self.delete_all_files_checkbox, lambda row: row.can_delete_files, lambda row: row.delete_files, ) update( self.remove_all_games_checkbox, lambda row: True, lambda row: row.remove_from_library, ) finally: self._setting_all_checkboxes = False def update_uninstall_button(self) -> None: if any(g for g in self.games if g.is_installed): self.uninstall_button.set_label(_("Uninstall")) @GtkTemplate.Callback def on_delete_all_files_checkbox_toggled(self, _widget): def update_row(row, active): if row.can_delete_files: row.delete_files = active self._apply_all_checkbox(self.delete_all_files_checkbox, update_row) @GtkTemplate.Callback def on_remove_all_games_checkbox_toggled(self, _widget): def update_row(row, active): row.remove_from_library = active self._apply_all_checkbox(self.remove_all_games_checkbox, update_row) def _apply_all_checkbox(self, checkbox, row_updater: Callable[["GameRemovalRow", bool], None]): """Sets the state of the checkboxes on all rows to agree with 'checkbox'; the actual change is performed by row_updater, so this can be used for either checkbox.""" if not self._setting_all_checkboxes and checkbox.get_visible(): active = checkbox.get_active() self._setting_all_checkboxes = True for row in self.uninstall_game_list.get_children(): row_updater(row, active) self._setting_all_checkboxes = False self.update_all_checkboxes() @GtkTemplate.Callback def on_cancel_button_clicked(self, _widget) -> None: self.destroy() @GtkTemplate.Callback def on_remove_button_clicked(self, _widget) -> None: rows = list(self.uninstall_game_list.get_children()) dirs_to_delete = list(set(row.game.directory for row in rows if row.delete_files)) if dirs_to_delete: if len(dirs_to_delete) == 1: question = _("Please confirm.\nEverything under %s\n" "will be moved to the trash.") % gtk_safe( dirs_to_delete[0] ) else: question = _("Please confirm.\nAll the files for %d games will be moved to the trash.") % len( dirs_to_delete ) dlg = QuestionDialog( { "parent": self, "question": question, "title": _("Permanently delete files?"), } ) if dlg.result != Gtk.ResponseType.YES: return games_removed_from_library = [] if settings.read_bool_setting("library_sync_enabled"): library_syncer = LibrarySyncer() for row in rows: if row.remove_from_library: games_removed_from_library.append(get_game_by_field(row.game._id, "id")) if games_removed_from_library: library_syncer.sync_local_library() for row in rows: row.perform_removal() if settings.read_bool_setting("library_sync_enabled") and games_removed_from_library: library_syncer.delete_from_remote_library(games_removed_from_library) self.parent.on_game_removed() self.destroy() def on_response(self, _dialog, response: Gtk.ResponseType) -> None: if response in ( Gtk.ResponseType.DELETE_EVENT, Gtk.ResponseType.CANCEL, Gtk.ResponseType.OK, ): self.destroy() @staticmethod def _get_next_folder_size(directories): """This runs on a thread and computes the size of the first directory in directories; the _get_next_folder_size_cb will run this again if required, until all directories have been sized.""" directory = directories.pop(0) size = get_disk_size(directory) return directory, size, directories def _get_next_folder_size_cb(self, result, error): if error: logger.error(error) return directory, size, remaining_directories = result if remaining_directories: AsyncCall( self._get_next_folder_size, self._get_next_folder_size_cb, remaining_directories, ) for row in self.uninstall_game_list.get_children(): if directory == row.game.directory: row.show_folder_size(size) class GameRemovalRow(Gtk.ListBoxRow): __gsignals__ = { "row-updated": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, game: Game): super().__init__(activatable=False) self.game = game self._can_delete_files = False self.delete_files_checkbox: Gtk.CheckButton = None self.folder_size_spinner: Gtk.Spinner = None self.directory_label: Gtk.Label = None hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) vbox.pack_start(hbox, False, False, 0) label = Gtk.Label(game.name, selectable=True) hbox.pack_start(label, False, False, 0) self.remove_from_library_checkbox = Gtk.CheckButton(_("Remove from Library"), halign=Gtk.Align.START) self.remove_from_library_checkbox.set_active(True) self.remove_from_library_checkbox.connect("toggled", self.on_checkbox_toggled) hbox.pack_end(self.remove_from_library_checkbox, False, False, 0) if game.is_installed and self.game.directory: self.delete_files_checkbox = Gtk.CheckButton(_("Delete Files")) self.delete_files_checkbox.set_sensitive(False) self.delete_files_checkbox.set_active(False) self.delete_files_checkbox.set_tooltip_text(self.game.directory) self.delete_files_checkbox.connect("toggled", self.on_checkbox_toggled) hbox.pack_end(self.delete_files_checkbox, False, False, 0) dir_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=6, margin_left=6, margin_right=6, height_request=16, ) self.directory_label = Gtk.Label(halign=Gtk.Align.START, selectable=True, valign=Gtk.Align.START) self.directory_label.set_markup(self._get_directory_markup()) dir_box.pack_start(self.directory_label, False, False, 0) self.folder_size_spinner = Gtk.Spinner(visible=False, no_show_all=True) dir_box.pack_start(self.folder_size_spinner, False, False, 0) vbox.pack_start(dir_box, False, False, 0) self.add(vbox) def _get_directory_markup(self, folder_size: int = None): if not self.game.directory or not self.game.is_installed: return "" markup = gtk_safe(self.game.directory) if folder_size is not None: markup += f" ({human_size(folder_size)})" return "%s" % markup def on_checkbox_toggled(self, _widget): self.emit("row-updated") def show_folder_size_spinner(self): if self.folder_size_spinner: self.folder_size_spinner.start() self.folder_size_spinner.show() def show_folder_size(self, folder_size: int) -> None: """Called to stop the spinner and show the size of the game folder.""" if self.directory_label: self.directory_label.set_markup(self._get_directory_markup(folder_size)) if self.folder_size_spinner: self.folder_size_spinner.stop() self.folder_size_spinner.hide() @property def delete_files(self) -> bool: """True if the game files should be deleted.""" return bool( self.game.is_installed and self.game.directory and self.delete_files_checkbox and self.delete_files_checkbox.get_active() ) @delete_files.setter def delete_files(self, active: bool) -> None: self.delete_files_checkbox.set_active(active) @property def can_delete_files(self): return self._can_delete_files @can_delete_files.setter def can_delete_files(self, can_delete): if self._can_delete_files != can_delete and self.delete_files_checkbox: self._can_delete_files = can_delete self.delete_files_checkbox.set_sensitive(can_delete) self.delete_files_checkbox.set_active(can_delete) @property def remove_from_library(self) -> bool: """True if the game should be removed from the database.""" return bool(self.remove_from_library_checkbox.get_active()) @remove_from_library.setter def remove_from_library(self, active: bool) -> None: self.remove_from_library_checkbox.set_active(active) def perform_removal(self) -> None: """Performs the actions this row describes, uninstalling or deleting a game.""" # We uninstall installed games, and delete games where self.remove_from_library is true if self.game.is_installed: remove_from_path_cache(self.game) if self.remove_from_library: self.game.uninstall(delete_files=self.delete_files) self.game.delete() else: self.game.uninstall(delete_files=self.delete_files) elif self.remove_from_library: self.game.delete() lutris-0.5.17/lutris/gui/dialogs/webconnect_dialog.py000066400000000000000000000121031460562010500226720ustar00rootroot00000000000000"""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.17/lutris/gui/download_queue.py000066400000000000000000000160701460562010500206240ustar00rootroot00000000000000# pylint: disable=no-member import os from typing import Any, Callable, Dict, Iterable, List, Set from gi.repository import Gtk from lutris.gui.widgets.gi_composites import GtkTemplate from lutris.gui.widgets.progress_box import ProgressBox from lutris.util import datapath from lutris.util.jobs import AsyncCall from lutris.util.log import logger @GtkTemplate(ui=os.path.join(datapath.get(), "ui", "download-queue.ui")) class DownloadQueue(Gtk.ScrolledWindow): """This class is a widget that displays a stack of progress boxes, which you can create and destroy with its methods.""" __gtype_name__ = "DownloadQueue" download_box: Gtk.Box = GtkTemplate.Child() CompletionFunction = Callable[[Any], None] ErrorFunction = Callable[[Exception], None] def __init__(self, revealer: Gtk.Revealer, **kwargs): super().__init__(**kwargs) self.revealer = revealer self.init_template() self.running_operation_names: Set[str] = set() self.progress_boxes: Dict[ProgressBox.ProgressFunction, ProgressBox] = {} try: # GTK 3.22 is required for this, but if this fails we can still run. # The download area comes out too small, but it's usable. self.set_max_content_height(250) self.set_propagate_natural_height(True) except AttributeError: pass @property def is_empty(self): """True if the queue has no progress boxes in it.""" return not bool(self.progress_boxes) def add_progress_box(self, progress_function: ProgressBox.ProgressFunction) -> ProgressBox: """Adds a progress box to the queue; it will display the progress indicated by the progress_function, which is called immediately to initialize the box and then polled to update it. Returns the new progress box. The progres-box is removed when its function returns ProgressInfo.ended(), or when you call remove_progress_box(). If called with a progress_function that has a box already, this method returns that box instead of creating one.""" def check_progress(): progress_info = progress_function() if progress_info.has_ended: self.remove_progress_box(progress_function) return progress_info if progress_info.label_markup: progress_info.label_markup = "%s" % progress_info.label_markup return progress_info progress_box = self.progress_boxes.get(progress_function) if progress_box: progress_box.update_progress() return progress_box progress_box = ProgressBox(check_progress, visible=False, margin=6) progress_box.update_progress() self.progress_boxes[progress_function] = progress_box self.download_box.pack_start(progress_box, False, False, 0) progress_box.show() self.revealer.set_reveal_child(True) return progress_box def remove_progress_box(self, progress_function: ProgressBox.ProgressFunction) -> None: """Removes and destroys the progress box created for the progress_function given, if any is present.""" progress_box = self.progress_boxes.get(progress_function) if progress_box: del self.progress_boxes[progress_function] progress_box.destroy() if not self.progress_boxes: self.revealer.set_reveal_child(False) def start( self, operation: Callable[[], Any], progress_function: ProgressBox.ProgressFunction, completion_function: CompletionFunction = None, error_function: ErrorFunction = None, operation_name: str = None, ) -> bool: """Runs 'operation' on a thread, while displaying a progress bar. The 'progress_function' controls this progress bar, and it is removed when the 'operation' completes. If 'operation_name' is given, it is added to self.running_operation_names while the 'operation' runs. If the name is present already, this method does nothing but returns False. If the worker thread has started, this returns True. Args: operation: Called on a worker thread progress_function: Called on the main thread for progress status completion_function: Called on the main thread on completion, with result error_function: Called on the main threa don error, with exception operation_name: Name of operation, to prevent duplicate queued work.""" return self.start_multiple( operation, [progress_function], completion_function=completion_function, error_function=error_function, operation_names=[operation_name] if operation_name else None, ) def start_multiple( self, operation: Callable[[], Any], progress_functions: Iterable[ProgressBox.ProgressFunction], completion_function: CompletionFunction = None, error_function: ErrorFunction = None, operation_names: List[str] = None, ) -> bool: """Runs 'operation' on a thread, while displaying a set of progress bars. The 'progress_functions' control these progress bars, and they are removed when the 'operation' completes. If 'operation_names' is given, they are added to self.running_operation_names while the 'operation' runs. If any name is present already, this method does nothing but returns False. If the worker thread has started, this returns True. Args: operation: Called on a worker thread progress_functions: Called on the main thread for progress status completion_function: Called on the main thread on completion, with result error_function: Called on the main threa don error, with exception operation_names: Names of operations, to prevent duplicate queued work.""" if operation_names: if not self.running_operation_names.isdisjoint(operation_names): return False self.running_operation_names.update(operation_names) # Must capture the functions, since in earlier (<3.8) Pythons functions do not provide # value equality, so we need to make sure we're always using what we started with. captured_functions = list(progress_functions) for f in captured_functions: self.add_progress_box(f) def completion_callback(result, error): for to_end in captured_functions: self.remove_progress_box(to_end) self.running_operation_names.difference_update(operation_names) if error: logger.exception("Failed to execute download-queue function: %s", error) if error_function: error_function(error) elif completion_function: completion_function(result) AsyncCall(operation, completion_callback) return True lutris-0.5.17/lutris/gui/installer/000077500000000000000000000000001460562010500172305ustar00rootroot00000000000000lutris-0.5.17/lutris/gui/installer/__init__.py000066400000000000000000000000001460562010500213270ustar00rootroot00000000000000lutris-0.5.17/lutris/gui/installer/file_box.py000066400000000000000000000232541460562010500213770ustar00rootroot00000000000000"""Widgets for the installer window""" import os from gettext import gettext as _ from gi.repository import GObject, Gtk 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 RuntimeError("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.17/lutris/gui/installer/files_box.py000066400000000000000000000106641460562010500215630ustar00rootroot00000000000000from 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.17/lutris/gui/installer/script_box.py000066400000000000000000000067361460562010500217720ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris.gui.installer.widgets import InstallerLabel from lutris.util.strings import gtk_safe, gtk_safe_urls 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(gtk_safe_urls(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(gtk_safe_urls(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.17/lutris/gui/installer/script_picker.py000066400000000000000000000016731460562010500224520ustar00rootroot00000000000000from 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.17/lutris/gui/installer/widgets.py000066400000000000000000000013611460562010500212510ustar00rootroot00000000000000from gi.repository import Gtk, Pango from lutris.util.strings import is_valid_pango_markup 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) if is_valid_pango_markup(text): self.set_markup(text) else: self.set_text(text) self.props.can_focus = False self.set_tooltip_text(text) self.set_selectable(selectable) lutris-0.5.17/lutris/gui/installerwindow.py000066400000000000000000001174301460562010500210400ustar00rootroot00000000000000"""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 import settings from lutris.config import LutrisConfig 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, interpreter from lutris.installer.errors import MissingGameDependencyError, ScriptingError from lutris.installer.interpreter import ScriptInterpreter from lutris.util import xdgshortcuts from lutris.util.jobs import AsyncCall from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.steam import shortcut as steam_shortcut from lutris.util.strings import human_size from lutris.util.system import is_removeable 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) 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.selected_extras = [] 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 = 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? object, # extras dict str, # label ) self.location_entry = FileChooserEntry( "Select folder", Gtk.FileChooserAction.SELECT_FOLDER, warn_if_non_empty=True, warn_if_non_writable_parent=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 def on_cache_clicked(self, _button): """Open the cache configuration dialog""" CacheConfigurationDialog(parent=self) def on_response(self, dialog, response: Gtk.ResponseType) -> None: if response in (Gtk.ResponseType.CLOSE, Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): self.on_cancel_clicked() else: super().on_response(dialog, response) def on_back_clicked(self, _button): self.stack.navigate_back() def on_navigate_home(self, _accel_group, _window, _keyval, _modifier): self.stack.navigate_home() 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() def on_source_clicked(self, _button): InstallerSourceDialog(self.interpreter.installer.script_pretty, self.interpreter.installer.game_name, self) def on_signal_error(self, error): self._handle_callback_error(error) def on_idle_error(self, error): self._handle_callback_error(error) def _handle_callback_error(self, error): if self.install_in_progress: self.load_error_message_page(str(error)) else: 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") % self.installers[0]["name"]) self.stack.present_page("choose_installer") self.display_cancel_button(extra_buttons=[self.cache_button]) 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 MissingGameDependencyError 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: application = Gio.Application.get_default() application.show_lutris_installer_window(game_slug=ex.slug) return self.set_title(_("Installing {}").format(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): installer_create_desktop_shortcut = settings.read_bool_setting("installer_create_desktop_shortcut", False) installer_create_menu_shortcut = settings.read_bool_setting("installer_create_menu_shortcut", False) 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.set_active(installer_create_desktop_shortcut) desktop_shortcut_button.connect("clicked", self.on_create_desktop_shortcut_clicked) self.config["create_desktop_shortcut"] = installer_create_desktop_shortcut vbox.pack_start(desktop_shortcut_button, False, False, 0) menu_shortcut_button = Gtk.CheckButton(_("Create application menu shortcut"), visible=True) menu_shortcut_button.set_active(installer_create_menu_shortcut) menu_shortcut_button.connect("clicked", self.on_create_menu_shortcut_clicked) self.config["create_menu_shortcut"] = installer_create_menu_shortcut 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.set_active(settings.read_bool_setting("installer_create_steam_shortcut", False)) 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] ) def on_destination_confirmed(self, _button=None): """Let the interpreter take charge of the next stages.""" self.load_spinner_page( _("Preparing Lutris for installation"), cancellable=False, extra_buttons=[self.cache_button, self.source_button], ) GLib.idle_add(self.launch_install) def launch_install(self): if not self.interpreter.launch_install(self): self.stack.navigation_reset() 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()) def on_create_desktop_shortcut_clicked(self, checkbutton): settings.write_setting("installer_create_desktop_shortcut", checkbutton.get_active()) self.config["create_desktop_shortcut"] = checkbutton.get_active() def on_create_menu_shortcut_clicked(self, checkbutton): settings.write_setting("installer_create_menu_shortcut", checkbutton.get_active()) self.config["create_menu_shortcut"] = checkbutton.get_active() def on_create_steam_shortcut_clicked(self, checkbutton): settings.write_setting("installer_create_steam_shortcut", checkbutton.get_active()) self.config["create_steam_shortcut"] = checkbutton.get_active() def on_runners_ready(self, _widget=None): AsyncCall(self.interpreter.get_extras, self.on_extras_loaded) # 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 get_extra_label(self, 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 def on_extras_loaded(self, all_extras, error): if error: self._handle_callback_error(error) return 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, self.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""" logger.debug("Showing extras page") 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]) 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 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, extra, _label = store[iter_] if selected and extra: selected_extras.append(extra) extra_store.foreach(save_extra) self.selected_extras = selected_extras GLib.idle_add(self.on_extras_ready) def on_extras_ready(self, *args): self.load_installer_files_page() # 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 AsyncCall( self.interpreter.installer.prepare_game_files, self.on_files_prepared, self.selected_extras, patch_version ) def on_files_prepared(self, _result, error): if error: self._handle_callback_error(error) return if not self.interpreter.installer.files: logger.debug("Installer doesn't require files") self.launch_installer_commands() return logger.debug("Game files prepared.") self.installer_files_box.load_installer(self.interpreter.installer) self.stack.navigate_to_page(self.present_installer_files_page) 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""" logger.debug("Presenting installer files page") self.set_status(_("Please review the files needed for the installation then click 'Install'")) 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 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) 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 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): logger.info("Launching installer commands") 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.installer.install_extras() 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) 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 = 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) if not LINUX_SYSTEM.is_flatpak(): # Lutris flatplak doesn't autodetect files on CD-ROM properly # and selecting this option doesn't let the user click "Back" # so the only option is to cancel the install. 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) 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) 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.stack.set_back_allowed(False) 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"): AsyncCall(self.create_shortcut, None, True) if self.config.get("create_menu_shortcut"): AsyncCall(self.create_shortcut, None) # 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"): AsyncCall(steam_shortcut.create_shortcut, None, 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) def on_launch_clicked(self, button): """Launch a game after it's been installed.""" button.set_sensitive(False) game = Game(self.interpreter.installer.game_id) if game.is_db_stored: # Since we're closing this window, we can't use # it as the delegate. application = Gio.Application.get_default() game.launch(application.launch_ui_delegate) else: logger.error("Game has no ID, launch button should not be drawn") self.on_cancel_clicked(button) 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) lutris-0.5.17/lutris/gui/lutriswindow.py000066400000000000000000001510711460562010500203640ustar00rootroot00000000000000"""Main window for the Lutris interface.""" # pylint: disable=too-many-lines # pylint: disable=no-member import os from collections import namedtuple from gettext import gettext as _ from typing import List from urllib.parse import unquote, urlparse from gi.repository import Gdk, Gio, GLib, GObject, Gtk from lutris import services, settings from lutris.api import ( LUTRIS_ACCOUNT_CONNECTED, LUTRIS_ACCOUNT_DISCONNECTED, get_runtime_versions, read_user_info, ) from lutris.database import categories as categories_db from lutris.database import games as games_db from lutris.database.services import ServiceGameCollection from lutris.exception_backstops import get_error_handler, register_error_handler from lutris.exceptions import EsyncLimitError 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 import ClientLoginDialog, ErrorDialog, QuestionDialog from lutris.gui.dialogs.delegates import DialogInstallUIDelegate, DialogLaunchUIDelegate from lutris.gui.dialogs.game_import import ImportGameDialog from lutris.gui.download_queue import DownloadQueue 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.runtime import ComponentUpdater, RuntimeUpdater 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.library_sync import LOCAL_LIBRARY_UPDATED, LibrarySyncer from lutris.util.log import logger from lutris.util.path_cache import MISSING_GAMES, add_to_path_cache from lutris.util.strings import get_natural_sort_key, strip_accents from lutris.util.system import update_desktop_icons @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" 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() download_revealer: Gtk.Revealer = GtkTemplate.Child() game_view_spinner: Gtk.Spinner = GtkTemplate.Child() login_notification_revealer: Gtk.Revealer = GtkTemplate.Child() lutris_log_in_label: Gtk.Label = GtkTemplate.Child() turn_on_library_sync_label: Gtk.Label = GtkTemplate.Child() version_notification_revealer: Gtk.Revealer = GtkTemplate.Child() version_notification_label: Gtk.Revealer = 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._game_store_generation = 0 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.update_notification() 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-unhandled-error", self.on_game_unhandled_error) GObject.add_emission_hook(PreferencesDialog, "settings-changed", self.on_settings_changed) MISSING_GAMES.updated.register(self.update_missing_games_sidebar_row) LUTRIS_ACCOUNT_CONNECTED.register(self.on_lutris_account_connected) LUTRIS_ACCOUNT_DISCONNECTED.register(self.on_lutris_account_disconnected) LOCAL_LIBRARY_UPDATED.register(self.on_local_library_updated) # 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(":", maxsplit=1) if selected_category else None GLib.timeout_add(1000, self.sync_library) 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_sensitive, ), "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_sensitive, ), "view-reverse-order": Action( self.on_view_sorting_direction_change, type="b", default=self.view_reverse_order, enabled=lambda: self.is_view_sort_sensitive, ), "show-side-panel": Action( self.on_side_panel_state_change, type="b", default=self.side_panel_visible, accel="F9", ), "show-hidden-games": Action( self.on_show_hidden_clicked, enabled=lambda: self.is_show_hidden_sensitive, 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 sync_library(self, force=False): """Tasks that can be run after the UI has been initialized.""" if settings.read_bool_setting("library_sync_enabled"): AsyncCall(LibrarySyncer().sync_local_library, None, force=force) 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() 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() 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 return {"installed": self.filter_installed} @property def is_show_hidden_sensitive(self): """True if there are any hiden games to show.""" return bool(categories_db.get_game_ids_for_categories([".hidden"])) def on_show_hidden_clicked(self, action, value): """Hides or shows the hidden games""" self.sidebar.hidden_row.show() self.sidebar.selected_category = "category", ".hidden" @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_bool_setting("filter_installed", True) @property def side_panel_visible(self): return settings.read_bool_setting("side_panel_visible", True) @property def show_tray_icon(self): """Setting to hide or show status icon""" return settings.read_bool_setting("show_tray_icon", False) @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_reverse_order(self): return settings.read_bool_setting("view_reverse_order", False) @property def view_sorting_installed_first(self): return settings.read_bool_setting("view_sorting_installed_first", True) @property def show_hidden_games(self): return settings.read_bool_setting("show_hidden_games", False) @property def is_view_sort_sensitive(self): """True if the view 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'.""" sort_defaults = { "name": "", "year": 0, "lastplayed": 0.0, "installed_at": 0.0, "playtime": 0.0, } def get_sort_value(item): db_game = resolver(item) if not db_game: installation_flag = False value = sort_defaults.get(self.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 self.view_sorting == "name" and sortname: value = sortname else: value = db_game.get(self.view_sorting) if self.view_sorting == "name": value = get_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(self.view_sorting, "") if self.view_sorting == "year": contains_year = bool(value) if self.view_reverse_order: contains_year = not contains_year value = [contains_year, value] if self.view_sorting_installed_first: # We want installed games to always be first, even in # a descending sort. if self.view_reverse_order: installation_flag = not installation_flag if self.view_sorting == "name": installation_flag = not installation_flag return [installation_flag, value] return value reverse = self.view_reverse_order if self.view_sorting == "name" else not self.view_reverse_order return sorted(items, key=get_sort_value, reverse=reverse) def get_running_games(self): """Return a list of currently running games""" return games_db.get_games_by_ids(self.application.get_running_game_ids()) def get_missing_games(self): return games_db.get_games_by_ids(MISSING_GAMES.missing_game_ids) def update_missing_games_sidebar_row(self) -> None: missing_games = self.get_missing_games() if missing_games: self.sidebar.missing_row.show() if self.selected_category == ("dynamic_category", "missing"): self.update_store() else: missing_ids = MISSING_GAMES.missing_game_ids if missing_ids: logger.warning("Path cache out of date? (%s IDs missing)", len(missing_ids)) self.sidebar.missing_row.hide() def get_recent_games(self): """Return a list of currently running games""" games = games_db.get_games(filters={"installed": "1"}) games = self.filter_games(games) return sorted(games, key=lambda game: max(game["installed_at"] or 0, game["lastplayed"] or 0), reverse=True) def filter_games(self, games): """Filters a list of games according to the 'installed' and 'text' filters, if those are set. But if not, can just return games unchanged.""" installed = bool(self.filters.get("installed")) text = self.filters.get("text") if text: text = strip_accents(text).casefold() def game_matches(game): if installed: if "appid" in game and game["appid"] not in games_db.get_service_games(self.service.id): return False if not text: return True name = strip_accents(game["name"]).casefold() return text in name if installed or text: return [game for game in games if game_matches(game)] return games 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_id): """Return games for the service indicated.""" service_games = ServiceGameCollection.get_for_service(service_id) if service_id == "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.filter_games( [ 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) ] ) def get_games_from_filters(self): service_id = self.filters.get("service") if service_id 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_id) if self.filters.get("dynamic_category") in self.dynamic_categories_game_factories: return self.dynamic_categories_game_factories[self.filters["dynamic_category"]]() category = self.filters.get("category") or "all" included = [category] if category != "all" else None excluded = [".hidden"] if category != ".hidden" else [] category_game_ids = categories_db.get_game_ids_for_categories(included, excluded) filters = self.get_sql_filters() games = games_db.get_games(filters=filters) games = self.filter_games([game for game in games if game["id"] in category_game_ids]) return self.apply_view_sort(games) def get_sql_filters(self): """Return the current filters for the view""" sql_filters = {} 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" # We omit the "text" search here because SQLite does a fairly literal # search, which is accent sensitive. We'll do better with self.filter_games() return sql_filters 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, games=None): if games: if self.game_bar: self.game_bar.destroy() if len(games) == 1 and games[0]: self.game_bar = GameBar(games[0], self.application, self) self.revealer_box.pack_start(self.game_bar, True, True, 0) else: self.game_bar = None 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") 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("category") == ".hidden": self.show_label(_("No hidden games matching '%s' found.") % 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 ) 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("category") == ".hidden": self.show_label(_("No games are hidden.")) elif self.filters.get("installed") and has_uninstalled_games: self.show_label(_("No installed games found. Press Ctrl+I to show uninstalled 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): service_id = self.filters.get("service") service = self.service service_media = self.service_media self._game_store_generation += 1 generation = self._game_store_generation def make_game_store(games): game_store = GameStore(service, service_media) game_store.add_preloaded_games(games, service_id) return games, game_store def on_games_ready(games, error): if generation != self._game_store_generation: return # no longer applicable, we got switched again! if error: raise error # bounce any error against the backstop # Since get_games_from_filters() seems to be much faster than making a GameStore, # we defer the spinner to here, when we know how many games we will show. If there # are "many" we show a spinner while the store is built. if len(games) > 512: self.show_spinner() AsyncCall(make_game_store, apply_store, games) def apply_store(result, error): if generation != self._game_store_generation: return # no longer applicable, we got switched again! if error: raise error # bounce any error against the backstop games, game_store = result 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) self.game_store = game_store 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) if games: self.hide_overlay() else: self.show_empty_label() self.update_notification() self.search_timer_id = None AsyncCall(self.get_games_from_filters, on_games_ready) 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) 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_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(0.5, 0.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) splash_box.is_splash = True self.show_overlay(splash_box, Gtk.Align.FILL, Gtk.Align.FILL) def is_showing_splash(self): if self.blank_overlay.get_visible(): for ch in self.blank_overlay.get_children(): if hasattr(ch, "is_splash"): return True return False def show_spinner(self): # This is inconsistent, but we can't use the blank overlay for the spinner- it # won't reliably start as a child of blank_overlay. It seems like it fails if # blank_overlay has never yet been visible. # It works better if created up front and shown like this. self.game_view_spinner.start() self.game_view_spinner.show() self.games_stack.hide() self.blank_overlay.hide() 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.show() self.games_stack.hide() self.game_view_spinner.hide() def hide_overlay(self): self.blank_overlay.hide() self.game_view_spinner.hide() self.games_stack.show() 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.""" default_icon_types = { "icon_type_grid": "coverart_med", } 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, default=default_icon_types.get(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 view_type = self.current_view_type if view_type not in self.views: self.game_store = GameStore(self.service, self.service_media) if view_type == "grid": self.current_view = GameGridView( self.game_store, hide_text=settings.read_bool_setting("hide_text_under_icons") ) 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_action_state() self.update_store() 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: view = self.views[view_type] scrolledwindow = self.games_stack.get_child_by_name(view_type) scrolledwindow.remove(view) del self.views[view_type] if self.current_view_type == view_type: self.redraw_view() # Because the view has hooks and such hooked up, it must be explicitly # destroyed to disconnect everything. view.destroy() 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 def update_notification(self): show_notification = self.is_showing_splash() if show_notification: if not read_user_info(): self.lutris_log_in_label.show() self.turn_on_library_sync_label.hide() elif not settings.read_bool_setting("library_sync_enabled"): self.lutris_log_in_label.hide() self.turn_on_library_sync_label.show() else: show_notification = False self.login_notification_revealer.set_reveal_child(show_notification) @GtkTemplate.Callback def on_lutris_log_in_label_activate_link(self, _label, _url): ClientLoginDialog(parent=self) @GtkTemplate.Callback def on_turn_on_library_sync_label_activate_link(self, _label, _url): settings.write_setting("library_sync_enabled", True) self.sync_library(force=True) self.update_notification() def on_version_notification_close_button_clicked(self, _button): dialog = QuestionDialog( { "title": _("Unsupported Lutris Version"), "question": _( "This version of Lutris will no longer receive support on Github and Discord, " "and may not interoperate properly with Lutris.net. Do you want to use it anyway?" ), "parent": self, } ) if dialog.result == Gtk.ResponseType.YES: self.version_notification_revealer.set_reveal_child(False) runtime_versions = get_runtime_versions() if runtime_versions: client_version = runtime_versions.get("client_version") settings.write_setting("ignored_supported_lutris_verison", client_version or "") 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.update_store() 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)) def on_service_login(self, service): self.update_notification() service.start_reload(self._service_reloaded_cb) return True def _service_reloaded_cb(self, error): if error: dialogs.ErrorDialog(error, parent=self) def on_service_logout(self, service): self.update_notification() if self.service and service.id == self.service.id: self.update_store() return True def on_lutris_account_connected(self): self.update_notification() self.sync_library(force=True) def on_lutris_account_disconnected(self): self.update_notification() def on_local_library_updated(self): self.redraw_view() @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): app = self.application if app.has_running_games: self.hide() return True if app.has_tray_icon(): 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 def on_hide(self, *_args): self.save_window_state() @GtkTemplate.Callback def on_show(self, *_args): self.restore_window_position() @GtkTemplate.Callback 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.update_store() @GtkTemplate.Callback 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 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 def on_about_clicked(self, *_args): """Open the about dialog.""" dialogs.AboutDialog(parent=self) def on_game_unhandled_error(self, _game: Game, error: BaseException) -> bool: """Called when a game has sent the 'game-error' signal""" error_handler = get_error_handler(type(error)) error_handler(error, self) return True @GtkTemplate.Callback 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 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() def on_icontype_state_change(self, action, value): action.set_state(value) self._set_icon_type(value.get_string()) 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.update_store() def on_view_sorting_direction_change(self, action, value): self.actions["view-reverse-order"].set_state(value) settings.write_setting("view_reverse_order", bool(value)) self.update_store() 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.update_store() 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) 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() if row_type != "category" or row_id != ".hidden": self.sidebar.hidden_row.hide() if not MISSING_GAMES.is_initialized or (row_type == "dynamic_category" and row_id == "missing"): MISSING_GAMES.update_all_missing() def on_game_selection_changed(self, view, selection): game_ids = [view.get_game_id_for_path(path) for path in selection] if not game_ids: GLib.idle_add(self.update_revealer) return False games = [] for game_id in game_ids: if self.service: game = ServiceGameCollection.get_game(self.service.id, game_id) else: game = games_db.get_game_by_field(game_id, "id") # There can be no game found if you are removing a game; it will # still have a selected icon in the UI just long enough to get here. if game: games.append(game) GLib.idle_add(self.update_revealer, games) return False 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, not state, "hide_badges_on_icons") def on_settings_changed(self, dialog, state, setting_key): if setting_key == "hide_text_under_icons": self.rebuild_view("grid") else: self.update_view_settings() self.update_notification() return True def is_game_displayed(self, game): """Return whether a game should be displayed on the view""" 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"): categories = game.get_categories() if row.id != ".hidden" and ".hidden" in categories: return False if row.id != "all" and row.id not in categories: return False return True def on_game_updated(self, game): """Updates an individual entry in the view when a game is updated""" add_to_path_cache(game) self.update_action_state() if self.service: db_game = self.service.get_service_db_game(game) else: db_game = games_db.get_game_by_field(game.id, "id") if db_game and not self.is_game_displayed(game) and "id" in db_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() return True 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): self.sync_library() return True def on_game_removed(self): """Simple method used to refresh the view""" self.sidebar.update_rows() self.update_missing_games_sidebar_row() self.update_store() return True 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 db_game and db_game["installed"]: game_id = db_game["id"] else: game_id = self.service.install_by_id(game_id) if game_id: game = Game(game_id) if game.is_installed: game.launch(launch_ui_delegate=self) else: game.install(launch_ui_delegate=self) @property def download_queue(self) -> DownloadQueue: queue = self.download_revealer.get_child() if not queue: queue = DownloadQueue(self.download_revealer) self.download_revealer.add(queue) return queue def start_runtime_updates(self, force_updates: bool) -> None: """Starts the process of applying runtime updates, asynchronously. No UI appears until we can determine that there are updates to perform.""" def create_runtime_updater(): """This function runs on a worker thread and decides what component updates are required; we do this on a thread because it involves hitting the Lutris.net website, which can easily block.""" runtime_updater = RuntimeUpdater(force=force_updates) component_updaters = runtime_updater.create_component_updaters() supported_client_version = runtime_updater.check_client_versions() return component_updaters, runtime_updater, supported_client_version def create_runtime_updater_cb(result, error): """Picks up the component updates when we know what they are, and begins the installation. This must be done on the main thread, since it updates the UI. This would be so much less ugly with asyncio, but here we are.""" if error: logger.exception("Failed to obtain updates from Lutris.net: %s", error) else: component_updaters, runtime_updater, supported_client_version = result if supported_client_version: markup = self.version_notification_label.get_label() markup = markup % (settings.VERSION, supported_client_version) self.version_notification_label.set_label(markup) self.version_notification_revealer.set_reveal_child(True) if component_updaters: self.install_runtime_component_updates(component_updaters, runtime_updater) else: logger.debug("Runtime up to date") AsyncCall(create_runtime_updater, create_runtime_updater_cb) def install_runtime_component_updates( self, updaters: List[ComponentUpdater], runtime_updater: RuntimeUpdater, completion_function: DownloadQueue.CompletionFunction = None, error_function: DownloadQueue.ErrorFunction = None, ) -> bool: """Installs a list of component updates. This displays progress bars in the sidebar as it installs updates, one at a time.""" queue = self.download_queue operation_names = [f"component_update:{u.name}" for u in updaters] def install_updates(): for updater in updaters: updater.install_update(runtime_updater) for updater in updaters: updater.join() return queue.start_multiple( install_updates, (u.get_progress for u in updaters), completion_function=completion_function, error_function=error_function, operation_names=operation_names, ) def _handle_esynclimiterror(error: EsyncLimitError, parent: Gtk.Window) -> None: message = _( "Your limits are not set correctly." " Please increase them as described here:" " " "How-to:-Esync (https://github.com/lutris/docs/blob/master/HowToEsync.md)" ) ErrorDialog(error, message_markup=message, parent=parent) register_error_handler(EsyncLimitError, _handle_esynclimiterror) lutris-0.5.17/lutris/gui/views/000077500000000000000000000000001460562010500163705ustar00rootroot00000000000000lutris-0.5.17/lutris/gui/views/__init__.py000066400000000000000000000011451460562010500205020ustar00rootroot00000000000000"""Common values used for views""" ( COL_ID, COL_SLUG, COL_NAME, COL_SORTNAME, COL_MEDIA_PATHS, 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.17/lutris/gui/views/base.py000066400000000000000000000176361460562010500176710ustar00rootroot00000000000000import time from typing import List from gi.repository import Gdk, Gio, GLib, 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, get_game_actions from lutris.gui.widgets.contextual_menu import ContextualMenu from lutris.gui.widgets.utils import MEDIA_CACHE_INVALIDATED from lutris.util.log import logger from lutris.util.path_cache import MISSING_GAMES class GameView: # pylint: disable=no-member __gsignals__ = { "game-selected": (GObject.SIGNAL_RUN_FIRST, None, (object,)), "game-activated": (GObject.SIGNAL_RUN_FIRST, None, (str,)), } def __init__(self): self.game_store = None self.service = None self.service_media = None self.cache_notification_id = None self.missing_games_updated_id = None self.game_start_hook_id = None self.image_renderer = None def connect_signals(self): """Signal handlers common to all views""" self.cache_notification_id = MEDIA_CACHE_INVALIDATED.register(self.on_media_cache_invalidated) self.missing_games_updated_id = MISSING_GAMES.updated.register(self.on_missing_games_updated) self.connect("destroy", self.on_destroy) self.connect("button-press-event", self.popup_contextual_menu) self.connect("key-press-event", self.handle_key_press) self.game_start_hook_id = GObject.add_emission_hook(Game, "game-start", self.on_game_start) def set_game_store(self, game_store): self.game_store = game_store self.service = game_store.service self.service_media = game_store.service_media size = self.service_media.size if self.image_renderer: self.image_renderer.media_width = size[0] self.image_renderer.media_height = size[1] self.image_renderer.service = self.service def on_media_cache_invalidated(self): self.queue_draw() def on_missing_games_updated(self): if self.image_renderer and self.image_renderer.show_badges: self.queue_draw() def on_destroy(self, _widget): if self.cache_notification_id: MEDIA_CACHE_INVALIDATED.unregister(self.cache_notification_id) if self.missing_games_updated_id: MISSING_GAMES.updated.unregister(self.missing_games_updated_id) if self.game_start_hook_id: GObject.remove_emission_hook(Game, "game-start", self.game_start_hook_id) def popup_contextual_menu(self, view, event): """Contextual menu.""" if event.button != Gdk.BUTTON_SECONDARY: return current_path = self.get_path_at(event.x, event.y) if current_path: selection = self.get_selected() if current_path not in selection: self.set_selected(current_path) selection = [current_path] game_actions = self.get_game_actions_for_paths(selection) contextual_menu = ContextualMenu(game_actions.get_game_actions()) contextual_menu.popup(event, game_actions) return True def get_selected_game_actions(self) -> GameActions: return self.get_game_actions_for_paths(self.get_selected()) def get_game_actions_for_paths(self, paths) -> GameActions: game_ids = [] for path in paths: game_ids.append(self.get_game_id_for_path(path)) games = self._get_games_by_ids(game_ids) return get_game_actions(games, window=self.get_toplevel()) def _get_games_by_ids(self, game_ids: List[str]) -> List[Game]: """Resolves a list of game-ids to a list of game objects, looking up running games, service games and all that.""" def _get_game_by_id(id_to_find: str) -> Game: application = Gio.Application.get_default() return application.get_game_by_id(id_to_find) if application else Game(id_to_find) games = [] for game_id in game_ids: if self.service: db_game = get_game_for_service(self.service.id, game_id) if db_game: if db_game["id"]: games.append(_get_game_by_id(db_game["id"])) else: db_game = ServiceGameCollection.get_game(self.service.id, game_id) games.append(Game.create_empty_service_game(db_game, self.service)) elif game_id: games.append(_get_game_by_id(game_id)) return games def get_selected_game_id(self): """Returns the ID of the selected game, if there is exactly one- or None if there is no selection or a multiple-selection.""" selected = self.get_selected() if len(selected) == 1: return self.get_game_id_for_path(selected[0]) return None def handle_key_press(self, widget, event): # pylint: disable=unused-argument try: key = event.keyval if key == Gdk.KEY_Delete: game_actions = self.get_selected_game_actions() if game_actions.is_game_removable: game_actions.on_remove_game(self) elif key == Gdk.KEY_Break: game_actions = self.get_selected_game_actions() if game_actions.is_game_running: game_actions.on_game_stop(self) except Exception as ex: logger.exception("Unable to handle key press: %s", ex) def get_toplevel(self): raise NotImplementedError() def get_selected(self): return [] def get_game_id_for_path(self, path): raise NotImplementedError() def on_game_start(self, game): """On game start, we trigger an animation to show the game is starting; it runs at least one cycle, but continues until the game exits the STATE_LAUNCHING state.""" # We animate by looking at how long the animation has been running; # This keeps things on track even if drawing is low or the timeout we use # is not quite regular. start_time = time.monotonic() cycle_time = 0.375 max_indent = 0.1 toplevel = self.get_toplevel() paused = False def is_modally_blocked(): # Is there a modal dialog that is block our top-level parent? # if so we want to pause the animated. for w in Gtk.Window.list_toplevels(): if w != toplevel and isinstance(w, Gtk.Dialog): if w.get_modal() and w.get_transient_for() == toplevel: return True def animate(): nonlocal paused, start_time now = time.monotonic() elapsed = now - start_time if elapsed > cycle_time: # Check for stopping and pausing only at cycle end, so we don't do it too often, # and to avoid a janky looking visible snap-back to full size. if game.state != game.STATE_LAUNCHING: if self.image_renderer.inset_game(game.id, 0.0): self.queue_draw() return False start_time = now paused = is_modally_blocked() cycle = elapsed % cycle_time # After 1/2 the cycle, start counting down instead of up if cycle > cycle_time / 2: cycle = cycle_time - cycle # scale to achieve the max_indent at cycle_time/2. if paused: fraction = 0.0 else: fraction = max_indent * (cycle * 2 / cycle_time) if self.image_renderer.inset_game(game.id, fraction): self.queue_draw() return True # Return True to call again after another timeout if self.image_renderer: GLib.timeout_add(25, animate) return True # Return True to continue handling the emission hook lutris-0.5.17/lutris/gui/views/grid.py000066400000000000000000000073371460562010500177010ustar00rootroot00000000000000"""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_PATHS, 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) Gtk.IconView.set_selection_mode(self, Gtk.SelectionMode.MULTIPLE) 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): super().set_game_store(game_store) self.model = game_store.store self.set_model(self.model) if self.text_renderer: size = game_store.service_media.size 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_paths", COL_MEDIA_PATHS) self.add_attribute(self.image_renderer, "platform", COL_PLATFORM) self.add_attribute(self.image_renderer, "is_installed", COL_INSTALLED) def get_path_at(self, x, y): return self.get_path_at_pos(x, y) def set_selected(self, path): self.unselect_all() self.select_path(path) def get_selected(self): """Return list of all selected items""" return self.get_selected_items() def get_game_id_for_path(self, path): iterator = self.get_model().get_iter(path) return self.get_model().get_value(iterator, COL_ID) def on_item_activated(self, _view, _path): """Handles double clicks""" selected_id = self.get_selected_game_id() if selected_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() 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.17/lutris/gui/views/list.py000066400000000000000000000170601460562010500177210ustar00rootroot00000000000000"""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_ID, COL_INSTALLED, COL_INSTALLED_AT, COL_INSTALLED_AT_TEXT, COL_LASTPLAYED, COL_LASTPLAYED_TEXT, COL_MEDIA_PATHS, 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) self.set_rules_hint(True) # Image column if settings.SHOW_MEDIA: self.image_renderer = GridViewCellRendererImage() self.media_column = Gtk.TreeViewColumn( "", self.image_renderer, media_paths=COL_MEDIA_PATHS, is_installed=COL_INSTALLED, game_id=COL_ID ) 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.MULTIPLE) 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): super().set_game_store(game_store) 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) if self.media_column: size = game_store.service_media.size 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], section="list view") is_visible = settings.read_setting("%s_visible" % COLUMN_NAMES[column_id], section="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_path_at(self, x, y): path_at = self.get_path_at_pos(x, y) if path_at is None: return None path, _col, _cx, _cy = path_at return path def set_selected(self, path): selection = self.get_selection() selection.unselect_all() selection.select_path(path) def get_selected(self): """Return list of all selected items""" selection = self.get_selection().get_selected_rows() if not selection: return None return selection[1] def get_game_id_for_path(self, path): iterator = self.get_model().get_iter(path) return self.get_model().get_value(iterator, COL_ID) 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() if selected_id: self.emit("game-activated", selected_id) def on_cursor_changed(self, widget, _line=None, _column=None): selected_items = self.get_selected() self.emit("game-selected", selected_items) @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.17/lutris/gui/views/media_loader.py000066400000000000000000000021401460562010500213440ustar00rootroot00000000000000"""Loads game media in parallel""" import concurrent.futures from lutris.gui.widgets.utils import MEDIA_CACHE_INVALIDATED 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. """ 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 if icons: MEDIA_CACHE_INVALIDATED.fire() return icons lutris-0.5.17/lutris/gui/views/store.py000066400000000000000000000156221460562010500201040ustar00rootroot00000000000000"""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_all_installed_game_for_service, 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_PATHS, 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): 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, GObject.TYPE_PYOBJECT, 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 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 information 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 and "service_id" in db_game: 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_PATHS] = store_item.get_media_paths() if settings.SHOW_MEDIA else [] 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 game to the store""" store_item = StoreItem(db_game, self.service_media) self.add_item(store_item) def add_item(self, store_item): self.store.append( ( store_item.id, store_item.slug, store_item.name, store_item.sortname if store_item.sortname else store_item.name, store_item.get_media_paths() if settings.SHOW_MEDIA else [], store_item.year, store_item.runner, store_item.runner_text, gtk_safe(store_item.platform), store_item.lastplayed, store_item.lastplayed_text, store_item.installed, store_item.installed_at, store_item.installed_at_text, store_item.playtime, store_item.playtime_text, ) ) def add_preloaded_games(self, db_games, service_id): """Add games to the store, but preload their installed-game data all at once, for faster database access. This should be used if all or almost all games are being loaded.""" installed_db_games = {} if service_id and db_games: installed_db_games = get_all_installed_game_for_service(service_id) for db_game in db_games: if installed_db_games is not None and "appid" in db_game: appid = db_game["appid"] store_item = StoreItem(db_game, self.service_media) store_item.apply_installed_game_data(installed_db_games.get(appid)) self.add_item(store_item) else: self.add_game(db_game) def on_game_updated(self, game): if self.service: db_games = sql.filtered_query( settings.DB_PATH, "service_games", filters=({"service": self.service_media.service, "appid": game.appid}), ) else: db_games = sql.filtered_query(settings.DB_PATH, "games", filters=({"id": game.id})) for db_game in db_games: GLib.idle_add(self.update, db_game) return True lutris-0.5.17/lutris/gui/views/store_item.py000066400000000000000000000132021460562010500211120ustar00rootroot00000000000000"""Game representation for views""" import time from lutris.database import 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._cached_installed_game_data_loaded = False 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.""" if not self._cached_installed_game_data_loaded: appid = self._game_data.get("appid") service_id = self._game_data.get("service") if appid and service_id: self._cached_installed_game_data = games.get_game_for_service(service_id, appid) or {} self._cached_installed_game_data_loaded = True return self._cached_installed_game_data def apply_installed_game_data(self, installed_game_data): self._cached_installed_game_data_loaded = True self._cached_installed_game_data = installed_game_data def _get_game_attribute(self, key): if key in self._game_data: return self._game_data[key] installed_game_data = self._installed_game_data if installed_game_data: return installed_game_data.get(key) return None @property def id(self) -> str: # 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: _id = self._game_data["appid"] else: _id = self._game_data["slug"] else: _id = self._game_data["id"] if not _id: logger.error("No id could be found for '%s'", self.name) return str(_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) -> bool: """Game is installed""" def check_data(data): return bool(data and data.get("installed") and data.get("runner")) if "installed" in self._game_data: return check_data(self._game_data) return check_data(self._installed_game_data) def get_media_paths(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_possible_media_paths(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.17/lutris/gui/widgets/000077500000000000000000000000001460562010500167015ustar00rootroot00000000000000lutris-0.5.17/lutris/gui/widgets/__init__.py000066400000000000000000000035521460562010500210170ustar00rootroot00000000000000from typing import Callable from lutris.util.jobs import schedule_at_idle class NotificationSource: """A class to inform interested code of changes in a global, like a signal but not attached to any object.""" def __init__(self): self._generation_number = 0 self._callbacks = {} self._next_callback_id = 1 self._scheduled_callbacks = set() def fire(self) -> None: """Signals that the thing, whatever it is, has happened. This increments the generation number, and schedules the callbacks to run (if they are not scheduled already).""" self._generation_number += 1 self._scheduled_callbacks.update(self._callbacks.values()) schedule_at_idle(self._notify) @property def generation_number(self) -> int: """Returns a number that is incremented on each call to fire(). This can be polled passively, when registering a callback is inappropriate.""" return self._generation_number def register(self, callback: Callable[[], None]) -> int: """Registers a callback to be called after the thing, whatever it is, has happened; fire() schedules callbacks to be called at idle time on the main thread. Note that a callback will be kept alive until unregistered, and this can keep large objects alive until then. Returns an id number to use to unregister the callback.""" callback_id = self._next_callback_id self._callbacks[callback_id] = callback self._next_callback_id += 1 return callback_id def unregister(self, callback_id: int) -> None: """Unregisters a callback that register() had registered.""" self._callbacks.pop(callback_id, None) def _notify(self): while self._scheduled_callbacks: callback = self._scheduled_callbacks.pop() callback() lutris-0.5.17/lutris/gui/widgets/cellrenderers.py000066400000000000000000000522071460562010500221120ustar00rootroot00000000000000# pylint: disable=no-member # 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 Gdk, GLib, GObject, Gtk, Pango, PangoCairo from lutris.gui.widgets.utils import ( MEDIA_CACHE_INVALIDATED, get_default_icon_path, get_runtime_icon_path, get_scaled_surface_by_path, get_surface_size, ) from lutris.services.service_media import resolve_media_path from lutris.util.path_cache import MISSING_GAMES 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._service = None self._media_paths = [] 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 self._inset_fractions = {} def inset_game(self, game_id: str, fraction: float) -> bool: """This function indicates that a particular game should be displayed inset by a certain fraction of its total size; 0 is full size, 0.1 would show it at 90% size, but centered. This is not bound as an attribute; it's used for an ephemeral animation, and we wouldn't want to mess with the GameStore to do it. Instead, the cell renderer tracks these per game ID, and the caller uses queue_draw() to trigger a redraw. Set the fraction to 0 for a game to remove the effect when done. This returns True if it alters the inset of a game, and False if not because it was already set that way.""" if fraction > 0.0: if fraction != self._inset_fractions.get(game_id): self._inset_fractions[game_id] = fraction return True elif game_id in self._inset_fractions: del self._inset_fractions[game_id] return True return False @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=GObject.TYPE_PYOBJECT) def service(self): return self._service @service.setter def service(self, value): self._service = value @GObject.Property(type=GObject.TYPE_PYOBJECT) def media_paths(self): """This is the list of paths where the media to be displayed may be.""" return self._media_paths @media_paths.setter def media_paths(self, value): self._media_paths = 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 = resolve_media_path(self.media_paths) if self.media_paths else None 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: media_area = self.get_media_area(surface, cell_area) self.select_badge_metrics(surface) cr.save() # Adjust context to place media_area at 0,0 and scale it. inset_fraction = self._inset_fractions.get(self.game_id) or 0.0 if self.game_id else 0.0 if inset_fraction > 0: media_area.x += (media_area.width * inset_fraction) / 2 media_area.y += (media_area.height * inset_fraction) / 2 cr.translate(media_area.x, media_area.y) cr.scale(1 - inset_fraction, 1 - inset_fraction) else: cr.translate(media_area.x, media_area.y) media_area.x = 0 media_area.y = 0 if alpha >= 1: self.render_media(cr, widget, surface, 0, 0) if self.show_badges: self._render_badges(cr, widget, surface, media_area) else: cr.push_group() self.render_media(cr, widget, surface, 0, 0) if self.show_badges: self._render_badges(cr, widget, surface, media_area) cr.pop_group_to_source() cr.paint_with_alpha(alpha) cr.restore() # 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) ) @staticmethod def get_media_area(surface, cell_area): """Computes the position of the upper left corner where we will render a surface within the cell area.""" media_area = Gdk.Rectangle() width, height = get_surface_size(surface) media_area.x = round(cell_area.x + (cell_area.width - width) / 2) # centered media_area.y = round(cell_area.y + cell_area.height - height) # at bottom of cell media_area.width, media_area.height = width, height return media_area 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_badges(self, cr, widget, surface, media_area): self.render_platforms(cr, widget, surface, 0, media_area) game_id = self.game_id if game_id: if self.service: game_id = self.service.resolve_game_id(game_id) if game_id in MISSING_GAMES.missing_game_ids: self.render_text_badge(cr, widget, _("Missing"), 0, media_area.y + media_area.height) def render_platforms(self, cr, widget, surface, surface_x, media_area): """Renders the stack of platform icons.""" platform = self.platform if platform and self.badge_size: icon_paths = self.get_platform_icon_paths(platform) if icon_paths: self.render_badge_stack(cr, widget, surface, surface_x, icon_paths, media_area) @staticmethod def get_platform_icon_paths(platform): if platform in GridViewCellRendererImage._platform_icon_paths: return GridViewCellRendererImage._platform_icon_paths[platform] if "," in platform: platforms = platform.split(",") # pylint:disable=no-member else: platforms = [platform] icon_paths = [] for p in platforms: icon_path = get_runtime_icon_path(p + "-symbolic") if icon_path: icon_paths.append(icon_path) GridViewCellRendererImage._platform_icon_paths[platform] = icon_paths return icon_paths _platform_icon_paths = {} def render_badge_stack(self, cr, widget, surface, surface_x, icon_paths, media_area): """Renders a vertical stack of badges, placed at the edge of the media, just to the left of 'media_area.right'. The icons in icon_paths are drawn from top to bottom, and spaced to fit in 'media_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 = (media_area.height - badge_height * len(icon_paths)) / max(1, len(icon_paths) - 1) spacing = min(spacing, 1) y_offset = floor(badge_height + spacing) y = media_area.y + media_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 != MEDIA_CACHE_INVALIDATED.generation_number: self.cached_surface_generation = MEDIA_CACHE_INVALIDATED.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) if surface: # We cache missing surfaces too, but only a successful load trigger # cache cycling 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.17/lutris/gui/widgets/common.py000066400000000000000000000362561460562010500205570ustar00rootroot00000000000000"""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 from lutris.gui.widgets.utils import open_uri # 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_non_writable_parent=False, warn_if_ntfs=False, activates_default=False, shell_quoting=False, ): # pylint: disable=too-many-arguments 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_non_writable_parent = warn_if_non_writable_parent 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.new_from_icon_name("view-more-horizontal-symbolic", Gtk.IconSize.BUTTON) browse_button.show() if action == Gtk.FileChooserAction.SELECT_FOLDER: browse_button.set_tooltip_text(_("Select a folder")) else: browse_button.set_tooltip_text(_("Select a file")) browse_button.get_style_context().add_class("circular") browse_button.connect("clicked", self.on_browse_clicked) self.open_button = Gtk.Button.new_from_icon_name("folder-symbolic", Gtk.IconSize.BUTTON) self.open_button.show() self.open_button.set_tooltip_text(_("Open in file browser")) self.open_button.get_style_context().add_class("circular") self.open_button.connect("clicked", self.on_open_clicked) self.open_button.set_sensitive(bool(self.get_open_directory())) box = Gtk.Box(spacing=6, visible=True) box.pack_start(self.entry, True, True, 0) box.pack_end(self.open_button, False, False, 0) box.pack_end(browse_button, False, False, 0) 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_open_clicked(self, _widget): path = self.get_open_directory() if path: open_uri(path) def get_open_directory(self): path = self.get_path() while path and not os.path.isdir(path): path = os.path.dirname(path) return path 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) if self.warn_if_non_writable_parent: 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.open_button.set_sensitive(bool(self.get_open_directory())) 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, visible=True) 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.Box): __gsignals__ = {"changed": (GObject.SIGNAL_RUN_FIRST, None, ())} def __init__(self, data, columns): self.columns = columns super().__init__(orientation=Gtk.Orientation.VERTICAL, 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_size_request(-1, 209) self.scrollable_treelist.add(self.treeview) self.scrollable_treelist.set_shadow_type(Gtk.ShadowType.IN) self.pack_start(self.scrollable_treelist, True, True, 0) button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) for button in reversed(self.buttons): button.set_size_request(80, -1) button_box.pack_end(button, False, False, 0) self.pack_end(button_box, False, False, 0) 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.17/lutris/gui/widgets/contextual_menu.py000066400000000000000000000045261460562010500224740ustar00rootroot00000000000000from 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. Returns the count of visible widgets that are not separators.""" visible_count = 0 previous_visible_widget = None for w in widgets: visible = visible_predicate(w) if visible: visible_count = visible_count + 1 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 previous_visible_widget and visible_predicate(previous_visible_widget) is None: previous_visible_widget.set_visible(False) return visible_count 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 popup(self, event, game_actions): for item in self.get_children(): self.remove(item) for entry in self.main_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) visible_count = update_action_widget_visibility(self.get_children(), is_visible) if visible_count > 0: self.popup_at_pointer(event) lutris-0.5.17/lutris/gui/widgets/download_collection_progress_box.py000066400000000000000000000210021460562010500260640ustar00rootroot00000000000000import 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_next_file_from_queue(self): """Returns the file to download; if there isn't one this will pull the next one from the queue and return that. If there are none there, this returns None.""" if not self._file_download: if not self._file_queue: return None self._file_download = self._file_queue.pop() self.num_retries = 0 return self._file_download def start(self): """Start downloading a file.""" file = self.get_next_file_from_queue() if not file: self.cancel_button.set_sensitive(False) self.is_complete = True self.emit("complete", {}) return 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, parent=self.get_toplevel()) self.emit("cancel") return 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() 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.17/lutris/gui/widgets/download_progress_box.py000066400000000000000000000123661460562010500236660ustar00rootroot00000000000000from 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, parent=self.get_toplevel()) 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.17/lutris/gui/widgets/game_bar.py000066400000000000000000000272611460562010500210200ustar00rootroot00000000000000from 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.game import Game from lutris.game_actions import get_game_actions 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_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_game_by_id(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-installed", self.game_installed_hook_id) return True def update_view(self): """Populate the view with widgets""" game_actions = get_game_actions([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) 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): if not self.game.has_runner: return Gtk.Box() 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) if not self.game.platform: return platform_label 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) 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) button.set_sensitive(game_actions.is_game_launchable) 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) button.set_sensitive(game_actions.is_game_running) else: button.set_label(_("Install")) button.connect("clicked", game_actions.on_install_clicked) button.set_sensitive(game_actions.is_installable) 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.has_runner 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 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) def on_install_clicked(self, button): """Handler for installing service games""" self.service.install(self.db_game) 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 for child in self.get_children(): child.destroy() self.update_view() return True lutris-0.5.17/lutris/gui/widgets/gi_composites.py000066400000000000000000000224051460562010500221220ustar00rootroot00000000000000"""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.17/lutris/gui/widgets/log_text_view.py000066400000000000000000000065511460562010500221410ustar00rootroot00000000000000# 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.17/lutris/gui/widgets/navigation_stack.py000066400000000000000000000163571460562010500226130ustar00rootroot00000000000000"""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 and self.current_navigated_page_presenter != 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.17/lutris/gui/widgets/notifications.py000066400000000000000000000007501460562010500221260ustar00rootroot00000000000000from 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.17/lutris/gui/widgets/progress_box.py000066400000000000000000000110241460562010500217650ustar00rootroot00000000000000from typing import Callable from gi.repository import GLib, Gtk, Pango from lutris.util.log import logger class ProgressInfo: """Contains the current state of a process being monitored. This can also provide for stopping the process via a function you can provide. Processes sometimes cannot be stopped after a certain point; at that point they start providing Progress objects with no stop-function.""" def __init__(self, progress: float = None, label_markup: str = "", stop_function: Callable = None): self.progress = progress self.label_markup = label_markup self.stop_function = stop_function self.has_ended = False @classmethod def ended(cls, label_markup: str = "") -> "ProgressInfo": """Creates a ProgressInfo whose has_ended flag is set, to indicate that the monitored process is over.""" info = cls(1.0, label_markup=label_markup) info.has_ended = True return info @property def can_stop(self) -> bool: """Called to check if the stop button should appear.""" return bool(self.stop_function) def stop(self): """Called whe the stop button is clicked.""" if self.stop_function: try: self.stop_function() except Exception as ex: logger.exception("Error during progress box stop: %s", ex) class ProgressBox(Gtk.Box): """Simple, small progress bar used to monitor the update of runtime or runner components. This class needs only a function that returns a Progress object, which describes the current progress and optionally can stop the update.""" ProgressFunction = Callable[[], "ProgressInfo"] def __init__(self, progress_function: ProgressFunction, **kwargs): super().__init__(orientation=Gtk.Orientation.HORIZONTAL, no_show_all=True, spacing=6, **kwargs) self.progress_function = progress_function self.progress = ProgressInfo(0.0) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True, spacing=6, valign=Gtk.Align.CENTER) self.label = Gtk.Label("", visible=False, wrap=True, ellipsize=Pango.EllipsizeMode.MIDDLE, xalign=0) vbox.pack_start(self.label, False, False, 0) self.progressbar = Gtk.ProgressBar(pulse_step=0.4, visible=True) self.progressbar.set_valign(Gtk.Align.CENTER) vbox.pack_start(self.progressbar, False, False, 0) self.pack_start(vbox, True, True, 0) self.stop_button = Gtk.Button.new_from_icon_name("media-playback-stop-symbolic", Gtk.IconSize.BUTTON) self.stop_button.hide() self.stop_button.get_style_context().add_class("circular") self.stop_button.connect("clicked", self.on_stop_clicked) self.pack_start(self.stop_button, False, False, 0) self._destroyed = False self._apply_progress(ProgressInfo(0.0, "Please wait...")) self._timer_id = GLib.timeout_add(500, self.on_update_progress) self.connect("destroy", self.on_destroy) def on_stop_clicked(self, _widget) -> None: if self.progress.can_stop: self.progress.stop() def on_destroy(self, _widget) -> None: self._destroyed = True if self._timer_id: GLib.source_remove(self._timer_id) def on_update_progress(self) -> bool: try: self.update_progress() return True except Exception as ex: logger.exception("Unable to obtain a progress update: %s", ex) self._timer_id = None return False def update_progress(self) -> None: """Invokes the progress function and displays what it returns; this can be called to ensure the box is immediately up-to-date, without waiting for idle-time.""" progress = self.progress_function() self._apply_progress(progress) def _apply_progress(self, progress: ProgressInfo): # Just in case the progress-function destroys the progress box. if self._destroyed: return self.progress = progress if progress.progress is None: self.progressbar.pulse() else: self.progressbar.set_fraction(min(progress.progress, 1)) self._set_label(progress.label_markup or "") self.stop_button.set_visible(progress.can_stop) def _set_label(self, markup: str) -> None: if markup: if markup != self.label.get_text(): self.label.set_markup(markup) self.label.show() else: self.label.hide() lutris-0.5.17/lutris/gui/widgets/scaled_image.py000066400000000000000000000077271460562010500216650ustar00rootroot00000000000000from gi.repository import Gtk from lutris.gui.widgets.utils import ( ICON_SIZE, get_default_icon_path, get_pixbuf_by_path, get_required_pixbuf_by_path, get_runtime_icon_path, has_stock_icon, ) 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_required_pixbuf_by_path(path, pixbuf_size) 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_required_pixbuf_by_path(default_icon, pixbuf_size, preserve_aspect_ratio=False) 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) if path: icon = ScaledImage.new_scaled_from_path(path, size=ICON_SIZE, scale_factor=scale_factor) else: 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.17/lutris/gui/widgets/searchable_combobox.py000066400000000000000000000063521460562010500232420ustar00rootroot00000000000000"""Extended combobox with search""" # pylint: disable=unsubscriptable-object from gi.repository import GLib, GObject, Gtk from lutris.gui.dialogs import ErrorDialog 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): try: 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) except Exception as ex: ErrorDialog(ex, 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.17/lutris/gui/widgets/sidebar.py000066400000000000000000000603101460562010500206640ustar00rootroot00000000000000"""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.config import LutrisConfig from lutris.database import categories as categories_db from lutris.database import games as games_db from lutris.game import Game 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 InvalidRunnerError from lutris.services import SERVICES from lutris.services.base import AuthTokenExpiredError, BaseService from lutris.util.library_sync import LOCAL_LIBRARY_SYNCED, LOCAL_LIBRARY_SYNCING from lutris.util.strings import get_natural_sort_key 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.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(self.get_toplevel()) 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, AuthTokenExpiredError): 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: runner = self.get_runner() except InvalidRunnerError: return entries if runner.multiple_versions: entries.append( ("system-software-install-symbolic", _("Manage Versions"), self.on_manage_versions, "manage-versions") ) if 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 def get_runner(self): return runners.import_runner(self.id)() def on_run_runner(self, *_args): """Runs the runner without no game.""" runner = self.get_runner() runner.run(self.get_toplevel()) def on_configure_runner(self, *_args): """Show runner configuration""" runner = self.get_runner() self.application.show_window(RunnerConfigDialog, runner=runner, parent=self.get_toplevel()) def on_manage_versions(self, *_args): """Manage runner versions""" runner = self.get_runner() dlg_title = _("Manage %s versions") % runner.human_name self.application.show_window(RunnerInstallDialog, title=dlg_title, runner=runner, 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"]) @property def sort_key(self): return get_natural_sort_key(self.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("lutrissidebar") # Empty values until LutrisWindow explicitly initializes the rows # at the right time. self.installed_runners = [] self.runner_visibility_cache = {} 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.games_row = DummyRow() self.running_row = DummyRow() self.hidden_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(RunnerConfigDialog, "runner-updated", self.update_runner_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-stopped", self.on_game_stopped) GObject.add_emission_hook(Game, "game-updated", 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) LOCAL_LIBRARY_SYNCING.register(self.on_local_library_syncing) LOCAL_LIBRARY_SYNCED.register(self.on_local_library_synced) 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.games_row = SidebarRow( "all", "category", _("Games"), Gtk.Image.new_from_icon_name("applications-games-symbolic", Gtk.IconSize.MENU), ) self.add(self.games_row) 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.hidden_row = SidebarRow( ".hidden", "category", _("Hidden"), Gtk.Image.new_from_icon_name("action-unavailable-symbolic", Gtk.IconSize.MENU), ) self.add(self.hidden_row) 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.hidden_row.hide() self.missing_row.hide() 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") children = list(self.get_children()) for row in children: if row.type == selected_row_type and row.id == selected_row_id: if row.get_visible(): self.select_row(row) return break for row in children: if row.get_visible(): self.select_row(row) return def _filter_func(self, row): def is_runner_visible(runner_name): if runner_name not in self.runner_visibility_cache: runner_config = LutrisConfig(runner_slug=row.id) self.runner_visibility_cache[runner_name] = runner_config.runner_config.get( "visible_in_side_panel", True ) return self.runner_visibility_cache[runner_name] if not row or not row.id or row.type in ("category", "dynamic_category"): return True if row.type == "runner": if row.id is None: return True # 'All' return row.id in self.installed_runners and is_runner_visible(row.id) if row.type == "user_category": allowed_ids = self.used_categories elif row.type == "service": allowed_ids = self.active_services else: allowed_ids = self.active_platforms return row.id in allowed_ids 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 header and 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_runner_rows(self, *_args): self.runner_visibility_cache.clear() self.update_rows() return True 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_stopped(self, _game): """Hide the "running" section when no games are running""" if not self.application.has_running_games: 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 def on_local_library_syncing(self): self.games_row.is_updating = True self.games_row.update_buttons() def on_local_library_synced(self): self.games_row.is_updating = False self.games_row.update_buttons() lutris-0.5.17/lutris/gui/widgets/status_icon.py000066400000000000000000000123741460562010500216150ustar00rootroot00000000000000"""AppIndicator based tray icon""" from gettext import gettext as _ import gi from gi.repository import Gdk, Gtk from lutris.database.games import get_games from lutris.game import Game from lutris.util import cache_single 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 @cache_single def supports_status_icon(): if APP_INDICATOR_SUPPORTED: return True display = Gdk.Display.get_default() return "x11" in type(display).__name__.casefold() class LutrisStatusIcon: """This is a proxy for the status icon, which can be an AppIndicator or a Gtk.StatusIcon. Or if neither is supported, it can be a null object that silently does nothing.""" def __init__(self, application): self.application = application self.indicator = None self.tray_icon = None self.menu = None self.present_menu = None if supports_status_icon(): self.menu = self._get_menu() if APP_INDICATOR_SUPPORTED: self.indicator = AppIndicator.Indicator.new( "net.lutris.Lutris", "lutris", AppIndicator.IndicatorCategory.APPLICATION_STATUS ) self.indicator.set_menu(self.menu) else: self.tray_icon = self._get_tray_icon() self.tray_icon.connect("activate", self.on_activate) self.tray_icon.connect("popup-menu", self.on_menu_popup) self.set_visible(True) def is_visible(self): """Whether the icon is visible""" if self.indicator: return self.indicator.get_status() != AppIndicator.IndicatorStatus.PASSIVE if self.tray_icon: return self.tray_icon.get_visible() return False def set_visible(self, value): """Set the visibility of the icon""" if self.indicator: if value: visible = AppIndicator.IndicatorStatus.ACTIVE else: visible = AppIndicator.IndicatorStatus.PASSIVE self.indicator.set_status(visible) elif self.tray_icon: self.tray_icon.set_visible(value) def _get_menu(self): """Instantiates the menu attached to the tray icon""" menu = Gtk.Menu() installed_games = self._get_installed_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 _get_tray_icon(self): tray_icon = Gtk.StatusIcon() tray_icon.set_tooltip_text(_("Lutris")) tray_icon.set_visible(True) tray_icon.set_from_icon_name("lutris") return tray_icon def update_present_menu(self): app_window = self.application.window if app_window and self.present_menu: 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 _get_installed_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) lutris-0.5.17/lutris/gui/widgets/utils.py000066400000000000000000000233241460562010500204170ustar00rootroot00000000000000"""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.exceptions import MissingMediaError from lutris.gui.widgets import NotificationSource 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) MEDIA_CACHE_INVALIDATED = NotificationSource() 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_extension(path): """Returns the canonical file extension for an image, either 'jpg' 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].casefold() if ext in [".jpg", ".jpeg"]: return ".jpg" if path == ".png": return ".png" file_type = magic.from_file(path).casefold() if "jpeg image data" in file_type: return ".jpg" 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 there's no file at the path, or it is empty, this function returns None. """ pixbuf = get_pixbuf_by_path(path) if not pixbuf: return None 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_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 empty, or if 'path' is None or empty, this returns None. Still raises GLib.GError for corrupt files.""" if not system.path_exists(path, exclude_empty=True): return None 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) def get_required_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 or empty, this raises MissingMediaError.""" try: pixbuf = get_pixbuf_by_path(path, size, preserve_aspect_ratio) if not pixbuf: raise MissingMediaError(filename=path) return pixbuf except GLib.GError as ex: logger.exception("Unable to load icon from image %s", path) raise MissingMediaError(message=str(ex), filename=path) from ex 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 Returns: The path to the icon, or None if it wasn't found. """ 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.Resampling.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.Resampling.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.17/lutris/gui/widgets/window.py000066400000000000000000000026231460562010500205650ustar00rootroot00000000000000# 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.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 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.17/lutris/installer/000077500000000000000000000000001460562010500164445ustar00rootroot00000000000000lutris-0.5.17/lutris/installer/__init__.py000066400000000000000000000021251460562010500205550ustar00rootroot00000000000000"""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.17/lutris/installer/commands.py000066400000000000000000000667101460562010500206310ustar00rootroot00000000000000"""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, has_custom_cache_path from lutris.exceptions import MissingExecutableError, UnspecifiedVersionError from lutris.installer.errors import ScriptingError from lutris.installer.installer import LutrisInstaller from lutris.monitored_command import MonitoredCommand from lutris.runners import InvalidRunnerError, import_runner, import_task from lutris.runners.wine import wine from lutris.util import extract, linux, selective_merge, system from lutris.util.fileio import EvilConfigParser, MultiOrderedDict from lutris.util.log import logger from lutris.util.wine.wine import WINE_DEFAULT_ARCH, get_default_wine_version, get_wine_path_for_version class CommandsMixin: """The directives for the `installer:` part of the install script.""" # pylint: disable=no-member installer: LutrisInstaller = NotImplemented def get_wine_path(self) -> str: """Return absolute path of wine version used during the installation, but None if the wine exe can't be located.""" runner = self.get_runner_class(self.installer.runner)() version = runner.get_installer_runner_version(self.installer, use_runner_config=False) if version: return get_wine_path_for_version(version) # Special case that lets the Wine configuration explicit specify the path # to the Wine executable, not just a version number. if self.installer.runner == "wine": try: config_version, runner_config = wine.get_runner_version_and_config() return get_wine_path_for_version(config_version, config=runner_config.runner_level["wine"]) except UnspecifiedVersionError: pass version = get_default_wine_version() return get_wine_path_for_version(version) def get_runner_class(self, runner_name): """Runner the runner class from its name""" try: runner = import_runner(runner_name) except InvalidRunnerError as err: raise ScriptingError(_("Invalid runner provided %s") % runner_name) from err return runner @staticmethod def _check_required_params(params, command_data, command_name): """Verify presence of a list of parameters required by a command.""" if isinstance(params, str): params = [params] for param in params: if isinstance(param, tuple): param_present = False for key in param: if key in command_data: param_present = True if not param_present: raise ScriptingError( _("One of {params} parameter is mandatory for the {cmd} command").format( params=_(" or ").join(param), cmd=command_name ), command_data, ) else: if param not in command_data: raise ScriptingError( _("The {param} parameter is mandatory for the {cmd} command").format( param=param, cmd=command_name ), command_data, ) @staticmethod def _is_cached_file(file_path): """Return whether a file referenced by file_id is stored in the cache""" if not has_custom_cache_path(): return False pga_cache_path = get_cache_path() 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) try: exec_abs_path = system.find_required_executable(exec_path) except MissingExecutableError as ex: raise ScriptingError(_("Unable to find executable %s") % exec_path) from ex if terminal: terminal = linux.get_default_terminal() if not working_dir or not os.path.exists(working_dir): working_dir = self.target_path command = MonitoredCommand( [exec_abs_path] + args, env=env, term=terminal, cwd=working_dir, include_processes=include_processes, exclude_processes=exclude_processes, ) command.accepted_return_code = return_code command.start() self.interpreter_ui_delegate.attach_log(command) 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 task(self, data): """Directive triggering another function specific to a runner. The 'name' parameter is mandatory. If 'args' is provided it will be passed to the runner task. """ self._check_required_params("name", data, "task") runner_name, task_name = self._get_task_runner_and_name(data.pop("name")) # Accept return codes other than 0 if "return_code" in data: return_code = data.pop("return_code") else: return_code = "0" if runner_name.startswith("wine"): data["wine_path"] = self.get_wine_path() data["prefix"] = data.get("prefix") or self.installer.script.get("game", {}).get("prefix") or "$GAMEDIR" data["arch"] = data.get("arch") or self.installer.script.get("game", {}).get("arch") or WINE_DEFAULT_ARCH if task_name == "wineexec": data["env"] = self.script_env for key in data: value = data[key] if isinstance(value, dict): for inner_key in value: value[inner_key] = self._substitute(value[inner_key]) elif isinstance(value, list): for index, elem in enumerate(value): value[index] = self._substitute(elem) else: value = self._substitute(data[key]) data[key] = value task = import_task(runner_name, task_name) command = task(**data) if 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 def _monitor_task(self, command): if not command.is_running: logger.debug("Return code: %s", command.return_code) if command.return_code not in (str(command.accepted_return_code), "0"): raise ScriptingError(_("Command exited with code %s") % command.return_code) self._iter_commands() return False return True # keep checking def write_file(self, params): """Write text to a file.""" self._check_required_params(["file", "content"], params, "write_file") # Get file dest_file_path = self._get_file_path(params["file"]) # Create dir if necessary basedir = os.path.dirname(dest_file_path) os.makedirs(basedir, exist_ok=True) mode = params.get("mode", "w") if not mode.startswith(("a", "w")): raise ScriptingError(_("Wrong value for write_file mode: '%s'") % mode) with open(dest_file_path, mode, encoding="utf-8") as dest_file: dest_file.write(self._substitute(params["content"])) def write_json(self, params): """Write data into a json file.""" self._check_required_params(["file", "data"], params, "write_json") # Get file filename = self._get_file_path(params["file"]) # Create dir if necessary basedir = os.path.dirname(filename) os.makedirs(basedir, exist_ok=True) merge = params.get("merge", True) # create an empty file if it doesn't exist Path(filename).touch(exist_ok=True) with open(filename, "r+" if merge else "w", encoding="utf-8") as json_file: json_data = {} if merge: try: json_data = json.load(json_file) except ValueError: logger.error("Failed to parse JSON from file %s", filename) json_data = selective_merge(json_data, params.get("data", {})) json_file.seek(0) json_file.truncate() json_file.write(json.dumps(json_data, indent=2)) def write_config(self, params): """Write a key-value pair into an INI type config file.""" if params.get("data"): self._check_required_params(["file", "data"], params, "write_config") else: self._check_required_params(["file", "section", "key", "value"], params, "write_config") # Get file config_file_path = self._get_file_path(params["file"]) # Create dir if necessary basedir = os.path.dirname(config_file_path) os.makedirs(basedir, exist_ok=True) merge = params.get("merge", True) parser = EvilConfigParser(allow_no_value=True, dict_type=MultiOrderedDict, strict=False) parser.optionxform = str # Preserve text case if merge: parser.read(config_file_path) data = {} if params.get("data"): data = params["data"] else: data[params["section"]] = {} data[params["section"]][params["key"]] = params["value"] for section, keys in data.items(): if not parser.has_section(section): parser.add_section(section) for key, value in keys.items(): value = self._substitute(value) parser.set(section, key, value) with open(config_file_path, "wb") as config_file: parser.write(config_file) def _get_file_path(self, fileid): file_path = self.game_files.get(fileid) if not file_path: file_path = self._substitute(fileid) return file_path def _killable_process(self, func, *args, **kwargs): """Run function `func` in a separate, killable process.""" with multiprocessing.Pool(1) as process: result_obj = process.apply_async(func, args, kwargs) self.abort_current_task = process.terminate result = result_obj.get() # Wait process end & re-raise exceptions self.abort_current_task = None logger.debug("Process %s returned: %s", func, result) return result def _extract_gog_game(self, file_id): self.extract({"src": file_id, "dst": "$GAMEDIR", "extractor": "innoextract"}) app_path = os.path.join(self.target_path, "app") if system.path_exists(app_path): for app_content in os.listdir(app_path): source_path = os.path.join(app_path, app_content) if os.path.exists(os.path.join(self.target_path, app_content)): self.merge({"src": source_path, "dst": self.target_path}) else: self.move({"src": source_path, "dst": self.target_path}) support_path = os.path.join(self.target_path, "__support/app") if system.path_exists(support_path): self.merge({"src": support_path, "dst": self.target_path}) def _get_scummvm_arguments(self, gog_config_path): """Return a ScummVM configuration from the GOG config files""" with open(gog_config_path, encoding="utf-8") as gog_config_file: gog_config = json.loads(gog_config_file.read()) game_tasks = [task for task in gog_config["playTasks"] if task["category"] == "game"] arguments = game_tasks[0]["arguments"] game_id = arguments.split()[-1] arguments = " ".join(arguments.split()[:-1]) base_dir = os.path.dirname(gog_config_path) return {"game_id": game_id, "path": base_dir, "arguments": arguments} def autosetup_gog_game(self, file_id, silent=False): """Automatically guess the best way to install a GOG game by inspecting its contents. This chooses the right runner (DOSBox, Wine) for Windows game files. Linux setup files don't use innosetup, they can be unzipped instead. """ file_path = self.game_files[file_id] file_list = extract.get_innoextract_list(file_path) dosbox_found = False scummvm_found = False windows_override_found = False # DOS games that also have a Windows executable for filename in file_list: if "dosbox.exe" in filename.lower(): dosbox_found = True if "scummvm.exe" in filename.lower(): scummvm_found = True if "_some_windows.exe" in filename.lower(): # There's not a good way to handle exceptions without extracting the .info file # before extracting the game. Added for Quake but GlQuake.exe doesn't run on modern wine windows_override_found = True if dosbox_found and not windows_override_found: self._extract_gog_game(file_id) if "DOSBOX" in os.listdir(self.target_path): dosbox_config = { "working_dir": "$GAMEDIR/DOSBOX", } else: dosbox_config = {} single_conf = None config_file = None for filename in os.listdir(self.target_path): if filename == "dosbox.conf": dosbox_config["main_file"] = filename elif filename.endswith("_single.conf"): single_conf = filename elif filename.endswith(".conf"): config_file = filename if single_conf: dosbox_config["main_file"] = single_conf if config_file: if dosbox_config.get("main_file"): dosbox_config["config_file"] = config_file else: dosbox_config["main_file"] = config_file self.installer.script["game"] = dosbox_config self.installer.runner = "dosbox" elif scummvm_found: self._extract_gog_game(file_id) arguments = None for filename in os.listdir(self.target_path): if filename.startswith("goggame") and filename.endswith(".info"): arguments = self._get_scummvm_arguments(os.path.join(self.target_path, filename)) if not arguments: raise RuntimeError("Unable to get ScummVM arguments") logger.info("ScummVM config: %s", arguments) self.installer.script["game"] = arguments self.installer.runner = "scummvm" else: args = "/SP- /NOCANCEL" if silent: args += " /SUPPRESSMSGBOXES /VERYSILENT /NOGUI" self.installer.is_gog = True return self.task({"name": "wineexec", "prefix": "$GAMEDIR", "executable": file_id, "args": args}) def autosetup_amazon(self, file_and_dir_dict): files = file_and_dir_dict["files"] directories = file_and_dir_dict["directories"] # create directories for directory in directories: self.mkdir(f"$GAMEDIR/drive_c/game/{directory}") # move installed files from CACHE to game folder for file_hash, file in self.game_files.items(): file_dir = os.path.dirname(files[file_hash]["path"]) self.move({"src": file, "dst": f"$GAMEDIR/drive_c/game/{file_dir}"}) def install_or_extract(self, file_id): """Runs if file is executable or extracts if file is archive""" file_path = self._get_file_path(file_id) runner = self.installer.runner if runner != "wine": raise ScriptingError(_("install_or_extract only works with wine!")) if file_path.endswith(".exe"): params = {"name": "wineexec", "executable": file_id} return self.task(params) slug = self.installer.game_slug params = {"file": file_id, "dst": f"$GAMEDIR/drive_c/{slug}"} return self.extract(params) lutris-0.5.17/lutris/installer/errors.py000066400000000000000000000031761460562010500203410ustar00rootroot00000000000000"""Installer specific exceptions""" import sys from gettext import gettext as _ from lutris.exceptions import LutrisError from lutris.gui.dialogs import ErrorDialog from lutris.util.log import logger from lutris.util.strings import gtk_safe class ScriptingError(LutrisError): """Custom exception for scripting errors, can be caught by modifying excepthook.""" def __init__(self, message, faulty_data=None): self.faulty_data = faulty_data super().__init__(message) logger.error(self.__str__()) def __str__(self): if self.faulty_data is None: return self.message faulty_data = repr(self.faulty_data) if not faulty_data: return faulty_data return self.message + "\n%s" % faulty_data def __repr__(self): return self.message class MissingGameDependencyError(LutrisError): """Raise when a game requires another game that isn't installed""" def __init__(self, *args, message=None, slug=None, **kwargs): self.slug = slug if not message: message = _("This game requires %s.") % slug super().__init__(message, *args, **kwargs) _excepthook = sys.excepthook # pylint: disable=invalid-name def error_handler(error_type, value, traceback): """Intercept all possible exceptions and raise them as ScriptingErrors""" if error_type == ScriptingError: message = value.message if value.faulty_data: message += "\n%s" % gtk_safe(value.faulty_data) ErrorDialog(message) else: _excepthook(error_type, value, traceback) sys.excepthook = error_handler lutris-0.5.17/lutris/installer/installer.py000066400000000000000000000335621460562010500210240ustar00rootroot00000000000000"""Lutris installer class""" import json from gettext import gettext as _ from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_or_update, get_game_by_field from lutris.exceptions import UnavailableGameError from lutris.installer import AUTO_ELF_EXE, AUTO_WIN32_EXE from lutris.installer.errors import ScriptingError from lutris.installer.installer_file import InstallerFile from lutris.runners import import_runner from lutris.services import SERVICES from lutris.util.game_finder import find_linux_game_executable, find_windows_game_executable from lutris.util.gog import convert_gog_config_to_lutris, get_gog_config_from_path, get_gog_game_path from lutris.util.log import logger from lutris.util.moddb import ModDB, is_moddb_url from lutris.util.system import fix_path_case class LutrisInstaller: # pylint: disable=too-many-instance-attributes """Represents a Lutris installer""" def __init__(self, installer, interpreter, service, appid): self.interpreter = interpreter self.installer = installer self.is_update = False 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.extra_file_paths = [] self.requires = self.script.get("requires") self.extends = self.script.get("extends") self.game_id = self.get_game_id() self.is_gog = False self.discord_id = installer.get("discord_id") def get_service(self, initial=None): if initial: return initial if "steam" in self.runner and "steam" in SERVICES: return SERVICES["steam"]() version = self.version.lower() if "humble" in version and "humblebundle" in SERVICES: return SERVICES["humblebundle"]() if "gog" in version and "gog" in SERVICES: return SERVICES["gog"]() if "itch.io" in version and "itchio" in SERVICES: return SERVICES["itchio"]() def get_appid(self, installer, initial=None): if installer.get("is_dlc"): return installer.get("dlcid") if initial: return initial if not self.service: return service_id = None if self.service.id == "steam": service_id = installer.get("steamid") or installer.get("service_id") game_config = self.script.get("game", {}) if self.service.id == "gog": service_id = game_config.get("gogid") or installer.get("gogid") or installer.get("service_id") if self.service.id == "humblebundle": service_id = game_config.get("humbleid") or installer.get("humblestoreid") or installer.get("service_id") if self.service.id == "itchio": service_id = game_config.get("itchid") or installer.get("itchid") or installer.get("service_id") if service_id: return service_id return @property def script_pretty(self): """Return a pretty print of the script""" return json.dumps(self.script, indent=4) def get_game_id(self): """Return the ID of the game in the local DB if one exists""" # If the game is in the library and uninstalled, the first installation # updates it existing_game = get_game_by_field(self.game_slug, "slug") if existing_game and (self.extends or not existing_game["installed"]): return existing_game["id"] @property def creates_game_folder(self): """Determines if an install script should create a game folder for the game""" if self.requires or self.extends: # Game is an extension of an existing game, folder exists return False if self.runner == "steam": # Steam games installs in their steamapps directory return False if not self.script.get("installer"): # No command can affect files return False if self.script_files or self.script.get("game", {}).get("gog") or self.script.get("game", {}).get("prefix"): return True command_names = [self.interpreter._get_command_name_and_params(c)[0] for c in self.script.get("installer", [])] if "insert_disc" in command_names: return True return False def get_errors(self): """Return potential errors in the script""" errors = [] if not isinstance(self.script, dict): errors.append("Script must be a dictionary") # Return early since the method assumes a dict return errors # Check that installers contains all required fields for field in ("runner", "game_name", "game_slug"): if not hasattr(self, field) or not getattr(self, field): errors.append("Missing field '%s'" % field) # Check that libretro installers have a core specified if self.runner == "libretro": if "game" not in self.script or "core" not in self.script["game"]: errors.append("Missing libretro core in game section") # Check that Steam games have an AppID if self.runner == "steam": if not self.script.get("game", {}).get("appid"): errors.append("Missing appid for Steam game") # Check that installers don't contain both 'requires' and 'extends' if self.script.get("requires") and self.script.get("extends"): errors.append("Scripts can't have both extends and requires") return errors def prepare_game_files(self, extras, patch_version=None): """Gathers necessary files before iterating through them.""" if not self.script_files: return installer_file_id = None installer_file_url = None if self.service: for file in self.script_files: if file.url.startswith("N/A"): installer_file_id = file.id installer_file_url = file.url break files = [file.copy() for file in self.script_files if file.id != installer_file_id] extra_file_paths = [] # Run variable substitution on the URLs from the script for file in files: file.set_url(self.interpreter._substitute(file.url)) if is_moddb_url(file.url): file.set_url(ModDB().transform_url(file.url)) if installer_file_id and self.service: logger.info("Getting files for %s", installer_file_id) try: if patch_version: # If a patch version is given download the patch files instead of the installer installer_files = self.service.get_patch_files(self, installer_file_id) else: content_files, extra_files = self.service.get_installer_files(self, installer_file_id, extras) extra_file_paths = [path for f in extra_files for path in f.get_dest_files_by_id().values()] installer_files = content_files + extra_files except UnavailableGameError as ex: logger.error("Game not available: %s", ex) installer_files = None if installer_files: for installer_file in installer_files: files.append(installer_file) else: # Failed to get the service game, put back a user provided file logger.debug("Unable to get files from service. Setting %s to manual.", installer_file_id) files.insert( 0, InstallerFile(self.game_slug, installer_file_id, {"url": installer_file_url, "filename": ""}) ) # Commit changes only at the end; this is more robust in this method is runner # my two threads concurrently- the GIL can probably save us. It's not desirable # to do this, but this is the easiest workaround. self.files = files self.extra_file_paths = extra_file_paths def install_extras(self): # Copy extras to game folder; this updates the installer script, so it needs # be called just once, before launching the installers commands. if self.extra_file_paths and len(self.extra_file_paths) == len(self.files): # Reset the install script in case there are only extras. logger.warning("Installer with only extras and no game files") self.script["installer"] = [] for extra_file in self.extra_file_paths: self.script["installer"].append({"copy": {"src": extra_file, "dst": "$GAMEDIR/extras"}}) def _substitute_config(self, script_config): """Substitute values such as $GAMEDIR in a config dict.""" config = {} for key in script_config: if not isinstance(key, str): raise ScriptingError(_("Game config key must be a string"), key) value = script_config[key] if str(value).lower() == "true": value = True if str(value).lower() == "false": value = False if key == "launch_configs": config[key] = [{k: self.interpreter._substitute(v) for (k, v) in _conf.items()} for _conf in value] elif isinstance(value, list): config[key] = [self.interpreter._substitute(i) for i in value] elif isinstance(value, dict): config[key] = {k: self.interpreter._substitute(v) for (k, v) in value.items()} elif isinstance(value, bool): config[key] = value else: config[key] = self.interpreter._substitute(value) return config def get_game_config(self): """Return the game configuration""" if self.requires: # Load the base game config required_game = get_game_by_field(self.requires, field="installer_slug") if not required_game: required_game = get_game_by_field(self.requires, field="slug") if not required_game: raise ValueError("No game matched '%s' on installer_slug or slug" % self.requires) base_config = LutrisConfig(runner_slug=self.runner, game_config_id=required_game["configpath"]) config = base_config.game_level else: config = {"game": {}} # Config update if "system" in self.script: config["system"] = self._substitute_config(self.script["system"]) if self.script.get(self.runner): installer_runner_config = self._substitute_config(self.script[self.runner]) import_runner(self.runner)().adjust_installer_runner_config(installer_runner_config) config[self.runner] = installer_runner_config 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()) self.game_id = add_or_update( name=self.game_name, runner=self.runner, slug=self.game_slug, platform=import_runner(self.runner)().get_platform(), directory=self.interpreter.target_path, installed=1, installer_slug=self.slug, parent_slug=self.requires, year=self.year, configpath=configpath, service=self.service.id if self.service else None, service_id=self.service_appid, id=self.game_id, discord_id=self.discord_id, ) return self.game_id lutris-0.5.17/lutris/installer/installer_file.py000066400000000000000000000226401460562010500220160ustar00rootroot00000000000000"""Manipulates installer files""" import os from gettext import gettext as _ from urllib.parse import urlparse from lutris.cache import get_cache_path, has_custom_cache_path, save_to_cache from lutris.gui.widgets.download_progress_box import DownloadProgressBox from lutris.installer.errors import ScriptingError from lutris.util import system from lutris.util.log import logger from lutris.util.strings import gtk_safe_urls class InstallerFile: """Representation of a file in the `files` sections of an installer""" def __init__(self, game_slug, file_id, file_meta, 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 gtk_safe_urls(label) @property def cached_filename(self): """Return the filename of the first file in the cache path""" cache_files = os.listdir(self.cache_path) if cache_files: return cache_files[0] return "" @property def default_provider(self): """Return file provider used""" if self.url.startswith("$STEAM"): return "steam" if self.is_cached: return "pga" if self.url.startswith("N/A"): return "user" if self.is_downloadable(): return "download" raise ValueError("Unsupported provider for %s" % self.url) @property def providers(self): """Return all supported providers""" _providers = set() if self.url.startswith("$STEAM"): _providers.add("steam") if self.is_cached: _providers.add("pga") if self.url.startswith("N/A"): _providers.add("user") if self.is_downloadable(): _providers.add("download") return _providers def is_downloadable(self): """Return True if the file can be downloaded (even from the local filesystem)""" return self.url.startswith(("http", "file")) def uses_pga_cache(self): """Determines whether the installer files are stored in a PGA cache Returns: bool """ if self.url.startswith("N/A"): return False return has_custom_cache_path() @property def is_user_pga_caching_allowed(self): """Returns true if this file can be transferred to the cache, if the user provides it.""" return self.uses_pga_cache() @property def cache_path(self): """Return the directory used as a cache for the duration of the installation""" _cache_path = get_cache_path() 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, and if we're using our own installer cache, we need to unsure that directory exists.""" 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.delete_folder(self.dest_file) else: os.remove(self.dest_file) lutris-0.5.17/lutris/installer/installer_file_collection.py000066400000000000000000000134141460562010500242300ustar00rootroot00000000000000"""Manipulates installer files""" import os from gettext import gettext as _ from urllib.parse import urlparse from lutris.cache import get_cache_path, has_custom_cache_path from lutris.gui.widgets.download_collection_progress_box import DownloadCollectionProgressBox from lutris.util import system from lutris.util.strings import gtk_safe_urls AMAZON_DOMAIN = "a2z.com" class InstallerFileCollection: """Representation of a collection of files in the `files` sections of an installer. Store files in a folder""" def __init__(self, game_slug, file_id, files_list, 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 gtk_safe_urls(self.game_slug) @property def default_provider(self): """Return file provider used. File Collection only supports 'pga' and 'download'""" if self.is_cached: return "pga" return "download" @property def providers(self): """Return all supported providers. File Collection only supports 'pga' and 'download'""" _providers = set() if self.is_cached: _providers.add("pga") _providers.add("download") return _providers def uses_pga_cache(self): """Determines whether the installer files are stored in a PGA cache Returns: bool """ return has_custom_cache_path() @property def is_user_pga_caching_allowed(self): return len(self.files_list) == 1 and self.files_list[0].is_user_pga_caching_allowed @property def cache_path(self): """Return the directory used as a cache for the duration of the installation""" _cache_path = get_cache_path() 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.17/lutris/installer/interpreter.py000066400000000000000000000466551460562010500214010ustar00rootroot00000000000000"""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 MisconfigurationError from lutris.gui.dialogs.delegates import Delegate from lutris.installer import AUTO_EXE_PREFIX from lutris.installer.commands import CommandsMixin from lutris.installer.errors import MissingGameDependencyError, ScriptingError from lutris.installer.installer import LutrisInstaller from lutris.runners import NonInstallableRunnerError, RunnerInstallationError, steam, wine from lutris.services.lutris import download_lutris_media from lutris.util import system from lutris.util.display import DISPLAY_MANAGER from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.strings import unpack_dependencies class ScriptInterpreter(GObject.Object, CommandsMixin): """Control the execution of an installer""" __gsignals__ = { "runners-installed": (GObject.SIGNAL_RUN_FIRST, None, ()), } class InterpreterUIDelegate(Delegate): """This is a base class for objects that provide UI services for running scripts. The InstallerWindow inherits from this.""" def __init__(self, service=None, appid=None): self.service = service self.appid = appid def report_error(self, error): """Called to report an error during installation. The installation will then stop.""" 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.game_disc = None self.game_files = {} self.cancelled = False self.abort_current_task = None self.user_inputs = [] self.current_command = 0 # Current installer command when iterating through them self.runners_to_install = [] self.current_resolution = DISPLAY_MANAGER.get_current_resolution() self.installer = LutrisInstaller(installer, self, service=self.service, appid=_appid) if not self.installer.script: raise ScriptingError(_("This installer doesn't have a 'script' section")) if not self.service and self.installer.service: self.service = self.installer.service script_errors = self.installer.get_errors() if script_errors: raise ScriptingError(_("Invalid script: \n{}").format("\n".join(script_errors)), self.installer.script) self._check_binary_dependencies() self._check_dependency() if self.installer.creates_game_folder: self.target_path = self.get_default_target() def on_timeout_error(self, error): self.interpreter_ui_delegate.report_error(error) def on_idle_error(self, error): self.interpreter_ui_delegate.report_error(error) def on_signal_error(self, error): self.interpreter_ui_delegate.report_error(error) def on_emission_hook_error(self, error): self.interpreter_ui_delegate.report_error(error) @property def appid(self): logger.warning("Do not access appid from interpreter") return self.installer.service_appid def get_default_target(self): """Return default installation dir""" config = LutrisConfig(runner_slug=self.installer.runner) games_dir = config.system_config.get("game_path", os.path.expanduser("~")) if self.service: service_dir = self.service.id else: service_dir = "" return os.path.expanduser(os.path.join(games_dir, service_dir, self.installer.game_slug)) @property def cache_path(self): """Return the directory used as a cache for the duration of the installation""" return os.path.join(settings.INSTALLER_CACHE_DIR, "%s" % self.installer.game_slug) @property def script_env(self): """Return the script's own environment variable with values susbtituted. This value can be used to provide the same environment variable as set for the game during the install process. """ return { key: self._substitute(value) for key, value in self.installer.script.get("system", {}).get("env", {}).items() } @staticmethod def _get_game_dependency(dependency): """Return a game database row from a dependency name""" game = get_game_by_field(dependency, field="installer_slug") if not game: game = get_game_by_field(dependency, "slug") # Game must be installed and have a directory # set so we can use that as the destination if game and game["installed"] and game["directory"]: return game def _check_binary_dependencies(self): """Check if all required binaries are installed on the system. This reads a `require-binaries` entry in the script, parsed the same way as the `requires` entry. """ binary_dependencies = unpack_dependencies(self.installer.script.get("require-binaries")) for dependency in binary_dependencies: if isinstance(dependency, tuple): installed_binaries = { dependency_option: system.can_find_executable(dependency_option) for dependency_option in dependency } if not any(installed_binaries.values()): raise ScriptingError(_("This installer requires %s on your system") % _(" or ").join(dependency)) else: if not system.can_find_executable(dependency): raise ScriptingError(_("This installer requires %s on your system") % dependency) def _check_dependency(self): """When a game is a mod or an extension of another game, check that the base game is installed. If the game is available, install the game in the base game folder. The first game available listed in the dependencies is the one picked to base the installed on. """ if self.installer.extends: dependencies = [self.installer.extends] else: dependencies = unpack_dependencies(self.installer.requires) error_message = _("You need to install {} before") for index, dependency in enumerate(dependencies): if isinstance(dependency, tuple): installed_games = [dep for dep in [self._get_game_dependency(dep) for dep in dependency] if dep] if not installed_games: if len(dependency) == 1: raise MissingGameDependencyError(slug=dependency) raise ScriptingError(error_message.format(_(" or ").join(dependency))) if index == 0: self.target_path = installed_games[0]["directory"] self.requires = installed_games[0]["installer_slug"] else: game = self._get_game_dependency(dependency) if not game: raise MissingGameDependencyError(slug=dependency) if index == 0: self.target_path = game["directory"] self.requires = game["installer_slug"] def get_extras(self): """Get extras and store them to move them at the end of the install""" if not self.service or not self.service.has_extras or not self.installer.service_appid: return [] 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: if not runner.is_installed_for(self): 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 # install_runner calls back into this method to get the next one self.emit("runners-installed") def install_runner(self, runner, ui_delegate): """Install runner required by the install script""" def install_more_runners(): self.install_runners(ui_delegate) logger.debug("Installing %s", runner.name) try: runner.install( ui_delegate, version=runner.get_installer_runner_version(self.installer), callback=install_more_runners, ) except (NonInstallableRunnerError, RunnerInstallationError) as ex: logger.error(ex.message) raise ScriptingError(ex.message) from ex def launch_installer_commands(self): """Run the pre-installation steps and launch install.""" self.create_game_folder() os.makedirs(self.cache_path, exist_ok=True) self._iter_commands() def _iter_commands(self, result=None, exception=None): if result == "STOP" or self.cancelled: return try: commands = self.installer.script.get("installer", []) if exception: logger.error("Last install command failed, show error") self.interpreter_ui_delegate.report_error(exception) elif self.current_command < len(commands): try: command = commands[self.current_command] except KeyError as err: raise ScriptingError(_("Installer commands are not formatted correctly")) from err self.current_command += 1 method, params = self._map_command(command) if isinstance(params, dict): status_text = params.pop("description", None) else: status_text = None if status_text: self.interpreter_ui_delegate.report_status(status_text) logger.debug("Installer command: %s", command) if self.target_path and os.path.exists(self.target_path): # Establish a CWD for the command, but remove it afterwards # for safety. We'd better not rely on this, many tasks can be # fiddling with the CWD at the same time. def dispatch(): prev_cwd = os.getcwd() os.chdir(self.target_path) try: return method(params) finally: os.chdir(prev_cwd) AsyncCall(dispatch, self._iter_commands) else: AsyncCall(method, self._iter_commands, params) else: logger.debug("Commands %d out of %s completed", self.current_command, len(commands)) self._finish_install() except Exception as ex: # Redirect errors to the delegate, instead of the default ErrorDialog. self.interpreter_ui_delegate.report_error(ex) @staticmethod def _get_command_name_and_params(command_data): if isinstance(command_data, dict): command_name = list(command_data.keys())[0] command_params = command_data[command_name] else: command_name = command_data command_params = {} command_name = command_name.replace("-", "_") # Prevent private methods from being accessed as commands command_name = command_name.strip("_") return command_name, command_params def _map_command(self, command_data): """Map a directive from the `installer` section to an internal method.""" command_name, command_params = self._get_command_name_and_params(command_data) if not hasattr(self, command_name): raise ScriptingError(_('The command "%s" does not exist.') % command_name) return getattr(self, command_name), command_params def _finish_install(self): game_id = self.installer.save() path = None if path and AUTO_EXE_PREFIX not in path and not os.path.isfile(path) and self.installer.runner != "web": status = ( _( "The executable at path %s can't be found, please check the destination folder.\n" "Some parts of the installation process may have not completed successfully." ) % path ) logger.warning("No executable found at specified location %s", path) else: status = self.installer.script.get("install_complete_text") or _("Installation completed!") AsyncCall(download_lutris_media, None, self.installer.game_slug) self.interpreter_ui_delegate.report_finished(game_id, status) def cleanup(self): """Clean up install dir after a successful install""" os.chdir(os.path.expanduser("~")) system.delete_folder(self.cache_path) def revert(self, remove_game_dir=True): """Revert installation in case of an error""" logger.info("Cancelling installation of %s", self.installer.game_name) if self.installer.runner.startswith("wine"): self.task({"name": "winekill"}) self.cancelled = True if self.abort_current_task: self.abort_current_task() if self.target_path and remove_game_dir: system.remove_folder(self.target_path) def _get_string_replacements(self): """Return a mapping of variables to their actual value""" current_res = self.current_resolution replacements = { "GAMEDIR": self.target_path, "CACHE": self.cache_path, "HOME": os.path.expanduser("~"), "STEAM_DATA_DIR": steam.steam().steam_data_dir, "DISC": self.game_disc, "USER": os.getenv("USER"), "INPUT": self.user_inputs[-1]["value"] if self.user_inputs else "", "VERSION": self.installer.version, "RESOLUTION": "x".join(current_res), "RESOLUTION_WIDTH": current_res[0], "RESOLUTION_HEIGHT": current_res[1], } try: replacements["RESOLUTION_WIDTH_HEX"] = hex(int(current_res[0])) replacements["RESOLUTION_HEIGHT_HEX"] = hex(int(current_res[1])) except (ValueError, TypeError): pass # If we can't generate hex, just omit the vars try: replacements["WINEBIN"] = self.get_wine_path() except MisconfigurationError: pass # If we can't get the path, just omit it # None values stringify as 'None', which is not what you want, so we'll # remove then pre-emptively. This happens for game install scripts that have # no 'self.target_path'. for key in [key for key, value in replacements.items() if value is None]: del replacements[key] replacements.update(self.installer.variables) # Add 'INPUT_' replacements for user inputs with an id for input_data in self.user_inputs: alias = input_data["alias"] if alias: replacements[alias] = input_data["value"] replacements.update(self.game_files) return replacements def _substitute(self, template_string): """Replace path aliases with real paths.""" if template_string is None: logger.warning("No template string given") return "" if str(template_string).replace("-", "_") in self.game_files: template_string = template_string.replace("-", "_") return system.substitute(template_string, self._get_string_replacements()) def eject_wine_disc(self): """Use Wine to eject a CD, otherwise Wine can have problems detecting disc changes""" wine_path = self.get_wine_path() wine.eject_disc(wine_path, self.target_path) lutris-0.5.17/lutris/installer/steam_installer.py000066400000000000000000000100201460562010500221750ustar00rootroot00000000000000"""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.17/lutris/migrations/000077500000000000000000000000001460562010500166235ustar00rootroot00000000000000lutris-0.5.17/lutris/migrations/__init__.py000066400000000000000000000020361460562010500207350ustar00rootroot00000000000000import importlib from lutris import settings from lutris.util.log import logger MIGRATION_VERSION = 14 # 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"], ["migrate_hidden_category"], ] 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.17/lutris/migrations/mess_to_mame.py000066400000000000000000000007101460562010500216430ustar00rootroot00000000000000"""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.17/lutris/migrations/migrate_banners.py000066400000000000000000000015541460562010500223420ustar00rootroot00000000000000"""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.17/lutris/migrations/migrate_hidden_category.py000066400000000000000000000012671460562010500240430ustar00rootroot00000000000000from sqlite3 import OperationalError from lutris.database.games import get_games from lutris.game import Game from lutris.util.log import logger def migrate(): """Put all previously hidden games into the new '.hidden' category.""" logger.info("Moving hidden games to the '.hidden' category") try: game_ids = [g["id"] for g in get_games(filters={"hidden": 1})] except OperationalError: # A brand-new DB will not have the hidden column at all, # so no migration is required. return for game_id in game_ids: game = Game(game_id) game.mark_as_hidden(True) logger.info("Migrated '%s' to '.hidden' category.", game.name) lutris-0.5.17/lutris/migrations/migrate_hidden_ids.py000066400000000000000000000013431460562010500230000ustar00rootroot00000000000000"""Move hidden games from settings to database""" from lutris import settings from lutris.game import Game def get_hidden_ids(): """Return a list of game IDs to be excluded from the library view""" # Load the ignore string and filter out empty strings to prevent issues ignores_raw = settings.read_setting("library_ignores").split(",") return [ignore.strip() for ignore in ignores_raw if ignore] def migrate(): """Run migration""" try: game_ids = get_hidden_ids() except: print("Failed to read hidden game IDs") return [] for game_id in game_ids: game = Game(game_id) game.mark_as_hidden(True) settings.write_setting("library_ignores", "", section="lutris") lutris-0.5.17/lutris/migrations/migrate_sortname.py000066400000000000000000000012461460562010500225400ustar00rootroot00000000000000from lutris import settings from lutris.api import get_api_games from lutris.database import sql from lutris.database.games import get_games from lutris.util.log import logger def migrate(): """Add blank sortname field to games that do not yet have one""" logger.info("Adding blank sortname field to database") slugs_to_update = [game["slug"] for game in get_games()] games = get_api_games(slugs_to_update) for game in games: if "sortname" not in game.keys() or game["sortname"] is None: sql.db_update(settings.DB_PATH, "games", {"sortname": ""}, {"slug": game["slug"]}) logger.info("Added blank sortname for %s", game["name"]) lutris-0.5.17/lutris/migrations/migrate_steam_appids.py000066400000000000000000000010101460562010500233460ustar00rootroot00000000000000"""Set service ID for Steam games""" from lutris import settings from lutris.database.games import get_games, sql def migrate(): """Run migration""" for game in get_games(): if not game.get("steamid"): continue if game["runner"] and game["runner"] != "steam": continue print("Migrating Steam game %s" % game["name"]) sql.db_update( settings.DB_PATH, "games", {"service": "steam", "service_id": game["steamid"]}, {"id": game["id"]} ) lutris-0.5.17/lutris/migrations/retrieve_discord_appids.py000066400000000000000000000012471460562010500240750ustar00rootroot00000000000000from lutris import settings from lutris.api import get_api_games from lutris.database.games import get_games, sql from lutris.util.log import logger def migrate(): """ Update Games that does not have a Discord ID """ logger.info("Updating Games Discord APP ID's") # Get Slugs from all games slugs_to_update = [game["slug"] for game in get_games()] # Retrieve game data games = get_api_games(slugs_to_update) for game in games: if not game["discord_id"]: continue sql.db_update(settings.DB_PATH, "games", {"discord_id": game["discord_id"]}, {"slug": game["slug"]}) logger.info("Updated %s", game["name"]) lutris-0.5.17/lutris/monitored_command.py000066400000000000000000000241741460562010500205270ustar00rootroot00000000000000"""Threading module, used to launch games while monitoring them.""" import contextlib import fcntl import io import os import shlex import subprocess import sys import uuid from copy import copy from 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_required_executable(self.terminal) script_path = get_terminal_script(self.command, self.cwd, self.env) return wrapper_command + [terminal_path, "-e", script_path] def set_log_buffer(self, log_buffer): """Attach a TextBuffer to this command enables the buffer handler""" if not log_buffer: return self.log_buffer = log_buffer if self.log_handler_buffer not in self.log_handlers: self.log_handlers.append(self.log_handler_buffer) def get_cwd(self, cwd): """Return the current working dir of the game""" if not cwd: cwd = self.runner.working_dir if self.runner else None return os.path.expanduser(cwd or "~") @staticmethod def get_environment(user_env): """Process the user provided environment variables for use as self.env""" env = copy(user_env) if user_env else {} # not clear why this needs to be added, the path is already added in # the wrappper script. env["PYTHONPATH"] = ":".join(sys.path) # Drop bad values of environment keys, those will confuse the Python # interpreter. env["LUTRIS_GAME_UUID"] = str(uuid.uuid4()) cleaned = {} for key, value in env.items(): if "=" in key: logger.warning("Environment variable name '%s' contains '=' so it can't be used; skipping.", key) elif value is None: logger.warning("Environment variable '%s' has None for its value; skipping.", key) elif not isinstance(value, str): logger.warning("Environment variable '%s' value '%s' is not a string; converting.", key, value) cleaned[key] = str(value) else: cleaned[key] = value return cleaned def get_child_environment(self): """Returns the calculated environment for the child process.""" env = system.get_environment() env.update(self.env) return env def start(self): """Run the thread.""" if os.environ.get("LUTRIS_DEBUG_ENV") == "1": for key, value in self.env.items(): logger.debug('%s="%s"', key, value) wrapper_command = self.get_wrapper_command() env = self.get_child_environment() self.game_process = self.execute_process(wrapper_command, env) if not self.game_process: logger.error("No game process available") return GLib.child_watch_add(self.game_process.pid, self.on_stop) # make stdout nonblocking. fileno = self.game_process.stdout.fileno() fcntl.fcntl(fileno, fcntl.F_SETFL, fcntl.fcntl(fileno, fcntl.F_GETFL) | os.O_NONBLOCK) self.stdout_monitor = GLib.io_add_watch( self.game_process.stdout, GLib.IO_IN | GLib.IO_HUP, self.on_stdout_output, ) def log_filter(self, line: str) -> bool: """Filter out some message we don't want to show to the user.""" if "GStreamer-WARNING **" in line: return False if "Bad file descriptor" in line: return False if "'libgamemodeauto.so.0' from LD_PRELOAD" in line: return False if "Unable to read VR Path Registry" in line: return False return True def log_handler_stdout(self, line): """Add the line to this command's stdout attribute""" if not self.log_filter(line): return self._stdout.write(line) def log_handler_buffer(self, line): """Add the line to the associated LogBuffer object""" self.log_buffer.insert(self.log_buffer.get_end_iter(), line, -1) def log_handler_console_output(self, line): """Print the line to stdout""" if not self.log_filter(line): return with contextlib.suppress(BlockingIOError): sys.stdout.write(line) sys.stdout.flush() def get_return_code(self): """Get the return code from the file written by the wrapper""" return_code_path = "/tmp/lutris-%s" % self.env["LUTRIS_GAME_UUID"] if os.path.exists(return_code_path): with open(return_code_path, encoding="utf-8") as return_code_file: return_code = return_code_file.read() os.unlink(return_code_path) else: return_code = "" logger.warning("No file %s", return_code_path) return return_code def on_stop(self, pid, _user_data): """Callback registered on game process termination""" if self.prevent_on_stop: # stop() already in progress return False self.game_process.wait() self.return_code = self.get_return_code() self.is_running = False logger.debug("Process %s has terminated with code %s", pid, self.return_code) resume_stop = self.stop() if not resume_stop: logger.info("Full shutdown prevented") return False return False def on_stdout_output(self, stdout, condition): """Called by the stdout monitor to dispatch output to log handlers""" if condition == GLib.IO_HUP: self.stdout_monitor = None return False if not self.is_running: return False try: line = stdout.read(262144).decode("utf-8", errors="ignore") except ValueError: # file_desc might be closed return True if "winemenubuilder.exe" in line: return True for log_handler in self.log_handlers: log_handler(line) return True def execute_process(self, command, env=None): """Execute and return a subprocess""" if 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.17/lutris/runner_interpreter.py000066400000000000000000000163671460562010500207720ustar00rootroot00000000000000"""Transform runner parameters to data usable for runtime execution""" import os import shlex import stat from lutris.util import cache_single, system from lutris.util.graphics.gpu import GPUS 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.can_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 = {} # 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"] # MangoHud if runner.name == "steam": logger.info( "Do not enable Mangodhud for Steam games in Lutris. " "Edit the launch options in Steam and set them to mangohud %%command%%" ) else: mango_args, mango_env = get_mangohud_conf(system_config) if mango_args: launch_arguments = mango_args + launch_arguments env.update(mango_env) 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 has_gamescope = system_config.get("gamescope") and system.can_find_executable("gamescope") if has_gamescope: launch_arguments = get_gamescope_args(launch_arguments, system_config) if system_config.get("gamescope_hdr"): env["ENABLE_HDR_WSI"] = "1" return launch_arguments, env def get_gamescope_args(launch_arguments, system_config): """Insert gamescope at the start of the launch arguments""" if system_config.get("gamescope_hdr"): launch_arguments.insert(0, "DISABLE_HDR_WSI=1") launch_arguments.insert(0, "DXVK_HDR=1") launch_arguments.insert(0, "ENABLE_GAMESCOPE_WSI=1") launch_arguments.insert(0, "env") 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"): launch_arguments.insert(0, system_config["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") if system_config.get("gpu") and len(GPUS) > 1: gpu = GPUS[system_config["gpu"]] launch_arguments.insert(0, gpu.pci_id) launch_arguments.insert(0, "--prefer-vk-device") if system_config.get("gamescope_hdr"): launch_arguments.insert(0, "--hdr-debug-force-output") launch_arguments.insert(0, "--hdr-enabled") launch_arguments.insert(0, "gamescope") return launch_arguments @cache_single 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 system.can_find_executable("gamescope"): # '-F fsr' is the trigger in gamescope 3.12. stdout, stderr = system.execute_with_error(["gamescope", "--help"]) help_text = stdout + stderr 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.17/lutris/runners/000077500000000000000000000000001460562010500161435ustar00rootroot00000000000000lutris-0.5.17/lutris/runners/__init__.py000066400000000000000000000063631460562010500202640ustar00rootroot00000000000000"""Runner loaders""" __all__ = [ # Native "linux", "steam", "web", "flatpak", "zdoom", # Microsoft based "wine", "dosbox", "xemu", # Multi-system "easyrpg", "mame", "mednafen", "scummvm", "libretro", # Commodore "fsuae", "vice", # Atari "atari800", "hatari", # Nintendo "snes9x", "mupen64plus", "dolphin", "ryujinx", "yuzu", "cemu", # Sony "pcsx2", "rpcs3", "vita3k", # Sega "osmose", "reicast", "redream", # Fantasy consoles "pico8", # Misc legacy systems "jzintv", "o2em", ] from lutris.exceptions import LutrisError, MisconfigurationError ADDON_RUNNERS = {} _cached_runner_human_names = {} class InvalidRunnerError(MisconfigurationError): """Raise if a runner name is used that is not known to Lutris.""" class RunnerInstallationError(LutrisError): """Raised if the attempt to install a runner fails, perhaps because of invalid data from a server.""" class NonInstallableRunnerError(LutrisError): """Raised if installed a runner that Lutris cannot install, like Flatpak. These must be installed separately.""" def get_runner_module(runner_name): if not is_valid_runner_name(runner_name): raise InvalidRunnerError("Invalid runner name '%s'" % runner_name) module = __import__("lutris.runners.%s" % runner_name, globals(), locals(), [runner_name], 0) if not module: raise InvalidRunnerError("Runner module for '%s' could not be imported." % runner_name) return module def import_runner(runner_name): """Dynamically import a runner class.""" if runner_name in ADDON_RUNNERS: return ADDON_RUNNERS[runner_name] runner_module = get_runner_module(runner_name) return getattr(runner_module, runner_name) def import_task(runner, task): """Return a runner task.""" runner_module = get_runner_module(runner) return getattr(runner_module, task) def get_installed(sort=True): """Return a list of installed runners (class instances).""" installed = [] for runner_name in __all__: runner = import_runner(runner_name)() if runner.is_installed(): installed.append(runner) return sorted(installed) if sort else installed def inject_runners(runners): for runner_name in runners: if runner_name not in __all__: ADDON_RUNNERS[runner_name] = runners[runner_name] __all__.append(runner_name) _cached_runner_human_names.clear() def get_runner_names(): return __all__ def is_valid_runner_name(runner_name: str) -> bool: return runner_name in __all__ def get_runner_human_name(runner_name): """Returns a human-readable name for a runner; as a convenience, if the name is falsy (None or blank) this returns an empty string. Provides caching for the names.""" if runner_name: if runner_name not in _cached_runner_human_names: try: _cached_runner_human_names[runner_name] = import_runner(runner_name)().human_name except InvalidRunnerError: _cached_runner_human_names[runner_name] = runner_name # an obsolete runner return _cached_runner_human_names[runner_name] return "" lutris-0.5.17/lutris/runners/atari800.py000066400000000000000000000124231460562010500200470ustar00rootroot00000000000000import logging import os.path from gettext import gettext as _ from lutris.config import LutrisConfig from lutris.exceptions import MissingBiosError, MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import display, extract, system def get_resolutions(): try: screen_resolutions = [(resolution, resolution) for resolution in display.DISPLAY_MANAGER.get_resolutions()] except OSError: screen_resolutions = [] screen_resolutions.insert(0, (_("Desktop resolution"), "desktop")) return screen_resolutions class atari800(Runner): human_name = _("Atari800") platforms = [_("Atari 8bit computers")] # FIXME try to determine the actual computer used runner_executable = "atari800/bin/atari800" bios_url = "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_command() if self.runner_config.get("fullscreen"): arguments.append("-fullscreen") else: arguments.append("-windowed") resolution = self.runner_config.get("resolution") if resolution: if resolution == "desktop": width, height = display.DISPLAY_MANAGER.get_current_resolution() else: width, height = resolution.split("x") arguments += ["-fs-width", "%s" % width, "-fs-height", "%s" % height] if self.runner_config.get("machine"): arguments.append("-%s" % self.runner_config["machine"]) bios_path = self.runner_config.get("bios_path") if not system.path_exists(bios_path): raise MissingBiosError() good_bios = self.find_good_bioses(bios_path) for bios, filename in good_bios.items(): arguments.append("-%s" % bios) arguments.append(os.path.join(bios_path, filename)) rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments.append(rom) return {"command": arguments} lutris-0.5.17/lutris/runners/cemu.py000066400000000000000000000055511460562010500174540ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ from lutris.exceptions import DirectoryNotFoundError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class cemu(Runner): human_name = _("Cemu") platforms = [_("Wii U")] description = _("Wii U emulator") runnable_alone = True runner_executable = "cemu/Cemu" flatpak_id = "info.cemu.Cemu" game_options = [ { "option": "main_file", "type": "directory_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." ), }, { "option": "wua_rom", "type": "file", "label": _("Compressed ROM"), "help": _("A game compressed into a single file (WUA format), only use if not using game directory"), }, ] runner_options = [ { "option": "fullscreen", "label": _("Fullscreen"), "type": "bool", "default": True, }, { "option": "mlc", "label": _("Custom mlc folder location"), "type": "directory_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): raise DirectoryNotFoundError(directory=mlc) arguments += ["-m", mlc] ud = self.runner_config.get("ud") if ud: arguments.append("-u") nsight = self.runner_config.get("nsight") if nsight: arguments.append("--nsight") legacy = self.runner_config.get("legacy") if legacy: arguments.append("--legacy") gamedir = self.game_config.get("main_file") or self.game_config.get("wua_rom") or "" if not system.path_exists(gamedir): raise DirectoryNotFoundError(directory=gamedir) arguments += ["-g", gamedir] return {"command": arguments} lutris-0.5.17/lutris/runners/commands/000077500000000000000000000000001460562010500177445ustar00rootroot00000000000000lutris-0.5.17/lutris/runners/commands/__init__.py000066400000000000000000000000001460562010500220430ustar00rootroot00000000000000lutris-0.5.17/lutris/runners/commands/dosbox.py000066400000000000000000000033411460562010500216150ustar00rootroot00000000000000"""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.17/lutris/runners/commands/wine.py000066400000000000000000000405571460562010500212730ustar00rootroot00000000000000"""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.monitored_command import MonitoredCommand from lutris.runners import import_runner from lutris.util import linux, system from lutris.util.log import logger from lutris.util.shell import get_shell_command from lutris.util.strings import split_arguments from lutris.util.wine import proton from lutris.util.wine.cabinstall import CabInstaller from lutris.util.wine.prefix import WinePrefixManager from lutris.util.wine.wine import ( WINE_DEFAULT_ARCH, WINE_DIR, detect_arch, get_overrides_env, get_real_executable, is_installed_systemwide, is_prefix_directory, ) GE_PROTON_LATEST = _("GE-Proton (Latest)") 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( prefix, wine_path=None, arch=WINE_DEFAULT_ARCH, overrides=None, install_gecko=None, install_mono=None, runner=None, env=None, ): """Create a new Wine prefix.""" # pylint: disable=too-many-locals if overrides is None: overrides = {} if not prefix: raise ValueError("No Wine prefix path given") prefix = os.path.expanduser(prefix) logger.info("Creating a %s prefix in %s", arch, prefix) # Follow symlinks, don't delete existing ones as it would break some setups if os.path.islink(prefix): prefix = os.readlink(prefix) # Avoid issue of 64bit Wine refusing to create win32 prefix # over an existing empty folder. if os.path.isdir(prefix) and not os.listdir(prefix): try: os.rmdir(prefix) except OSError: logger.error("Failed to delete %s, you may lack permissions on this folder.", prefix) if not wine_path: if not runner: runner = import_runner("wine")() wine_path = runner.get_executable() logger.info("Winepath: %s", wine_path) if not proton.is_proton_path(wine_path): 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" if not proton.is_proton_path(wine_path): system.execute([wineboot_path], env=wineenv) else: wineenv["GAMEID"] = proton.DEFAULT_GAMEID wineenv["ULWGL_LOG"] = "debug" wineenv["WINEARCH"] = "win64" wineenv["PROTONPATH"] = proton.get_proton_path_from_bin(wine_path) command = MonitoredCommand([proton.get_umu_path(), "createprefix"], env=wineenv) command.start() for loop_index in range(1000): time.sleep(0.5) if ( system.path_exists(os.path.join(prefix, "user.reg")) and system.path_exists(os.path.join(prefix, "userdef.reg")) and system.path_exists(os.path.join(prefix, "system.reg")) ): break if loop_index == 60: logger.warning("Wine prefix creation is taking longer than expected...") if not os.path.exists(os.path.join(prefix, "user.reg")): logger.error("No user.reg found after prefix creation. Prefix might not be valid") return logger.info("%s Prefix created in %s", arch, prefix) prefix_manager = WinePrefixManager(prefix) prefix_manager.setup_defaults() def winekill(prefix, arch=WINE_DEFAULT_ARCH, wine_path=None, env=None, initial_pids=None, runner=None): """Kill processes in Wine prefix.""" initial_pids = initial_pids or [] steam_data_dir = os.path.expanduser("~/.local/share/Steam/compatibilitytools.d") if not env: env = {"WINEARCH": arch, "WINEPREFIX": prefix} if wine_path == GE_PROTON_LATEST and os.path.exists(f"{steam_data_dir}/UMU-Latest"): proton_version = os.path.realpath(f"{steam_data_dir}/UMU-Latest") command = [os.path.join(proton_version, "files", "bin", "wineserver"), "-k"] env["GAMEID"] = proton.DEFAULT_GAMEID env["WINEPREFIX"] = prefix else: if not wine_path: if not runner: runner = import_runner("wine")() wine_path = runner.get_executable() wine_root = os.path.dirname(wine_path) command = [os.path.join(wine_root, "wineserver"), "-k"] logger.debug("Killing all wine processes (%s) in prefix %s: %s", initial_pids, prefix, command) logger.debug(command) logger.debug(" ".join(command)) system.execute(command, env=env, quiet=True) logger.debug("Waiting for wine processes to terminate") # Wineserver needs time to terminate processes num_cycles = 0 while True: num_cycles += 1 running_processes = [pid for pid in initial_pids if system.path_exists("/proc/%s" % pid)] if not running_processes: break if num_cycles > 20: logger.warning( "Some wine processes are still running: %s", ", ".join(running_processes), ) break time.sleep(0.1) logger.debug("Done waiting.") def use_lutris_runtime(wine_path, force_disable=False): """Returns whether to use the Lutris runtime. The runtime can be forced to be disabled, otherwise it's disabled automatically if Wine is installed system wide. """ if force_disable or runtime.RUNTIME_DISABLED: return False if WINE_DIR in wine_path: return True if is_installed_systemwide(): return False return True # pragma pylint: disable=too-many-locals def wineexec( executable, args="", wine_path=None, prefix=None, arch=None, working_dir=None, winetricks_wine="", blocking=False, config=None, include_processes=None, exclude_processes=None, disable_runtime=False, env=None, overrides=None, runner=None, ): """ Execute a Wine command. Args: executable (str): wine program to run, pass None to run wine itself args (str): program arguments wine_path (str): path to the wine version to use prefix (str): path to the wine prefix to use arch (str): wine architecture of the prefix working_dir (str): path to the working dir for the process winetricks_wine (str): path to the wine version used by winetricks blocking (bool): if true, do not run the process in a thread config (LutrisConfig): LutrisConfig object for the process context watch (list): list of process names to monitor (even when in a ignore list) runner (runner): the wine runner that carries the configuration to use Returns: Process results if the process is running in blocking mode or MonitoredCommand instance otherwise. """ if env is None: env = {} if exclude_processes is None: exclude_processes = [] if include_processes is None: include_processes = [] executable = str(executable) if executable else "" if isinstance(include_processes, str): include_processes = shlex.split(include_processes) if isinstance(exclude_processes, str): exclude_processes = shlex.split(exclude_processes) if not runner: runner = import_runner("wine")(prefix=prefix, working_dir=working_dir, wine_arch=arch) if not wine_path: wine_path = runner.get_executable() if not working_dir: if os.path.isfile(executable): working_dir = os.path.dirname(executable) executable, _args, working_dir = get_real_executable(executable, working_dir) if _args: args = '{} "{}"'.format(_args[0], _args[1]) # Create prefix if necessary if arch not in ("win32", "win64"): arch = detect_arch(prefix, wine_path) if not is_prefix_directory(prefix): wine_bin = winetricks_wine if winetricks_wine 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) # TODO: Move this to somewhere that a reference to the game object if proton.is_proton_path(wine_path): game = None wineenv["GAMEID"] = proton.get_game_id(game) wineenv["PROTONPATH"] = proton.get_proton_path_from_bin(wine_path) baseenv = runner.get_env(disable_runtime=disable_runtime) baseenv.update(wineenv) baseenv.update(env) if proton.is_proton_path(wine_path): wine_path = proton.get_umu_path() 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_required_executable("winetricks") working_dir = None else: # We will use our own zenity if available, which is here, and it # also needs a data file in this directory. We have to set the # working_dir, so it will find the data file. working_dir = os.path.join(settings.RUNTIME_DIR, "winetricks") if not env: env = {} path = env.get("PATH", os.environ["PATH"]) env["PATH"] = "%s:%s" % (working_dir, path) return (winetricks_path, working_dir, env) def winetricks( app, prefix=None, arch=None, silent=True, wine_path=None, config=None, env=None, disable_runtime=False, system_winetricks=False, runner=None, ): """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() # We only need to perform winetricks if not using umu/proton. umu uses protonfixes if proton.is_proton_path(wine_path): logger.warning("Winetricks is currently not supported with Proton") return 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.spawn([terminal, "-e", shell_command]) lutris-0.5.17/lutris/runners/dolphin.py000066400000000000000000000043501460562010500201540ustar00rootroot00000000000000"""Dolphin runner""" from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system PLATFORMS = [_("Nintendo GameCube"), _("Nintendo Wii")] class dolphin(Runner): description = _("GameCube and Wii emulator") human_name = _("Dolphin") platforms = PLATFORMS require_libs = [ "libOpenGL.so.0", ] runnable_alone = True runner_executable = "dolphin/dolphin-emu" flatpak_id = "org.DolphinEmu.dolphin-emu" game_options = [ { "option": "main_file", "type": "file", "default_path": "game_path", "label": _("ISO file"), }, { "option": "platform", "type": "choice", "label": _("Platform"), "choices": ((_("Nintendo GameCube"), "0"), (_("Nintendo Wii"), "1")), }, ] runner_options = [ { "option": "batch", "type": "bool", "label": _("Batch"), "default": True, "advanced": True, "help": _("Exit Dolphin with emulator."), }, { "option": "user_directory", "type": "directory_chooser", "warn_if_non_writable_parent": True, "advanced": True, "label": _("Custom Global User Directory"), }, ] def get_platform(self): selected_platform = self.game_config.get("platform") if selected_platform: return self.platforms[int(selected_platform)] return "" def play(self): command = self.get_command() # Batch isn't available in nogui if self.runner_config.get("batch"): command.append("--batch") # Custom Global User Directory if self.runner_config.get("user_directory"): command.append("-u") command.append(self.runner_config["user_directory"]) # Retrieve the path to the file iso = self.game_config.get("main_file") or "" if not system.path_exists(iso): raise MissingGameExecutableError(filename=iso) command.extend(["-e", iso]) return {"command": command} lutris-0.5.17/lutris/runners/dosbox.py000066400000000000000000000112221460562010500200110ustar00rootroot00000000000000# Standard Library import os import shlex from gettext import gettext as _ from lutris import settings from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.commands.dosbox import dosexec, makeconfig # NOQA pylint: disable=unused-import from lutris.runners.runner import Runner from lutris.util import system class dosbox(Runner): human_name = _("DOSBox") description = _("MS-DOS emulator") platforms = [_("MS-DOS")] runnable_alone = True runner_executable = "dosbox/dosbox" flatpak_id = "io.github.dosbox-staging" game_options = [ { "option": "main_file", "type": "file", "label": _("Main file"), "help": _( "The CONF, EXE, COM or BAT file to launch.\n" "If the executable is managed in the config file, this should be the config file, " "instead specifying it in 'Configuration file'." ), }, { "option": "config_file", "type": "file", "label": _("Configuration file"), "help": _( "Start DOSBox with the options specified in this file. \n" "It can have a section in which you can put commands " "to execute on startup. Read DOSBox's documentation " "for more information." ), }, { "option": "args", "type": "string", "label": _("Command line arguments"), "help": _("Command line arguments used when launching DOSBox"), "validator": shlex.split, }, { "option": "working_dir", "type": "directory_chooser", "label": _("Working directory"), "warn_if_non_writable_parent": True, "help": _( "The location where the game is run from.\n" "By default, Lutris uses the directory of the " "executable." ), }, ] runner_options = [ { "option": "fullscreen", "section": _("Graphics"), "label": _("Open game in fullscreen"), "type": "bool", "default": False, "help": _("Tells DOSBox to launch the game in fullscreen."), }, { "option": "exit", "label": _("Exit DOSBox with the game"), "type": "bool", "default": True, "help": _("Shut down DOSBox when the game is quit."), }, ] def make_absolute(self, path): """Return a guaranteed absolute path""" if not path: return "" path = os.path.expanduser(path) if os.path.isabs(path): return path directory = self.game_data.get("directory") if directory: directory = os.path.expanduser(directory) return os.path.join(directory, path) return "" @property def main_file(self): return self.make_absolute(self.game_config.get("main_file")) @property def libs_dir(self): path = os.path.join(settings.RUNNER_DIR, "dosbox/lib") return path if system.path_exists(path) else "" def get_run_data(self): env = self.get_env() env["LD_LIBRARY_PATH"] = os.pathsep.join(filter(None, [self.libs_dir, env.get("LD_LIBRARY_PATH")])) return {"env": env, "command": self.get_command()} @property def working_dir(self): """Return the working directory to use when running the game.""" option = self.game_config.get("working_dir") if option: return os.path.expanduser(option) if self.main_file: return os.path.dirname(self.main_file) return super().working_dir def play(self): main_file = self.main_file if not system.path_exists(main_file): raise MissingGameExecutableError(filename=main_file) args = shlex.split(self.game_config.get("args") or "") command = self.get_command() if main_file.endswith(".conf"): command.append("-conf") command.append(main_file) else: command.append(main_file) # Options if self.game_config.get("config_file"): command.append("-conf") command.append(self.make_absolute(self.game_config["config_file"])) if self.runner_config.get("fullscreen"): command.append("-fullscreen") if self.runner_config.get("exit"): command.append("-exit") if args: command.extend(args) return {"command": command, "ld_library_path": self.libs_dir} lutris-0.5.17/lutris/runners/easyrpg.py000066400000000000000000000503261460562010500201750ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ from os import path from lutris.exceptions import DirectoryNotFoundError, GameConfigError, MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner class easyrpg(Runner): human_name = _("EasyRPG Player") description = _("Runs RPG Maker 2000/2003 games") platforms = [_("Linux")] runnable_alone = True entry_point_option = "project_path" runner_executable = "easyrpg/easyrpg-player" download_url = "https://easyrpg.org/downloads/player/0.8/easyrpg-player-0.8-linux.tar.gz" game_options = [ { "option": "project_path", "type": "directory_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"), "warn_if_non_writable_parent": True, "help": _( "Instead of storing save files in the game directory they are stored in the specified path. " "The directory must exist." ), }, { "option": "new_game", "type": "bool", "label": _("New game"), "help": _("Skip the title scene and start a new game directly."), "default": False, }, { "option": "load_game_id", "type": "range", "label": _("Load game ID"), "help": _("Skip the title scene and load SaveXX.lsd.\n" "Set to 0 to disable."), "min": 0, "max": 99, "default": 0, }, { "option": "record_input", "type": "file", "advanced": True, "label": _("Record input"), "help": _("Records all button input to the specified log file."), }, { "option": "replay_input", "type": "file", "advanced": True, "label": _("Replay input"), "help": _( "Replays button input from the specified log file, as generated by 'Record input'.\n" "If the RNG seed and the state of the save file directory is also the same as it was " "when the log was recorded, this should reproduce an identical run to the one recorded." ), }, { "option": "test_play", "type": "bool", "advanced": True, "section": _("Debug"), "label": _("Test play"), "help": _("Enable TestPlay (debug) mode."), "default": False, }, { "option": "hide_title", "type": "bool", "advanced": True, "section": _("Debug"), "label": _("Hide title"), "help": _("Hide the title background image and center the command menu."), "default": False, }, { "option": "start_map_id", "type": "range", "advanced": True, "section": _("Debug"), "label": _("Start map ID"), "help": _( "Overwrite the map used for new games and use MapXXXX.lmu instead.\n" "Set to 0 to disable.\n\n" "Incompatible with 'Load game ID'." ), "min": 0, "max": 9999, "default": 0, }, { "option": "start_position", "type": "string", "advanced": True, "section": _("Debug"), "label": _("Start position"), "help": _( "Overwrite the party start position and move the party to the specified position.\n" "Provide two numbers separated by a space.\n\n" "Incompatible with 'Load game ID'." ), }, { "option": "start_party", "type": "string", "advanced": True, "section": _("Debug"), "label": _("Start party"), "help": _( "Overwrite the starting party members with the actors with the specified IDs.\n" "Provide one to four numbers separated by spaces.\n\n" "Incompatible with 'Load game ID'." ), }, { "option": "battle_test", "type": "string", "advanced": True, "section": _("Debug"), "label": _("Battle test"), "help": _("Start a battle test with the specified monster party."), }, ] runner_options = [ { "option": "autobattle_algo", "type": "choice", "advanced": True, "section": _("Engine"), "label": _("AutoBattle algorithm"), "help": _( "Which AutoBattle algorithm to use.\n\n" "RPG_RT: The default RPG_RT compatible algorithm, including RPG_RT bugs.\n" "RPG_RT+: The default RPG_RT compatible algorithm, with bug-fixes.\n" "ATTACK: Like RPG_RT+ but only physical attacks, no skills." ), "choices": [ (_("Auto"), ""), (_("RPG_RT"), "RPG_RT"), (_("RPG_RT+"), "RPG_RT+"), (_("ATTACK"), "ATTACK"), ], "default": "", }, { "option": "enemyai_algo", "type": "choice", "advanced": True, "section": _("Engine"), "label": _("EnemyAI algorithm"), "help": _( "Which EnemyAI algorithm to use.\n\n" "RPG_RT: The default RPG_RT compatible algorithm, including RPG_RT bugs.\n" "RPG_RT+: The default RPG_RT compatible algorithm, with bug-fixes.\n" ), "choices": [ (_("Auto"), ""), (_("RPG_RT"), "RPG_RT"), (_("RPG_RT+"), "RPG_RT+"), ], "default": "", }, { "option": "seed", "type": "range", "advanced": True, "section": _("Engine"), "label": _("RNG seed"), "help": _("Seeds the random number generator.\n" "Use -1 to disable."), "min": -1, "max": 2147483647, "default": -1, }, { "option": "audio", "type": "bool", "section": _("Audio"), "label": _("Enable audio"), "help": _("Switch off to disable audio."), "default": True, }, { "option": "music_volume", "type": "range", "section": _("Audio"), "label": _("BGM volume"), "help": _("Volume of the background music."), "min": 0, "max": 100, "default": 100, }, { "option": "sound_volume", "type": "range", "section": _("Audio"), "label": _("SFX volume"), "help": _("Volume of the sound effects."), "min": 0, "max": 100, "default": 100, }, { "option": "soundfont", "type": "file", "advanced": True, "section": _("Audio"), "label": _("Soundfont"), "help": _("Soundfont in sf2 format to use when playing MIDI files."), }, { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "help": _("Start in fullscreen mode."), "default": False, }, { "option": "game_resolution", "type": "choice", "section": _("Graphics"), "advanced": True, "label": _("Game resolution"), "help": _( "Force a different game resolution.\n\n" "This is experimental and can cause glitches or break games!" ), "choices": [ (_("320×240 (4:3, Original)"), "original"), (_("416×240 (16:9, Widescreen)"), "widescreen"), (_("560×240 (21:9, Ultrawide)"), "ultrawide"), ], "default": "original", }, { "option": "scaling", "type": "choice", "section": _("Graphics"), "label": _("Scaling"), "help": _( "How the video output is scaled.\n\n" "Nearest: Scale to screen size (causes scaling artifacts)\n" "Integer: Scale to multiple of the game resolution\n" "Bilinear: Like Nearest, but output is blurred to avoid artifacts\n" ), "choices": [ (_("Nearest"), "nearest"), (_("Integer"), "integer"), (_("Bilinear"), "bilinear"), ], "default": "bilinear", }, { "option": "stretch", "type": "bool", "section": _("Graphics"), "label": _("Stretch"), "help": _("Ignore the aspect ratio and stretch video output to the entire width of the screen."), "default": False, }, { "option": "vsync", "type": "bool", "section": _("Graphics"), "label": _("Enable VSync"), "help": _("Switch off to disable VSync and use the FPS limit."), "default": True, }, { "option": "fps_limit", "type": "range", "section": _("Graphics"), "label": _("FPS limit"), "help": _( "Set a custom frames per second limit.\n" "If unspecified, the default is 60 FPS.\n" "Set to 0 to disable the frame limiter." ), "min": 0, "max": 9999, "default": 60, }, { "option": "show_fps", "type": "choice", "section": _("Graphics"), "label": _("Show FPS"), "help": _("Enable frames per second counter."), "choices": [ (_("Disabled"), "off"), (_("Fullscreen & title bar"), "on"), (_("Fullscreen, title bar & window"), "full"), ], "default": "off", }, { "option": "rtp", "type": "bool", "section": _("Runtime Package"), "label": _("Enable RTP"), "help": _("Switch off to disable support for the Runtime Package (RTP)."), "default": True, }, { "option": "rpg2k_rtp_path", "type": "directory_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 path.expanduser(game_path) # just in case # Default to the directory of the entry point entry_point = self.game_config.get(self.entry_point_option) if entry_point: return path.expanduser(entry_point) return "" def get_env(self, os_env=False, disable_runtime=False): env = super().get_env(os_env, disable_runtime=disable_runtime) rpg2k_rtp_path = self.runner_config.get("rpg2k_rtp_path") if rpg2k_rtp_path: env["RPG2K_RTP_PATH"] = rpg2k_rtp_path rpg2k3_rtp_path = self.runner_config.get("rpg2k3_rtp_path") if rpg2k3_rtp_path: env["RPG2K3_RTP_PATH"] = rpg2k3_rtp_path rpg_rtp_path = self.runner_config.get("rpg_rtp_path") if rpg_rtp_path: env["RPG_RTP_PATH"] = rpg_rtp_path return env def get_command(self): cmd = super().get_command() # Engine autobattle_algo = self.runner_config.get("autobattle_algo") if autobattle_algo: cmd.extend(("--autobattle-algo", autobattle_algo)) enemyai_algo = self.runner_config.get("enemyai_algo") if enemyai_algo: cmd.extend(("--enemyai-algo", enemyai_algo)) seed = self.runner_config.get("seed") if seed: cmd.extend(("--seed", str(seed))) # Audio if not self.runner_config["audio"]: cmd.append("--no-audio") music_volume = self.runner_config.get("music_volume") if music_volume: cmd.extend(("--music-volume", str(music_volume))) sound_volume = self.runner_config.get("sound_volume") if sound_volume: cmd.extend(("--sound-volume", str(sound_volume))) soundfont = self.runner_config.get("soundfont") if soundfont: cmd.extend(("--soundfont", soundfont)) # Graphics if self.runner_config["fullscreen"]: cmd.append("--fullscreen") else: cmd.append("--window") game_resolution = self.runner_config.get("game_resolution") if game_resolution: cmd.extend(("--game-resolution", game_resolution)) scaling = self.runner_config.get("scaling") if scaling: cmd.extend(("--scaling", scaling)) if self.runner_config["stretch"]: cmd.append("--stretch") if not self.runner_config["vsync"]: cmd.append("--no-vsync") fps_limit = self.runner_config.get("fps_limit") if fps_limit: cmd.extend(("--fps-limit", str(fps_limit))) show_fps = self.runner_config.get("show_fps") if show_fps != "off": cmd.append("--show-fps") if show_fps == "full": cmd.append("--fps-render-window") # Runtime Package if not self.runner_config["rtp"]: cmd.append("--no-rtp") return cmd def get_run_data(self): cmd = self.get_command() if self.default_path: game_path = path.expanduser(self.default_path) cmd.extend(("--project-path", game_path)) return {"command": cmd, "env": self.get_env()} def play(self): if not self.game_path: raise GameConfigError(_("No game directory provided")) if not path.isdir(self.game_path): raise DirectoryNotFoundError(directory=self.game_path) cmd = self.get_command() cmd.extend(("--project-path", self.game_path)) encoding = self.game_config.get("encoding") if encoding: cmd.extend(("--encoding", encoding)) engine = self.game_config.get("engine") if engine: cmd.extend(("--engine", engine)) patches = self.game_config.get("patches") if patches == "none": cmd.append("--no-patch") elif patches: cmd.extend(("--patches", *patches.split())) language = self.game_config.get("language") if language: cmd.extend(("--language", language)) save_path = self.game_config.get("save_path") if save_path: save_path = path.expanduser(save_path) if not path.isdir(save_path): raise DirectoryNotFoundError(directory=self.game_path) cmd.extend(("--save-path", save_path)) record_input = self.game_config.get("record_input") if record_input: record_input = path.expanduser(record_input) cmd.extend(("--record-input", record_input)) replay_input = self.game_config.get("replay_input") if replay_input: replay_input = path.expanduser(replay_input) if not path.isfile(replay_input): raise MissingGameExecutableError(filename=replay_input) cmd.extend(("--replay-input", replay_input)) load_game_id = self.game_config.get("load_game_id") if load_game_id: cmd.extend(("--load-game-id", str(load_game_id))) # Debug if self.game_config["test_play"]: cmd.append("--test-play") if self.game_config["hide_title"]: cmd.append("--hide-title") start_map_id = self.game_config.get("start_map_id") if start_map_id: cmd.extend(("--start-map-id", str(start_map_id))) start_position = self.game_config.get("start_position") if start_position: cmd.extend(("--start-position", *start_position.split())) start_party = self.game_config.get("start_party") if start_party: cmd.extend(("--start-party", *start_party.split())) battle_test = self.game_config.get("battle_test") if battle_test: cmd.extend(("--battle-test", battle_test)) return {"command": cmd} lutris-0.5.17/lutris/runners/flatpak.py000066400000000000000000000125641460562010500201470ustar00rootroot00000000000000import os import shutil from gettext import gettext as _ from pathlib import Path from typing import Callable from lutris.exceptions import GameConfigError, MissingExecutableError from lutris.monitored_command import MonitoredCommand from lutris.runners import NonInstallableRunnerError from lutris.runners.runner import Runner from lutris.util import flatpak as _flatpak from lutris.util import system from lutris.util.strings import split_arguments class flatpak(Runner): """ Runner for Flatpak applications. """ description = _("Runs Flatpak applications") platforms = [_("Linux")] entry_point_option = "application" human_name = _("Flatpak") runnable_alone = False system_options_override = [{"option": "disable_runtime", "default": True}] install_locations = {"system": "var/lib/flatpak/app/", "user": f"{Path.home()}/.local/share/flatpak/app/"} game_options = [ { "option": "appid", "type": "string", "label": _("Application ID"), "help": _("The application's unique three-part identifier (tld.domain.app)."), }, { "option": "arch", "type": "string", "label": _("Architecture"), "help": _( "The architecture to run. " "See flatpak --supported-arches for architectures supported by the host." ), "advanced": True, }, {"option": "branch", "type": "string", "label": _("Branch"), "help": _("The branch to use."), "advanced": True}, { "option": "install_type", "type": "string", "label": _("Install type"), "help": _("Can be system or user."), "advanced": True, }, { "option": "args", "type": "string", "label": _("Args"), "help": _("Arguments to be passed to the application."), }, { "option": "fcommand", "type": "string", "label": _("Command"), "help": _("The command to run instead of the one listed in the application metadata."), "advanced": True, }, { "option": "working_dir", "type": "directory_chooser", "label": _("Working directory"), "warn_if_non_writable_parent": True, "help": _("The directory to run the command in. Note that this must be a directory inside the sandbox."), "advanced": True, }, { "option": "env_vars", "type": "string", "label": _("Environment variables"), "help": _( "Set an environment variable in the application. " "This overrides to the Context section from the application metadata." ), "advanced": True, }, ] def is_installed(self, flatpak_allowed: bool = True) -> bool: return _flatpak.is_installed() def get_executable(self) -> str: exe = _flatpak.get_executable() if not system.path_exists(exe): raise MissingExecutableError(_("The Flatpak executable could not be found.")) return exe def install(self, install_ui_delegate, version=None, callback=None): raise NonInstallableRunnerError( _( "Flatpak installation is not handled by Lutris.\n" "Install Flatpak with the package provided by your distribution." ) ) def can_uninstall(self): return False def uninstall(self, uninstall_callback: Callable[[], None]) -> None: raise RuntimeError("Flatpak can't be uninstalled from Lutris") @property def game_path(self): if shutil.which("flatpak-spawn"): return "/" install_type, application, arch, fcommand, branch = ( self.game_config.get(key, "") for key in ("install_type", "appid", "arch", "fcommand", "branch") ) return os.path.join(self.install_locations[install_type or "user"], application, arch, fcommand, branch) def remove_game_data(self, app_id=None, game_path=None, **kwargs): if not self.is_installed(): return False command = MonitoredCommand( [self.get_executable(), f"uninstall --app --noninteractive {app_id}"], runner=self, env=self.get_env(), title=f"Uninstalling Flatpak App: {app_id}", ) command.start() def get_command(self): arch = self.game_config.get("arch", "") branch = self.game_config.get("branch", "") fcommand = self.game_config.get("fcommand", "") return _flatpak.get_bare_run_command(arch, fcommand, branch) def play(self): appid = self.game_config.get("appid", "") args = self.game_config.get("args", "") if not appid: raise GameConfigError(_("No application specified.")) if appid.count(".") < 2: raise GameConfigError( _("Application ID is not specified in correct format." "Must be something like: tld.domain.app") ) if any(x in appid for x in ("--", "/")): raise GameConfigError(_("Application ID field must not contain options or arguments.")) command = self.get_command() + [appid] if args: command.extend(split_arguments(args)) return {"command": command} lutris-0.5.17/lutris/runners/fsuae.py000066400000000000000000000411631460562010500176250ustar00rootroot00000000000000import 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", "help": _("Location of extended Kickstart used for CD32"), }, { "option": "gfx_fullscreen_amiga", "section": _("Graphics"), "label": _("Fullscreen (F12 + F to switch)"), "type": "bool", "default": False, }, { "option": "scanlines", "section": _("Graphics"), "label": _("Scanlines display style"), "type": "bool", "default": False, "help": _("Activates a display filter adding scanlines to imitate " "the displays of yesteryear."), }, { "option": "grafixcard", "section": _("Graphics"), "label": _("Graphics Card"), "type": "choice", "choices": gpucard_choices, "default": "None", "advanced": True, "help": _( "Use this option to enable a graphics card. This option is none by default, in " "which case only chipset graphics (OCS/ECS/AGA) support is available." ), }, { "option": "grafixmemory", "section": _("Graphics"), "label": _("Graphics Card RAM"), "type": "choice", "choices": gpumem_choices, "default": "0", "advanced": True, "help": _( "Override the amount of graphics memory on the graphics card. The 0 MB option is " "not really valid, but exists for user interface reasons." ), }, { "option": "cpumodel", "label": _("CPU"), "type": "choice", "choices": cpumodel_choices, "default": "auto", "advanced": True, "help": _( "Use this option to override the CPU model in the emulated Amiga. All Amiga " "models imply a default CPU model, so you only need to use this option if you " "want to use another CPU." ), }, { "option": "fmemory", "label": _("Fast Memory"), "type": "choice", "choices": memory_choices, "default": "0", "advanced": True, "help": _("Specify how much Fast Memory the Amiga model should have."), }, { "option": "ziiimem", "label": _("Zorro III RAM"), "type": "choice", "choices": zorroiii_choices, "default": "0", "advanced": True, "help": _( "Override the amount of Zorro III Fast memory, specified in KB. Must be a " "multiple of 1024. The default value depends on [amiga_model]. Requires a " "processor with 32-bit address bus, (use for example the A1200/020 model)." ), }, { "option": "fdvolume", "section": _("Media"), "label": _("Floppy Drive Volume"), "type": "choice", "choices": flsound_choices, "default": "0", "advanced": True, "help": _("Set volume to 0 to disable floppy drive clicks " "when the drive is empty. Max volume is 100."), }, { "option": "fdspeed", "section": _("Media"), "label": _("Floppy Drive Speed"), "type": "choice", "choices": flspeed_choices, "default": "100", "advanced": True, "help": _( "Set the speed of the emulated floppy drives, in percent. " "For example, you can specify 800 to get an 8x increase in " "speed. Use 0 to specify turbo mode. Turbo mode means that " "all floppy operations complete immediately. The default is 100 for most models." ), }, { "option": "jitcompiler", "label": _("JIT Compiler"), "type": "bool", "default": False, "advanced": True, }, { "option": "gamemode", "label": _("Feral GameMode"), "type": "bool", "default": False, "advanced": True, "help": _("Automatically uses Feral GameMode daemon if available. " "Set to true to disable the feature."), }, { "option": "govwarning", "label": _("CPU governor warning"), "type": "bool", "default": False, "advanced": True, "help": _( "Warn if running with a CPU governor other than performance. " "Set to true to disable the warning." ), }, { "option": "bsdsocket", "label": _("UAE bsdsocket.library"), "type": "bool", "default": False, "advanced": True, }, ] @property def directory(self): return os.path.join(settings.RUNNER_DIR, "fs-uae") def get_platform(self): model = self.runner_config.get("model") if model: for index, machine in enumerate(self.model_choices): if machine[1] == model: return self.platforms[index] return "" def get_absolute_path(self, path): """Return the absolute path for a file""" return path if os.path.isabs(path) else os.path.join(self.game_path, path) def insert_floppies(self): disks = [] main_disk = self.game_config.get("main_file") if main_disk: disks.append(main_disk) game_disks = self.game_config.get("disks") or [] for disk in game_disks: if disk not in disks: disks.append(disk) # Make all paths absolute disks = [self.get_absolute_path(disk) for disk in disks] drives = [] floppy_images = [] for drive, disk_path in enumerate(disks): disk_param = self.get_disk_param(disk_path) drives.append("--%s_%d=%s" % (disk_param, drive, disk_path)) if disk_param == "floppy_drive": floppy_images.append("--floppy_image_%d=%s" % (drive, disk_path)) cdrom_image = self.game_config.get("cdrom_image") if cdrom_image: drives.append("--cdrom_drive_0=%s" % self.get_absolute_path(cdrom_image)) return drives + floppy_images def get_disk_param(self, disk_path): amiga_model = self.runner_config.get("model") if amiga_model in ("CD32", "CDTV"): return "cdrom_drive" if disk_path.lower().endswith(".hdf"): return "hard_drive" return "floppy_drive" def get_params(self): # pylint: disable=too-many-branches params = [] option_params = { "kickstart_file": "--kickstart_file=%s", "kickstart_ext_file": "--kickstart_ext_file=%s", "model": "--amiga_model=%s", "cpumodel": "--cpu=%s", "fmemory": "--fast_memory=%s", "ziiimem": "--zorro_iii_memory=%s", "fdvolume": "--floppy_drive_volume=%s", "fdspeed": "--floppy_drive_speed=%s", "grafixcard": "--graphics_card=%s", "grafixmemory": "--graphics_memory=%s", } for option, param in option_params.items(): option_value = self.runner_config.get(option) if option_value: params.append(param % option_value) if self.runner_config.get("gfx_fullscreen_amiga"): width = DISPLAY_MANAGER.get_current_resolution()[0] params.append("--fullscreen") # params.append("--fullscreen_mode=fullscreen-window") params.append("--fullscreen_mode=fullscreen") params.append("--fullscreen_width=%s" % width) if self.runner_config.get("jitcompiler"): params.append("--jit_compiler=1") if self.runner_config.get("bsdsocket"): params.append("--bsdsocket_library=1") if self.runner_config.get("gamemode"): params.append("--game_mode=0") if self.runner_config.get("govwarning"): params.append("--governor_warning=0") if self.runner_config.get("scanlines"): params.append("--scanlines=1") return params def play(self): return {"command": self.get_command() + self.get_params() + self.insert_floppies()} lutris-0.5.17/lutris/runners/hatari.py000066400000000000000000000144551460562010500177760ustar00rootroot00000000000000# Standard Library import os import shutil from gettext import gettext as _ # Lutris Modules from lutris.config import LutrisConfig from lutris.exceptions import MissingBiosError, MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system class hatari(Runner): human_name = _("Hatari") description = _("Atari ST computers emulator") platforms = [_("Atari ST")] runnable_alone = True flatpak_id = "org.tuxfamily.hatari" runner_executable = "hatari/bin/hatari" entry_point_option = "disk-a" game_options = [ { "option": "disk-a", "type": "file", "label": _("Floppy Disk A"), "help": _( "Hatari supports floppy disk images in the following " "formats: ST, DIM, MSA, STX, IPF, RAW and CRT. The last " "three require the caps library (capslib). ZIP is " "supported, you don't need to uncompress the file." ), }, { "option": "disk-b", "type": "file", "label": _("Floppy Disk B"), "help": _( "Hatari supports floppy disk images in the following " "formats: ST, DIM, MSA, STX, IPF, RAW and CRT. The last " "three require the caps library (capslib). ZIP is " "supported, you don't need to uncompress the file." ), }, ] joystick_choices = [(_("None"), "none"), (_("Keyboard"), "keys"), (_("Joystick"), "real")] runner_options = [ { "option": "bios_file", "type": "file", "label": _("Bios file (TOS)"), "help": _( "TOS is the operating system of the Atari ST " "and is necessary to run applications with the best " "fidelity, minimizing risks of issues.\n" "TOS 1.02 is recommended for games." ), }, { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": False, }, { "option": "zoom", "type": "bool", "section": _("Graphics"), "label": _("Scale up display by 2 (Atari ST/STE)"), "default": True, "help": _("Double the screen size in windowed mode."), }, { "option": "borders", "type": "bool", "section": _("Graphics"), "label": _("Add borders to display"), "default": False, "help": _( "Useful for some games and demos using the overscan " "technique. The Atari ST displayed borders around the " "screen because it was not powerful enough to display " "graphics in fullscreen. But people from the demo scene " "were able to remove them and some games made use of " "this technique." ), }, { "option": "status", "type": "bool", "section": _("Graphics"), "label": _("Display status bar"), "default": False, "help": _( "Displays a status bar with some useful information, " "like green leds lighting up when the floppy disks are " "read." ), }, { "option": "joy0", "type": "choice", "section": _("Joysticks"), "label": _("Joystick 0"), "choices": joystick_choices, "default": "none", }, { "option": "joy1", "type": "choice", "section": _("Joysticks"), "label": _("Joystick 1"), "choices": joystick_choices, "default": "real", }, ] def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*args): bios_path = system.create_folder("~/.hatari/bios") bios_filename = install_ui_delegate.show_install_file_inquiry( question=_("Do you want to select an Atari ST BIOS file?"), title=_("Use BIOS file?"), message=_("Select a BIOS file"), ) if bios_filename: shutil.copy(bios_filename, bios_path) bios_path = os.path.join(bios_path, os.path.basename(bios_filename)) config = LutrisConfig(runner_slug="hatari") config.raw_runner_config.update({"bios_file": bios_path}) config.save() if callback: callback() super().install(install_ui_delegate, version=version, callback=on_runner_installed) def play(self): # pylint: disable=too-many-branches params = self.get_command() if self.runner_config.get("fullscreen"): params.append("--fullscreen") else: params.append("--window") params.append("--zoom") if self.runner_config.get("zoom"): params.append("2") else: params.append("1") params.append("--borders") if self.runner_config.get("borders"): params.append("true") else: params.append("false") params.append("--statusbar") if self.runner_config.get("status"): params.append("true") else: params.append("false") if self.runner_config.get("joy0"): params.append("--joy0") params.append(self.runner_config["joy0"]) if self.runner_config.get("joy1"): params.append("--joy1") params.append(self.runner_config["joy1"]) if system.path_exists(self.runner_config.get("bios_file", "")): params.append("--tos") params.append(self.runner_config["bios_file"]) else: raise MissingBiosError() diska = self.game_config.get("disk-a") if not system.path_exists(diska): raise MissingGameExecutableError(filename=diska) params.append("--disk-a") params.append(diska) diskb = self.game_config.get("disk-b") params.append("--disk-b") params.append(diskb) return {"command": params} lutris-0.5.17/lutris/runners/json.py000066400000000000000000000061131460562010500174670ustar00rootroot00000000000000"""Base class and utilities for JSON based runners""" import json import os from lutris import settings from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import datapath, system JSON_RUNNER_DIRS = [ os.path.join(datapath.get(), "json"), os.path.join(settings.RUNNER_DIR, "json"), ] class JsonRunner(Runner): json_path = None def __init__(self, config=None): super().__init__(config) if not self.json_path: raise RuntimeError("Create subclasses of JsonRunner with the json_path attribute set") with open(self.json_path, encoding="utf-8") as json_file: self._json_data = json.load(json_file) self.game_options = self._json_data["game_options"] self.runner_options = self._json_data.get("runner_options", []) self.human_name = self._json_data["human_name"] self.description = self._json_data["description"] self.platforms = self._json_data["platforms"] self.runner_executable = self._json_data["runner_executable"] self.system_options_override = self._json_data.get("system_options_override", []) self.entry_point_option = self._json_data.get("entry_point_option", "main_file") self.download_url = self._json_data.get("download_url") self.runnable_alone = self._json_data.get("runnable_alone") self.flatpak_id = self._json_data.get("flatpak_id") def play(self): """Return a launchable command constructed from the options""" arguments = self.get_command() for option in self.runner_options: if option["option"] not in self.runner_config: continue if option["type"] == "bool": if self.runner_config.get(option["option"]): arguments.append(option["argument"]) elif option["type"] == "choice": if self.runner_config.get(option["option"]) != "off": arguments.append(option["argument"]) arguments.append(self.runner_config.get(option["option"])) elif option["type"] == "string": arguments.append(option["argument"]) arguments.append(self.runner_config.get(option["option"])) else: raise RuntimeError("Unhandled type %s" % option["type"]) main_file = self.game_config.get(self.entry_point_option) if not system.path_exists(main_file): raise MissingGameExecutableError(filename=main_file) arguments.append(main_file) return {"command": arguments} def load_json_runners(): json_runners = {} for json_dir in JSON_RUNNER_DIRS: if not os.path.exists(json_dir): continue for json_path in os.listdir(json_dir): if not json_path.endswith(".json"): continue runner_name = json_path[:-5] runner_class = type(runner_name, (JsonRunner,), {"json_path": os.path.join(json_dir, json_path)}) json_runners[runner_name] = runner_class return json_runners lutris-0.5.17/lutris/runners/jzintv.py000066400000000000000000000054651460562010500200530ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ from lutris.exceptions import MissingBiosError, MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class jzintv(Runner): human_name = _("jzIntv") description = _("Intellivision Emulator") platforms = [_("Intellivision")] runner_executable = "jzintv/bin/jzintv" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "default_path": "game_path", "help": _( "The game data, commonly called a ROM image. \n" "Supported formats: ROM, BIN+CFG, INT, ITV \n" "The file extension must be lower-case." ), } ] runner_options = [ { "option": "bios_path", "type": "directory_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_command() selected_resolution = self.runner_config.get("resolution") if selected_resolution: arguments = arguments + ["-z%s" % selected_resolution] if self.runner_config.get("fullscreen"): arguments = arguments + ["-f"] bios_path = self.runner_config.get("bios_path", "") if system.path_exists(bios_path): arguments.append("--execimg=%s/exec.bin" % bios_path) arguments.append("--gromimg=%s/grom.bin" % bios_path) else: raise MissingBiosError() rom_path = self.game_config.get("main_file") or "" if not system.path_exists(rom_path): raise MissingGameExecutableError(filename=rom_path) romdir = os.path.dirname(rom_path) romfile = os.path.basename(rom_path) arguments += ["--rom-path=%s/" % romdir] arguments += [romfile] return {"command": arguments} lutris-0.5.17/lutris/runners/libretro.py000066400000000000000000000267671460562010500203610ustar00rootroot00000000000000"""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.exceptions import GameConfigError, MissingGameExecutableError, UnspecifiedVersionError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.libretro import RetroConfig from lutris.util.log import logger 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 directory(self): return os.path.join(settings.RUNNER_DIR, "retroarch") @property def platforms(self): return [core[2] for core in LIBRETRO_CORES] def get_platform(self): game_core = self.game_config.get("core") if not game_core: logger.warning("Game don't have a core set") return for core in LIBRETRO_CORES: if core[1] == game_core: return core[2] logger.warning("'%s' not found in Libretro cores", game_core) return "" def get_core_path(self, core): """Return the path of a core, prioritizing Retroarch cores""" lutris_cores_folder = os.path.join(self.directory, "cores") retroarch_core_folder = os.path.join(os.path.expanduser("~/.config/retroarch/cores")) core_filename = "{}_libretro.so".format(core) retroarch_core = os.path.join(retroarch_core_folder, core_filename) if system.path_exists(retroarch_core): return retroarch_core return os.path.join(lutris_cores_folder, core_filename) def get_version(self, use_default=True): return self.game_config["core"] def is_installed(self, flatpak_allowed: bool = True, core=None) -> bool: if not core and self.has_explicit_config and self.game_config.get("core"): core = self.game_config["core"] if not core or self.runner_config.get("runner_executable"): return super().is_installed(flatpak_allowed=flatpak_allowed) is_core_installed = system.path_exists(self.get_core_path(core)) return super().is_installed(flatpak_allowed=flatpak_allowed) and is_core_installed def is_installed_for(self, interpreter): core = interpreter.installer.script["game"].get("core") return self.is_installed(core=core) def get_installer_runner_version(self, installer, use_runner_config: bool = True) -> str: version = installer.script["game"].get("core") if not version: raise UnspecifiedVersionError(_("The installer does not specify the libretro 'core' version.")) return version def install(self, install_ui_delegate, version=None, callback=None): captured_super = super() # super() does not work inside install_core() def install_core(): if not version: if callback: callback() else: captured_super.install(install_ui_delegate, version, callback) if not super().is_installed(): captured_super.install(install_ui_delegate, version=None, callback=install_core) else: captured_super.install(install_ui_delegate, version, callback) def get_run_data(self): return { "command": self.get_command() + self.get_runner_parameters(), "env": self.get_env(), } def get_config_file(self): return self.runner_config.get("config_file") or 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: raise GameConfigError(_("No core has been selected for this game")) command.append("--libretro={}".format(self.get_core_path(core))) # Main file file = self.game_config.get("main_file") if not file: raise GameConfigError(_("No game file specified")) if not system.path_exists(file): raise MissingGameExecutableError(filename=file) command.append(file) return {"command": command} lutris-0.5.17/lutris/runners/linux.py000066400000000000000000000142521460562010500176600ustar00rootroot00000000000000"""Runner for Linux games""" # Standard Library import os import stat from gettext import gettext as _ from typing import Callable # Lutris Modules from lutris.exceptions import GameConfigError, MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.strings import split_arguments class linux(Runner): human_name = _("Linux") description = _("Runs native games") platforms = [_("Linux")] entry_point_option = "exe" game_options = [ { "option": "exe", "type": "file", "default_path": "game_path", "label": _("Executable"), "help": _("The game's main executable file"), }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _("Command line arguments used when launching the game"), }, { "option": "working_dir", "type": "directory_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 exe = os.path.expanduser(exe) # just in case! if os.path.isabs(exe): return exe if self.game_path: return os.path.join(self.game_path, exe) return system.find_executable(exe) def resolve_game_path(self): return super().resolve_game_path() or os.path.dirname(self.game_exe or "") def get_relative_exe(self, exe_path, working_dir): """Return a relative path if a working dir is provided Some games such as Unreal Gold fail to run if given the absolute path """ if exe_path and working_dir: relative = os.path.relpath(exe_path, start=working_dir) if not relative.startswith("../"): # We can't use the working dir implicitly to start a command # so we make it explicit with "./" if not os.path.isabs(relative): relative = "./" + relative return relative return exe_path @property def working_dir(self): """Return the working directory to use when running the game.""" option = self.game_config.get("working_dir") if option: return os.path.expanduser(option) if self.game_exe: return os.path.dirname(self.game_exe) return super().working_dir @property def nvidia_shader_cache_path(self): """Linux programs should get individual shader caches if possible.""" return self.game_path or self.shader_cache_dir def is_installed(self, flatpak_allowed: bool = True) -> bool: """Well of course Linux is installed, you're using Linux right ?""" return True def can_uninstall(self): return False def uninstall(self, uninstall_callback: Callable[[], None]) -> None: raise RuntimeError("Linux shouldn't be installed.") def get_launch_config_command(self, gameplay_info, launch_config): # The linux runner has no command (by default) beyond the 'exe' itself; # so the command in gameplay_info is discarded. if "command" in launch_config: command = split_arguments(launch_config["command"]) else: command = [] working_dir = os.path.expanduser(launch_config.get("working_dir") or self.working_dir) if "exe" in launch_config: config_exe = os.path.expanduser(launch_config["exe"] or "") command.append(self.get_relative_exe(config_exe, working_dir)) elif len(command) == 0: raise GameConfigError(_("The runner could not find a command or exe to use for this configuration.")) if launch_config.get("args"): command += split_arguments(launch_config["args"]) return command def get_command(self): # There's no command for a Linux game; the game executable # is the first thing in the game's command line, not any runner thing. return [] def play(self): """Run native game.""" launch_info = {} exe = self.game_exe if not exe or not system.path_exists(exe): raise MissingGameExecutableError(filename=exe) # Quit if the file is not executable mode = os.stat(exe).st_mode if not mode & stat.S_IXUSR: raise GameConfigError(_("The file %s is not executable") % exe) ld_preload = self.game_config.get("ld_preload") if ld_preload: launch_info["ld_preload"] = ld_preload ld_library_path = self.game_config.get("ld_library_path") if ld_library_path: launch_info["ld_library_path"] = os.path.expanduser(ld_library_path) command = [self.get_relative_exe(exe, self.working_dir)] args = self.game_config.get("args") or "" for arg in split_arguments(args): command.append(arg) launch_info["command"] = command return launch_info lutris-0.5.17/lutris/runners/mame.py000066400000000000000000000312021460562010500174320ustar00rootroot00000000000000"""Runner for MAME""" import os from gettext import gettext as _ from lutris import runtime, settings from lutris.exceptions import GameConfigError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.mame.database import get_supported_systems from lutris.util.strings import split_arguments MAME_CACHE_DIR = os.path.join(settings.CACHE_DIR, "mame") MAME_XML_PATH = os.path.join(MAME_CACHE_DIR, "mame.xml") def write_mame_xml(force=False): if not system.path_exists(MAME_CACHE_DIR): system.create_folder(MAME_CACHE_DIR) if system.path_exists(MAME_XML_PATH, exclude_empty=True) and not force: return False logger.info("Writing full game list from MAME to %s", MAME_XML_PATH) mame_inst = mame() mame_inst.write_xml_list() if system.get_disk_size(MAME_XML_PATH) == 0: logger.warning("MAME did not write anything to %s", MAME_XML_PATH) return False return True def notify_mame_xml(result, error): if error: logger.error("Failed writing MAME XML") elif result: logger.info("Finished writing MAME XML") def get_system_choices(include_year=True): """Return list of systems for inclusion in dropdown""" if not system.path_exists(MAME_XML_PATH, exclude_empty=True): mame_inst = mame() if mame_inst.is_installed(): AsyncCall(write_mame_xml, notify_mame_xml) return [] for system_id, info in sorted( get_supported_systems(MAME_XML_PATH).items(), key=lambda sys: (sys[1]["manufacturer"], sys[1]["description"]), ): if info["description"].startswith(info["manufacturer"]): template = "" else: template = "%(manufacturer)s " template += "%(description)s" if include_year: template += " %(year)s" system_name = template % info system_name = system_name.replace("", "").strip() yield (system_name, system_id) class mame(Runner): # pylint: disable=invalid-name """MAME runner""" human_name = _("MAME") description = _("Arcade game emulator") runner_executable = "mame/mame" flatpak_id = "org.mamedev.MAME" runnable_alone = True config_dir = os.path.expanduser("~/.mame") cache_dir = os.path.join(settings.CACHE_DIR, "mame") xml_path = os.path.join(cache_dir, "mame.xml") _platforms = [] game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), }, { "option": "machine", "type": "choice_with_search", "label": _("Machine"), "choices": get_system_choices, "help": _("The emulated machine."), }, { "option": "device", "type": "choice_with_entry", "label": _("Storage type"), "choices": [ (_("Floppy disk"), "flop"), (_("Floppy drive 1"), "flop1"), (_("Floppy drive 2"), "flop2"), (_("Floppy drive 3"), "flop3"), (_("Floppy drive 4"), "flop4"), (_("Cassette (tape)"), "cass"), (_("Cassette 1 (tape)"), "cass1"), (_("Cassette 2 (tape)"), "cass2"), (_("Cartridge"), "cart"), (_("Cartridge 1"), "cart1"), (_("Cartridge 2"), "cart2"), (_("Cartridge 3"), "cart3"), (_("Cartridge 4"), "cart4"), (_("Snapshot"), "snapshot"), (_("Hard Disk"), "hard"), (_("Hard Disk 1"), "hard1"), (_("Hard Disk 2"), "hard2"), (_("CD-ROM"), "cdrm"), (_("CD-ROM 1"), "cdrm1"), (_("CD-ROM 2"), "cdrm2"), (_("Snapshot"), "dump"), (_("Quickload"), "quickload"), (_("Memory Card"), "memc"), (_("Cylinder"), "cyln"), (_("Punch Tape 1"), "ptap1"), (_("Punch Tape 2"), "ptap2"), (_("Print Out"), "prin"), ], }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _("Command line arguments used when launching the game"), }, { "option": "slots", "type": "string", "label": ("Slot System"), "help": ("For slot devices that is needed for romsloads"), }, { "option": "autoboot_command", "type": "string", "section": _("Autoboot"), "label": _("Autoboot command"), "help": _( "Autotype this command when the system has started, " "an enter keypress is automatically added." ), }, { "option": "autoboot_delay", "type": "range", "section": _("Autoboot"), "label": _("Delay before entering autoboot command"), "min": 0, "max": 120, }, ] runner_options = [ { "option": "rompath", "type": "directory_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""" env = runtime.get_env() listxml_command = self.get_command() + ["-listxml"] os.makedirs(self.cache_dir, exist_ok=True) output, error_output = system.execute_with_error(listxml_command, env=env) if output: with open(self.xml_path, "w", encoding="utf-8") as xml_file: xml_file.write(output) logger.info("MAME XML list written to %s", self.xml_path) else: logger.warning("Couldn't get any output for mame -listxml: %s", error_output) def get_platform(self): selected_platform = self.game_config.get("platform") if selected_platform: return self.platforms[int(selected_platform)] if self.game_config.get("machine"): machine_mapping = {choice[1]: choice[0] for choice in get_system_choices(include_year=False)} # get_system_choices() can return [] if not yet ready, so we'll return # None in that case. return machine_mapping.get(self.game_config["machine"]) rom_file = os.path.basename(self.game_config.get("main_file", "")) if rom_file.startswith("gnw_"): return _("Nintendo Game & Watch") return _("Arcade") def prelaunch(self): if not system.path_exists(os.path.join(self.config_dir, "mame.ini")): try: os.makedirs(self.config_dir) except OSError: pass system.execute( self.get_command() + ["-createconfig", "-inipath", self.config_dir], env=runtime.get_env(), cwd=self.working_dir, ) def get_shader_params(self, shader_dir, shaders): """Returns a list of CLI parameters to apply a list of shaders""" params = [] shader_path = os.path.join(self.working_dir, "shaders", shader_dir) for index, shader in enumerate(shaders): params += ["-gl_glsl", "-glsl_shader_mame%s" % index, os.path.join(shader_path, shader)] return params def play(self): command = self.get_command() + ["-skip_gameinfo", "-inipath", self.config_dir] if self.runner_config.get("video"): command += ["-video", self.runner_config["video"]] if not self.runner_config.get("fullscreen"): command.append("-window") if self.runner_config.get("waitvsync"): command.append("-waitvsync") if self.runner_config.get("uimodekey"): command += ["-uimodekey", self.runner_config["uimodekey"]] if self.runner_config.get("crt"): command += self.get_shader_params("CRT-geom", ["Gaussx", "Gaussy", "CRT-geom-halation"]) command += ["-nounevenstretch"] if self.game_config.get("machine"): rompath = self.runner_config.get("rompath") if rompath: command += ["-rompath", rompath] command.append(self.game_config["machine"]) for slot_arg in split_arguments(self.game_config.get("slots")): command.append(slot_arg) device = self.game_config.get("device") if not device: raise GameConfigError(_("No device is set for machine %s") % self.game_config["machine"]) rom = self.game_config.get("main_file") if rom: command += ["-" + device, rom] else: rompath = os.path.dirname(self.game_config.get("main_file")) if not rompath: rompath = self.runner_config.get("rompath") rom = os.path.basename(self.game_config.get("main_file")) if not rompath: raise GameConfigError(_("The path '%s' is not set. please set it in the options.") % "rompath") command += ["-rompath", rompath, rom] if self.game_config.get("autoboot_command"): command += ["-autoboot_command", self.game_config["autoboot_command"] + "\\n"] if self.game_config.get("autoboot_delay"): command += ["-autoboot_delay", str(self.game_config["autoboot_delay"])] for arg in split_arguments(self.game_config.get("args")): command.append(arg) return {"command": command} lutris-0.5.17/lutris/runners/mednafen.py000066400000000000000000000412111460562010500202710ustar00rootroot00000000000000# Standard Library import subprocess from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system from lutris.util.display import DISPLAY_MANAGER from lutris.util.joypad import get_controller_mappings from lutris.util.log import logger DEFAULT_MEDNAFEN_SCALER = "nn4x" class mednafen(Runner): human_name = _("Mednafen") description = _("Multi-system emulator: NES, PC Engine, PSX…") platforms = [ _("Nintendo Game Boy (Color)"), _("Nintendo Game Boy Advance"), _("Sega Game Gear"), _("Sega Genesis/Mega Drive"), _("Atari Lynx"), _("Sega Master System"), _("SNK Neo Geo Pocket (Color)"), _("Nintendo NES"), _("NEC PC Engine TurboGrafx-16"), _("NEC PC-FX"), _("Sony PlayStation"), _("Sega Saturn"), _("Nintendo SNES"), _("Bandai WonderSwan"), _("Nintendo Virtual Boy"), ] machine_choices = ( (_("Game Boy (Color)"), "gb"), (_("Game Boy Advance"), "gba"), (_("Game Gear"), "gg"), (_("Genesis/Mega Drive"), "md"), (_("Lynx"), "lynx"), (_("Master System"), "sms"), (_("Neo Geo Pocket (Color)"), "gnp"), (_("NES"), "nes"), (_("PC Engine"), "pce_fast"), (_("PC-FX"), "pcfx"), (_("PlayStation"), "psx"), (_("Saturn"), "ss"), (_("SNES"), "snes"), (_("WonderSwan"), "wswan"), (_("Virtual Boy"), "vb"), ) runner_executable = "mednafen/bin/mednafen" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _( "The game data, commonly called a ROM image. \n" "Mednafen supports GZIP and ZIP compressed ROMs." ), }, { "option": "machine", "type": "choice", "label": _("Machine type"), "choices": machine_choices, "help": _("The emulated machine."), }, ] runner_options = [ {"option": "fs", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": False}, { "option": "stretch", "type": "choice", "section": _("Graphics"), "label": _("Aspect ratio"), "choices": ( (_("Disabled"), "0"), (_("Stretched"), "full"), (_("Preserve aspect ratio"), "aspect"), (_("Integer scale"), "aspect_int"), (_("Multiple of 2 scale"), "aspect_mult2"), ), "default": "aspect_int", }, { "option": "scaler", "type": "choice", "section": _("Graphics"), "label": _("Video scaler"), "choices": ( ("none", "none"), ("hq2x", "hq2x"), ("hq3x", "hq3x"), ("hq4x", "hq4x"), ("scale2x", "scale2x"), ("scale3x", "scale3x"), ("scale4x", "scale4x"), ("2xsai", "2xsai"), ("super2xsai", "super2xsai"), ("supereagle", "supereagle"), ("nn2x", "nn2x"), ("nn3x", "nn3x"), ("nn4x", "nn4x"), ("nny2x", "nny2x"), ("nny3x", "nny3x"), ("nny4x", "nny4x"), ), "default": DEFAULT_MEDNAFEN_SCALER, }, { "option": "sound_device", "type": "choice", "label": _("Sound device"), "choices": ( (_("Mednafen default"), "default"), (_("ALSA default"), "sexyal-literal-default"), ("hw:0", "hw:0,0"), ("hw:1", "hw:1,0"), ("hw:2", "hw:2,0"), ), "default": "sexyal-literal-default", }, { "option": "dont_map_controllers", "type": "bool", "label": _("Use default Mednafen controller configuration"), "default": False, }, ] def get_platform(self): machine = self.game_config.get("machine") if machine: for index, choice in enumerate(self.machine_choices): if choice[1] == machine: return self.platforms[index] return "" def find_joysticks(self): """Detect connected joysticks and return their ids""" joy_ids = [] if not self.is_installed: return [] with subprocess.Popen( [self.get_executable(), "dummy"], stdout=subprocess.PIPE, universal_newlines=True, ) as mednafen_process: output = mednafen_process.communicate()[0].split("\n") found = False joy_list = [] for line in output: if found and "Joystick" in line: joy_list.append(line) else: found = False if "Initializing joysticks" in line: found = True for joy in joy_list: index = joy.find("Unique ID:") joy_id = joy[index + 11 :] logger.debug("Joystick found id %s ", joy_id) joy_ids.append(joy_id) return joy_ids @staticmethod def set_joystick_controls(joy_ids, machine): """Setup joystick mappings per machine""" # Get the controller mappings controller_mappings = get_controller_mappings() if not controller_mappings: logger.warning("No controller detected for joysticks %s.", joy_ids) return [] # TODO currently only supports the first controller. Add support for other controllers. mapping = controller_mappings[0][1] # Construct a dictionnary of button codes to parse to mendafen map_code = { "a": "", "b": "", "c": "", "x": "", "y": "", "z": "", "back": "", "start": "", "leftshoulder": "", "rightshoulder": "", "lefttrigger": "", "righttrigger": "", "leftstick": "", "rightstick": "", "select": "", "shoulder_l": "", "shoulder_r": "", "i": "", "ii": "", "iii": "", "iv": "", "v": "", "vi": "", "run": "", "ls": "", "rs": "", "fire1": "", "fire2": "", "option_1": "", "option_2": "", "cross": "", "circle": "", "square": "", "triangle": "", "r1": "", "r2": "", "l1": "", "l2": "", "option": "", "l": "", "r": "", "right-x": "", "right-y": "", "left-x": "", "left-y": "", "up-x": "", "up-y": "", "down-x": "", "down-y": "", "up-l": "", "up-r": "", "down-l": "", "down-r": "", "left-l": "", "left-r": "", "right-l": "", "right-r": "", "lstick_up": "0000c001", "lstick_down": "00008001", "lstick_right": "00008000", "lstick_left": "0000c000", "rstick_up": "0000c003", "rstick_down": "00008003", "rstick_left": "0000c002", "rstick_right": "00008002", "dpup": "0000c005", "dpdown": "00008005", "dpleft": "0000c004", "dpright": "00008004", } # Insert the button mapping number into the map_codes for button in mapping.keys: bttn_id = mapping.keys[button] if bttn_id[0] == "b": # it's a button map_code[button] = "000000" + bttn_id[1:].zfill(2) # Duplicate button names that are emulated in mednanfen map_code["up"] = map_code["dpup"] # map_code["down"] = map_code["dpdown"] # map_code["left"] = map_code["dpleft"] # Multiple systems map_code["right"] = map_code["dpright"] map_code["select"] = map_code["back"] # map_code["shoulder_r"] = map_code["rightshoulder"] # GBA map_code["shoulder_l"] = map_code["leftshoulder"] # map_code["i"] = map_code["b"] # map_code["ii"] = map_code["a"] # map_code["iii"] = map_code["leftshoulder"] map_code["iv"] = map_code["y"] # PCEngine and PCFX map_code["v"] = map_code["x"] # map_code["vi"] = map_code["rightshoulder"] map_code["run"] = map_code["start"] # map_code["ls"] = map_code["leftshoulder"] # map_code["rs"] = map_code["rightshoulder"] # Saturn map_code["c"] = map_code["righttrigger"] # map_code["z"] = map_code["lefttrigger"] # map_code["fire1"] = map_code["a"] # Master System map_code["fire2"] = map_code["b"] # map_code["option_1"] = map_code["x"] # Lynx map_code["option_2"] = map_code["y"] # map_code["r1"] = map_code["rightshoulder"] # map_code["r2"] = map_code["righttrigger"] # map_code["l1"] = map_code["leftshoulder"] # map_code["l2"] = map_code["lefttrigger"] # PlayStation map_code["cross"] = map_code["a"] # map_code["circle"] = map_code["b"] # map_code["square"] = map_code["x"] # map_code["triangle"] = map_code["y"] # map_code["option"] = map_code["select"] # NeoGeo pocket map_code["l"] = map_code["leftshoulder"] # SNES map_code["r"] = map_code["rightshoulder"] # map_code["right-x"] = map_code["dpright"] # map_code["left-x"] = map_code["dpleft"] # map_code["up-x"] = map_code["dpup"] # map_code["down-x"] = map_code["dpdown"] # Wonder Swan map_code["right-y"] = map_code["lstick_right"] map_code["left-y"] = map_code["lstick_left"] # map_code["up-y"] = map_code["lstick_up"] # map_code["down-y"] = map_code["lstick_down"] # map_code["up-l"] = map_code["dpup"] # map_code["down-l"] = map_code["dpdown"] # map_code["left-l"] = map_code["dpleft"] # map_code["right-l"] = map_code["dpright"] # map_code["up-r"] = map_code["rstick_up"] # map_code["down-r"] = map_code["rstick_down"] # Virtual boy map_code["left-r"] = map_code["rstick_left"] # map_code["right-r"] = map_code["rstick_right"] # map_code["lt"] = map_code["leftshoulder"] # map_code["rt"] = map_code["rightshoulder"] # # Define which buttons to use for each machine layout = { "nes": ["a", "b", "start", "select", "up", "down", "left", "right"], "gb": ["a", "b", "start", "select", "up", "down", "left", "right"], "gba": [ "a", "b", "shoulder_r", "shoulder_l", "start", "select", "up", "down", "left", "right", ], "pce": [ "i", "ii", "iii", "iv", "v", "vi", "run", "select", "up", "down", "left", "right", ], "ss": [ "a", "b", "c", "x", "y", "z", "ls", "rs", "start", "up", "down", "left", "right", ], "gg": ["button1", "button2", "start", "up", "down", "left", "right"], "md": [ "a", "b", "c", "x", "y", "z", "start", "up", "down", "left", "right", ], "sms": ["fire1", "fire2", "up", "down", "left", "right"], "lynx": ["a", "b", "option_1", "option_2", "up", "down", "left", "right"], "psx": [ "cross", "circle", "square", "triangle", "l1", "l2", "r1", "r2", "start", "select", "lstick_up", "lstick_down", "lstick_right", "lstick_left", "rstick_up", "rstick_down", "rstick_left", "rstick_right", "up", "down", "left", "right", ], "pcfx": [ "i", "ii", "iii", "iv", "v", "vi", "run", "select", "up", "down", "left", "right", ], "ngp": ["a", "b", "option", "up", "down", "left", "right"], "snes": [ "a", "b", "x", "y", "l", "r", "start", "select", "up", "down", "left", "right", ], "wswan": [ "a", "b", "right-x", "right-y", "left-x", "left-y", "up-x", "up-y", "down-x", "down-y", "start", ], "vb": [ "up-l", "down-l", "left-l", "right-l", "up-r", "down-r", "left-r", "right-r", "a", "b", "lt", "rt", ], } # Select a the gamepad type controls = [] if machine in ["gg", "lynx", "wswan", "gb", "gba", "vb"]: gamepad = "builtin.gamepad" elif machine in ["md"]: gamepad = "port1.gamepad6" controls.append("-md.input.port1") controls.append("gamepad6") elif machine in ["psx"]: gamepad = "port1.dualshock" controls.append("-psx.input.port1") controls.append("dualshock") else: gamepad = "port1.gamepad" # Construct the controlls options for button in layout[machine]: controls.append("-{}.input.{}.{}".format(machine, gamepad, button)) controls.append("joystick {} {}".format(joy_ids[0], map_code[button])) return controls def play(self): """Runs the game""" rom = self.game_config.get("main_file") or "" machine = self.game_config.get("machine") or "" fullscreen = self.runner_config.get("fs") or "0" if fullscreen is True: fullscreen = "1" elif fullscreen is False: fullscreen = "0" stretch = self.runner_config.get("stretch") or "0" scaler = self.runner_config.get("scaler") or DEFAULT_MEDNAFEN_SCALER sound_device = self.runner_config.get("sound_device") xres, yres = DISPLAY_MANAGER.get_current_resolution() options = [ "-fs", fullscreen, "-force_module", machine, "-sound.device", sound_device, "-" + machine + ".xres", xres, "-" + machine + ".yres", yres, "-" + machine + ".stretch", stretch, "-" + machine + ".special", scaler, "-" + machine + ".videoip", "1", ] joy_ids = self.find_joysticks() dont_map_controllers = self.runner_config.get("dont_map_controllers") if joy_ids and not dont_map_controllers: controls = self.set_joystick_controls(joy_ids, machine) for control in controls: options.append(control) if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) command = self.get_command() for option in options: command.append(option) command.append(rom) return {"command": command} lutris-0.5.17/lutris/runners/mupen64plus.py000066400000000000000000000030661460562010500207240ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system class mupen64plus(Runner): human_name = _("Mupen64Plus") description = _("Nintendo 64 emulator") platforms = [_("Nintendo 64")] runner_executable = "mupen64plus/mupen64plus" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": True, }, {"option": "hideosd", "type": "bool", "label": _("Hide OSD"), "default": True}, ] @property def working_dir(self): return os.path.join(settings.RUNNER_DIR, "mupen64plus") def play(self): arguments = self.get_command() if self.runner_config.get("hideosd"): arguments.append("--noosd") else: arguments.append("--osd") if self.runner_config.get("fullscreen"): arguments.append("--fullscreen") else: arguments.append("--windowed") rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments.append(rom) return {"command": arguments} lutris-0.5.17/lutris/runners/o2em.py000066400000000000000000000077731460562010500173750ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class o2em(Runner): human_name = _("O2EM") description = _("Magnavox Odyssey² Emulator") platforms = ( _("Magnavox Odyssey²"), _("Phillips C52"), _("Phillips Videopac+"), _("Brandt Jopac"), ) bios_path = os.path.expanduser("~/.o2em/bios") runner_executable = "o2em/o2em" checksums = { "o2rom": "562d5ebf9e030a40d6fabfc2f33139fd", "c52": "f1071cdb0b6b10dde94d3bc8a6146387", "jopac": "279008e4a0db2dc5f1c048853b033828", "g7400": "79008e4a0db2dc5f1c048853b033828", } bios_choices = [ (_("Magnavox Odyssey²"), "o2rom"), (_("Phillips C52"), "c52"), (_("Phillips Videopac+"), "g7400"), (_("Brandt Jopac"), "jopac"), ] controller_choices = [ (_("Disable"), "0"), (_("Arrow Keys and Right Shift"), "1"), (_("W,S,A,D,SPACE"), "2"), (_("Joystick"), "3"), ] game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "default_path": "game_path", "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ { "option": "bios", "type": "choice", "choices": bios_choices, "label": _("BIOS"), "default": "o2rom", }, { "option": "controller1", "type": "choice", "choices": controller_choices, "section": _("Controllers"), "label": _("First controller"), "default": "2", }, { "option": "controller2", "type": "choice", "choices": controller_choices, "section": _("Controllers"), "label": _("Second controller"), "default": "1", }, { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": False, }, { "option": "scanlines", "type": "bool", "section": _("Graphics"), "label": _("Scanlines display style"), "default": False, "help": _("Activates a display filter adding scanlines to imitate " "the displays of yesteryear."), }, ] def get_platform(self): bios = self.runner_config.get("bios") if bios: for i, b in enumerate(self.bios_choices): if b[1] == bios: return self.platforms[i] return "" def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*args): if not system.path_exists(self.bios_path): os.makedirs(self.bios_path) if callback: callback() super().install(install_ui_delegate, version, on_runner_installed) def play(self): arguments = ["-biosdir=%s" % self.bios_path] if self.runner_config.get("fullscreen"): arguments.append("-fullscreen") if self.runner_config.get("scanlines"): arguments.append("-scanlines") if "controller1" in self.runner_config: arguments.append("-s1=%s" % self.runner_config["controller1"]) if "controller2" in self.runner_config: arguments.append("-s2=%s" % self.runner_config["controller2"]) rom_path = self.game_config.get("main_file") or "" if not system.path_exists(rom_path): raise MissingGameExecutableError(filename=rom_path) romdir = os.path.dirname(rom_path) romfile = os.path.basename(rom_path) arguments.append("-romdir=%s/" % romdir) arguments.append(romfile) return {"command": self.get_command() + arguments} lutris-0.5.17/lutris/runners/openmsx.py000066400000000000000000000015041460562010500202060ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class openmsx(Runner): human_name = _("openMSX") description = _("MSX computer emulator") platforms = [_("MSX, MSX2, MSX2+, MSX turboR")] flatpak_id = "org.openmsx.openMSX" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image."), } ] def play(self): rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) return {"command": self.get_command() + [rom]} lutris-0.5.17/lutris/runners/osmose.py000066400000000000000000000026071460562010500200270ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class osmose(Runner): human_name = _("Osmose") description = _("Sega Master System Emulator") platforms = [_("Sega Master System")] runner_executable = "osmose/osmose" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "default_path": "game_path", "help": _( "The game data, commonly called a ROM image.\n" "Supported formats: SMS and GG files. ZIP compressed " "ROMs are supported." ), } ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": False, } ] def play(self): """Run Sega Master System game""" arguments = self.get_command() rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments.append(rom) if self.runner_config.get("fullscreen"): arguments.append("-fs") arguments.append("-bilinear") return {"command": arguments} lutris-0.5.17/lutris/runners/pcsx2.py000066400000000000000000000032161460562010500175560ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class pcsx2(Runner): human_name = _("PCSX2") description = _("PlayStation 2 emulator") platforms = [_("Sony PlayStation 2")] runnable_alone = True runner_executable = "pcsx2/PCSX2" flatpak_id = "net.pcsx2.PCSX2" game_options = [ { "option": "main_file", "type": "file", "label": _("ISO file"), "default_path": "game_path", } ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": False, }, {"option": "full_boot", "type": "bool", "label": _("Fullboot"), "default": False}, {"option": "nogui", "type": "bool", "label": _("No GUI"), "default": False}, ] # PCSX2 currently uses an AppImage, no need for the runtime. system_options_override = [{"option": "disable_runtime", "default": True}] def play(self): arguments = self.get_command() if self.runner_config.get("fullscreen"): arguments.append("-fullscreen") if self.runner_config.get("full_boot"): arguments.append("-slowboot") if self.runner_config.get("nogui"): arguments.append("-nogui") iso = self.game_config.get("main_file") or "" if not system.path_exists(iso): raise MissingGameExecutableError(filename=iso) arguments.append(iso) return {"command": arguments} lutris-0.5.17/lutris/runners/pico8.py000066400000000000000000000211111460562010500175330ustar00rootroot00000000000000"""Runner for the PICO-8 fantasy console""" import json import math import os import shutil from gettext import gettext as _ from time import sleep from lutris import settings from lutris.database.games import get_game_by_field from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.downloader import Downloader from lutris.util.log import logger from lutris.util.strings import split_arguments class pico8(Runner): description = _("Runs PICO-8 fantasy console cartridges") multiple_versions = False human_name = _("PICO-8") platforms = [_("PICO-8")] game_options = [ { "option": "main_file", "type": "file", "label": _("Cartridge file/URL/ID"), "help": _("You can put a .p8.png file path, URL, or BBS cartridge ID here."), } ] runner_options = [ { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": True, "help": _("Launch in fullscreen."), }, { "option": "window_size", "section": _("Graphics"), "label": _("Window size"), "type": "string", "default": "640x512", "help": _("The initial size of the game window."), }, { "option": "splore", "type": "bool", "label": _("Start in splore mode"), "default": False, }, { "option": "args", "type": "string", "label": _("Extra arguments"), "default": "", "help": _("Extra arguments to the executable"), "advanced": True, }, { "option": "engine", "type": "string", "label": _("Engine (web only)"), "default": "pico8_0111g_4", "help": _("Name of engine (will be downloaded) or local file path"), }, ] system_options_override = [{"option": "disable_runtime", "default": True}] runner_executable = "pico8/web.py" def __init__(self, config=None): super().__init__(config) self.runnable_alone = self.is_native @property def is_native(self): return self.runner_config.get("runner_executable", "") != "" @property def engine_path(self): engine = self.runner_config.get("engine") if not engine.lower().endswith(".js") and not os.path.exists(engine): engine = os.path.join( settings.RUNNER_DIR, "pico8/web/engines", self.runner_config.get("engine") + ".js", ) return engine @property def cart_path(self): main_file = self.game_config.get("main_file") if self.is_native and main_file.startswith("http"): return os.path.join(settings.RUNNER_DIR, "pico8/cartridges", "tmp.p8.png") if not os.path.exists(main_file) and main_file.isdigit(): return os.path.join(settings.RUNNER_DIR, "pico8/cartridges", main_file + ".p8.png") return main_file @property def launch_args(self): if self.is_native: args = self.get_command() args.append("-windowed") args.append("0" if self.runner_config.get("fullscreen") else "1") if self.runner_config.get("splore"): args.append("-splore") size = self.runner_config.get("window_size").split("x") if len(size) == 2: args.append("-width") args.append(size[0]) args.append("-height") args.append(size[1]) extra_args = self.runner_config.get("args", "") for arg in split_arguments(extra_args): args.append(arg) else: args = self.get_command() + [ os.path.join(settings.RUNNER_DIR, "pico8/web/player.html"), "--window-size", self.runner_config.get("window_size"), ] return args def get_run_data(self): return {"command": self.launch_args, "env": self.get_env(os_env=False)} def is_installed(self, flatpak_allowed: bool = True) -> bool: """Checks if pico8 runner is installed and if the pico8 executable available.""" if self.is_native and system.path_exists(self.runner_config.get("runner_executable")): return True return system.path_exists(os.path.join(settings.RUNNER_DIR, "pico8/web/player.html")) def prelaunch(self): if not self.game_config.get("main_file") and self.is_installed(): return True if os.path.exists(os.path.join(settings.RUNNER_DIR, "pico8/cartridges", "tmp.p8.png")): os.remove(os.path.join(settings.RUNNER_DIR, "pico8/cartridges", "tmp.p8.png")) # Don't download cartridge if using web backend and cart is url if self.is_native or not self.game_config.get("main_file").startswith("http"): if not os.path.exists(self.game_config.get("main_file")) and ( self.game_config.get("main_file").isdigit() or self.game_config.get("main_file").startswith("http") ): if not self.game_config.get("main_file").startswith("http"): pid = int(self.game_config.get("main_file")) num = math.floor(pid / 10000) downloadUrl = "https://www.lexaloffle.com/bbs/cposts/" + str(num) + "/" + str(pid) + ".p8.png" else: downloadUrl = self.game_config.get("main_file") cartPath = self.cart_path system.create_folder(os.path.dirname(cartPath)) downloadCompleted = False def on_downloaded_cart(): nonlocal downloadCompleted # If we are offline we don't want an empty file to overwrite the cartridge if dl.downloaded_size: shutil.move(cartPath + ".download", cartPath) else: os.remove(cartPath + ".download") downloadCompleted = True dl = Downloader( downloadUrl, cartPath + ".download", True, callback=on_downloaded_cart, ) dl.start() # Wait for download to complete or continue if it exists (to work in offline mode) while not os.path.exists(cartPath): if downloadCompleted or dl.state == Downloader.ERROR: logger.error("Could not download cartridge from %s", downloadUrl) return False sleep(0.1) # Download js engine if not self.is_native and not os.path.exists(self.runner_config.get("engine")): enginePath = os.path.join( settings.RUNNER_DIR, "pico8/web/engines", self.runner_config.get("engine") + ".js", ) if not os.path.exists(enginePath): downloadUrl = "https://www.lexaloffle.com/bbs/" + self.runner_config.get("engine") + ".js" system.create_folder(os.path.dirname(enginePath)) dl = Downloader(downloadUrl, enginePath, True) dl.start() dl.join() def play(self): launch_info = {} launch_info["env"] = self.get_env(os_env=False) game_data = get_game_by_field(self.config.game_config_id, "configpath") command = self.launch_args if self.is_native: if not self.runner_config.get("splore"): command.append("-run") cartPath = self.cart_path if not os.path.exists(cartPath): raise MissingGameExecutableError(filename=cartPath) command.append(cartPath) else: command.append("--name") command.append(game_data.get("name") + " - PICO-8") # icon = datapath.get_icon_path(game_data.get("slug")) # if icon: # command.append("--icon") # command.append(icon) webargs = { "cartridge": self.cart_path, "engine": self.engine_path, "fullscreen": self.runner_config.get("fullscreen") is True, } command.append("--execjs") command.append("load_config(" + json.dumps(webargs) + ")") launch_info["command"] = command return launch_info lutris-0.5.17/lutris/runners/redream.py000066400000000000000000000111441460562010500201350ustar00rootroot00000000000000import os import shutil from gettext import gettext as _ from lutris import settings from lutris.runners.runner import Runner class redream(Runner): human_name = _("Redream") description = _("Sega Dreamcast emulator") platforms = [_("Sega Dreamcast")] runner_executable = "redream/redream" download_url = "https://redream.io/download/redream.x86_64-linux-v1.5.0.tar.gz" game_options = [ { "option": "main_file", "type": "file", "label": _("Disc image file"), "help": _("Game data file\nSupported formats: GDI, CDI, CHD"), } ] runner_options = [ {"option": "fs", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": False}, { "option": "ar", "type": "choice", "section": _("Graphics"), "label": _("Aspect Ratio"), "choices": [(_("4:3"), "4:3"), (_("Stretch"), "stretch")], "default": "4:3", }, { "option": "region", "type": "choice", "label": _("Region"), "choices": [(_("USA"), "usa"), (_("Europe"), "europe"), (_("Japan"), "japan")], "default": "usa", }, { "option": "language", "type": "choice", "label": _("System Language"), "choices": [ (_("English"), "english"), (_("German"), "german"), (_("French"), "french"), (_("Spanish"), "spanish"), (_("Italian"), "italian"), (_("Japanese"), "japanese"), ], "default": "english", }, { "option": "broadcast", "type": "choice", "label": "Television System", "choices": [ (_("NTSC"), "ntsc"), (_("PAL"), "pal"), (_("PAL-M (Brazil)"), "pal_m"), (_("PAL-N (Argentina, Paraguay, Uruguay)"), "pal_n"), ], "default": "ntsc", }, { "option": "time_sync", "type": "choice", "label": _("Time Sync"), "choices": [ (_("Audio and video"), "audio and video"), (_("Audio"), "audio"), (_("Video"), "video"), (_("None"), "none"), ], "default": "audio and video", "advanced": True, }, { "option": "int_res", "type": "choice", "label": _("Internal Video Resolution Scale"), "choices": [ ("×1", "1"), ("×2", "2"), ("×3", "3"), ("×4", "4"), ("×5", "5"), ("×6", "6"), ("×7", "7"), ("×8", "8"), ], "default": "2", "advanced": True, "help": _("Only available in premium version."), }, ] def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*args): license_filename = install_ui_delegate.show_install_file_inquiry( question=_("Do you want to select a premium license file?"), title=_("Use premium version?"), message=_("Use premium version?"), ) if license_filename: shutil.copy(license_filename, os.path.join(settings.RUNNER_DIR, "redream")) super().install(install_ui_delegate, version=version, callback=on_runner_installed) def play(self): command = self.get_command() if self.runner_config.get("fs") is True: command.append("--fullscreen=1") else: command.append("--fullscreen=0") if self.runner_config.get("ar"): command.append("--aspect=" + self.runner_config.get("ar")) if self.runner_config.get("region"): command.append("--region=" + self.runner_config.get("region")) if self.runner_config.get("language"): command.append("--language=" + self.runner_config.get("language")) if self.runner_config.get("broadcast"): command.append("--broadcast=" + self.runner_config.get("broadcast")) if self.runner_config.get("time_sync"): command.append("--time_sync=" + self.runner_config.get("time_sync")) if self.runner_config.get("int_res"): command.append("--res=" + self.runner_config.get("int_res")) command.append(self.game_config.get("main_file")) return {"command": command} lutris-0.5.17/lutris/runners/reicast.py000066400000000000000000000123141460562010500201500ustar00rootroot00000000000000# 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.17/lutris/runners/rpcs3.py000066400000000000000000000023461460562010500175540ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class rpcs3(Runner): human_name = _("RPCS3") description = _("PlayStation 3 emulator") platforms = [_("Sony PlayStation 3")] runnable_alone = True runner_executable = "rpcs3/rpcs3" flatpak_id = "net.rpcs3.RPCS3" game_options = [ { "option": "main_file", "type": "file", "default_path": "game_path", "label": _("Path to EBOOT.BIN"), } ] runner_options = [{"option": "nogui", "type": "bool", "label": _("No GUI"), "default": False}] # RPCS3 currently uses an AppImage, no need for the runtime. system_options_override = [{"option": "disable_runtime", "default": True}] def play(self): arguments = self.get_command() if self.runner_config.get("nogui"): arguments.append("--no-gui") eboot = self.game_config.get("main_file") or "" if not system.path_exists(eboot): raise MissingGameExecutableError(filename=eboot) arguments.append(eboot) return {"command": arguments} lutris-0.5.17/lutris/runners/runner.py000066400000000000000000000523111460562010500200300ustar00rootroot00000000000000"""Base module for runners""" import os import signal from gettext import gettext as _ from typing import Any, Callable, Dict, Optional from lutris import runtime, settings from lutris.api import format_runner_version, get_default_runner_version_info from lutris.config import LutrisConfig from lutris.database.games import get_game_by_field from lutris.exceptions import MisconfigurationError, MissingExecutableError, UnavailableLibrariesError from lutris.monitored_command import MonitoredCommand from lutris.runners import RunnerInstallationError from lutris.util import flatpak, strings, system from lutris.util.extract import ExtractError, extract_archive from lutris.util.graphics.gpu import GPUS from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger class Runner: # pylint: disable=too-many-public-methods """Generic runner (base class for other runners).""" multiple_versions = False platforms = [] runnable_alone = False game_options = [] runner_options = [] system_options_override = [] context_menu_entries = [] require_libs = [] runner_executable = None entry_point_option = "main_file" download_url = None arch = None # If the runner is only available for an architecture that isn't x86_64 flatpak_id = None def __init__(self, config=None): """Initialize runner.""" if config: self.has_explicit_config = True self._config = config self.game_data = get_game_by_field(config.game_config_id, "configpath") else: self.has_explicit_config = False self._config = None self.game_data = {} def __lt__(self, other): return self.name < other.name @property def description(self): """Return the class' docstring as the description.""" return self.__doc__ @description.setter def description(self, value): """Leave the ability to override the docstring.""" self.__doc__ = value # What the shit @property def runner_warning(self): """Returns a message (as markup) that is displayed in the configuration dialog as a warning.""" return None @property def name(self): return self.__class__.__name__ @property def directory(self): return os.path.join(settings.RUNNER_DIR, self.name) @property def config(self): if not self._config: self._config = LutrisConfig(runner_slug=self.name) return self._config @config.setter def config(self, new_config): self._config = new_config self.has_explicit_config = new_config is not None @property def game_config(self): """Return the cascaded game config as a dict.""" if not self.has_explicit_config: logger.warning("Accessing game config while runner wasn't given one.") return self.config.game_config @property def runner_config(self): """Return the cascaded runner config as a dict.""" return self.config.runner_config @property def system_config(self): """Return the cascaded system config as a dict.""" return self.config.system_config @property def default_path(self): """Return the default path where games are installed.""" return self.system_config.get("game_path") @property def game_path(self): """Return the directory where the game is installed.""" game_path = self.game_data.get("directory") if game_path: return os.path.expanduser(game_path) # expanduser just in case! if self.has_explicit_config: # Default to the directory where the entry point is located. entry_point = self.game_config.get(self.entry_point_option) if entry_point: return os.path.dirname(os.path.expanduser(entry_point)) return "" def resolve_game_path(self): """Returns the path where the game is found; if game_path does not provide a path, this may try to resolve the path by runner-specific means, which can find things like /usr/games when applicable.""" return self.game_path @property def working_dir(self): """Return the working directory to use when running the game.""" return self.game_path or os.path.expanduser("~/") @property def shader_cache_dir(self): """Return the cache directory for this runner to use. We create this if it does not exist.""" path = os.path.join(settings.SHADER_CACHE_DIR, self.name) if not os.path.isdir(path): os.mkdir(path) return path @property def nvidia_shader_cache_path(self): """The path to place in __GL_SHADER_DISK_CACHE_PATH; NVidia will place its cache cache in a subdirectory here.""" return self.shader_cache_dir @property def discord_client_id(self): if self.game_data.get("discord_client_id"): return self.game_data.get("discord_client_id") def get_platform(self): return self.platforms[0] def get_runner_options(self): runner_options = self.runner_options[:] if self.runner_executable: runner_options.append( { "option": "runner_executable", "type": "file", "label": _("Custom executable for the runner"), "advanced": True, } ) runner_options.append( { "section": _("Side Panel"), "option": "visible_in_side_panel", "type": "bool", "label": _("Visible in Side Panel"), "default": True, "advanced": True, "scope": ["runner"], "help": _("Show this runner in the side panel if it is installed or available through Flatpak."), } ) return runner_options def get_executable(self) -> str: if "runner_executable" in self.runner_config: runner_executable = self.runner_config["runner_executable"] if os.path.isfile(runner_executable): return runner_executable if not self.runner_executable: raise MisconfigurationError("runner_executable not set for {}".format(self.name)) exe = os.path.join(settings.RUNNER_DIR, self.runner_executable) if not os.path.isfile(exe): raise MissingExecutableError(_("The executable '%s' could not be found.") % self.runner_executable) return exe def get_command(self): """Returns the command line to run the runner itself; generally a game will be appended to this by play().""" try: exe = self.get_executable() if not system.path_exists(exe): raise MissingExecutableError(_("The executable '%s' could not be found.") % exe) return [exe] except MisconfigurationError: if flatpak.is_app_installed(self.flatpak_id): return flatpak.get_run_command(self.flatpak_id) raise def get_env(self, os_env=False, disable_runtime=False): """Return environment variables used for a game.""" env = {} if os_env: env = system.get_environment() # By default we'll set NVidia's shader disk cache to be # per-game, so it overflows less readily. env["__GL_SHADER_DISK_CACHE"] = "1" env["__GL_SHADER_DISK_CACHE_PATH"] = self.nvidia_shader_cache_path # Override SDL2 controller configuration sdl_gamecontrollerconfig = self.system_config.get("sdl_gamecontrollerconfig") if sdl_gamecontrollerconfig: path = os.path.expanduser(sdl_gamecontrollerconfig) if system.path_exists(path): with open(path, "r", encoding="utf-8") as controllerdb_file: sdl_gamecontrollerconfig = controllerdb_file.read() env["SDL_GAMECONTROLLERCONFIG"] = sdl_gamecontrollerconfig # Set monitor to use for SDL 1 games sdl_video_fullscreen = self.system_config.get("sdl_video_fullscreen") if sdl_video_fullscreen and sdl_video_fullscreen != "off": env["SDL_VIDEO_FULLSCREEN_DISPLAY"] = sdl_video_fullscreen if self.system_config.get("gpu") and len(GPUS) > 1: gpu = GPUS[self.system_config["gpu"]] if gpu.driver == "nvidia": env["DRI_PRIME"] = "1" env["__NV_PRIME_RENDER_OFFLOAD"] = "1" env["__GLX_VENDOR_LIBRARY_NAME"] = "nvidia" env["__VK_LAYER_NV_optimus"] = "NVIDIA_only" else: env["DRI_PRIME"] = gpu.pci_id env["VK_ICD_FILENAMES"] = gpu.icd_files # Deprecated env["VK_DRIVER_FILES"] = gpu.icd_files # Current form # Set PulseAudio latency to 60ms if self.system_config.get("pulse_latency"): env["PULSE_LATENCY_MSEC"] = "60" runtime_ld_library_path = None if not disable_runtime and self.use_runtime(): runtime_env = self.get_runtime_env() runtime_ld_library_path = runtime_env.get("LD_LIBRARY_PATH") if runtime_ld_library_path: ld_library_path = env.get("LD_LIBRARY_PATH") env["LD_LIBRARY_PATH"] = os.pathsep.join(filter(None, [runtime_ld_library_path, ld_library_path])) # Apply user overrides at the end env.update(self.system_config.get("env") or {}) return env def get_runtime_env(self): """Return runtime environment variables. This method may be overridden in runner classes. (Notably for Lutris wine builds) Returns: dict """ return runtime.get_env(prefer_system_libs=self.system_config.get("prefer_system_libs", True)) def apply_launch_config(self, gameplay_info, launch_config): """Updates the gameplay_info to reflect a launch_config section. Called only if a non-default config is chosen.""" gameplay_info["command"] = self.get_launch_config_command(gameplay_info, launch_config) config_working_dir = self.get_launch_config_working_dir(launch_config) if config_working_dir: gameplay_info["working_dir"] = config_working_dir def get_launch_config_command(self, gameplay_info, launch_config): """Generates a new command for the gameplay_info, to implement the launch_config. Returns a new list of strings; the caller can modify it further. If launch_config has no command, this builds one from the gameplay_info command and the 'exe' value in the launch_config. Runners override this when required to control the command used.""" if "command" in launch_config: command = strings.split_arguments(launch_config["command"]) else: command = self.get_command() exe = self.get_launch_config_exe(launch_config) if exe: command.append(exe) if launch_config.get("args"): command += strings.split_arguments(launch_config["args"]) return command def get_launch_config_exe(self, launch_config): """Locates the "exe" of the launch config. If it appears to be relative to the game's working_dir, this will try to adjust it to be relative to the config's instead. """ exe = launch_config.get("exe") config_working_dir = self.get_launch_config_working_dir(launch_config) if exe: exe = os.path.expanduser(exe) # just in case! if config_working_dir and not os.path.isabs(exe): exe_from_config = self.resolve_config_path(exe, config_working_dir) exe_from_game = self.resolve_config_path(exe) if os.path.exists(exe_from_game) and not os.path.exists(exe_from_config): relative = os.path.relpath(exe_from_game, start=config_working_dir) if not relative.startswith("../"): return relative return exe def get_launch_config_working_dir(self, launch_config): """Extracts the "working_dir" from the config, and resolves this relative to the game's working directory, so that an absolute path results. This returns None if no working_dir is present, or if it found to be missing. """ config_working_dir = launch_config.get("working_dir") if config_working_dir: config_working_dir = self.resolve_config_path(config_working_dir) if os.path.isdir(config_working_dir): return config_working_dir return None def resolve_config_path(self, path, relative_to=None): """Interpret a path taken from the launch_config relative to a working directory, using the game's working_dir if that is omitted, and expanding the '~' if we get one. This is provided as a method so the WINE runner can try to convert Windows-style paths to usable paths. """ path = os.path.expanduser(path) if not os.path.isabs(path): if not relative_to: relative_to = self.working_dir if relative_to: return os.path.join(relative_to, path) return path def prelaunch(self): """Run actions before running the game, override this method in runners; raise an exception if prelaunch fails, and it will be reported to the user, and then the game won't start.""" available_libs = set() for lib in set(self.require_libs): if lib in LINUX_SYSTEM.shared_libraries: if self.arch: if self.arch in [_lib.arch for _lib in LINUX_SYSTEM.shared_libraries[lib]]: available_libs.add(lib) else: available_libs.add(lib) unavailable_libs = set(self.require_libs) - available_libs if unavailable_libs: raise UnavailableLibrariesError(unavailable_libs, self.arch) def get_run_data(self): """Return dict with command (exe & args list) and env vars (dict). Reimplement in derived runner if need be.""" return {"command": self.get_command(), "env": self.get_env()} def run(self, ui_delegate): """Run the runner alone.""" if not self.runnable_alone: return if not self.is_installed(): if not self.install_dialog(ui_delegate): logger.info("Runner install cancelled") return command_data = self.get_run_data() command = command_data.get("command") env = (command_data.get("env") or {}).copy() self.prelaunch() command_runner = MonitoredCommand(command, runner=self, env=env) command_runner.start() def use_runtime(self): if runtime.RUNTIME_DISABLED: logger.info("Runtime disabled by environment") return False if self.system_config.get("disable_runtime"): logger.info("Runtime disabled by system configuration") return False return True def install_dialog(self, ui_delegate): """Ask the user if they want to install the runner. Return success of runner installation. """ if ui_delegate.show_install_yesno_inquiry( question=_("The required runner is not installed.\n" "Do you wish to install it now?"), title=_("Required runner unavailable"), ): if hasattr(self, "get_version"): version = self.get_version(use_default=False) # pylint: disable=no-member self.install(ui_delegate, version=version) else: self.install(ui_delegate) return self.is_installed() return False def is_installed(self, flatpak_allowed: bool = True) -> bool: """Return whether the runner is installed""" try: # Don't care where the exe is, only if we can find it. exe = self.get_executable() if system.path_exists(exe): return True except MisconfigurationError: pass # We can still try flatpak even if 'which' fails us! return bool(flatpak_allowed and self.flatpak_id and flatpak.is_app_installed(self.flatpak_id)) def is_installed_for(self, interpreter): """Returns whether the runner is installed. Specific runners can extract additional script settings, to determine more precisely what must be installed.""" return self.is_installed() def get_installer_runner_version(self, installer, use_runner_config: bool = True) -> Optional[str]: return None def adjust_installer_runner_config(self, installer_runner_config: Dict[str, Any]) -> None: """This is called during installation to let to run fix up in the runner's section of the configuration before it is saved. This method should modify the dict given.""" pass def get_runner_version(self, version: str = None) -> Dict[str, str]: """Get the appropriate version for a runner, as with get_default_runner_version(), but this method allows the runner to apply its configuration.""" return get_default_runner_version_info(self.name, version) def install(self, install_ui_delegate, version=None, callback=None): """Install runner using package management systems.""" logger.debug( "Installing %s (version=%s, callback=%s)", self.name, version, callback, ) opts = {"install_ui_delegate": install_ui_delegate, "callback": callback} if self.download_url: opts["dest"] = self.directory return self.download_and_extract(self.download_url, **opts) runner_version_info = self.get_runner_version(version) if not runner_version_info: raise RunnerInstallationError(_("Failed to retrieve {} ({}) information").format(self.name, version)) if "wine" in self.name: opts["merge_single"] = True opts["dest"] = os.path.join(self.directory, format_runner_version(runner_version_info)) if self.name == "libretro" and version: opts["merge_single"] = False opts["dest"] = os.path.join(settings.RUNNER_DIR, "retroarch/cores") self.download_and_extract(runner_version_info["url"], **opts) def download_and_extract(self, url, dest=None, **opts): install_ui_delegate = opts["install_ui_delegate"] merge_single = opts.get("merge_single", False) callback = opts.get("callback") tarball_filename = os.path.basename(url) runner_archive = os.path.join(settings.CACHE_DIR, tarball_filename) if not dest: dest = settings.RUNNER_DIR 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 ExtractError as ex: logger.error("Failed to extract the archive %s file may be corrupt", archive) raise RunnerInstallationError(_("Failed to extract {}: {}").format(archive, ex)) from ex os.remove(archive) if self.name == "wine": logger.debug("Clearing wine version cache") from lutris.util.wine.wine import 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, uninstall_callback: Callable[[], None]) -> None: runner_path = self.directory if os.path.isdir(runner_path): system.remove_folder(runner_path, completion_function=uninstall_callback) else: uninstall_callback() def find_option(self, options_group, option_name): """Retrieve an option dict if it exists in the group""" if options_group not in ["game_options", "runner_options"]: return None output = None for item in getattr(self, options_group): if item["option"] == option_name: output = item break return output def force_stop_game(self, game): """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.17/lutris/runners/ryujinx.py000066400000000000000000000060771460562010500202370ustar00rootroot00000000000000import filecmp import os from gettext import gettext as _ from shutil import copyfile from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger class ryujinx(Runner): human_name = _("Ryujinx") platforms = [_("Nintendo Switch")] description = _("Nintendo Switch emulator") runnable_alone = True runner_executable = "ryujinx/publish/Ryujinx" flatpak_id = "org.ryujinx.Ryujinx" download_url = "https://lutris.nyc3.digitaloceanspaces.com/runners/ryujinx/ryujinx-1.0.7074-linux_x64.tar.gz" game_options = [ { "option": "main_file", "type": "file", "label": _("NSP file"), "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ { "option": "prod_keys", "label": _("Encryption keys"), "type": "file", "help": _("File containing the encryption keys."), }, { "option": "title_keys", "label": _("Title keys"), "type": "file", "help": _("File containing the title keys."), }, ] @property def ryujinx_data_dir(self): """Return dir where Ryujinx files lie.""" candidates = ("~/.local/share/ryujinx",) for candidate in candidates: path = system.fix_path_case(os.path.join(os.path.expanduser(candidate), "nand")) if system.path_exists(path): return path[: -len("nand")] def play(self): """Run the game.""" arguments = self.get_command() rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments.append(rom) return {"command": arguments} def _update_key(self, key_type): """Update a keys file if set""" ryujinx_data_dir = self.ryujinx_data_dir if not ryujinx_data_dir: logger.error("Ryujinx data dir not set") return if key_type == "prod_keys": key_loc = os.path.join(ryujinx_data_dir, "keys/prod.keys") elif key_type == "title_keys": key_loc = os.path.join(ryujinx_data_dir, "keys/title.keys") else: logger.error("Invalid keys type %s!", key_type) return key = self.runner_config.get(key_type) if not key: logger.debug("No %s file was set.", key_type) return if not system.path_exists(key): logger.warning("Keys file %s does not exist!", key) return keys_dir = os.path.dirname(key_loc) if not os.path.exists(keys_dir): os.makedirs(keys_dir) elif os.path.isfile(key_loc) and filecmp.cmp(key, key_loc): # If the files are identical, don't do anything return copyfile(key, key_loc) def prelaunch(self): for key in ["prod_keys", "title_keys"]: self._update_key(key_type=key) lutris-0.5.17/lutris/runners/scummvm.py000066400000000000000000000461341460562010500202140ustar00rootroot00000000000000import os import subprocess from gettext import gettext as _ from typing import Any, Dict, List 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) -> List[str]: """Scummvm runner ships additional libraries, they may be removed in a future version.""" base_runner_path = os.path.join(settings.RUNNER_DIR, "scummvm") if self.get_executable().startswith(base_runner_path): path = os.path.join(settings.RUNNER_DIR, "scummvm/lib") if system.path_exists(path): return [path] return [] def get_command(self) -> List[str]: command = super().get_command() if not command: return [] if "flatpak" in command[0]: return command data_dir = self.get_scummvm_data_dir() return command + [ "--extrapath=%s" % data_dir, "--themepath=%s" % data_dir, ] def get_scummvm_data_dir(self) -> str: data_dir = self.runner_config.get("datadir") if data_dir is None: root_dir = os.path.dirname(os.path.dirname(self.get_executable())) data_dir = os.path.join(root_dir, "share/scummvm") return data_dir def get_run_data(self) -> Dict[str, Any]: env = self.get_env() lib_paths = filter(None, self.get_extra_libs() + [env.get("LD_LIBRARY_PATH")]) env["LD_LIBRARY_PATH"] = os.pathsep.join(lib_paths) return {"env": env, "command": self.get_command()} def inject_runner_option(self, command, key, cmdline, cmdline_empty=None): value = self.runner_config.get(key) if value: if "%s" in cmdline: command.append(cmdline % value) else: command.append(cmdline) elif cmdline_empty: command.append(cmdline_empty) def play(self): command = self.get_command() for option, cmdline in self.option_map.items(): self.inject_runner_option(command, option, cmdline, self.option_empty_map.get(option)) command.append("--path=%s" % self.game_path) args = self.game_config.get("args") or "" for arg in split_arguments(args): command.append(arg) command.append(self.game_config.get("game_id")) output = {"command": command} extra_libs = self.get_extra_libs() if extra_libs: output["ld_library_path"] = os.pathsep.join(extra_libs) return output def get_game_list(self) -> List[List[str]]: """Return the entire list of games supported by ScummVM.""" with subprocess.Popen( self.get_command() + ["--list-games"], stdout=subprocess.PIPE, encoding="utf-8", universal_newlines=True ) as scummvm_process: scumm_output = scummvm_process.communicate()[0] game_list = str.split(scumm_output, "\n") game_array = [] game_list_start = False for game in game_list: if game_list_start: if len(game) > 1: dir_limit = game.index(" ") else: dir_limit = None if dir_limit is not None: game_dir = game[0:dir_limit] game_name = game[dir_limit + 1 : len(game)].strip() game_array.append([game_dir, game_name]) # The actual list is below a separator if game.startswith("-----"): game_list_start = True return game_array lutris-0.5.17/lutris/runners/snes9x.py000066400000000000000000000055371460562010500177600ustar00rootroot00000000000000# Standard Library import os import subprocess import xml.etree.ElementTree as etree from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger SNES9X_DIR = os.path.join(settings.DATA_DIR, "runners/snes9x") class snes9x(Runner): description = _("Super Nintendo emulator") human_name = _("Snes9x") platforms = [_("Nintendo SNES")] runnable_alone = True runner_executable = "snes9x/bin/snes9x-gtk" flatpak_id = "com.snes9x.Snes9x" game_options = [ { "option": "main_file", "type": "file", "default_path": "game_path", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ {"option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": "1"}, { "option": "maintain_aspect_ratio", "type": "bool", "section": _("Graphics"), "label": _("Maintain aspect ratio (4:3)"), "default": "1", "help": _( "Super Nintendo games were made for 4:3 " "screens with rectangular pixels, but modern screens " "have square pixels, which results in a vertically " "squeezed image. This option corrects this by displaying " "rectangular pixels." ), }, { "option": "sound_driver", "type": "choice", "label": _("Sound driver"), "advanced": True, "choices": (("SDL", "1"), ("ALSA", "2"), ("OSS", "0")), "default": "1", }, ] def set_option(self, option, value): config_file = os.path.expanduser("~/.snes9x/snes9x.xml") if not system.path_exists(config_file): with subprocess.Popen(self.get_command() + ["-help"]) as snes9x_process: snes9x_process.communicate() if not system.path_exists(config_file): logger.error("Snes9x config file creation failed") return tree = etree.parse(config_file) node = tree.find("./preferences/option[@name='%s']" % option) if 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): raise MissingGameExecutableError(filename=rom) return {"command": self.get_command() + [rom]} lutris-0.5.17/lutris/runners/steam.py000066400000000000000000000222651460562010500176350ustar00rootroot00000000000000"""Steam for Linux runner""" import os from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError, UnavailableRunnerError from lutris.monitored_command import MonitoredCommand from lutris.runners import NonInstallableRunnerError from lutris.runners.runner import Runner from lutris.util import linux, system from lutris.util.log import logger from lutris.util.steam.appmanifest import get_appmanifest_from_appid, get_path_from_appmanifest from lutris.util.steam.config import get_default_acf, get_steam_dir, get_steamapps_dirs from lutris.util.steam.vdfutils import to_vdf from lutris.util.strings import split_arguments def get_steam_pid(): """Return pid of Steam process.""" return system.get_pid("steam$") def is_running(): """Checks if Steam is running.""" return bool(get_steam_pid()) class steam(Runner): description = _("Runs Steam for Linux games") human_name = _("Steam") platforms = [_("Linux")] runner_executable = "steam" flatpak_id = "com.valvesoftware.Steam" game_options = [ { "option": "appid", "label": _("Application ID"), "type": "string", "help": _( "The application ID can be retrieved from the game's " "page at steampowered.com. Example: 235320 is the " "app ID for Original War in: \n" "http://store.steampowered.com/app/235320/" ), }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _( "Command line arguments used when launching the game.\n" "Ignored when Steam Big Picture mode is enabled." ), }, { "option": "run_without_steam", "label": _("DRM free mode (Do not launch Steam)"), "type": "bool", "default": False, "advanced": True, "help": _("Run the game directly without Steam, requires the game binary path to be set"), }, { "option": "steamless_binary", "type": "file", "label": _("Game binary path"), "advanced": True, "help": _("Path to the game executable (Required by DRM free mode)"), }, ] runner_options = [ { "option": "start_in_big_picture", "label": _("Start Steam in Big Picture mode"), "type": "bool", "default": False, "help": _( "Launches Steam in Big Picture mode.\n" "Only works if Steam is not running or " "already running in Big Picture mode.\n" "Useful when playing with a Steam Controller." ), }, { "option": "lsi_steam", "label": _("Start Steam with LSI"), "type": "bool", "default": False, "help": _( "Launches steam with LSI patches enabled. " "Make sure Lutris Runtime is disabled and " "you have LSI installed. " "https://github.com/solus-project/linux-steam-integration" ), }, { "option": "args", "type": "string", "label": _("Arguments"), "advanced": True, "help": _("Extra command line arguments used when launching Steam"), }, ] system_options_override = [ {"option": "disable_runtime", "default": True}, {"option": "gamemode", "default": False}, ] @property def runnable_alone(self): return not linux.LINUX_SYSTEM.is_flatpak() @property def appid(self): return self.game_config.get("appid") or "" @property def game_path(self): if not self.appid: return None return self.get_game_path_from_appid(self.appid) @property def steam_data_dir(self): """Main installation directory for Steam""" return get_steam_dir() def get_appmanifest(self): """Return an AppManifest instance for the game""" appmanifests = [] for apps_path in get_steamapps_dirs(): appmanifest = get_appmanifest_from_appid(apps_path, self.appid) if appmanifest: appmanifests.append(appmanifest) if len(appmanifests) > 1: logger.warning("More than one AppManifest for %s returning only 1st", self.appid) if appmanifests: return appmanifests[0] def get_executable(self) -> str: if linux.LINUX_SYSTEM.is_flatpak(): # Fallback to xgd-open for Steam URIs in Flatpak return system.find_required_executable("xdg-open") if self.runner_config.get("lsi_steam"): lsi_steam_path = system.find_executable("lsi-steam") if lsi_steam_path: return lsi_steam_path runner_executable = self.runner_config.get("runner_executable") if runner_executable and os.path.isfile(runner_executable): return runner_executable return system.find_required_executable(self.runner_executable) @property def working_dir(self): """Return the working directory to use when running the game.""" if self.game_config.get("run_without_steam"): steamless_binary = self.game_config.get("steamless_binary") if steamless_binary and os.path.isfile(steamless_binary): return os.path.dirname(steamless_binary) return super().working_dir @property def launch_args(self): """Provide launch arguments for Steam""" command = self.get_command() if self.runner_config.get("start_in_big_picture"): command.append("-bigpicture") return command + split_arguments(self.runner_config.get("args") or "") def get_game_path_from_appid(self, appid): """Return the game directory.""" for apps_path in get_steamapps_dirs(): game_path = get_path_from_appmanifest(apps_path, appid) if game_path: return game_path logger.info("Data path for SteamApp %s not found.", appid) return "" def get_default_steamapps_path(self): steamapps_paths = get_steamapps_dirs() if steamapps_paths: return steamapps_paths[0] return "" def install(self, install_ui_delegate, version=None, callback=None): raise NonInstallableRunnerError( _( "Steam for Linux installation is not handled by Lutris.\n" "Please go to " "http://steampowered.com" " or install Steam with the package provided by your distribution." ) ) def install_game(self, appid, generate_acf=False): logger.debug("Installing steam game %s", appid) if generate_acf: acf_data = get_default_acf(appid, appid) acf_content = to_vdf(acf_data) steamapps_path = self.get_default_steamapps_path() if not steamapps_path: raise UnavailableRunnerError(_("Could not find Steam path, is Steam installed?")) acf_path = os.path.join(steamapps_path, "appmanifest_%s.acf" % appid) with open(acf_path, "w", encoding="utf-8") as acf_file: acf_file.write(acf_content) system.spawn(self.get_command() + [f"steam://install/{appid}"]) def get_run_data(self): return {"command": self.launch_args, "env": self.get_env()} def play(self): game_args = self.game_config.get("args") or "" binary_path = self.game_config.get("steamless_binary") if self.game_config.get("run_without_steam") and binary_path: # Start without steam if not system.path_exists(binary_path): raise MissingGameExecutableError(filename=binary_path) command = [binary_path] else: # Start through steam if linux.LINUX_SYSTEM.is_flatpak(): if game_args: steam_uri = "steam://run/%s//%s/" % (self.appid, game_args) else: steam_uri = "steam://rungameid/%s" % self.appid return { "command": self.launch_args + [steam_uri], "env": self.get_env(), } command = self.launch_args if self.runner_config.get("start_in_big_picture") or not game_args: command.append("steam://rungameid/%s" % self.appid) else: command.append("-applaunch") command.append(self.appid) if game_args: for arg in split_arguments(game_args): command.append(arg) return { "command": command, "env": self.get_env(), } def remove_game_data(self, app_id=None, **kwargs): if not self.is_installed(): return False app_id = app_id or self.appid command = MonitoredCommand( self.get_command() + [f"steam://uninstall/{app_id}"], runner=self, env=self.get_env(), ) command.start() lutris-0.5.17/lutris/runners/vice.py000066400000000000000000000156301460562010500174500ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.exceptions import GameConfigError, MisconfigurationError, MissingExecutableError, MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger class vice(Runner): description = _("Commodore Emulator") human_name = _("Vice") # flatpak_id = "net.sf.VICE" # needs adjustments platforms = [ _("Commodore 64"), _("Commodore 128"), _("Commodore VIC20"), _("Commodore PET"), _("Commodore Plus/4"), _("Commodore CBM II"), ] machine_choices = [ ("C64", "c64"), ("C128", "c128"), ("vic20", "vic20"), ("PET", "pet"), ("Plus/4", "plus4"), ("CBM-II", "cbmii"), ] game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _( "The game data, commonly called a ROM image.\n" "Supported formats: X64, D64, G64, P64, D67, D71, D81, " "D80, D82, D1M, D2M, D4M, T46, P00 and CRT." ), } ] runner_options = [ {"option": "joy", "type": "bool", "label": _("Use joysticks"), "default": False}, { "option": "fullscreen", "type": "bool", "section": _("Graphics"), "label": _("Fullscreen"), "default": False, }, { "option": "double", "type": "bool", "section": _("Graphics"), "label": _("Scale up display by 2"), "default": True, }, { "option": "aspect_ratio", "type": "bool", "section": _("Graphics"), "label": _("Preserve aspect ratio"), "default": True, }, { "option": "renderer", "type": "choice", "section": _("Graphics"), "label": _("Graphics renderer"), "choices": [("OpenGL", "opengl"), (_("Software"), "software")], "default": "opengl", }, { "option": "drivesound", "type": "bool", "label": _("Enable sound emulation of disk drives"), "default": False, }, { "option": "machine", "type": "choice", "label": _("Machine"), "choices": machine_choices, "default": "c64", }, ] def get_platform(self): machine = self.game_config.get("machine") if machine: for index, choice in enumerate(self.machine_choices): if choice[1] == machine: return self.platforms[index] return self.platforms[0] # Default to C64 def get_executable(self, machine: str = None) -> str: if not machine: machine = "c64" executables = { "c64": "x64", "c128": "x128", "vic20": "xvic", "pet": "xpet", "plus4": "xplus4", "cbmii": "xcbm2", } try: executable = executables[machine] exe = os.path.join(settings.RUNNER_DIR, "vice/bin/%s" % executable) if not os.path.isfile(exe): raise MissingExecutableError(_("The executable '%s' could not be found.") % exe) return exe except KeyError as ex: raise MisconfigurationError("Invalid machine '%s'" % machine) from ex def install(self, install_ui_delegate, version=None, callback=None): def on_runner_installed(*args): config_path = system.create_folder("~/.vice") lib_dir = os.path.join(settings.RUNNER_DIR, "vice/lib/vice") if not system.path_exists(lib_dir): lib_dir = os.path.join(settings.RUNNER_DIR, "vice/lib64/vice") if not system.path_exists(lib_dir): logger.error("Missing lib folder in the Vice runner") else: system.merge_folders(lib_dir, config_path) if callback: callback() super().install(install_ui_delegate, version, on_runner_installed) def get_roms_path(self, machine=None): if not machine: machine = "c64" paths = { "c64": "C64", "c128": "C128", "vic20": "VIC20", "pet": "PET", "plus4": "PLUS4", "cmbii": "CBM-II", } root_dir = os.path.dirname(os.path.dirname(self.get_executable())) return os.path.join(root_dir, "lib64/vice", paths[machine]) @staticmethod def get_option_prefix(machine): prefixes = { "c64": "VICII", "c128": "VICII", "vic20": "VIC", "pet": "CRTC", "plus4": "TED", "cmbii": "CRTC", } return prefixes[machine] @staticmethod def get_joydevs(machine): joydevs = {"c64": 2, "c128": 2, "vic20": 1, "pet": 0, "plus4": 2, "cmbii": 0} return joydevs[machine] @staticmethod def get_rom_args(machine, rom): args = [] if rom.endswith(".crt"): crt_option = { "c64": "-cartcrt", "c128": "-cartcrt", "vic20": "-cartgeneric", "pet": None, "plus4": "-cart", "cmbii": None, } if crt_option[machine]: args.append(crt_option[machine]) args.append(rom) return args def play(self): machine = self.runner_config.get("machine") rom = self.game_config.get("main_file") if not rom: raise GameConfigError(_("No rom provided")) if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) params = [self.get_executable(machine)] rom_dir = os.path.dirname(rom) params.append("-chdir") params.append(rom_dir) option_prefix = self.get_option_prefix(machine) if self.runner_config.get("fullscreen"): params.append("-{}full".format(option_prefix)) if self.runner_config.get("double"): params.append("-{}dsize".format(option_prefix)) if self.runner_config.get("renderer"): params.append("-sdl2renderer") params.append(self.runner_config["renderer"]) if not self.runner_config.get("aspect_ratio", True): params.append("-sdlaspectmode") params.append("0") if self.runner_config.get("drivesound"): params.append("-drivesound") if self.runner_config.get("joy"): for dev in range(self.get_joydevs(machine)): params += ["-joydev{}".format(dev + 1), "4"] params.extend(self.get_rom_args(machine, rom)) return {"command": params} lutris-0.5.17/lutris/runners/vita3k.py000066400000000000000000000076611460562010500177300ustar00rootroot00000000000000from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner class MissingVitaTitleIDError(MissingGameExecutableError): """Raise when the Title ID field has not be supplied to the Vita runner game options""" def __init__(self, message=None, *args, **kwargs): if not message: message = _("The Vita App has no Title ID set") super().__init__(message, *args, **kwargs) class vita3k(Runner): human_name = _("Vita3K") platforms = [_("Sony PlayStation Vita")] description = _("Sony PlayStation Vita emulator") runnable_alone = True runner_executable = "vita3k/Vita3K-x86_64.AppImage" flatpak_id = None download_url = "https://github.com/Vita3K/Vita3K/releases/download/continuous/Vita3K-x86_64.AppImage" game_options = [ { "option": "main_file", "type": "string", "label": _("Title ID of Installed Application"), "argument": "-r", "help": _( 'Title ID of installed application. Eg."PCSG00042". User installed apps are located in ' "ux0:/app/<title-id>." ), } ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": True, "argument": "-F", "help": _("Start the emulator in fullscreen mode."), }, { "option": "config", "type": "file", "label": _("Config location"), "argument": "-c", "help": _( 'Get a configuration file from a given location. If a filename is given, it must end with ".yml", ' "otherwise it will be assumed to be a directory." ), }, { "option": "load-config", "label": _("Load configuration file"), "type": "bool", "argument": "-f", "help": _('If trues, informs the emualtor to load the config file from the "Config location" option.'), }, ] # Vita3k uses an AppImage and doesn't require the Lutris runtime. system_options_override = [{"option": "disable_runtime", "default": True}] def play(self): """Run the game.""" arguments = self.get_command() # adds arguments from the supplied option dictionary to the arguments list def append_args(option_dict, config): for option in option_dict: if option["option"] not in config: continue if option["type"] == "bool": if self.runner_config.get(option["option"]): if "argument" in option: arguments.append(option["argument"]) elif option["type"] == "choice": if self.runner_config.get(option["option"]) != "off": if "argument" in option: arguments.append(option["argument"]) arguments.append(config.get(option["option"])) elif option["type"] in ("string", "file"): if "argument" in option: arguments.append(option["argument"]) arguments.append(config.get(option["option"])) else: raise RuntimeError("Unhandled type %s" % option["type"]) # Append the runner arguments first, and game arguments afterwards append_args(self.runner_options, self.runner_config) title_id = self.game_config.get("main_file") or "" if not title_id: raise MissingVitaTitleIDError(_("The Vita App has no Title ID set")) append_args(self.game_options, self.game_config) return {"command": arguments} @property def game_path(self): return self.game_config.get(self.entry_point_option, "") lutris-0.5.17/lutris/runners/web.py000066400000000000000000000203531460562010500172750ustar00rootroot00000000000000"""Run web based games""" import os import string from gettext import gettext as _ from urllib.parse import urlparse from lutris import settings from lutris.database.games import get_game_by_field from lutris.exceptions import GameConfigError from lutris.runners.runner import Runner from lutris.util import datapath, linux, resources, system from lutris.util.strings import split_arguments DEFAULT_ICON = os.path.join(datapath.get(), "media/default_icon.png") class web(Runner): human_name = _("Web") description = _("Runs web based games") platforms = [_("Web")] game_options = [ { "option": "main_file", "type": "string", "label": _("Full URL or HTML file path"), "help": _("The full address of the game's web page or path to a HTML file."), } ] runner_options = [ { "option": "fullscreen", "label": _("Open in fullscreen"), "type": "bool", "default": False, "help": _("Launch the game in fullscreen."), }, { "option": "maximize_window", "label": _("Open window maximized"), "type": "bool", "default": False, "help": _("Maximizes the window when game starts."), }, { "option": "window_size", "label": _("Window size"), "type": "choice_with_entry", "choices": [ "640x480", "800x600", "1024x768", "1280x720", "1280x1024", "1920x1080", ], "default": "800x600", "help": _("The initial size of the game window when not opened."), }, { "option": "disable_resizing", "label": _("Disable window resizing (disables fullscreen and maximize)"), "type": "bool", "default": False, "help": _("You can't resize this window."), }, { "option": "frameless", "label": _("Borderless window"), "type": "bool", "default": False, "help": _("The window has no borders/frame."), }, { "option": "disable_menu_bar", "label": _("Disable menu bar and default shortcuts"), "type": "bool", "default": False, "help": _("This also disables default keyboard shortcuts, " "like copy/paste and fullscreen toggling."), }, { "option": "disable_scrolling", "label": _("Disable page scrolling and hide scrollbars"), "type": "bool", "default": False, "help": _("Disables scrolling on the page."), }, { "option": "hide_cursor", "label": _("Hide mouse cursor"), "type": "bool", "default": False, "help": _("Prevents the mouse cursor from showing " "when hovering above the window."), }, { "option": "open_links", "label": _("Open links in game window"), "type": "bool", "default": False, "help": _( "Enable this option if you want clicked links to open inside the " "game window. By default all links open in your default web browser." ), }, { "option": "remove_margin", "label": _("Remove default margin & padding"), "type": "bool", "default": False, "help": _("Sets margin and padding to zero " "on <html> and <body> elements."), }, { "option": "enable_flash", "label": _("Enable Adobe Flash Player"), "type": "bool", "default": False, "help": _("Enable Adobe Flash Player."), }, { "option": "user_agent", "label": _("Custom User-Agent"), "type": "string", "default": "", "help": _("Overrides the default User-Agent header used by the runner."), "advanced": True, }, { "option": "devtools", "label": _("Debug with Developer Tools"), "type": "bool", "default": False, "help": _("Let's you debug the page."), "advanced": True, }, { "option": "external_browser", "label": _("Open in web browser (old behavior)"), "type": "bool", "default": False, "help": _("Launch the game in a web browser."), }, { "option": "custom_browser_executable", "label": _("Custom web browser executable"), "type": "file", "help": _( "Select the executable of a browser on your system.\n" "If left blank, Lutris will launch your default browser (xdg-open)." ), }, { "option": "custom_browser_args", "label": _("Web browser arguments"), "type": "string", "default": '"$GAME"', "help": _( "Command line arguments to pass to the executable.\n" "$GAME or $URL inserts the game url.\n\n" 'For Chrome/Chromium app mode use: --app="$GAME"' ), }, ] runner_executable = "web/electron/electron" def get_env(self, os_env=True, disable_runtime=False): env = super().get_env(os_env, disable_runtime=disable_runtime) enable_flash_player = self.runner_config.get("enable_flash") env["ENABLE_FLASH_PLAYER"] = "1" if enable_flash_player else "0" return env def play(self): url = self.game_config.get("main_file") if not url: raise GameConfigError(_("The web address is empty, \n" "verify the game's configuration.")) # check if it's an url or a file is_url = urlparse(url).scheme != "" if not is_url: if not system.path_exists(url): raise GameConfigError(_("The file %s does not exist, \n" "verify the game's configuration.") % url) url = "file://" + url game_data = get_game_by_field(self.config.game_config_id, "configpath") # keep the old behavior from browser runner, but with support for extra arguments! if self.runner_config.get("external_browser"): # is it possible to disable lutris runtime here? browser = self.runner_config.get("custom_browser_executable") or "xdg-open" args = self.runner_config.get("custom_browser_args") args = args or '"$GAME"' arguments = string.Template(args).safe_substitute({"GAME": url, "URL": url}) command = [browser] for arg in split_arguments(arguments): command.append(arg) return {"command": command} icon = resources.get_icon_path(game_data.get("slug")) if not system.path_exists(icon): icon = DEFAULT_ICON command = [ self.get_executable(), os.path.join(settings.RUNNER_DIR, "web/electron/resources/app.asar"), url, "--name", game_data.get("name"), "--icon", icon, ] for key in [ "fullscreen", "frameless", "devtools", "disable_resizing", "disable_menu_bar", "maximize_window", "disable_scrolling", "hide_cursor", "open_links", "remove_margin", ]: if self.runner_config.get(key): converted_opt_name = key.replace("_", "-") command.append("--{option}".format(option=converted_opt_name)) if self.runner_config.get("window_size"): command.append("--window-size") command.append(self.runner_config.get("window_size")) if self.runner_config.get("user_agent"): command.append("--user-agent") command.append(self.runner_config.get("user_agent")) if linux.LINUX_SYSTEM.is_flatpak(): command.append("--no-sandbox") return {"command": command, "env": self.get_env(False)} lutris-0.5.17/lutris/runners/wine.py000066400000000000000000001416271460562010500174720ustar00rootroot00000000000000"""Wine runner""" # pylint: disable=too-many-lines import os import shlex from gettext import gettext as _ from typing import Any, Dict, Optional, Tuple from lutris import runtime, settings from lutris.api import format_runner_version, normalize_version_architecture from lutris.config import LutrisConfig from lutris.database.games import get_game_by_field from lutris.exceptions import ( EsyncLimitError, FsyncUnsupportedError, MisconfigurationError, MissingExecutableError, MissingGameExecutableError, UnspecifiedVersionError, ) from lutris.game import Game from lutris.gui.dialogs import FileDialog from lutris.runners.commands.wine import ( # noqa: F401 pylint: disable=unused-import create_prefix, delete_registry_key, eject_disc, install_cab_component, open_wine_terminal, set_regedit, set_regedit_file, winecfg, wineexec, winekill, winetricks, ) from lutris.runners.runner import Runner from lutris.util import system from lutris.util.display import DISPLAY_MANAGER, get_default_dpi from lutris.util.graphics import drivers, vkquery from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.strings import split_arguments from lutris.util.wine import proton from lutris.util.wine.d3d_extras import D3DExtrasManager from lutris.util.wine.dgvoodoo2 import dgvoodoo2Manager from lutris.util.wine.dxvk import REQUIRED_VULKAN_API_VERSION, DXVKManager from lutris.util.wine.dxvk_nvapi import DXVKNVAPIManager from lutris.util.wine.extract_icon import PEFILE_AVAILABLE, ExtractIcon from lutris.util.wine.prefix import DEFAULT_DLL_OVERRIDES, WinePrefixManager, find_prefix from lutris.util.wine.vkd3d import VKD3DManager from lutris.util.wine.wine import ( WINE_DEFAULT_ARCH, WINE_DIR, WINE_PATHS, detect_arch, get_default_wine_runner_version_info, get_default_wine_version, get_installed_wine_versions, get_overrides_env, get_real_executable, get_system_wine_version, get_wine_path_for_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." ) % (driver_info["version"],) return None def _get_simple_vulkan_support_error(config, option_key, feature): if os.environ.get("LUTRIS_NO_VKQUERY"): return None if config.get(option_key) and not LINUX_SYSTEM.is_vulkan_supported(): return ( _("Error Vulkan is not installed or is not supported by your system, " "%s is not available.") % feature ) return None def _get_dxvk_version_warning(config, _option_key): if os.environ.get("LUTRIS_NO_VKQUERY"): return None 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_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 "" def _get_virtual_desktop_warning(config, _option_key): message = _("Wine virtual desktop is no longer supported") if config.get("Desktop"): version = str(config.get("version")).casefold() if "-ge-" in version or "proton" in version: message += "\n" message += _("Virtual desktops cannot be enabled in Proton or GE Wine versions.") return message 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_wine_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": lambda c, k: _get_simple_vulkan_support_error(c, k, _("DXVK")), "active": True, "help": _( "Use DXVK to " "increase compatibility and performance in Direct3D 11, 10 " "and 9 applications by translating their calls to Vulkan." ), }, { "option": "dxvk_version", "section": _("Graphics"), "label": _("DXVK version"), "advanced": True, "type": "choice_with_entry", "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", "advanced": True, "warning": _get_virtual_desktop_warning, "default": False, "help": _( "Run the whole Windows desktop in a window.\n" "Otherwise, run it fullscreen.\n" "This corresponds to Wine's Virtual Desktop option." ), }, { "option": "WineDesktop", "section": _("Virtual Desktop"), "label": _("Virtual desktop resolution"), "type": "choice_with_entry", "advanced": True, "choices": DISPLAY_MANAGER.get_resolutions, "help": _("The size of the virtual desktop in pixels."), }, { "option": "Dpi", "section": _("DPI"), "label": _("Enable DPI Scaling"), "type": "bool", "advanced": True, "default": False, "help": _( "Enables the Windows application's DPI scaling.\n" "Otherwise, the Screen Resolution option in 'Wine configuration' controls this." ), }, { "option": "ExplicitDpi", "section": _("DPI"), "label": _("DPI"), "type": "string", "advanced": True, "help": _( "The DPI to be used if 'Enable DPI Scaling' is turned on.\n" "If blank or 'auto', Lutris will auto-detect this." ), }, { "option": "MouseWarpOverride", "label": _("Mouse Warp Override"), "type": "choice", "choices": [ (_("Enable"), "enable"), (_("Disable"), "disable"), (_("Force"), "force"), ], "default": "enable", "advanced": True, "help": _( "Override the default mouse pointer warping behavior\n" "Enable: (Wine default) warp the pointer when the " "mouse is exclusively acquired \n" "Disable: never warp the mouse pointer \n" "Force: always warp the pointer" ), }, { "option": "Audio", "label": _("Audio driver"), "type": "choice", "advanced": True, "choices": [ (_("Auto"), "auto"), ("ALSA", "alsa"), ("PulseAudio", "pulse"), ("OSS", "oss"), ], "default": "auto", "help": _( "Which audio backend to use.\n" "By default, Wine automatically picks the right one " "for your system." ), }, { "option": "overrides", "type": "mapping", "label": _("DLL overrides"), "help": _("Sets WINEDLLOVERRIDES when launching the game."), }, { "option": "show_debug", "label": _("Output debugging info"), "type": "choice", "choices": [ (_("Disabled"), "-all"), (_("Enabled"), ""), (_("Inherit from environment"), "inherit"), (_("Show FPS"), "+fps"), (_("Full (CAUTION: Will cause MASSIVE slowdown)"), "+all"), ], "default": "-all", "help": _("Output debugging information in the game log " "(might affect performance)"), }, { "option": "ShowCrashDialog", "label": _("Show crash dialogs"), "type": "bool", "default": False, "advanced": True, }, { "option": "autoconf_joypad", "type": "bool", "label": _("Autoconfigure joypads"), "advanced": True, "default": False, "help": _( "Automatically disables one of Wine's detected joypad " "to avoid having 2 controllers detected" ), }, { "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"), "warn_if_non_writable_parent": True, "help": _("Custom directory for desktop integration folders."), "advanced": True, }, ] @property def runner_warning(self): if not get_system_wine_version(): return _( "Warning Wine is not installed on your system\n\n" "Having Wine installed on your system guarantees that " "Wine builds from Lutris will have all required dependencies.\nPlease " "follow the instructions given in the Lutris Wiki to " "install Wine." ) @property def context_menu_entries(self): """Return the contexual menu entries for wine""" return [ ("wineexec", _("Run EXE inside Wine prefix"), self.run_wineexec), ("wineshell", _("Open Bash terminal"), self.run_wine_terminal), ("wineconsole", _("Open Wine console"), self.run_wineconsole), (None, "-", None), ("winecfg", _("Wine configuration"), self.run_winecfg), ("wine-regedit", _("Wine registry"), self.run_regedit), ("winecpl", _("Wine Control Panel"), self.run_winecpl), ("winetaskmgr", _("Wine Task Manager"), self.run_taskmgr), (None, "-", None), ("winetricks", _("Winetricks"), self.run_winetricks), ] @property def prefix_path(self): """Return the absolute path of the Wine prefix. Falls back to default WINE prefix.""" _prefix_path = self._prefix or self.game_config.get("prefix") or os.environ.get("WINEPREFIX") if not _prefix_path and self.game_config.get("exe"): # Find prefix from game if we have one _prefix_path = find_prefix(self.game_exe) if _prefix_path: _prefix_path = os.path.expanduser(_prefix_path) # just in case! return _prefix_path @property def game_exe(self): """Return the game's executable's path, which may not exist. None if there is no exe path defined.""" exe = self.game_config.get("exe") if not exe: logger.error("The game doesn't have an executable") return None exe = os.path.expanduser(exe) # just in case! if os.path.isabs(exe): return system.fix_path_case(exe) if not self.game_path: logger.warning("The game has an executable, but not a game path") return None return system.fix_path_case(os.path.join(self.game_path, exe)) @property def working_dir(self): """Return the working directory to use when running the game.""" _working_dir = self._working_dir or self.game_config.get("working_dir") if _working_dir: return os.path.expanduser(_working_dir) if self.game_exe: game_dir = os.path.dirname(self.game_exe) if os.path.isdir(game_dir): return game_dir return super().working_dir @property def nvidia_shader_cache_path(self): """WINE should give each game its own shader cache if possible.""" return self.game_path or self.shader_cache_dir @property def wine_arch(self): """Return the wine architecture. Get it from the config or detect it from the prefix""" arch = self._wine_arch or self.game_config.get("arch") or "auto" if arch not in ("win32", "win64"): prefix_path = self.prefix_path if prefix_path: arch = detect_arch(prefix_path, self.get_executable()) else: arch = WINE_DEFAULT_ARCH return arch def get_runner_version(self, version: str = None) -> Dict[str, str]: if not version: default_version_info = get_default_wine_runner_version_info() default_version = format_runner_version(default_version_info) if default_version_info else None version = self.read_version_from_config(default=default_version) if version in WINE_PATHS: return {"version": version} return super().get_runner_version(version) def read_version_from_config(self, default: str = None) -> str: """Return the Wine version to use. use_default can be set to false to force the installation of a specific wine version. If no version is configured, we return the default supplied, or the4 global Wine default if none is.""" # We must use the config levels to avoid getting a default if the setting # is not set; we'll fall back to get_default_version() for level in [self.config.game_level, self.config.runner_level]: if "wine" in level: runner_version = level["wine"].get("version") if runner_version: return runner_version if default: return default return get_default_wine_version() def get_path_for_version(self, version: str) -> str: """Return the absolute path of a wine executable for a given version""" return get_wine_path_for_version(version, config=self.runner_config) def resolve_config_path(self, path, relative_to=None): # Resolve paths with tolerance for Windows-isms; # first try to fix mismatched casing, and then if that # finds no file or directory, try again after swapping in # slashes for backslashes. resolved = super().resolve_config_path(path, relative_to) resolved = system.fix_path_case(resolved) if not os.path.exists(resolved) and "\\" in path: fixed = path.replace("\\", "/") fixed_resolved = super().resolve_config_path(fixed, relative_to) fixed_resolved = system.fix_path_case(fixed_resolved) return fixed_resolved return resolved def get_executable(self, version: str = None, fallback: bool = True) -> str: """Return the path to the Wine executable. A specific version can be specified if needed. """ if version is None: version = self.read_version_from_config() if version == proton.GE_PROTON_LATEST: return version wine_path = self.get_path_for_version(version) if system.path_exists(wine_path): return wine_path if not fallback: raise MissingExecutableError(_("The Wine executable at '%s' is missing.") % wine_path) # Fallback to default version default_version = get_default_wine_version() wine_path = self.get_path_for_version(default_version) if not system.path_exists(wine_path): raise MissingExecutableError(_("The Wine executable at '%s' is missing.") % wine_path) # Update the version in the config if version == self.runner_config.get("version"): self.runner_config["version"] = default_version # TODO: runner_config is a dict so we have to instanciate a # LutrisConfig object to save it. # XXX: The version key could be either in the game specific # config or the runner specific config. We need to know # which one to get the correct LutrisConfig object. return wine_path def is_installed(self, flatpak_allowed: bool = True, version: str = None, fallback: bool = True) -> bool: """Check if Wine is installed. If no version is passed, checks if any version of wine is available """ try: if version: # We don't care where Wine is, but only if it was found at all. self.get_executable(version, fallback) return True return bool(get_installed_wine_versions()) except MisconfigurationError: return False def is_installed_for(self, interpreter): try: version = self.get_installer_runner_version(interpreter.installer, use_api=True) return self.is_installed(version=version, fallback=False) except MisconfigurationError: return False def get_installer_runner_version( self, installer, use_runner_config: bool = True, use_api: bool = False ) -> Optional[str]: # If a version is specified in the script choose this one version = None if installer.script.get(installer.runner): version = installer.script[installer.runner].get("version") version = normalize_version_architecture(version) # If the installer is an extension, use the wine version from the base game elif installer.requires: db_game = get_game_by_field(installer.requires, field="installer_slug") if not db_game: db_game = get_game_by_field(installer.requires, field="slug") if not db_game: raise MisconfigurationError(_("The required game '%s' could not be found.") % installer.requires) game = Game(db_game["id"]) version = game.config.runner_config["version"] if not version and use_runner_config: # Try to read the version from the saved runner config for Wine. try: return wine.get_runner_version_and_config()[0] except UnspecifiedVersionError: pass # fall back to the API in this case if not version and use_api: # Try to obtain the default wine version from the Lutris API. default_version_info = self.get_runner_version() if "version" in default_version_info: logger.debug("Default wine version is %s", default_version_info["version"]) version = format_runner_version(default_version_info) return version def adjust_installer_runner_config(self, installer_runner_config: Dict[str, Any]) -> None: version = installer_runner_config.get("version") if version: installer_runner_config["version"] = normalize_version_architecture(version) @classmethod def get_runner_version_and_config(cls) -> Tuple[str, LutrisConfig]: runner_config = LutrisConfig(runner_slug="wine") if "wine" in runner_config.runner_level: config_version = runner_config.runner_level["wine"].get("version") if config_version: return config_version, runner_config raise UnspecifiedVersionError(_("The runner configuration does not specify a Wine version.")) @classmethod def msi_exec( cls, msi_file, quiet=False, prefix=None, wine_path=None, working_dir=None, blocking=False, ): msi_args = "/i %s" % msi_file if quiet: msi_args += " /q" return wineexec( "msiexec", args=msi_args, prefix=prefix, wine_path=wine_path, working_dir=working_dir, blocking=blocking, ) def _run_executable(self, executable): """Runs a Windows executable using this game's configuration""" wineexec( executable, wine_path=self.get_executable(), prefix=self.prefix_path, working_dir=self.prefix_path, config=self, env=self.get_env(os_env=True), runner=self, ) def run_wineexec(self, *args): """Ask the user for an arbitrary exe file to run in the game's prefix""" dlg = FileDialog(_("Select an EXE or MSI file"), default_path=self.game_path) filename = dlg.filename if not filename: return self.prelaunch() self._run_executable(filename) def run_wineconsole(self, *args): """Runs wineconsole inside wine prefix.""" self.prelaunch() self._run_executable("wineconsole") def run_winecfg(self, *args): """Run winecfg in the current context""" self.prelaunch() winecfg( wine_path=self.get_executable(), prefix=self.prefix_path, arch=self.wine_arch, config=self, env=self.get_env(os_env=True), runner=self, ) def run_regedit(self, *args): """Run regedit in the current context""" self.prelaunch() self._run_executable("regedit") def run_wine_terminal(self, *args): terminal = self.system_config.get("terminal_app") system_winetricks = self.runner_config.get("system_winetricks") open_wine_terminal( terminal=terminal, wine_path=self.get_executable(), prefix=self.prefix_path, env=self.get_env(os_env=True), system_winetricks=system_winetricks, ) def run_winetricks(self, *args): """Run winetricks in the current context""" self.prelaunch() disable_runtime = not self.use_runtime() system_winetricks = self.runner_config.get("system_winetricks") if system_winetricks: # Don't run the system winetricks with the runtime; let the # system be the system disable_runtime = True winetricks( "", prefix=self.prefix_path, wine_path=self.get_executable(), config=self, disable_runtime=disable_runtime, system_winetricks=system_winetricks, env=self.get_env(os_env=True, disable_runtime=disable_runtime), runner=self, ) def run_winecpl(self, *args): """Execute Wine control panel.""" self.prelaunch() self._run_executable("control") def run_taskmgr(self, *args): """Execute Wine task manager""" self.prelaunch() self._run_executable("taskmgr") def run_winekill(self, *args): """Runs wineserver -k.""" winekill( self.prefix_path, arch=self.wine_arch, wine_path=self.get_executable(), env=self.get_env(), initial_pids=self.get_pids(), ) return True def set_regedit_keys(self): """Reset regedit keys according to config.""" prefix_manager = WinePrefixManager(self.prefix_path) # Those options are directly changed with the prefix manager and skip # any calls to regedit. managed_keys = { "ShowCrashDialog": prefix_manager.set_crash_dialogs, "Desktop": prefix_manager.set_virtual_desktop, "WineDesktop": prefix_manager.set_desktop_size, } for key, path in self.reg_keys.items(): value = self.runner_config.get(key) or "auto" if not value or value == "auto" and key not in managed_keys: prefix_manager.clear_registry_subkeys(path, key) elif key in self.runner_config: if key in managed_keys: # Do not pass fallback 'auto' value to managed keys if value == "auto": value = None if ( value and key in ("Desktop", "WineDesktop") and ("wine-ge" in self.get_executable().lower() or "proton" in self.get_executable().lower()) ): logger.warning("Wine Virtual Desktop can't be used with Wine-GE and Proton") value = None managed_keys[key](value) continue # Convert numeric strings to integers so they are saved as dword if value.isdigit(): value = int(value) prefix_manager.set_registry_key(path, key, value) # We always configure the DPI, because if the user turns off DPI scaling, but it # had been on the only way to implement that is to save 96 DPI into the registry. prefix_manager.set_dpi(self.get_dpi()) def get_dpi(self): """Return the DPI to be used by Wine; returns None to allow Wine's own setting to govern.""" if bool(self.runner_config.get("Dpi")): explicit_dpi = self.runner_config.get("ExplicitDpi") if explicit_dpi == "auto": explicit_dpi = None else: try: explicit_dpi = int(explicit_dpi) except: explicit_dpi = None return explicit_dpi or get_default_dpi() return None def prelaunch(self): if not get_system_wine_version(): logger.warning("Wine is not installed on your system; required dependencies may be missing.") prefix_path = self.prefix_path if prefix_path: if not system.path_exists(os.path.join(prefix_path, "user.reg")): logger.warning("No valid prefix detected in %s, creating one...", prefix_path) create_prefix(prefix_path, wine_path=self.get_executable(), arch=self.wine_arch, runner=self) prefix_manager = WinePrefixManager(prefix_path) if self.runner_config.get("autoconf_joypad", False): prefix_manager.configure_joypads() prefix_manager.create_user_symlinks() self.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 wine_exe = self.get_executable() wine_config_version = self.read_version_from_config() env["WINE"] = wine_exe env["WINE_MONO_CACHE_DIR"] = os.path.join(WINE_DIR, wine_config_version, "mono") env["WINE_GECKO_CACHE_DIR"] = os.path.join(WINE_DIR, wine_config_version, "gecko") # We don't want to override gstreamer for proton, it has it's own version if not proton.is_proton_path(WINE_DIR) and is_gstreamer_build(wine_exe): path_64 = os.path.join(WINE_DIR, wine_config_version, "lib64/gstreamer-1.0/") path_32 = os.path.join(WINE_DIR, wine_config_version, "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) if proton.is_proton_path(wine_config_version): # In stable versions of proton this can be dist/bin insteasd of files/bin if "files/bin" in wine_exe: env["PROTONPATH"] = wine_exe[: wine_exe.index("files/bin")] else: try: env["PROTONPATH"] = wine_exe[: wine_exe.index("dist/bin")] except ValueError: pass return env def get_runtime_env(self): """Return runtime environment variables with path to wine for Lutris builds""" wine_path = None try: exe = self.get_executable() if WINE_DIR: wine_path = os.path.dirname(os.path.dirname(exe)) for proton_path in proton.get_proton_paths(): if proton_path in exe: wine_path = os.path.dirname(os.path.dirname(exe)) except MisconfigurationError: wine_path = None return runtime.get_env( version="Ubuntu-18.04", prefer_system_libs=self.system_config.get("prefer_system_libs", True), wine_path=wine_path, ) def get_pids(self, wine_path=None): """Return a list of pids of processes using the current wine exe.""" try: exe = wine_path or self.get_executable() except MisconfigurationError: return set() if proton.is_proton_path(exe): logger.debug("Tracking PIDs of Proton games is not possible at the moment") return set() if not exe.startswith("/"): exe = system.find_required_executable(exe) pids = system.get_pids_using_file(exe) if self.wine_arch == "win64" and os.path.basename(exe) == "wine": pids = pids | system.get_pids_using_file(exe + "64") # 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): try: 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() except Exception as ex: logger.exception("Failed to setup desktop integration, the prefix may not be valid: %s", ex) def get_command(self): exe = self.get_executable() if proton.is_proton_path(exe): umu_path = proton.get_umu_path() if umu_path: return [umu_path] raise MissingExecutableError("Install umu to use Proton") return super().get_command() 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): raise MissingGameExecutableError(filename=game_exe) if launch_info["env"].get("WINEESYNC") == "1": limit_set = is_esync_limit_set() if not limit_set: raise EsyncLimitError() if launch_info["env"].get("WINEFSYNC") == "1": fsync_supported = is_fsync_supported() if not fsync_supported: raise FsyncUnsupportedError() command = self.get_command() game_exe, args, _working_dir = get_real_executable(game_exe, self.working_dir) command.append(game_exe) if args: command = command + args if arguments: for arg in split_arguments(arguments): command.append(arg) launch_info["command"] = command return launch_info def 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() 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""" 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 return False lutris-0.5.17/lutris/runners/xemu.py000066400000000000000000000024741460562010500175020ustar00rootroot00000000000000from gettext import gettext as _ from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system class xemu(Runner): human_name = _("xemu") platforms = [_("Xbox")] description = _("Xbox emulator") runnable_alone = True runner_executable = "xemu/xemu" flatpak_id = "app.xemu.xemu" game_options = [ { "option": "main_file", "type": "file", "label": _("ISO file"), "help": _("DVD image in iso format"), } ] runner_options = [ { "option": "fullscreen", "label": _("Fullscreen"), "type": "bool", "default": True, }, ] # xemu currently uses an AppImage, no need for the runtime. system_options_override = [{"option": "disable_runtime", "default": True}] def play(self): """Run the game.""" arguments = self.get_command() fullscreen = self.runner_config.get("fullscreen") if fullscreen: arguments.append("-full-screen") iso = self.game_config.get("main_file") or "" if not system.path_exists(iso): raise MissingGameExecutableError(filename=iso) arguments += ["-dvd_path", iso] return {"command": arguments} lutris-0.5.17/lutris/runners/yuzu.py000066400000000000000000000065161460562010500175410ustar00rootroot00000000000000import filecmp import os from gettext import gettext as _ from shutil import copyfile from lutris.exceptions import MissingGameExecutableError from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger class yuzu(Runner): human_name = _("Yuzu") platforms = [_("Nintendo Switch")] description = _("Nintendo Switch emulator") runnable_alone = True runner_executable = "yuzu/yuzu-mainline.AppImage" flatpak_id = "org.yuzu_emu.yuzu" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ { "option": "prod_keys", "label": _("Encryption keys"), "type": "file", "help": _("File containing the encryption keys."), }, { "option": "title_keys", "label": _("Title keys"), "type": "file", "help": _("File containing the title keys."), }, { "option": "fullscreen", "label": _("Fullscreen"), "type": "bool", "default": True, }, ] # yuzu currently uses an AppImage, no need for the runtime. system_options_override = [{"option": "disable_runtime", "default": True}] @property def yuzu_data_dir(self): """Return dir where Yuzu files lie.""" candidates = ("~/.local/share/yuzu",) for candidate in candidates: path = system.fix_path_case(os.path.join(os.path.expanduser(candidate), "nand")) if system.path_exists(path): return path[: -len("nand")] def play(self): """Run the game.""" arguments = self.get_command() fullscreen = self.runner_config.get("fullscreen") if fullscreen: arguments.append("-f") rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): raise MissingGameExecutableError(filename=rom) arguments += ["-g", rom] return {"command": arguments} def _update_key(self, key_type): """Update a keys file if set""" yuzu_data_dir = self.yuzu_data_dir if not yuzu_data_dir: logger.error("Yuzu data dir not set") return if key_type == "prod_keys": key_loc = os.path.join(yuzu_data_dir, "keys/prod.keys") elif key_type == "title_keys": key_loc = os.path.join(yuzu_data_dir, "keys/title.keys") else: logger.error("Invalid keys type %s!", key_type) return key = self.runner_config.get(key_type) if not key: logger.debug("No %s file was set.", key_type) return if not system.path_exists(key): logger.warning("Keys file %s does not exist!", key) return keys_dir = os.path.dirname(key_loc) if not os.path.exists(keys_dir): os.makedirs(keys_dir) elif os.path.isfile(key_loc) and filecmp.cmp(key, key_loc): # If the files are identical, don't do anything return copyfile(key, key_loc) def prelaunch(self): for key in ["prod_keys", "title_keys"]: self._update_key(key_type=key) lutris-0.5.17/lutris/runners/zdoom.py000066400000000000000000000131521460562010500176470ustar00rootroot00000000000000import 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"), "warn_if_non_writable_parent": True, "help": _("User-specified path where save files should be located."), }, ] runner_options = [ {"option": "2", "label": _("Pixel Doubling"), "type": "bool", "default": False}, {"option": "4", "label": _("Pixel Quadrupling"), "type": "bool", "default": False}, { "option": "nostartup", "label": _("Disable Startup Screens"), "type": "bool", "default": False, }, { "option": "skill", "label": _("Skill"), "type": "choice", "default": "", "choices": { (_("None"), ""), (_("I'm Too Young To Die (1)"), "1"), (_("Hey, Not Too Rough (2)"), "2"), (_("Hurt Me Plenty (3)"), "3"), (_("Ultra-Violence (4)"), "4"), (_("Nightmare! (5)"), "5"), }, }, { "option": "config", "label": _("Config file"), "type": "file", "help": _( "Used to load a user-created configuration file. If specified, " "the file must contain the wad directory list or launch will fail." ), }, ] def prelaunch(self): if not LINUX_SYSTEM.get_soundfonts(): logger.warning("FluidSynth is not installed, you might not have any music") @property def working_dir(self): wad = self.game_config.get("main_file") if wad: return os.path.dirname(os.path.expanduser(wad)) wad_files = self.game_config.get("files") if wad_files: return os.path.dirname(os.path.expanduser(wad_files[0])) def play(self): # noqa: C901 command = self.get_command() resolution = self.runner_config.get("resolution") if resolution: if resolution == "desktop": width, height = display.DISPLAY_MANAGER.get_current_resolution() else: width, height = resolution.split("x") command.append("-width") command.append(width) command.append("-height") command.append(height) # Append any boolean options. bool_options = ["2", "4", "nostartup"] for option in bool_options: if self.runner_config.get(option): command.append("-%s" % option) # Append the skill level. skill = self.runner_config.get("skill") if skill: command.append("-skill") command.append(skill) # Append directory for configuration file, if provided. config = self.runner_config.get("config") if config: command.append("-config") command.append(config) # Append the warp arguments. warp = self.game_config.get("warp") if warp: command.append("-warp") for warparg in warp.split(" "): command.append(warparg) # Append directory for save games, if provided. savedir = self.game_config.get("savedir") if savedir: command.append("-savedir") command.append(savedir) # Append the wad file to load, if provided. wad = self.game_config.get("main_file") if wad: command.append("-iwad") command.append(wad) # Append the pwad files to load, if provided. files = self.game_config.get("files") or [] pwads = [f for f in files if f.lower().endswith(".wad") or f.lower().endswith(".pk3")] deh = [f for f in files if f.lower().endswith(".deh")] bex = [f for f in files if f.lower().endswith(".bex")] if deh: command.append("-deh") command.append(deh[0]) if bex: command.append("-bex") command.append(bex[0]) if pwads: command.append("-file") for pwad in pwads: command.append(pwad) # Append additional arguments, if provided. args = self.game_config.get("args") or "" for arg in split_arguments(args): command.append(arg) return {"command": command} lutris-0.5.17/lutris/runtime.py000066400000000000000000000506451460562010500165160ustar00rootroot00000000000000"""Runtime handling module""" import concurrent.futures import os import threading import time from gettext import gettext as _ from typing import Any, Dict, List, Optional from lutris import settings from lutris.api import ( check_stale_runtime_versions, download_runtime_versions, format_runner_version, get_runtime_versions, get_time_from_api_date, ) from lutris.gui.widgets.progress_box import ProgressInfo from lutris.settings import UPDATE_CHANNEL_STABLE from lutris.util import http, system from lutris.util.downloader import Downloader from lutris.util.extract import extract_archive from lutris.util.jobs import AsyncCall from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.strings import parse_version 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, } def get_env(version: str = None, prefer_system_libs: bool = False, wine_path: str = None) -> Dict[str, str]: """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: str) -> List[str]: """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: str = None, prefer_system_libs: bool = True, wine_path: str = None) -> List[str]: """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: str = None, prefer_system_libs: bool = True, wine_path: str = None) -> List[str]: """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 class ComponentUpdater: (PENDING, DOWNLOADING, EXTRACTING, COMPLETED) = list(range(4)) status_formats = { PENDING: _("Updating %s"), DOWNLOADING: _("Downloading %s"), EXTRACTING: _("Extracting %s"), COMPLETED: _("Updated %s"), } @property def name(self) -> str: raise NotImplementedError @property def should_update(self) -> bool: """True if this update should be installed; false to discard it.""" return True def install_update(self, updater: "RuntimeUpdater") -> None: """Performs the update; runs on a worker thread, and returns when complete. However, some updates spawn a further thread to extract in parallel with the next update even after this.""" raise NotImplementedError def join(self): """Blocks until the update is entirely complete; used when install_update() spawns a thread to finish up. We call this on each update before closing the download queue.""" def get_progress(self) -> ProgressInfo: """Returns the current progress for the updater as it runs; called from the main thread.""" return ProgressInfo.ended() class RuntimeUpdater: """Class handling the runtime updates""" def __init__(self, force: bool = False): if RUNTIME_DISABLED: logger.warning("Runtime disabled by environment variable. Re-enable runtime before submitting issues.") self.update_runtime = False self.update_runners = False elif force: self.update_runtime = True self.update_runners = True else: self.update_runtime = settings.read_bool_setting("auto_update_runtime", default=True) wine_update_channel = settings.read_setting("wine-update-channel", default=UPDATE_CHANNEL_STABLE) self.update_runners = wine_update_channel.casefold() == UPDATE_CHANNEL_STABLE if not self.update_runtime: logger.warning("Runtime updates are disabled. This configuration is not supported.") if not check_stale_runtime_versions(): self.update_runtime = False self.update_runners = False if self.has_updates: self.runtime_versions = download_runtime_versions() else: self.runtime_versions = get_runtime_versions() @property def has_updates(self): return self.update_runtime or self.update_runners def create_component_updaters(self) -> List[ComponentUpdater]: """Creates the component updaters that need to be applied and returns them in a list. This tests each to see if it should be used and returns only those you should install. It returns an empty list if has_updates is false. This method also downloads fresh runner versions on each call, so we call this on a worker thread, instead of blocking the UI.""" if not self.runtime_versions: return [] updaters: List[ComponentUpdater] = [] if self.update_runtime: updaters += self._get_runtime_updaters(self.runtime_versions) if self.update_runners: updaters += self._get_runner_updaters(self.runtime_versions) return [u for u in updaters if u.should_update] def check_client_versions(self) -> Optional[str]: """Checks if the client is of an old version and no longer supported; this can be blocked with an env-var, and can be temporarily ignored as well, but if we're out of date, then this method returns the version of Lutris that is required. If we're good, or we are ignoring the problem, this returns None. I expect that most users will not discover the env-var, so they will be prompted on each new Lutris release.""" if self.runtime_versions and not os.environ.get("LUTRIS_NO_CLIENT_VERSION_CHECK"): client_version = self.runtime_versions.get("client_version") if client_version: if parse_version(client_version) > parse_version(settings.VERSION): ignored = settings.read_setting("ignored_supported_lutris_verison") if not ignored or ignored != client_version: return client_version return None @staticmethod def _get_runtime_updaters(runtime_versions: Dict[str, Any]) -> List[ComponentUpdater]: """Launch the update process""" updaters: List[ComponentUpdater] = [] for name, remote_runtime in 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 try: if remote_runtime.get("url"): updaters.append(RuntimeExtractedComponentUpdater(remote_runtime)) else: updaters.append(RuntimeFilesComponentUpdater(remote_runtime)) except Exception as ex: logger.exception("Unable to download %s: %s", name, ex) return updaters @staticmethod def _get_runner_updaters(runtime_versions: Dict[str, Any]) -> List[ComponentUpdater]: """Update installed runners (only works for Wine at the moment)""" updaters: List[ComponentUpdater] = [] upstream_runners = 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 upstream_runner: updaters.append(RunnerComponentUpdater(name, upstream_runner)) return updaters class RuntimeComponentUpdater(ComponentUpdater): """A base class for component updates that use the timestamp from a runtime-info dict to decide when an update is required. These dicts are part of the 'versions.json' file, sent down from the server. They describe what versions of components are available from the server. This dict includes the modification date-time of the component, and we compare it to the mtime of the relevant directory. """ def __init__(self, remote_runtime_info: Dict[str, Any]) -> None: self.remote_runtime_info = remote_runtime_info self.state = ComponentUpdater.PENDING # Versioned runtimes keep 1 version per folder self.versioned = bool(self.remote_runtime_info.get("versioned")) self.version = str(self.remote_runtime_info.get("version") or "") if self.versioned else "" @property def name(self) -> str: return self.remote_runtime_info["name"] def install_update(self, updater: "RuntimeUpdater") -> None: raise NotImplementedError def get_progress(self) -> ProgressInfo: status_text = ComponentUpdater.status_formats[self.state] % self.name if self.state == ComponentUpdater.COMPLETED: return ProgressInfo.ended(status_text) if self.state == ComponentUpdater.PENDING: return ProgressInfo(0.0, status_text) return ProgressInfo(None, status_text) @property def local_runtime_path(self) -> str: """Return the local path for the runtime folder""" return os.path.join(settings.RUNTIME_DIR, self.name) def get_updated_at(self) -> time.struct_time: """Return the modification date of the runtime folder""" return time.gmtime(os.path.getmtime(self.local_runtime_path)) def set_updated_at(self) -> None: """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) @property def should_update(self) -> bool: """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)) try: local_updated_at = self.get_updated_at() except FileNotFoundError: return True remote_updated_at = get_time_from_api_date(self.remote_runtime_info["created_at"]) 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 class RuntimeExtractedComponentUpdater(RuntimeComponentUpdater): """Component updater that downloads and extracts an archive.""" def __init__(self, remote_runtime_info: Dict[str, Any]) -> None: super().__init__(remote_runtime_info) self.url = remote_runtime_info["url"] self.downloader: Downloader = None self.complete_event = threading.Event() def get_progress(self) -> ProgressInfo: progress_info = super().get_progress() if self.downloader and not progress_info.has_ended: return ProgressInfo(self.downloader.progress_fraction, progress_info.label_markup, self.downloader.cancel) return progress_info def install_update(self, updater: RuntimeUpdater) -> None: self.state = ComponentUpdater.DOWNLOADING self.complete_event.clear() archive_path = os.path.join(settings.RUNTIME_DIR, os.path.basename(self.url)) self.downloader = Downloader(self.url, archive_path, overwrite=True) self.downloader.start() self.downloader.join() self.downloader = None AsyncCall(self._install, self._install_cb, archive_path) def join(self): self.complete_event.wait() def _complete(self): self.state = ComponentUpdater.COMPLETED self.complete_event.set() def _install(self, path: str): """Finishes the installation after download, on a worker thread. This extracts the archive and downloads the versions file for it, the marks the update complete so join() above will be unblocked.""" try: self._extract(path) self.set_updated_at() if self.name in DLL_MANAGERS: manager = DLL_MANAGERS[self.name]() manager.fetch_versions() finally: self._complete() def _install_cb(self, _completed: bool, error: Exception): if error: logger.error("Runtime update failed: %s", error) def _extract(self, path: str) -> None: """Actions taken once a runtime is downloaded Arguments: path: local path to the runtime archive, or None on download failure """ if not path: return stats = os.stat(path) if not stats.st_size: logger.error("Download failed: file %s is empty, Deleting file.", path) os.unlink(path) return 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.delete_folder(dest_path) # Extract the runtime archive self.state = ComponentUpdater.EXTRACTING archive_path, _destination_path = extract_archive(path, dest_path, merge_single=True) os.unlink(archive_path) class RuntimeFilesComponentUpdater(RuntimeComponentUpdater): """Component updaters that downloads a set of files described by the server individually.""" def install_update(self, updater: RuntimeUpdater) -> None: """Download a runtime item by individual components. Used for icons only at the moment""" self.state = ComponentUpdater.DOWNLOADING 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): if not future.cancelled() and future.exception(): expected_filename = future_downloads[future] logger.warning("Failed to get '%s': %s", expected_filename, future.exception()) self.state = ComponentUpdater.COMPLETED def _should_update_component(self, filename: str, remote_modified_at: time.struct_time) -> bool: """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_runtime_components(self) -> List[Dict[str, Any]]: """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_component(self, component: Dict[str, Any]) -> None: """Download an individual file from a runtime item""" file_path = os.path.join(settings.RUNTIME_DIR, self.name, component["filename"]) http.download_file(component["url"], file_path) class RunnerComponentUpdater(ComponentUpdater): """Component updaters that downloads new versions of runners. These are download as archives and extracted into place.""" def __init__(self, name: str, upstream_runner: Dict[str, Any]): self._name = name self.upstream_runner = upstream_runner self.runner_version = format_runner_version(upstream_runner) self.version_path = os.path.join(settings.RUNNER_DIR, name, self.runner_version) self.downloader: Downloader = None self.state = ComponentUpdater.PENDING @property def name(self) -> str: return self._name @property def should_update(self): # This has the responsibility to update existing runners, not installing new ones runner_base_path = os.path.join(settings.RUNNER_DIR, self.name) return system.path_exists(runner_base_path) and not system.path_exists(self.version_path) def install_update(self, updater: "RuntimeUpdater") -> None: url = self.upstream_runner["url"] archive_download_path = os.path.join(settings.TMP_DIR, os.path.basename(url)) self.state = ComponentUpdater.DOWNLOADING self.downloader = Downloader(self.upstream_runner["url"], archive_download_path) self.downloader.start() self.downloader.join() if self.downloader.state == self.downloader.COMPLETED: self.downloader = None self.state = ComponentUpdater.EXTRACTING extract_archive(archive_download_path, self.version_path) get_installed_wine_versions.cache_clear() os.remove(archive_download_path) self.state = ComponentUpdater.COMPLETED def get_progress(self) -> ProgressInfo: status_text = ComponentUpdater.status_formats[self.state] % self.name d = self.downloader if d: return ProgressInfo(d.progress_fraction, status_text, d.cancel) if self.state == ComponentUpdater.EXTRACTING: return ProgressInfo(None, status_text) if self.state == ComponentUpdater.COMPLETED: return ProgressInfo.ended(status_text) return ProgressInfo(0.0, status_text) lutris-0.5.17/lutris/scanners/000077500000000000000000000000001460562010500162635ustar00rootroot00000000000000lutris-0.5.17/lutris/scanners/__init__.py000066400000000000000000000000001460562010500203620ustar00rootroot00000000000000lutris-0.5.17/lutris/scanners/default_installers.py000066400000000000000000000102671460562010500225270ustar00rootroot00000000000000DEFAULT_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"}}, "amiga-cd32": {"runner": "fsuae", "game": {"main_file": "rom"}, "fsuae": {"model": "CD32"}}, "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.17/lutris/scanners/lutris.py000066400000000000000000000070151460562010500201620ustar00rootroot00000000000000import os from lutris.api import get_api_games, get_game_installers from lutris.database.games import get_games from lutris.installer.errors import MissingGameDependencyError 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 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: try: exe_path = installer["script"]["game"].get("main_file") except KeyError: pass if not exe_path: return None 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 MissingGameDependencyError 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 lutris-0.5.17/lutris/scanners/retroarch.py000066400000000000000000000045321460562010500206320ustar00rootroot00000000000000import os from lutris.config import write_game_config from lutris.database.games import add_game, get_games 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: continue 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.17/lutris/scanners/tosec.py000066400000000000000000000121371460562010500177560ustar00rootroot00000000000000import 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.17/lutris/services/000077500000000000000000000000001460562010500162725ustar00rootroot00000000000000lutris-0.5.17/lutris/services/__init__.py000066400000000000000000000050021460562010500204000ustar00rootroot00000000000000"""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 = ["gog", "egs", "ea_app", "ubisoft", "steam"] def get_services(): """Return a mapping of available services""" _services = { "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 if os.environ.get("LUTRIS_SERVICE_ENABLED") == "1": _services["lutris"] = LutrisService 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.17/lutris/services/amazon.py000066400000000000000000000574731460562010500201510ustar00rootroot00000000000000"""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_patterns = ["%s.jpg"] 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" runner = "wine" 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"), 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.")) 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 file_collection = InstallerFileCollection(installer.game_slug, "amazongame", files) return [file_collection], [] def get_installed_slug(self, db_game): details = json.loads(db_game["details"]) return slugify(details["product"]["title"]) 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": self.get_installed_runner_name(db_game), "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, }, } def get_installed_runner_name(self, db_game): return self.runner lutris-0.5.17/lutris/services/base.py000066400000000000000000000427731460562010500175730ustar00rootroot00000000000000"""Generic service utilities""" import os import shutil from gettext import gettext as _ from typing import List 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_game_for_service, 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.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 from lutris.util.strings import slugify class AuthTokenExpiredError(Exception): """Exception raised when a token is no longer valid; the sidebar will log-out and log-in again in response to this rather than reporting it.""" class LutrisBanner(ServiceMedia): service = "lutris" size = BANNER_SIZE dest_path = settings.BANNER_PATH file_patterns = ["%s.jpg", "%s.png"] api_field = "banner" class LutrisIcon(LutrisBanner): size = ICON_SIZE dest_path = settings.ICON_PATH file_patterns = ["lutris_%s.png"] api_field = "icon" @property def custom_media_storage_size(self): return (128, 128) def run_system_update_desktop_icons(self): system.update_desktop_icons() class LutrisCoverart(ServiceMedia): service = "lutris" size = (264, 352) file_patterns = ["%s.jpg", "%s.png"] 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_ui_delegate): """Launch the game client""" launcher = self.get_launcher() if launcher: launcher.launch(launch_ui_delegate) def is_launchable(self): if self.client_installer: return bool(get_game_by_field(self.client_installer, "slug")) return False def get_launcher(self): if not self.client_installer: return None 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(settings.DB_PATH, "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 install_from_api(self, db_game, appid=None): """Install a game, using the API or generate_installer() to obtain the installer.""" if not appid: appid = db_game["appid"] def on_installers_ready(service_installers, error): if error: raise error # bounce any error off the backstop 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) AsyncCall(self.get_installers_from_api, on_installers_ready, appid) def get_installer_files(self, installer, installer_file_id, selected_extras): """Used to obtains the content files from the service, when an 'N/A' file is left in the installer. This handles 'extras', and must return a tuple; first a list of InstallerFile or InstallerFileCollection objects that are for the files themselves, and then a list of such objects for the extras. This separation allows us to generate extra installer script steps to move the extras in.""" return [], [] def match_game(self, service_game, lutris_game): """Match a service game to a lutris game referenced by its slug""" if not service_game: return sql.db_update( settings.DB_PATH, "service_games", {"lutris_slug": lutris_game["slug"]}, conditions={"appid": service_game["appid"], "service": self.id}, ) unmatched_lutris_games = get_games( searches={"installer_slug": self.matcher}, filters={"slug": lutris_game["slug"]}, excludes={"service": self.id}, ) for game in unmatched_lutris_games: logger.debug("Updating unmatched game %s", game) sql.db_update( settings.DB_PATH, "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, no_signal=False): """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(no_signal=no_signal) service_game = ServiceGameCollection.get_game(self.id, appid) sql.db_update(settings.DB_PATH, "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 get_installed_slug(self, db_game): """Returns the slug the game will have after installation, by default. This is Lutris's slug, not the one for the service. By default, we derive it from the Game's name.""" return slugify(db_game["name"]) def get_installed_runner_name(self, db_game): """Returns the name of the runner this game will have after installation, or blank if this is not known.""" return "" def get_service_installers(self, db_game, update): appid = db_game["appid"] 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, no_signal=True, # we're on a thread here, signals can crash us! ) if existing_game: logger.debug("Found existing game, aborting install") return None, None, existing_game installer = self.generate_installer(db_game) if not update else None 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 return service_installers, db_game, None 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. """ logger.debug("Installing %s from service %s", db_game["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) AsyncCall(self.get_service_installers, self.on_service_installers_loaded, db_game, update) def install_by_id(self, appid): """Installs a game given the appid for the game on this service.""" db_game = ServiceGameCollection.get_game(self.id, appid) if not db_game: logger.error("No game %s found for %s", appid, self.id) return None return self.install(db_game) def on_service_installers_loaded(self, result, error): if error: raise error # bounce this error off the backstop for default handling service_installers, db_game, existing_game = result # If an existing game was found, it may have been updated, # and it's not safe to fire this until we get here. if existing_game: existing_game.emit("game-updated") if service_installers and db_game: application = Gio.Application.get_default() application.show_installer_window(service_installers, service=self, appid=db_game["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: dict) -> List[str]: """Interprets the database record for this game from this service to extract its platform, or returns an empty list if this is not available.""" return [] def resolve_game_id(self, appid): db_game = get_game_for_service(self.id, appid) if db_game and db_game.get("id"): return str(db_game.get("id")) return None def get_service_db_game(self, game: Game): """Returns the row dictionary for the service-game corresponding to the game given, if any, or None.""" if game.service == self.id and game.appid: return ServiceGameCollection.get_game(self.id, game.appid) 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() application.show_lutris_installer_window(game_slug=self.client_installer) 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.17/lutris/services/battlenet.py000066400000000000000000000237351460562010500206400ustar00rootroot00000000000000"""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.lutris import sync_media from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util.battlenet.definitions import ProductDbInfo from lutris.util.log import logger try: from lutris.util.battlenet.product_db_pb2 import ProductDb BNET_ENABLED = True except (ImportError, TypeError) as ex: logger.warning("The Battle.net source is unavailable because Google protobuf could not be loaded: %s", ex) BNET_ENABLED = False 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_patterns = ["%s.jpg"] 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) installed_slugs = [] for game in parser.games: slug = self.install_from_battlenet(bnet_game, game) if slug: installed_slugs.append(slug) sync_media(installed_slugs) 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) slug = service_game["slug"] add_game( name=service_game["name"], runner=bnet_game["runner"], slug=slug, directory=bnet_game["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=app_id, platform="Windows", ) return slug def get_installed_slug(self, db_game): return db_game["slug"] 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": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "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 get_installed_runner_name(self, db_game): return self.runner 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.17/lutris/services/dolphin.py000066400000000000000000000100171460562010500203000ustar00rootroot00000000000000import json import os from gettext import gettext as _ from typing import List 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_patterns = ["%s.png"] dest_path = os.path.join(settings.CACHE_DIR, "dolphin/banners/small") class DolphinService(BaseService): id = "dolphin" icon = "dolphin" name = _("Dolphin") runner = "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": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "script": { "game": {"main_file": details["path"], "platform": details["platform"]}, }, } def get_installed_runner_name(self, db_game): return self.runner 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: dict) -> List[str]: details_json = db_game.get("details") if details_json: details = json.loads(details_json) 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 [] 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_possible_media_paths(self.appid)[0] # Dolphin only supports one media type 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.17/lutris/services/ea_app.py000066400000000000000000000347741460562010500201100ustar00rootroot00000000000000"""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.services.base import OnlineService from lutris.services.lutris import sync_media 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_patterns = ["%s.jpg"] 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 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_slugs = [] for content_ids in ea_app_launcher.get_installed_games_content_ids(): slug = self.install_from_ea_app(ea_app_game, content_ids) if slug: installed_slugs.append(slug) sync_media(installed_slugs) logger.debug("Installed %s EA games", len(installed_slugs)) 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) slug = self.get_installed_slug(ea_game) add_game( name=service_game["name"], runner=ea_game["runner"], slug=slug, directory=ea_game["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=offer_id, ) return slug 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": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "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 get_installed_runner_name(self, db_game): return self.runner 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") application.show_lutris_installer_window(game_slug=self.client_installer) 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.17/lutris/services/egs.py000066400000000000000000000362551460562010500174350ustar00rootroot00000000000000"""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.services.base import AuthTokenExpiredError, OnlineService from lutris.services.lutris import sync_media 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_patterns = ["%s.jpg"] 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.Resampling.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.Resampling.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_patterns = ["%s.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 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 AuthTokenExpiredError("EGS Token expired") 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) slug = self.get_installed_slug(egs_game) add_game( name=service_game["name"], runner=egs_game["runner"], slug=slug, directory=egs_game["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=app_name, ) return slug 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) installed_slugs = [] for manifest in egs_launcher.iter_manifests(): slug = self.install_from_egs(egs_game, manifest) if slug: installed_slugs.append(slug) sync_media(installed_slugs) 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": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "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 get_installed_runner_name(self, db_game): return self.runner 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) application.show_lutris_installer_window(game_slug=self.client_installer) 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.17/lutris/services/flathub.py000066400000000000000000000171561460562010500203030ustar00rootroot00000000000000import json import os import shutil import subprocess from gettext import gettext as _ from pathlib import Path import requests from lutris import settings from lutris.exceptions import MissingExecutableError 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_patterns = ["%s.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 MissingExecutableError(_("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(): raise RuntimeError( _("Flathub is not configured on the system. Visit https://flatpak.org/setup/ for instructions.") ) # Install the game self.install_from_api(db_game, 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": self.get_installed_slug(db_game), "slug": slugify(db_game["name"]) + "-" + self.id, "name": db_game["name"], "version": "Flathub", "runner": self.get_installed_runner_name(db_game), "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 --{self.install_type} --app --noninteractive flathub " f"app/{db_game['appid']}/{self.arch}/{self.branch}", "disable_runtime": True, } } ], }, } def get_installed_runner_name(self, db_game): return self.runner 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.17/lutris/services/gog.py000066400000000000000000000641651460562010500174340ustar00rootroot00000000000000"""Module for handling the GOG service""" import json import os import time from collections import defaultdict from gettext import gettext as _ from typing import List 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, UnauthorizedAccessError 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_patterns = ["%s.jpg"] 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 UnauthorizedAccessError: 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.") 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", 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, a list of dict containing the 'url' and 'filename' for each file.""" 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) return [] if not response: logger.error("No download info obtained for %s", downlink) return [] expanded = [] for field in ("checksum", "downlink"): field_url = response[field] parsed = urlparse(field_url) query = dict(parse_qsl(parsed.query)) filename = os.path.basename(query.get("path") or parsed.path) expanded.append({"url": response[field], "filename": filename}) return expanded 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 for DLCs you don't own are listed, but are not installable. if product.get("is_installable"): extras = [ { "name": download.get("name", "").strip().capitalize(), "type": download.get("type", "").strip(), "total_size": download.get("total_size", 0), "id": str(download["id"]), "downlinks": [f.get("downlink") for f in download.get("files") or []], } 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 for info in self.get_download_info(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": info["url"], "filename": info["filename"], } ) return download_links def get_extra_files(self, installer, selected_extras): extra_files = [] for extra in selected_extras: if extra.get("downlinks"): links = [info for link in extra.get("downlinks") for info in self.get_download_info(link)] elif str(extra["id"]) in selected_extras: links = self.query_download_links(extra) else: links = [] if not links: logger.error("No download link for bonus content '%s' could be obtained.", extra.get("name")) 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 = [] extra_files = [] if selected_extras: for extra_file in self.get_extra_files(installer, selected_extras): extra_files.append(extra_file) return files, extra_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": self.get_installed_slug(db_game), "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_installed_runner_name(self, db_game): platforms = [platform.casefold() for platform in self.get_game_platforms(db_game)] return "linux" if "linux" in platforms else "wine" 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"].casefold() == "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": self.get_installed_slug(db_game), "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: dict) -> List[str]: 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 [] lutris-0.5.17/lutris/services/humblebundle.py000066400000000000000000000357361460562010500213300ustar00rootroot00000000000000"""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 class HumbleBundleIcon(ServiceMedia): """HumbleBundle icon""" service = "humblebundle" size = (70, 70) dest_path = os.path.join(settings.CACHE_DIR, "humblebundle/icons") file_patterns = ["%s.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", 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] file = InstallerFile(installer.game_slug, installer_file_id, {"url": link, "filename": filename}) return [file], [] @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": self.get_installed_slug(db_game), "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 get_installed_runner_name(self, db_game): details = json.loads(db_game["details"]) platforms = [download["platform"] for download in details["downloads"]] if "linux" in platforms and self.platform_has_downloads(details["downloads"], "linux"): return "linux" if "windows" in platforms: return "wine" return "" 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.17/lutris/services/itchio.py000066400000000000000000000551531460562010500201340ustar00rootroot00000000000000"""itch.io service""" import datetime import json import os from gettext import gettext as _ from typing import List 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, UnauthorizedAccessError 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_patterns = ["%s.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 ItchIoCoverMedium(ItchIoCover): """itch.io game cover, at 60% size""" size = (189, 150) class ItchIoCoverSmall(ItchIoCover): """itch.io game cover, at 30% size""" size = (95, 75) 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_small": ItchIoCoverSmall, "banner_med": ItchIoCoverMedium, "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, UnauthorizedAccessError): 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)) try: request = Request(url, cookies=self.load_cookies()) request.get() return request.json except UnauthorizedAccessError: # We aren't logged in, so we'll log out! This allows you to # log in again. self.logout() raise 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""" if not appid: raise ValueError("Missing Itch.io app ID") 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 extras: all_extras["Bonus Content"] = extras return all_extras def get_installed_slug(self, db_game): return db_game["slug"] 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": self.get_installed_slug(db_game), "runner": runner, "itchid": db_game["appid"], "script": { "files": [{"itchupload": "N/A:Select the installer from itch.io"}], "game": game_config, "installer": script, }, } def get_installed_runner_name(self, db_game): details = json.loads(db_game["details"]) if "p_linux" in details["traits"]: return "linux" if "p_windows" in details["traits"]: return "wine" return "" def get_game_platforms(self, db_game: dict) -> List[str]: platforms = [] details = json.loads(db_game["details"]) if "p_linux" in details["traits"]: platforms.append("Linux") if "p_windows" in details["traits"]: platforms.append("Windows") return platforms 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": self.get_installed_slug(db_game), "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 = [] extra_files = [] link = None filename = "setup.zip" selected_extras_ids = set(x["id"] for x in selected_extras or []) 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_ids) > 0: for upload in uploads["uploads"]: if selected_extras_ids 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_ids: continue link = self.get_download_link(extra["id"], key) extra_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, extra_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.17/lutris/services/lutris.py000066400000000000000000000216041460562010500201710ustar00rootroot00000000000000import json import os from gettext import gettext as _ from typing import Any, Dict, Iterable, List, Optional 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.game import Game 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.jobs import AsyncCall 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/users/library" 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 [] return response.json 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 load_icons(self): super().load_icons() # Also load any media for games that use Lutris media, # but are not in the Lutris library. sync_media() def get_installed_slug(self, db_game): return db_game["slug"] def install(self, db_game): slug = db_game["slug"] return self.install_by_id(slug) def install_by_id(self, appid): def on_installers_ready(installers, error): if error: raise error # bounce any error off the backstop if not installers: raise RuntimeError(_("Lutris has no installers for %s. Try using a different service instead.") % appid) application = Gio.Application.get_default() application.show_installer_window(installers) AsyncCall(get_game_installers, on_installers_ready, appid) # appid is the slug for Lutris games def get_installed_runner_name(self, db_game): platforms = self.get_game_platforms(db_game) if platforms and len(platforms) == 1: platform = platforms[0].casefold() if platform == "windows": return "wine" if platform == "linux": return "linux" if platform == "ms-dos": return "dosbox" return "" def get_game_platforms(self, db_game: dict) -> List[str]: 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 [] def get_service_db_game(self, game: Game): if game.service == self.id and game.slug: return ServiceGameCollection.get_game(self.id, game.slug) 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 = _get_response_game_icon(response_data) if icon_url: download_media({slug: icon_url}, LutrisIcon()) banner_url = _get_response_game_banner(response_data) if banner_url: download_media({slug: banner_url}, LutrisBanner()) coverart_url = _get_response_game_coverart(response_data) if coverart_url: download_media({slug: coverart_url}, LutrisCoverart()) def sync_media(slugs: Iterable[str] = None) -> Dict[str, int]: """Download missing media for Lutris games; if a set of slugs is not provided, downloads them for all games in the PGA.""" if slugs is None: slugs = {game["slug"] for game in get_games()} else: slugs = set(s for s in slugs if s) if not slugs: return {} 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) slugs_to_download = slugs - complete_games if not slugs_to_download: return {} games = get_api_games(list(slugs_to_download)) alias_map = {} api_slugs = set() for game in games: api_slugs.add(game["slug"]) for alias in game["aliases"]: if alias["slug"] in slugs_to_download: 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"]: _get_response_game_banner(game) for game in games if game["slug"] not in banners_available and _get_response_game_banner(game) } icon_urls = { game["slug"]: _get_response_game_icon(game) for game in games if game["slug"] not in icons_available and _get_response_game_icon(game) } coverart_urls = { game["slug"]: _get_response_game_coverart(game) for game in games if game["slug"] not in covers_available and _get_response_game_coverart(game) } logger.debug("Syncing %s banners, %s icons and %s covers", len(banner_urls), len(icon_urls), len(coverart_urls)) download_media(banner_urls, LutrisBanner()) download_media(icon_urls, LutrisIcon()) download_media(coverart_urls, LutrisCoverart()) return { "banners": len(banner_urls), "icons": len(icon_urls), "covers": len(coverart_urls), } def _get_response_game_coverart(api_game: Dict[str, Any]) -> Optional[str]: return api_game.get("coverart") def _get_response_game_banner(api_game: Dict[str, Any]) -> Optional[str]: return api_game.get("banner_url") or api_game.get("banner") def _get_response_game_icon(api_game: Dict[str, Any]) -> Optional[str]: return api_game.get("icon_url") or api_game.get("icon") lutris-0.5.17/lutris/services/mame.py000066400000000000000000000003541460562010500175650ustar00rootroot00000000000000"""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.17/lutris/services/origin.py000066400000000000000000000346351460562010500201460ustar00rootroot00000000000000"""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.services.base import OnlineService from lutris.services.lutris import sync_media 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_patterns = ["%s.jpg"] 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 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_slugs = [] for manifest in origin_launcher.iter_manifests(): slug = self.install_from_origin(origin_game, manifest) if slug: installed_slugs.append(slug) sync_media(installed_slugs) logger.debug("Installed %s Origin games", len(installed_slugs)) 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) slug = self.get_installed_slug(service_game) add_game( name=service_game["name"], runner=origin_game["runner"], slug=slug, directory=origin_game["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=offer_id, ) return slug 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": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "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 get_installed_runner_name(self, db_game): return self.runner 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") application.show_lutris_installer_window(game_slug=self.client_installer) 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.17/lutris/services/scummvm.py000066400000000000000000000044351460562010500203410ustar00rootroot00000000000000import 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_patterns = ["%s.png"] 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": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "script": { "game": { "game_id": db_game["appid"], "path": details["path"], } }, } def get_installed_slug(self, db_game): return db_game.get("lutris_slug") or slugify(db_game["name"]) def get_installed_runner_name(self, db_game): return "scummvm" class ScummvmGame(ServiceGame): service = "scummvm" runner = "scummvm" lutris-0.5.17/lutris/services/service_game.py000066400000000000000000000027651460562010500213070ustar00rootroot00000000000000"""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 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(settings.DB_PATH, "service_games", game_data, {"id": existing_game["id"]}) else: sql.db_insert(settings.DB_PATH, "service_games", game_data) lutris-0.5.17/lutris/services/service_media.py000066400000000000000000000110761460562010500214500ustar00rootroot00000000000000import json import os import random import time from typing import List 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 from lutris.util.portals import TrashPortal def resolve_media_path(possible_paths: List[str]) -> str: """Selects the best path from a list of paths to media. This will take the first one that exists and has contents, or the just first one if none are usable.""" if len(possible_paths) > 1: for path in possible_paths: if system.path_exists(path, exclude_empty=True) and os.path.isfile(path): return path elif not possible_paths: raise ValueError("resolve_media_path() requires at least one path.") return possible_paths[0] 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 dest_path = NotImplemented file_patterns = 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_patterns[0] % slug def get_possible_media_paths(self, slug: str) -> List[str]: """Returns a list of each path where the media might be found. At most one of these should be found, but they are in a priority order - the first is in the preferred format.""" return [os.path.join(self.dest_path, pattern % slug) for pattern in self.file_patterns] def trash_media( self, slug: str, completion_function: TrashPortal.CompletionFunction = None, error_function: TrashPortal.ErrorFunction = None, ) -> None: """Sends each media file for a game to the trash, and invokes callsbacks when this has been completed or has failed.""" paths = [path for path in self.get_possible_media_paths(slug) if os.path.exists(path)] if paths: TrashPortal(paths, completion_function=completion_function, error_function=error_function) elif completion_function: completion_function() 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 run_system_update_desktop_icons(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.17/lutris/services/steam.py000066400000000000000000000212651460562010500177630ustar00rootroot00000000000000"""Steam service""" import json import os from collections import defaultdict from gettext import gettext as _ from lutris import settings from lutris.config import LutrisConfig, 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.installer.installer_file import InstallerFile from lutris.services.base import BaseService from lutris.services.lutris import sync_media 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_patterns = ["%s.jpg"] 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_patterns = ["%s.jpg"] 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_patterns = ["%s.jpg"] 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): """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 match_game(self, service_game, lutris_game): super().match_game(service_game, lutris_game) if service_game: # Copy playtimes from Steam's data for game in get_games(filters={"service": self.id, "service_id": service_game["appid"]}): steam_game_playtime = json.loads(service_game["details"]).get("playtime_forever") playtime = steam_game_playtime / 60 sql.db_update(settings.DB_PATH, "games", {"playtime": playtime}, conditions={"id": game["id"]}) def get_installer_files(self, installer, _installer_file_id, _selected_extras): steam_uri = "$STEAM:%s:." appid = str(installer.script["game"]["appid"]) file = InstallerFile(installer.game_slug, "steam_game", {"url": steam_uri % appid, "filename": appid}) return [file], [] 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) slug = self.get_installed_slug(service_game) add_game( name=service_game["name"], runner="steam", slug=slug, installed=1, installer_slug=lutris_game_id, configpath=configpath, platform="Linux", service=self.id, service_id=appid, ) return slug @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_slugs = [] 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) slug = self.install_from_steam(app_manifest) if slug: installed_slugs.append(slug) 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.uninstall() 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.uninstall() steam_game.delete() stats["deduped"] += 1 sync_media(installed_slugs) 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": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "appid": db_game["appid"], "script": {"game": {"appid": db_game["appid"]}}, } def get_installed_runner_name(self, db_game): return self.runner 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 self.install_from_api(db_game, appid) lutris-0.5.17/lutris/services/steamwindows.py000066400000000000000000000061551460562010500213770ustar00rootroot00000000000000import 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.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": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "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_installed_runner_name(self, db_game): return self.runner 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() application = Gio.Application.get_default() if not steam_game: application.show_lutris_installer_window(game_slug=self.client_installer) return 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.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.17/lutris/services/ubisoft.py000066400000000000000000000266021460562010500203250ustar00rootroot00000000000000"""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.services.base import OnlineService from lutris.services.lutris import sync_media 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_patterns = ["%s.jpg"] 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) slug = self.get_installed_slug(game) if existing_game: update_existing( id=existing_game["id"], name=game["name"], runner=self.runner, slug=slug, directory=ubisoft_connect["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=game["appid"], ) return existing_game["id"] add_game( name=game["name"], runner=self.runner, slug=slug, directory=ubisoft_connect["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=game["appid"], ) return slug 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) installed_slugs = [] 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: slug = self.install_from_ubisoft(ubisoft_connect, game) if slug: installed_slugs.append(slug) sync_media(installed_slugs) 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": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "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 get_installed_runner_name(self, db_game): return self.runner 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) application.show_lutris_installer_window(game_slug=self.client_installer) else: application.show_installer_window( [self.generate_installer(db_game, ubisoft_connect)], service=self, appid=db_game["appid"] ) lutris-0.5.17/lutris/services/xdg.py000066400000000000000000000140351460562010500174310ustar00rootroot00000000000000"""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_patterns = ["%s.png"] class XDGService(BaseService): id = "xdg" name = _("Local") icon = "linux" runner = "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 ignored_categories = set(c.casefold() for c in cls.ignored_categories) if any(c for c in categories if c in 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": self.get_installed_slug(db_game), "runner": self.get_installed_runner_name(db_game), "script": { "game": { "exe": details["exe"], "args": details["args"], "working_dir": details["path"], }, "system": {"disable_runtime": True}, }, } def get_installed_runner_name(self, db_game): return self.runner 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) path = cls.get_desktop_entry_path(xdg_app) service_game.details = json.dumps( { "exe": exe, "args": args, "path": path, } ) return service_game @staticmethod def get_desktop_entry_path(xdg_app): """Retrieve the Path variable from the .desktop file""" # I expect we'll only see DesktopAppInfos here, but just in case # we get something else, we'll make sure. if hasattr(xdg_app, "get_string"): return xdg_app.get_string("Path") return None @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_required_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.17/lutris/settings.py000066400000000000000000000115721460562010500166670ustar00rootroot00000000000000"""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") DATA_DIR = os.path.join(GLib.get_user_data_dir(), "lutris") if not os.path.exists(CONFIG_DIR): # Set the config dir to ~/.local/share/lutris as we're deprecating ~/.config/lutris CONFIG_DIR = DATA_DIR CONFIG_FILE = os.path.join(CONFIG_DIR, "lutris.conf") sio = SettingsIO(CONFIG_FILE) 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") TMP_DIR = os.path.join(CACHE_DIR, "tmp") GAME_CONFIG_DIR = os.path.join(CONFIG_DIR, "games") RUNNERS_CONFIG_DIR = os.path.join(CONFIG_DIR, "runners") SHADER_CACHE_DIR = os.path.join(CACHE_DIR, "shaders") INSTALLER_CACHE_DIR = os.path.join(CACHE_DIR, "installer") BANNER_PATH = os.path.join(CACHE_DIR, "banners") if not os.path.exists(BANNER_PATH): BANNER_PATH = os.path.join(DATA_DIR, "banners") COVERART_PATH = os.path.join(CACHE_DIR, "coverart") if not os.path.exists(COVERART_PATH): COVERART_PATH = os.path.join(DATA_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]: DB_PATH = "/tmp/pga.db" else: DB_PATH = sio.read_setting("pga_path") or os.path.join(DATA_DIR, "pga.db") SITE_URL = sio.read_setting("website") or "https://lutris.net" 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", default="1280") DEFAULT_RESOLUTION_HEIGHT = sio.read_setting("default_resolution_height", default="720") UPDATE_CHANNEL_STABLE = "stable" UPDATE_CHANNEL_UMU = "umu" UPDATE_CHANNEL_UNSUPPORTED = "self-maintained" read_setting = sio.read_setting read_bool_setting = sio.read_bool_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.17/lutris/startup.py000066400000000000000000000113011460562010500165170ustar00rootroot00000000000000"""Check to run at program start""" import os import sqlite3 from gettext import gettext as _ import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import GdkPixbuf from lutris import runners, settings from lutris.database.games import get_games from lutris.database.schema import syncdb from lutris.game import Game from lutris.runners.json import load_json_runners from lutris.services import DEFAULT_SERVICES from lutris.util.graphics import vkquery from lutris.util.graphics.drivers import get_gpu_cards from lutris.util.graphics.gpu import GPU, GPUS from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.path_cache import build_path_cache from lutris.util.system import create_folder from lutris.util.wine.dxvk import REQUIRED_VULKAN_API_VERSION def init_dirs(): """Creates Lutris directories""" directories = [ settings.CONFIG_DIR, settings.RUNNERS_CONFIG_DIR, settings.GAME_CONFIG_DIR, settings.DATA_DIR, settings.ICON_PATH, settings.BANNER_PATH, settings.COVERART_PATH, settings.RUNNER_DIR, settings.RUNTIME_DIR, settings.CACHE_DIR, settings.SHADER_CACHE_DIR, settings.INSTALLER_CACHE_DIR, settings.TMP_DIR, ] for directory in directories: create_folder(directory) def check_libs(all_components=False): """Checks that required libraries are installed on the system""" missing_libs = LINUX_SYSTEM.get_missing_libs() if all_components: components = LINUX_SYSTEM.requirements else: components = LINUX_SYSTEM.critical_requirements for req in components: for index, arch in enumerate(LINUX_SYSTEM.runtime_architectures): for lib in missing_libs[req][index]: logger.error("%s %s missing (needed by %s)", arch, lib, req.lower()) def check_vulkan(): """Reports if Vulkan is enabled on the system""" if os.environ.get("LUTRIS_NO_VKQUERY"): return if not vkquery.is_vulkan_supported(): logger.warning("Vulkan is not available or your system isn't Vulkan capable") else: required_api_version = REQUIRED_VULKAN_API_VERSION library_api_version = vkquery.get_vulkan_api_version() if library_api_version and library_api_version < required_api_version: logger.warning( "Vulkan reports an API version of %s. " "%s is required for the latest DXVK.", vkquery.format_version(library_api_version), vkquery.format_version(required_api_version), ) devices = vkquery.get_device_info() if devices and devices[0].api_version < required_api_version: logger.warning( "Vulkan reports that the '%s' device has API version of %s. " "%s is required for the latest DXVK.", devices[0].name, vkquery.format_version(devices[0].api_version), vkquery.format_version(required_api_version), ) def check_gnome(): required_names = ["svg", "png", "jpeg"] format_names = [f.get_name() for f in GdkPixbuf.Pixbuf.get_formats()] for required in required_names: if required not in format_names: logger.error("'%s' PixBuf support is not installed.", required.upper()) def fill_missing_platforms(): """Sets the platform on games where it's missing. This should never happen. """ pga_games = get_games(filters={"installed": 1}) for pga_game in pga_games: if pga_game.get("platform") or not pga_game["runner"]: continue game = Game(game_id=pga_game["id"]) game.set_platform_from_runner() if game.platform: logger.info("Platform for %s set to %s", game.name, game.platform) game.save_platform() def run_all_checks() -> None: """Run all startup checks""" for card in get_gpu_cards(): gpu = GPU(card) driver_info = gpu.get_driver_info() logger.info("%s Driver %s", gpu, driver_info.get("version")) GPUS[card] = gpu check_libs() check_vulkan() check_gnome() fill_missing_platforms() build_path_cache() def init_lutris(): """Run full initialization of Lutris""" runners.inject_runners(load_json_runners()) init_dirs() try: syncdb() except sqlite3.DatabaseError as err: raise RuntimeError( _("Failed to open database file in %s. Try renaming this file and relaunch Lutris") % settings.DB_PATH ) from err for service in DEFAULT_SERVICES: if not settings.read_setting(service, section="services"): settings.write_setting(service, True, section="services") lutris-0.5.17/lutris/style_manager.py000066400000000000000000000112621460562010500176550ustar00rootroot00000000000000import enum from gi.repository import Gio, GLib, GObject, Gtk from lutris import settings from lutris.util.log import logger PORTAL_BUS_NAME = "org.freedesktop.portal.Desktop" PORTAL_OBJECT_PATH = "/org/freedesktop/portal/desktop" PORTAL_SETTINGS_INTERFACE = "org.freedesktop.portal.Settings" 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): try: proxy = obj.new_for_bus_finish(result) if proxy: proxy.connect("g-signal", self._on_settings_changed) self._dbus_proxy = proxy self._read_portal_setting() else: raise RuntimeError("Could not start GDBusProxy") except Exception as ex: logger.exception("Error setting up style change monitoring: %s", ex) def _call_cb(self, obj, result): try: values = obj.call_finish(result) if values: value = values[0] self.color_scheme = self._read_value(value) else: raise RuntimeError("Could not read color-scheme") except Exception as ex: logger.exception("Error reading color-scheme: %s", ex) def _on_settings_changed(self, _proxy, _sender_name, signal_name, params): if signal_name != "SettingChanged": return namespace, name, value = params if namespace == "org.freedesktop.appearance" and name == "color-scheme": self.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.17/lutris/sysoptions.py000066400000000000000000000445171460562010500172660ustar00rootroot00000000000000"""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, is_compositing_enabled from lutris.util.graphics.gpu import GPUS 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. """ return [ (_("System"), ""), (_("Chinese"), "zh_CN.utf8"), (_("Croatian"), "hr_HR.utf8"), (_("Dutch"), "nl_NL.utf8"), (_("English"), "en_US.utf8"), (_("Finnish"), "fi_FI.utf8"), (_("French"), "fr_FR.utf"), (_("Georgian"), "ka_GE.utf8"), (_("German"), "de_DE.utf8"), (_("Greek"), "el_GR.utf8"), (_("Italian"), "it_IT.utf8"), (_("Japanese"), "ja_JP.utf8"), (_("Korean"), "ko_KR.utf8"), (_("Portuguese (Brazilian)"), "pt_BR.utf8"), (_("Polish"), "pl_PL.utf8"), (_("Russian"), "ru_RU.utf8"), (_("Spanish"), "es_ES.utf8"), (_("Turkish"), "tr_TR.utf8"), ] def get_gpu_list(): choices = [(_("Auto"), "")] for card, gpu in GPUS.items(): choices.append((gpu.short_name, card)) return 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 system_options = [ # pylint: disable=invalid-name { "section": _("Lutris"), "option": "game_path", "type": "directory_chooser", "label": _("Default installation folder"), "warn_if_non_writable_parent": True, "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": _("Display"), "option": "gpu", "type": "choice", "label": _("GPU"), "choices": get_gpu_list, "default": "", "condition": lambda: len(GPUS) > 1, "help": _("GPU to use to run games"), }, { "section": _("Display"), "option": "mangohud", "type": "bool", "label": _("FPS counter (MangoHud)"), "default": False, "condition": system.can_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, "advanced": True, "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": "disable_compositor", "label": _("Disable desktop effects"), "type": "bool", "default": False, "advanced": True, "condition": is_compositing_enabled(), "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": True, "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": "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"), "advanced": True, "choices": get_resolution_choices, "default": "off", "help": _("Switch to this screen resolution while the game is running."), }, { "section": _("Gamescope"), "option": "gamescope", "type": "bool", "label": _("Enable Gamescope"), "default": False, "condition": system.can_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_hdr", "type": "bool", "label": _("Enable HDR (Experimental)"), "advanced": False, "default": False, "condition": bool(system.can_find_executable("gamescope")), "help": _("Enable HDR for games that support it.\bn" "Requires Plasma 6 and VK_hdr_layer."), }, { "section": _("Gamescope"), "option": "gamescope_force_grab_cursor", "type": "bool", "label": _("Relative Mouse Mode"), "advanced": True, "default": False, "condition": bool(system.can_find_executable("gamescope")), "help": _( "Always use relative mouse mode instead of flipping\n" "dependent on cursor visibility\n" "Can help with games where the player's camera faces the floor" ), }, { "section": _("Gamescope"), "option": "gamescope_output_res", "type": "choice_with_entry", "label": _("Output Resolution"), "choices": DISPLAY_MANAGER.get_resolutions, "advanced": True, "condition": system.can_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"), "choices": DISPLAY_MANAGER.get_resolutions, "condition": system.can_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"), "type": "choice", "choices": ( (_("Fullscreen"), "-f"), (_("Windowed"), ""), (_("Borderless"), "-b"), ), "default": "-f", "condition": system.can_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": system.can_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": _("Framerate Limiter"), "advanced": False, "type": "string", "condition": system.can_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": system.can_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": _("Audio"), "option": "pulse_latency", "type": "bool", "label": _("Reduce PulseAudio latency"), "default": False, "advanced": True, "condition": system.can_find_executable("pulseaudio") or system.can_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": _("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_entry", "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.InvalidRunnerError: 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.17/lutris/util/000077500000000000000000000000001460562010500154245ustar00rootroot00000000000000lutris-0.5.17/lutris/util/__init__.py000066400000000000000000000024161460562010500175400ustar00rootroot00000000000000"""Misc common functions""" from functools import wraps def selective_merge(base_obj, delta_obj): """used by write_json""" if not isinstance(base_obj, dict): return delta_obj common_keys = set(base_obj).intersection(delta_obj) new_keys = set(delta_obj).difference(common_keys) for k in common_keys: base_obj[k] = selective_merge(base_obj[k], delta_obj[k]) for k in new_keys: base_obj[k] = delta_obj[k] return base_obj def cache_single(function): """A simple replacement for lru_cache, with no LRU behavior. This caches a single result from a function that has no arguments at all. Exceptions are not cached; there's a 'clear_cache()' function on the wrapper like with lru_cache to explicitly clear the cache.""" is_cached = False cached_item = None @wraps(function) def wrapper(*args, **kwargs): nonlocal is_cached, cached_item if args or kwargs: return function(*args, **kwargs) if not is_cached: cached_item = function() is_cached = True return cached_item def cache_clear(): nonlocal is_cached, cached_item is_cached = False cached_item = None wrapper.cache_clear = cache_clear return wrapper lutris-0.5.17/lutris/util/amazon/000077500000000000000000000000001460562010500167115ustar00rootroot00000000000000lutris-0.5.17/lutris/util/amazon/__init__.py000066400000000000000000000000001460562010500210100ustar00rootroot00000000000000lutris-0.5.17/lutris/util/amazon/protobuf_decoder.py000066400000000000000000000142631460562010500226160ustar00rootroot00000000000000import 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.17/lutris/util/amazon/sds_proto2.py000066400000000000000000000050661460562010500213700ustar00rootroot00000000000000from 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.17/lutris/util/battlenet/000077500000000000000000000000001460562010500174065ustar00rootroot00000000000000lutris-0.5.17/lutris/util/battlenet/__init__.py000066400000000000000000000002531460562010500215170ustar00rootroot00000000000000# 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.17/lutris/util/battlenet/definitions.py000066400000000000000000000127471460562010500223060ustar00rootroot00000000000000import 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.17/lutris/util/battlenet/product_db_pb2.py000066400000000000000000001510541460562010500226560ustar00rootroot00000000000000# 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 ), # 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.17/lutris/util/cookies.py000066400000000000000000000052441460562010500174370ustar00rootroot00000000000000import 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.17/lutris/util/datapath.py000066400000000000000000000020501460562010500175610ustar00rootroot00000000000000"""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.17/lutris/util/discord/000077500000000000000000000000001460562010500170535ustar00rootroot00000000000000lutris-0.5.17/lutris/util/discord/__init__.py000066400000000000000000000000561460562010500211650ustar00rootroot00000000000000__all__ = ["client"] from .rpc import client lutris-0.5.17/lutris/util/discord/base.py000066400000000000000000000010261460562010500203360ustar00rootroot00000000000000""" 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.17/lutris/util/discord/client.py000066400000000000000000000017271460562010500207120ustar00rootroot00000000000000from 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.17/lutris/util/discord/rpc.py000066400000000000000000000010331460562010500202060ustar00rootroot00000000000000""" 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.17/lutris/util/display.py000066400000000000000000000361301460562010500174460ustar00rootroot00000000000000"""Module to deal with various aspects of displays""" # isort:skip_file import enum import os import subprocess from typing import Any, Dict import gi try: gi.require_version("GnomeDesktop", "3.0") from gi.repository import GnomeDesktop LIB_GNOME_DESKTOP_AVAILABLE = True except ValueError: LIB_GNOME_DESKTOP_AVAILABLE = False GnomeDesktop = None try: from dbus.exceptions import DBusException DBUS_AVAILABLE = True except ImportError: DBUS_AVAILABLE = False from gi.repository import Gdk, GLib, Gio, Gtk from lutris.util import cache_single from lutris.settings import DEFAULT_RESOLUTION_HEIGHT, DEFAULT_RESOLUTION_WIDTH from lutris.util.graphics.displayconfig import MutterDisplayManager from lutris.util.graphics.xrandr import LegacyDisplayManager, change_resolution, get_outputs from lutris.util.log import logger def get_default_dpi(): """Computes the DPI to use for the primary monitor which we pass to WINE.""" display = Gdk.Display.get_default() if display: monitor = display.get_primary_monitor() if monitor: scale = monitor.get_scale_factor() dpi = 96 * scale return int(dpi) return 96 class DisplayManager: """Get display and resolution using GnomeDesktop""" def __init__(self, screen: Gdk.Screen): self.rr_screen = GnomeDesktop.RRScreen.new(screen) self.rr_config = GnomeDesktop.RRConfig.new_current(self.rr_screen) self.rr_config.load_current() def get_display_names(self): """Return names of connected displays""" return [output_info.get_display_name() for output_info in self.rr_config.get_outputs()] def get_resolutions(self): """Return available resolutions""" resolutions = ["%sx%s" % (mode.get_width(), mode.get_height()) for mode in self.rr_screen.list_modes()] if not resolutions: logger.error("Failed to generate resolution list from default GdkScreen") return ["%sx%s" % (DEFAULT_RESOLUTION_WIDTH, DEFAULT_RESOLUTION_HEIGHT)] return sorted(set(resolutions), key=lambda x: int(x.split("x")[0]), reverse=True) def _get_primary_output(self): """Return the RROutput used as a primary display""" for output in self.rr_screen.list_outputs(): if output.get_is_primary(): return output return def get_current_resolution(self): """Return the current resolution for the primary display""" output = self._get_primary_output() if not output: logger.error("Failed to get a default output") return str(DEFAULT_RESOLUTION_WIDTH), str(DEFAULT_RESOLUTION_HEIGHT) current_mode = output.get_current_mode() return str(current_mode.get_width()), str(current_mode.get_height()) @staticmethod def set_resolution(resolution): """Set the resolution of one or more displays. The resolution can either be a string, which will be applied to the primary display or a list of configurations as returned by `get_config`. This method uses XrandR and will not work on Wayland. """ return change_resolution(resolution) @staticmethod def get_config(): """Return the current display resolution This method uses XrandR and will not work on wayland The output can be fed in `set_resolution` """ return get_outputs() def get_display_manager(): """Return the appropriate display manager instance. Defaults to Mutter if available. This is the only one to support Wayland. """ if DBUS_AVAILABLE: try: return MutterDisplayManager() except DBusException as ex: logger.debug("Mutter DBus service not reachable: %s", ex) except Exception as ex: # pylint: disable=broad-except logger.exception("Failed to instantiate MutterDisplayConfig. Please report with exception: %s", ex) else: logger.error("DBus is not available, Lutris was not properly installed.") if LIB_GNOME_DESKTOP_AVAILABLE: try: screen = Gdk.Screen.get_default() if screen: return DisplayManager(screen) except GLib.Error: pass return LegacyDisplayManager() DISPLAY_MANAGER = get_display_manager() class DesktopEnvironment(enum.Enum): """Enum of desktop environments.""" PLASMA = 0 MATE = 1 XFCE = 2 DEEPIN = 3 UNKNOWN = 999 # These desktop environment use a compositor that can be detected with a specific # command, and which provide a definite answer; the DE can be asked to start and stop it.. _compositor_commands_by_de = { DesktopEnvironment.PLASMA: { "check": ["qdbus", "org.kde.KWin", "/Compositor", "org.kde.kwin.Compositing.active"], "active_result": b"true\n", "stop_compositor": ["qdbus", "org.kde.KWin", "/Compositor", "org.kde.kwin.Compositing.suspend"], "start_compositor": ["qdbus", "org.kde.KWin", "/Compositor", "org.kde.kwin.Compositing.resume"], }, DesktopEnvironment.MATE: { "check": ["gsettings", "get org.mate.Marco.general", "compositing-manager"], "active_result": b"true\n", "stop_compositor": ["gsettings", "set org.mate.Marco.general", "compositing-manager", "false"], "start_compositor": ["gsettings", "set org.mate.Marco.general", "compositing-manager", "true"], }, DesktopEnvironment.XFCE: { "check": ["xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing"], "active_result": b"true\n", "stop_compositor": ["xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing", "--set=false"], "start_compositor": ["xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing", "--set=true"], }, DesktopEnvironment.DEEPIN: { "check": [ "dbus-send", "--session", "--dest=com.deepin.WMSwitcher", "--type=method_call", "--print-reply=literal", "/com/deepin/WMSwitcher", "com.deepin.WMSwitcher.CurrentWM", ], "active_result": b"deepin wm\n", "stop_compositor": [ "dbus-send", "--session", "--dest=com.deepin.WMSwitcher", "--type=method_call", "/com/deepin/WMSwitcher", "com.deepin.WMSwitcher.RequestSwitchWM", ], "start_compositor": [ "dbus-send", "--session", "--dest=com.deepin.WMSwitcher", "--type=method_call", "/com/deepin/WMSwitcher", "com.deepin.WMSwitcher.RequestSwitchWM", ], }, } # These additional compositors can be detected by looking for their process, # and must be started more directly. _non_de_compositor_commands = [ { "check": ["pgrep", "picom"], "stop_compositor": ["pkill", "picom"], "start_compositor": ["picom", ""], "run_in_background": True, }, { "check": ["pgrep", "compton"], "stop_compositor": ["pkill", "compton"], "start_compositor": ["compton", ""], "run_in_background": True, }, ] def get_desktop_environment(): """Converts the value of the DESKTOP_SESSION environment variable to one of the constants in the DesktopEnvironment class. Returns None if DESKTOP_SESSION is empty or unset. """ desktop_session = os.environ.get("DESKTOP_SESSION", "").lower() if not desktop_session: return None if desktop_session.endswith("mate"): return DesktopEnvironment.MATE if desktop_session.endswith("xfce"): return DesktopEnvironment.XFCE if desktop_session.endswith("deepin"): return DesktopEnvironment.DEEPIN if "plasma" in desktop_session: return DesktopEnvironment.PLASMA return DesktopEnvironment.UNKNOWN def _get_command_output(command): """Some rogue function that gives no shit about residing in the correct module""" try: return subprocess.Popen( # pylint: disable=consider-using-with command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, close_fds=True ).communicate()[0] except FileNotFoundError: logger.error("Unable to run command, %s not found", command[0]) def is_compositing_enabled(): """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 in _compositor_commands_by_de: command_set = _compositor_commands_by_de[desktop_environment] return _check_compositor_active(command_set) for command_set in _non_de_compositor_commands: if _check_compositor_active(command_set): return True # It might be a compositor we don't know about, so return None for unknown. return None def _check_compositor_active(command_set: Dict[str, Any]) -> bool: """Applies the 'check' command; and returns whether the result was the desired 'active_result'; if that is omitted, we check for any result at all.""" command = command_set["check"] result = _get_command_output(command) if "active_result" in command_set: return result == command_set["active_result"] return result != b"" # One element is appended to this for every invocation of disable_compositing: # True if compositing has been disabled, False if not. enable_compositing # removes the last element, and only re-enables compositing if that element # was True. _COMPOSITING_DISABLED_STACK = [] @cache_single def _get_compositor_commands(): """Returns the commands to enable/disable compositing on the current desktop environment as a 3-tuple: start command, stop-command and a flag to indicate if we need to run the commands in the background. """ desktop_environment = get_desktop_environment() command_set = _compositor_commands_by_de.get(desktop_environment) if not command_set: for c in _non_de_compositor_commands: if _check_compositor_active(c): command_set = c break if command_set: start_compositor = command_set["start_compositor"] stop_compositor = command_set["stop_compositor"] run_in_background = bool(command_set.get("run_in_background")) return start_compositor, stop_compositor, run_in_background return None, None, False def _run_command(*command, run_in_background=False): """Random _run_command lost in the middle of the project, are you lost little _run_command? """ try: if run_in_background: command = " ".join(command) return subprocess.Popen( # pylint: disable=consider-using-with command, stdin=subprocess.DEVNULL, close_fds=True, shell=run_in_background, start_new_session=run_in_background, ) except FileNotFoundError: errorMessage = "FileNotFoundError when running command:", command logger.error(errorMessage) def disable_compositing(): """Disable compositing if not already disabled.""" compositing_enabled = is_compositing_enabled() if 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, background = _get_compositor_commands() if stop_compositor: _run_command(*stop_compositor, run_in_background=background) def enable_compositing(): """Re-enable compositing if the corresponding call to disable_compositing disabled it.""" compositing_disabled = _COMPOSITING_DISABLED_STACK.pop() if not compositing_disabled: return start_compositor, _, background = _get_compositor_commands() if start_compositor: _run_command(*start_compositor, run_in_background=background) class DBusScreenSaverInhibitor: """Inhibit and uninhibit the screen saver using DBus. It will use the Gtk.Application's inhibit and uninhibit methods to inhibit the screen saver. For enviroments which don't support either org.freedesktop.ScreenSaver or org.gnome.ScreenSaver interfaces one can declare a DBus interface which requires the Inhibit() and UnInhibit() methods to be exposed.""" def __init__(self): self.proxy = None def set_dbus_iface(self, name, path, interface, bus_type=Gio.BusType.SESSION): """Sets a dbus proxy to be used instead of Gtk.Application methods, this method can raise an exception.""" self.proxy = Gio.DBusProxy.new_for_bus_sync( bus_type, Gio.DBusProxyFlags.NONE, None, name, path, interface, None ) def inhibit(self, game_name): """Inhibit the screen saver. Returns a cookie that must be passed to the corresponding uninhibit() call. If an error occurs, None is returned instead.""" reason = "Running game: %s" % game_name if self.proxy: try: return self.proxy.Inhibit("(ss)", "Lutris", reason) except Exception: return None else: app = Gio.Application.get_default() window = app.window flags = Gtk.ApplicationInhibitFlags.SUSPEND | Gtk.ApplicationInhibitFlags.IDLE cookie = app.inhibit(window, flags, reason) # Gtk.Application.inhibit returns 0 if there was an error. if cookie == 0: return None return cookie def uninhibit(self, cookie): """Uninhibit the screen saver. Takes a cookie as returned by inhibit. If cookie is None, no action is taken.""" if not cookie: return if self.proxy: self.proxy.UnInhibit("(u)", cookie) else: app = Gio.Application.get_default() app.uninhibit(cookie) def _get_screen_saver_inhibitor(): """Return the appropriate screen saver inhibitor instance. If the required interface isn't available, it will default to GTK's implementation.""" desktop_environment = get_desktop_environment() name = None inhibitor = DBusScreenSaverInhibitor() if desktop_environment is DesktopEnvironment.MATE: name = "org.mate.ScreenSaver" path = "/" interface = "org.mate.ScreenSaver" elif desktop_environment is DesktopEnvironment.XFCE: # According to # https://github.com/xfce-mirror/xfce4-session/blob/master/xfce4-session/xfce-screensaver.c#L240 # The XFCE enviroment does support the org.freedesktop.ScreenSaver interface # but this might be not present in older releases. name = "org.xfce.ScreenSaver" path = "/" interface = "org.xfce.ScreenSaver" if name: try: inhibitor.set_dbus_iface(name, path, interface) except GLib.Error as err: logger.warning( "Failed to set up a DBus proxy for name %s, path %s, " "interface %s: %s", name, path, interface, err ) return inhibitor SCREEN_SAVER_INHIBITOR = _get_screen_saver_inhibitor() lutris-0.5.17/lutris/util/dolphin.py000066400000000000000000000024311460562010500174330ustar00rootroot00000000000000# 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.17/lutris/util/dolphin/000077500000000000000000000000001460562010500170615ustar00rootroot00000000000000lutris-0.5.17/lutris/util/dolphin/__init__.py000066400000000000000000000000001460562010500211600ustar00rootroot00000000000000lutris-0.5.17/lutris/util/dolphin/cache_reader.py000066400000000000000000000111101460562010500220120ustar00rootroot00000000000000"""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.17/lutris/util/downloader.py000066400000000000000000000177241460562010500201470ustar00rootroot00000000000000import 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=8192): if not self.file_pointer: break if chunk: self.downloaded_size += len(chunk) self.file_pointer.write(chunk) self.progress_event.set() self.on_download_completed() except Exception as ex: logger.exception("Download failed: %s", ex) self.on_download_failed(ex) def on_download_failed(self, error: Exception): # Cancelling closes the file, which can result in an # error. If so, we just remain cancelled. if self.state != self.CANCELLED: self.state = self.ERROR self.error = error if self.file_pointer: self.file_pointer.close() self.file_pointer = None def on_download_completed(self): if self.state == self.CANCELLED: return logger.debug("Finished downloading %s", self.url) if not self.downloaded_size: logger.warning("Downloaded file is empty") if not self.full_size: self.progress_fraction = 1.0 self.progress_percentage = 100 self.state = self.COMPLETED self.file_pointer.close() self.file_pointer = None def get_stats(self): """Calculate and store download stats.""" self.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.17/lutris/util/egs/000077500000000000000000000000001460562010500162025ustar00rootroot00000000000000lutris-0.5.17/lutris/util/egs/__init__.py000066400000000000000000000000001460562010500203010ustar00rootroot00000000000000lutris-0.5.17/lutris/util/egs/egs_launcher.py000066400000000000000000000017321460562010500212160ustar00rootroot00000000000000"""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.17/lutris/util/extract.py000066400000000000000000000245721460562010500174620ustar00rootroot00000000000000import gzip import os import shutil import subprocess import tarfile import uuid import zlib from typing import List, Tuple from lutris import settings from lutris.exceptions import MissingExecutableError from lutris.util import system from lutris.util.log import logger class ExtractError(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.casefold().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) -> Tuple[str, str]: 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 ExtractError(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 ExtractError(str(ex)) from ex else: shutil.move(source_path, destination_path) system.delete_folder(temp_dir) logger.debug("Finished extracting %s to %s", path, to_directory) return path, to_directory def _do_extract(archive: str, dest: str, opener, mode: str = None, extractor=None) -> 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: str, dest: str) -> None: 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_required_executable("7za") 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: str, dest: str) -> None: """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: str, dest: str) -> None: """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: str, dest: str) -> None: if check_inno_exe(path): decompress_gog(path, dest) else: raise RuntimeError("specified exe is not a GOG setup file") def get_innoextract_path() -> str: """Return the path where innoextract is installed""" inno_dirs = [path for path in os.listdir(settings.RUNTIME_DIR) if path.startswith("innoextract")] for inno_dir in inno_dirs: inno_path = os.path.join(settings.RUNTIME_DIR, inno_dir, "innoextract") if system.path_exists(inno_path): return inno_path inno_path = system.find_required_executable("innoextract") logger.warning("innoextract not available in the runtime folder, using some random version") return inno_path def check_inno_exe(path) -> bool: """Check if a path in a compatible innosetup archive""" try: innoextract_path = get_innoextract_path() except MissingExecutableError: 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: str) -> List[str]: """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: str, destination_path: str) -> None: innoextract_path = get_innoextract_path() 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: str, dest_path: str): """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() def extract_7zip(path: str, dest: str, archive_type: str = None) -> None: _7zip_path = os.path.join(settings.RUNTIME_DIR, "p7zip/7z") if not system.path_exists(_7zip_path): _7zip_path = system.find_required_executable("7z") 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.17/lutris/util/fileio.py000066400000000000000000000046771460562010500172630ustar00rootroot00000000000000# 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