pax_global_header00006660000000000000000000000064141326743570014526gustar00rootroot0000000000000052 comment=f4dee7eac3baaab5a5324fab2e5186729189bba8 lutris-0.5.9.1/000077500000000000000000000000001413267435700132225ustar00rootroot00000000000000lutris-0.5.9.1/.agignore000066400000000000000000000000341413267435700150130ustar00rootroot00000000000000share/lutris/bin/winetricks lutris-0.5.9.1/.editorconfig000066400000000000000000000005451413267435700157030ustar00rootroot00000000000000# 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 [*.yml] tab_width = 2 [Makefile] indent_style = tab indent_size = 4 lutris-0.5.9.1/.flake8000066400000000000000000000005241413267435700143760ustar00rootroot00000000000000[flake8] ignore = # , # description E722, # do not use bare except' (done by pylint) W503, # line break before binary operator E402, # module level import not at top of file (gtk stuff) max-line-length = 120 exclude = .venv,venv,.env,env,lutris/game.py max-complexity = 15 accept-encodings = utf-8 lutris-0.5.9.1/.github/000077500000000000000000000000001413267435700145625ustar00rootroot00000000000000lutris-0.5.9.1/.github/FUNDING.yml000066400000000000000000000001041413267435700163720ustar00rootroot00000000000000patreon: lutris liberapay: Lutris custom: https://lutris.net/donate lutris-0.5.9.1/.github/workflows/000077500000000000000000000000001413267435700166175ustar00rootroot00000000000000lutris-0.5.9.1/.github/workflows/lint_python.yml000066400000000000000000000022121413267435700217060ustar00rootroot00000000000000name: lint_python on: [pull_request, push] jobs: lint_python: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - run: pip install --upgrade pip wheel - run: pip install bandit black flake8 isort mypy pytest pyupgrade safety - run: bandit --recursive --skip B101,B105,B107,B108,B303,B310,B311,B314,B320,B404,B405,B410,B602,B603,B607,B608 . - run: black --check . || true - run: flake8 . --count --max-complexity=15 --max-line-length=120 --show-source --statistics - run: isort --check-only . || true - run: sudo apt-get update -y - run: sudo apt-get install libdbus-1-dev libgirepository1.0-dev gir1.2-gnomedesktop-3.0 gir1.2-gtk-3.0 gir1.2-notify-0.7 gir1.2-webkit2-4.0 - run: pip install lxml Pillow PyGObject -r requirements.txt # -r requirements-dev.txt - run: mypy --ignore-missing-imports --install-types --non-interactive . || true - uses: GabrielBB/xvfb-action@v1 with: run: pytest . - run: shopt -s globstar && pyupgrade --py36-plus **/*.py || true - run: safety check || true lutris-0.5.9.1/.gitignore000066400000000000000000000005141413267435700152120ustar00rootroot00000000000000nbproject 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.9.1/.isort.cfg000066400000000000000000000004171413267435700151230ustar00rootroot00000000000000[settings] line_length=120 multi_line_output=6 skip= application.py, keyring.py ;not_skip= ;known_deps = ;known_third_party = known_first_party = lutris sections = FUTURE, STDLIB, THIRDPARTY, FIRSTPARTY, LOCALFOLDER default_section=THIRDPARTY lutris-0.5.9.1/.pylintrc000066400000000000000000000336251413267435700151000ustar00rootroot00000000000000[MASTER] # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code extension-pkg-whitelist=lxml.etree # Add files or directories to the blacklist. They should be base names, not # paths. ignore= # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. ignore-patterns= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Use multiple processes to speed up Pylint. jobs=4 # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins= # Pickle collected data for later comparisons. persistent=yes # Specify a configuration file. #rcfile= # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED confidence= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once).You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" disable= broad-except, fixme, global-statement, invalid-name, missing-docstring, no-self-use, too-few-public-methods, unexpected-keyword-arg, ungrouped-imports, useless-object-inheritance, bad-continuation, inconsistent-return-statements, unsubscriptable-object, not-an-iterable, unused-argument, bare-except, too-many-statements, too-many-locals, too-many-branches, too-many-public-methods, arguments-differ, signature-differs, unsupported-membership-test, protected-access, wrong-import-position, import-outside-toplevel # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable= [REPORTS] # Python expression which should return a note less than 10 (10 is the highest # note). You have access to the variables errors warning, statement which # respectively contain the number of errors / warnings messages and the total # number of statements analyzed. This is used by the global evaluation report # (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details #msg-template= # Set the output format. Available formats are text, parseable, colorized, json # and msvs (visual studio).You can also give a reporter class, eg # mypackage.mymodule.MyReporterClass. output-format=text # Tells whether to display a full report or only the messages reports=no # Activate the evaluation score. score=yes [REFACTORING] # Maximum number of nested blocks for function / method body max-nested-blocks=5 [LOGGING] # Logging modules to check that the string format arguments are in logging # function parameter format logging-modules=logging [SPELLING] # Spelling dictionary name. Available dictionaries: none. To make it working # install python-enchant package. spelling-dict= # List of comma separated words that should not be checked. spelling-ignore-words= # A path to a file that contains private dictionary; one word per line. spelling-private-dict-file= # Tells whether to store unknown words to indicated private dictionary in # --spelling-private-dict-file option instead of raising a message. spelling-store-unknown-words=no [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME,TODO [SIMILARITIES] # Ignore comments when computing similarities. ignore-comments=yes # Ignore docstrings when computing similarities. ignore-docstrings=yes # Ignore imports when computing similarities. ignore-imports=yes # Minimum lines number of a similarity. min-similarity-lines=4 [TYPECHECK] # List of decorators that produce context managers, such as # contextlib.contextmanager. Add to this list to register other decorators that # produce valid context managers. contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. generated-members= # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members=yes # This flag controls whether pylint should warn about no-member and similar # checks whenever an opaque object is returned when inferring. The inference # can return multiple potential results while evaluating a Python object, but # some branches might not be evaluated, which results in partial inference. In # that case, it might be useful to still emit no-member and other checks for # the rest of the inferred objects. ignore-on-opaque-inference=yes # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of # qualified names. ignored-classes=optparse.Values,thread._local,_thread._local # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. ignored-modules= # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. missing-member-hint=yes # The minimum edit distance a name should have in order to be considered a # similar match for a missing member name. missing-member-hint-distance=1 # The total number of similar names that should be taken in consideration when # showing a hint for a missing member. missing-member-max-choices=1 [VARIABLES] # List of additional names supposed to be defined in builtins. Remember that # you should avoid to define new builtins when possible. additional-builtins= # Tells whether unused global variables should be treated as a violation. allow-global-unused-variables=yes # List of strings which can identify a callback function by name. A callback # name must start or end with one of those strings. callbacks=cb_,_cb # A regular expression matching the name of dummy variables (i.e. expectedly # not used). dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ # Argument names that match this expression will be ignored. Default to name # with leading underscore ignored-argument-names=_.*|^ignored_|^unused_ # Tells whether we should check for unused import in __init__ files. init-import=no # List of qualified module names which can have objects that can redefine # builtins. redefining-builtins-modules=six.moves,future.builtins [FORMAT] # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' # Maximum number of characters on a single line. max-line-length=120 # Maximum number of lines in a module max-module-lines=1000 # List of optional constructs for which whitespace checking is disabled. `dict- # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. # `trailing-comma` allows a space between comma and closing bracket: (a, ). # `empty-line` allows space-only lines. no-space-check=trailing-comma,dict-separator # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=no [BASIC] # Naming hint for argument names argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Regular expression matching correct argument names argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Naming hint for attribute names attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Regular expression matching correct attribute names attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Bad variable names which should always be refused, separated by a comma bad-names=foo,bar,baz,toto,tutu,tata # Naming hint for class attribute names class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ # Regular expression matching correct class attribute names class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ # Naming hint for class names class-name-hint=[A-Z_][a-zA-Z0-9]+$ # Regular expression matching correct class names class-rgx=[A-Z_][a-zA-Z0-9]+$ # Naming hint for constant names const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ # Regular expression matching correct constant names const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length=-1 # Naming hint for function names function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Regular expression matching correct function names function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Good variable names which should always be accepted, separated by a comma good-names=i,j,k,ex,Run,_ # Include a hint for the correct naming format with invalid-name include-naming-hint=no # Naming hint for inline iteration names inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ # Regular expression matching correct inline iteration names inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ # Naming hint for method names method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Regular expression matching correct method names method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Naming hint for module names module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Regular expression matching correct module names module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=^_ # List of decorators that produce properties, such as abc.abstractproperty. Add # to this list to register other decorators that produce valid properties. property-classes=abc.abstractproperty # Naming hint for variable names variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ # Regular expression matching correct variable names variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ [DESIGN] # Maximum number of arguments for function / method max-args=9 # Maximum number of attributes for a class (see R0902). max-attributes=35 # Maximum number of boolean expressions in a if statement max-bool-expr=5 # Maximum number of branch for function / method body max-branches=12 # Maximum number of locals for function / method body max-locals=15 # Maximum number of parents for a class (see R0901). max-parents=7 # Maximum number of public methods for a class (see R0904). max-public-methods=20 # Maximum number of return / yield for function / method body max-returns=6 # Maximum number of statements in function / method body max-statements=50 # Minimum number of public methods for a class (see R0903). min-public-methods=2 [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__,__new__,setUp # List of member names, which should be excluded from the protected access # warning. exclude-protected=_asdict,_fields,_replace,_source,_make # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=mcs [IMPORTS] # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all=no # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists # only in one or another interpreter, leading to false positives when analysed. analyse-fallback-blocks=no # Deprecated modules which should not be used, separated by a comma deprecated-modules=regsub,TERMIOS,Bastion,rexec # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled) ext-import-graph= # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled) import-graph= # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled) int-import-graph= # Force import order to recognize a module as part of the standard # compatibility libraries. known-standard-library= # Force import order to recognize a module as part of a third party library. known-third-party= [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to # "Exception" overgeneral-exceptions=Exception lutris-0.5.9.1/.travis.yml000066400000000000000000000023311413267435700153320ustar00rootroot00000000000000dist: bionic language: python python: - "3.6" - "3.7" - "3.8" - "3.9" env: - PIPENV_VERBOSITY=-1 LUTRIS_SKIP_INIT=1 virtualenv: system_site_packages: false services: - xvfb addons: apt: update: true packages: - xvfb - libdbus-1-dev - python3-yaml - python3-gi - python3-pil - python3-setproctitle - python3-distro - python3-magic - python3-lxml - gir1.2-gtk-3.0 - psmisc - gir1.2-glib-2.0 - libgirepository1.0-dev - gir1.2-gnomedesktop-3.0 - gir1.2-webkit2-4.0 - gir1.2-notify-0.7 - at-spi2-core before_install: - "export DISPLAY=:99.0" - Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & - "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16" - sleep 3 # Give xvfb some time to start install: - pip install --upgrade pip pypresence~=3.3.2 pipenv - pipenv install --dev --deploy --ignore-pipfile script: - pipenv run isort -y -rc lutris - pipenv run flake8 lutris - pipenv run pylint --rcfile=.pylintrc --output-format=colorized lutris - nosetests # Cache the pip dependencies cache: pip lutris-0.5.9.1/.yapf000066400000000000000000000007661413267435700141730ustar00rootroot00000000000000[style] based_on_style = pep8 column_limit = 120 align_closing_bracket_with_visual_indent = true blank_line_before_class_docstring = true blank_line_before_module_docstring = true blank_line_before_nested_class_or_def = true coalesce_brackets = false dedent_closing_brackets = true spaces_around_power_operator = false spaces_before_comment = 2 split_before_first_argument = false split_before_logical_operator = true split_before_arithmetic_operator = true split_penalty_after_opening_bracket = 1000 lutris-0.5.9.1/AUTHORS000066400000000000000000000031641413267435700142760ustar00rootroot00000000000000Copyright (C) 2010-2020 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) lutris-0.5.9.1/CONTRIBUTING.md000066400000000000000000000170031413267435700154540ustar00rootroot00000000000000Contributing 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%22need+help%22) that can't be reproduced on the developers setup. Other issues, tagged [need help](https://github.com/lutris/lutris/issues?q=is%3Aissue+is%3Aopen+label%3A%22need+help%22) might be a bit more technical to resolve but you can always have a look and see if they fit your area of expertise. Also, while not fully ready, we do appreciate receiving translations for other languages, support for i18n will come in a future update. Note that Lutris is not a playground or a toy project. One cannot submit new features that aren't on the roadmap and submit a pull request for them without agreeing on a design first with the development team. Please get in touch with the developers before writing any code, so that you don't waste your efforts on something that isn't going to be merged. Make sure to post all the relevant information in a ticket or on the pull request. New features must at all times have a valid use case based on an actual game, be very specific about why you are implementing a feature otherwise it will get rejected. Avoid adding options in the GUI or introducing new installer directives for things that can be automated. Lutris focuses heavily on automation and on doing the right thing by default. Only introduce new option when absolutely necessary. Contributors are welcome to suggest architectural changes or better code design if they feel like the current implementation should be improved but please take note that we're trying to stay as lean as possible. Requests introducing complex architectural changes for the sake of "modularity", "Unix pureness" or subjective aspects might not be received warmly. There are no plans for any rewrite in another language or switching to another toolkit. Running Lutris from Git ----------------------- Running Lutris from a local git repository is easy, it only requires cloning the repository and executing Lutris from there. git clone https://github.com/lutris/lutris cd lutris ./bin/lutris -d Make sure you have all necessary dependencies installed. It is recommended that you keep a copy of the stable version installed with your package manager to ensure that all dependencies are available. If you are working on newly written code that might introduce new dependencies, check in the package configuration files for new packages to install. Debian based distros will have their dependencies listed in `debian/control` and RPM based ones in `lutris.spec`. The PyGOject introspection libraries are not regular python packages and it is not possible for pip to install them or use them from a virtualenv. Make sure to always use PyGOject from your distribution's package manager. Also install the necessary GObject bindings as described in the INSTALL file. Set up your development environment ----------------------------------- To ensure you have the proper dependencies installed run: `make dev` This will use pipenv to create a virtual environment installing all necessary python packages to get you up and running. This project includes .editorconfig so you're good to go if you're using any editor/IDE that supports this. Otherwise make sure to configure your max line length to 120, indent style to space and always end files with an empty new line. Formatting your code -------------------- To ensure getting your contributions getting merged faster and to avoid other developers from going back and fixing your code, please make sure your code passes style checks by running `make sc` and fixing any reported issues before submitting your code. This runs a series of tools to apply pep8 coding style conventions, sorting and grouping imports and checking for formatting issues and other code smells. You can help fix formatting issues or other code smells by having a look at the CodeFactor page: https://www.codefactor.io/repository/github/lutris/lutris When writing docstrings, you should follow the Google style (See: https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) You should always provide docstrings, otherwise your code wouldn't pass a Pylint check. Do *not* add type annotations, those are not supported in Python 3.4. Writing tests ------------- If your patch does not require interactions with a GUI or external processes, please consider adding unit tests for your code. Have a look at the existing test suite in the `tests` folder to see what kind of features are tested. Running tests ------------- Be sure to test your changes thoroughly, never submit changes without running the code. At the very least, run the test suite and check that nothing broke. You can run the test suite by typing `make test` in the source directory. In order to run the test, you'll need to install nosetests and flake8: pip3 install nose flake8 QAing your changes ------------------ It is very important that any of your changes be tested manually, especially if you didn't add unit tests for the patch. Even trivial changes should be tested as they could potentially introduce breaking changes from a simple oversight. Submitting your changes ----------------------- Make a new git branch based of `master` in most cases, or `next` if you want to target a future release. Send a pull request through GitHub describing what issue the patch solves. If the PR is related to and existing bug report, you can add `(Closes #nnn)` or `(Fixes #nnn)` to your PR title or message, where `nnn` is the ticket number you're fixing. If you have been fixing your PR with several commits, please consider squashing those commits into one with `git rebase -i`. Developer resources ------------------- Lutris uses Python 3 and GObject / Gtk+ 3 as its core stack, here are some links to some resources that can help you familiarize yourself with the project's code base. * [Python 3 documentation](https://docs.python.org/3/) * [PyGObject documentation](https://pygobject.readthedocs.io/en/latest/) * [Python Gtk 3 tutorial](https://python-gtk-3-tutorial.readthedocs.io/en/latest/objects.html) * [Fakegir GObject code completion](https://github.com/strycore/fakegir) Project structure ----------------- [root]-+ Config files and READMEs | +-[bin] Main lutris executable script +-[debian] Debian / Ubuntu packaging configuration +-[docs] User documentation +-[lutris]-+ Source folder | | | +-[gui] Gtk UI code | +-[installer] Install script interpreter | +-[migrations] Migration scripts for user side changes | +-[runners] Runner code, detailing launch options and settings | +-[services] External services (Steam, GOG, ...) | +-[util] Generic utilities | +-[po] Translation files +-[share] Lutris resources like icons, ui files, scripts +-[tests] Unit tests lutris-0.5.9.1/INSTALL.rst000066400000000000000000000067551413267435700150770ustar00rootroot00000000000000Installing 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.4 * PyGObject * PyGObject bindings for: Gtk, Gdk, 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.) * libc6-i386 and lib32gcc1 for 32bit games support * 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 \ 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 libc6-i386 lib32gcc1 libgirepository1.0-dev \ python3-setproctitle python3-distro Note : If you use OpenSUSE, some dependencies are missing. You need to install python3-gobject-Gdk and typelib-1_0-Gtk-3_0 ``sudo apt install python3-gobject-Gdk typelib-1_0-Gtk-3_0`` Installation ------------ To install Lutris, please follow instructions listed on our `Downloads Page `_. Getting Lutris from a PPA or a repository is the preferred way of installing it and we *strongly advise* to use this method if you can. However, if the instructions on our Downloads page don't apply to your Linux distribution or there's some other reason you can't get it from a package, you can run it directly from the source directory:: git clone https://github.com/lutris/lutris cd lutris ./bin/lutris Alternatively you can install Lutris manually with the help of **virtualenv**. First, install ``python-virtualenv`` from your distribution's repositories, along with dependencies listed in Requirements_. Then, create and activate virtual environment for Lutris:: virtualenv --system-site-packages ~/lutris source ~/lutris/bin/activate While in the virtual environment, run the installation script:: python3 setup.py install Run Lutris ----------- If you installed Lutris using a package, you can launch the program by typing ``lutris`` at the command line (same applies to virtualenv method, but you need to activate the virtual environment first). And if you want to run Lutris without installing it, start ``./bin/lutris`` from within the source directory. lutris-0.5.9.1/LICENSE000066400000000000000000001043741413267435700142400ustar00rootroot00000000000000 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.9.1/MANIFEST.in000066400000000000000000000002211413267435700147530ustar00rootroot00000000000000recursive-include lutris *.py include bin/lutris include LICENSE include AUTHORS include MANIFEST.in include README.rst graft debian prune tests lutris-0.5.9.1/Makefile000066400000000000000000000037761413267435700146770ustar00rootroot00000000000000VERSION=`grep "__version__" lutris/__init__.py | cut -d" " -f 3 | sed 's|"\(.*\)"|\1|'` GITBRANCH ?= master PIPENV:=pipenv PYTHON:=$(shell which python3) PIP:=$(PYTHON) -m pip PIPENV_LOCK_ARGS:= --deploy --ignore-pipfile all: export GITBRANCH=master debuild debclean 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 nosetests3 cover: rm tests/fixtures/pga.db -f rm tests/coverage/ -rf nosetests3 --with-coverage --cover-package=lutris --cover-html --cover-html-dir=tests/coverage pgp-renew: osc signkey --extend home:strycore osc rebuildpac home:strycore --all changelog-add: EDITOR=vim dch -i changelog-edit: EDITOR=vim dch -e upload: scp build/lutris_${VERSION}.tar.xz anaheim:~/volumes/releases/ upload-ppa: dput ppa:lutris-team/lutris build/lutris_${VERSION}*_source.changes upload-staging: dput --force ppa:lutris-team/lutris-staging build/lutris_${VERSION}*_source.changes snap: snapcraft clean lutris -s pull snapcraft dev: $(PIP) install --user --upgrade pipenv $(PIPENV) install --dev $(PIPENV_LOCK_ARGS) --python $(PYTHON) requirements: # Generate new requirements.txt and requirements-dev.txt based on Pipfile.lock # These files are needed by Travis CI $(PIPENV) run pipenv_to_requirements -f # ============ # Style checks # ============ style: isort autopep8 ## Format code isort: $(PIPENV) run isort -rc lutris autopep8: $(PIPENV) run autopep8 --in-place --recursive --ignore E402 setup.py lutris # =============== # Static analysis # =============== check: isort-check flake8 pylint isort-check: $(PIPENV) run isort -c -rc lutris flake8: $(PIPENV) run flake8 lutris pylint: $(PIPENV) run pylint --rcfile=.pylintrc --output-format=colorized lutris # ============= # Abbreviations # ============= sc: style check req: requirements styles: style checks: check lutris-0.5.9.1/Pipfile000066400000000000000000000010711413267435700145340ustar00rootroot00000000000000[dev-packages] autopep8 = '>=1.5' flake8 = '>=3.7' isort = '>=4.3' pycodestyle = '>=2.5' PyGObject-stubs = '*' pylint = '==2.4.4' pipenv-to-requirements = "*" [packages] astroid = '>=2.3' certifi = '>=2020.4.5.1' chardet = '>=3.0' dbus-python = '>=1.2' entrypoints = '>=0.3' evdev = '>=1.3' idna = '>=2.9' lazy-object-proxy = '>=1.4' mccabe = '>=0.6' pycairo = '>=1.19' pyflakes = '>=2.1' PyGObject = '>=3.36' python-magic = '>=0.4.16' PyYAML = '>=5.3' requests = '>=2.23' six = '>=1.14' urllib3 = '>=1.25' wrapt = '>=1.11' keyring = "*" lxml = '>=4.5.2' Pillow = "*" lutris-0.5.9.1/Pipfile.lock000066400000000000000000001070401413267435700154660ustar00rootroot00000000000000{ "_meta": { "hash": { "sha256": "73138fc476bdb7d85c92c3d974d428b948acc1878932279fddf80b5e73fbaeff" }, "pipfile-spec": 6, "requires": {}, "sources": [ { "name": "pypi", "url": "https://pypi.org/simple", "verify_ssl": true } ] }, "default": { "astroid": { "hashes": [ "sha256:09bdb456e02564731f8b5957cdd0c98a7f01d2db5e90eb1d794c353c28bfd705", "sha256:6a8a51f64dae307f6e0c9db752b66a7951e282389d8362cc1d39a56f3feeb31d" ], "index": "pypi", "version": "==2.6.0" }, "certifi": { "hashes": [ "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" ], "index": "pypi", "version": "==2021.5.30" }, "cffi": { "hashes": [ "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813", "sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373", "sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69", "sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f", "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06", "sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05", "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea", "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee", "sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0", "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396", "sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7", "sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f", "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73", "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315", "sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76", "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1", "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49", "sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed", "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892", "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482", "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058", "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5", "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53", "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045", "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3", "sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55", "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5", "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e", "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c", "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369", "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827", "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053", "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa", "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4", "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322", "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132", "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62", "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa", "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0", "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396", "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e", "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991", "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6", "sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc", "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1", "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406", "sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333", "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d", "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c" ], "version": "==1.14.5" }, "chardet": { "hashes": [ "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" ], "index": "pypi", "version": "==4.0.0" }, "cryptography": { "hashes": [ "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d", "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959", "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6", "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873", "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2", "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713", "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1", "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177", "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250", "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca", "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d", "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9" ], "markers": "python_version >= '3.6'", "version": "==3.4.7" }, "dbus-python": { "hashes": [ "sha256:11238f1d86c995d8aed2e22f04a1e3779f0d70e587caffeab4857f3c662ed5a4" ], "index": "pypi", "version": "==1.2.16" }, "entrypoints": { "hashes": [ "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" ], "index": "pypi", "version": "==0.3" }, "evdev": { "hashes": [ "sha256:8782740eb1a86b187334c07feb5127d3faa0b236e113206dfe3ae8f77fb1aaf1" ], "index": "pypi", "version": "==1.4.0" }, "idna": { "hashes": [ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], "index": "pypi", "version": "==2.10" }, "importlib-metadata": { "hashes": [ "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00", "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139" ], "markers": "python_version >= '3.6'", "version": "==4.5.0" }, "jeepney": { "hashes": [ "sha256:7d59b6622675ca9e993a6bd38de845051d315f8b0c72cca3aef733a20b648657", "sha256:aec56c0eb1691a841795111e184e13cad504f7703b9a64f63020816afa79a8ae" ], "markers": "sys_platform == 'linux'", "version": "==0.6.0" }, "keyring": { "hashes": [ "sha256:045703609dd3fccfcdb27da201684278823b72af515aedec1a8515719a038cb8", "sha256:8f607d7d1cc502c43a932a275a56fe47db50271904513a379d39df1af277ac48" ], "index": "pypi", "version": "==23.0.1" }, "lazy-object-proxy": { "hashes": [ "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653", "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61", "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2", "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837", "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3", "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43", "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726", "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3", "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587", "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8", "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a", "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd", "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f", "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad", "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4", "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b", "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf", "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981", "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741", "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e", "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93", "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b" ], "index": "pypi", "version": "==1.6.0" }, "lxml": { "hashes": [ "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d", "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3", "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2", "sha256:1b38116b6e628118dea5b2186ee6820ab138dbb1e24a13e478490c7db2f326ae", "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f", "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927", "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3", "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7", "sha256:3082c518be8e97324390614dacd041bb1358c882d77108ca1957ba47738d9d59", "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f", "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade", "sha256:36108c73739985979bf302006527cf8a20515ce444ba916281d1c43938b8bb96", "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468", "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b", "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4", "sha256:4c61b3a0db43a1607d6264166b230438f85bfed02e8cff20c22e564d0faff354", "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83", "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04", "sha256:5c8c163396cc0df3fd151b927e74f6e4acd67160d6c33304e805b84293351d16", "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791", "sha256:6f12e1427285008fd32a6025e38e977d44d6382cf28e7201ed10d6c1698d2a9a", "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51", "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1", "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a", "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f", "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee", "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec", "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969", "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28", "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a", "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa", "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106", "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d", "sha256:c47ff7e0a36d4efac9fd692cfa33fbd0636674c102e9e8d9b26e1b93a94e7617", "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4", "sha256:cdaf11d2bd275bf391b5308f86731e5194a21af45fbaaaf1d9e8147b9160ea92", "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0", "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4", "sha256:d916d31fd85b2f78c76400d625076d9124de3e4bda8b016d25a050cc7d603f24", "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2", "sha256:e1cbd3f19a61e27e011e02f9600837b921ac661f0c40560eefb366e4e4fb275e", "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0", "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654", "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2", "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23", "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586" ], "index": "pypi", "version": "==4.6.3" }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" ], "index": "pypi", "version": "==0.6.1" }, "pillow": { "hashes": [ "sha256:01425106e4e8cee195a411f729cff2a7d61813b0b11737c12bd5991f5f14bcd5", "sha256:031a6c88c77d08aab84fecc05c3cde8414cd6f8406f4d2b16fed1e97634cc8a4", "sha256:083781abd261bdabf090ad07bb69f8f5599943ddb539d64497ed021b2a67e5a9", "sha256:0d19d70ee7c2ba97631bae1e7d4725cdb2ecf238178096e8c82ee481e189168a", "sha256:0e04d61f0064b545b989126197930807c86bcbd4534d39168f4aa5fda39bb8f9", "sha256:12e5e7471f9b637762453da74e390e56cc43e486a88289995c1f4c1dc0bfe727", "sha256:22fd0f42ad15dfdde6c581347eaa4adb9a6fc4b865f90b23378aa7914895e120", "sha256:238c197fc275b475e87c1453b05b467d2d02c2915fdfdd4af126145ff2e4610c", "sha256:3b570f84a6161cf8865c4e08adf629441f56e32f180f7aa4ccbd2e0a5a02cba2", "sha256:463822e2f0d81459e113372a168f2ff59723e78528f91f0bd25680ac185cf797", "sha256:4d98abdd6b1e3bf1a1cbb14c3895226816e666749ac040c4e2554231068c639b", "sha256:5afe6b237a0b81bd54b53f835a153770802f164c5570bab5e005aad693dab87f", "sha256:5b70110acb39f3aff6b74cf09bb4169b167e2660dabc304c1e25b6555fa781ef", "sha256:5cbf3e3b1014dddc45496e8cf38b9f099c95a326275885199f427825c6522232", "sha256:624b977355cde8b065f6d51b98497d6cd5fbdd4f36405f7a8790e3376125e2bb", "sha256:63728564c1410d99e6d1ae8e3b810fe012bc440952168af0a2877e8ff5ab96b9", "sha256:66cc56579fd91f517290ab02c51e3a80f581aba45fd924fcdee01fa06e635812", "sha256:6c32cc3145928c4305d142ebec682419a6c0a8ce9e33db900027ddca1ec39178", "sha256:8b56553c0345ad6dcb2e9b433ae47d67f95fc23fe28a0bde15a120f25257e291", "sha256:8bb1e155a74e1bfbacd84555ea62fa21c58e0b4e7e6b20e4447b8d07990ac78b", "sha256:95d5ef984eff897850f3a83883363da64aae1000e79cb3c321915468e8c6add5", "sha256:a013cbe25d20c2e0c4e85a9daf438f85121a4d0344ddc76e33fd7e3965d9af4b", "sha256:a787ab10d7bb5494e5f76536ac460741788f1fbce851068d73a87ca7c35fc3e1", "sha256:a7d5e9fad90eff8f6f6106d3b98b553a88b6f976e51fce287192a5d2d5363713", "sha256:aac00e4bc94d1b7813fe882c28990c1bc2f9d0e1aa765a5f2b516e8a6a16a9e4", "sha256:b91c36492a4bbb1ee855b7d16fe51379e5f96b85692dc8210831fbb24c43e484", "sha256:c03c07ed32c5324939b19e36ae5f75c660c81461e312a41aea30acdd46f93a7c", "sha256:c5236606e8570542ed424849f7852a0ff0bce2c4c8d0ba05cc202a5a9c97dee9", "sha256:c6b39294464b03457f9064e98c124e09008b35a62e3189d3513e5148611c9388", "sha256:cb7a09e173903541fa888ba010c345893cd9fc1b5891aaf060f6ca77b6a3722d", "sha256:d68cb92c408261f806b15923834203f024110a2e2872ecb0bd2a110f89d3c602", "sha256:dc38f57d8f20f06dd7c3161c59ca2c86893632623f33a42d592f097b00f720a9", "sha256:e98eca29a05913e82177b3ba3d198b1728e164869c613d76d0de4bde6768a50e", "sha256:f217c3954ce5fd88303fc0c317af55d5e0204106d86dea17eb8205700d47dec2" ], "index": "pypi", "version": "==8.2.0" }, "pycairo": { "hashes": [ "sha256:0d7a6754d410d911a46f00396bee4be96500ccd3d178e7e98aef1140e3dd67ae", "sha256:1ee72b035b21a475e1ed648e26541b04e5d7e753d75ca79de8c583b25785531b", "sha256:261c69850d4b2ec03346c9745bad2a835bb8124e4c6961b8ceac503d744eb3b3", "sha256:5525da2d8de912750dd157752aa96f1f0a42a437c5625e85b14c936b5c6305ae", "sha256:6db823a18e7be1eb2a29c28961f2f01e84d3b449f06be7338d05ac8f90592cd5", "sha256:736ffc618e851601e861a630293e5c910ef016b83b2d035a336f83a367bf56ab", "sha256:9a32e4a3574a104aa876c35d5e71485dfd6986b18d045534c6ec510c44d5d6a7", "sha256:b605151cdd23cedb31855b8666371b6e26b80f02753a52c8b8023a916b1df812", "sha256:c8c2bb933974d91c5d19e54b846d964de177e7bf33433bf34ac34c85f9b30e94", "sha256:e800486b51fffeb11ed867b4f2220d446e2a60a81a73b7c377123e0cbb72f49d", "sha256:f123d3818e30b77b7209d70a6dcfd5b4e34885f9fa539d92dd7ff3e4e2037213" ], "index": "pypi", "version": "==1.20.1" }, "pycparser": { "hashes": [ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pyflakes": { "hashes": [ "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3", "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db" ], "index": "pypi", "version": "==2.3.1" }, "pygobject": { "hashes": [ "sha256:6fb599aa59ceb9dd05fafb0d72b3862943e7d5e85c8ef6c74856bc6d4321cbab" ], "index": "pypi", "version": "==3.40.1" }, "python-magic": { "hashes": [ "sha256:4fec8ee805fea30c07afccd1592c0f17977089895bdfaae5fec870a84e997626", "sha256:de800df9fb50f8ec5974761054a708af6e4246b03b4bdaee993f948947b0ebcf" ], "index": "pypi", "version": "==0.4.24" }, "pyyaml": { "hashes": [ "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" ], "index": "pypi", "version": "==5.4.1" }, "requests": { "hashes": [ "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" ], "index": "pypi", "version": "==2.25.1" }, "secretstorage": { "hashes": [ "sha256:422d82c36172d88d6a0ed5afdec956514b189ddbfb72fefab0c8a1cee4eaf71f", "sha256:fd666c51a6bf200643495a04abb261f83229dcb6fd8472ec393df7ffc8b6f195" ], "markers": "sys_platform == 'linux'", "version": "==3.3.1" }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], "index": "pypi", "version": "==1.16.0" }, "urllib3": { "hashes": [ "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4", "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f" ], "index": "pypi", "version": "==1.26.6" }, "wrapt": { "hashes": [ "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" ], "index": "pypi", "version": "==1.12.1" }, "zipp": { "hashes": [ "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76", "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098" ], "markers": "python_version >= '3.6'", "version": "==3.4.1" } }, "develop": { "appdirs": { "hashes": [ "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" ], "version": "==1.4.4" }, "astroid": { "hashes": [ "sha256:09bdb456e02564731f8b5957cdd0c98a7f01d2db5e90eb1d794c353c28bfd705", "sha256:6a8a51f64dae307f6e0c9db752b66a7951e282389d8362cc1d39a56f3feeb31d" ], "index": "pypi", "version": "==2.6.0" }, "autopep8": { "hashes": [ "sha256:276ced7e9e3cb22e5d7c14748384a5cf5d9002257c0ed50c0e075b68011bb6d0", "sha256:aa213493c30dcdac99537249ee65b24af0b2c29f2e83cd8b3f68760441ed0db9" ], "index": "pypi", "version": "==1.5.7" }, "certifi": { "hashes": [ "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee", "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8" ], "index": "pypi", "version": "==2021.5.30" }, "distlib": { "hashes": [ "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736", "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c" ], "version": "==0.3.2" }, "filelock": { "hashes": [ "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" ], "version": "==3.0.12" }, "flake8": { "hashes": [ "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b", "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907" ], "index": "pypi", "version": "==3.9.2" }, "isort": { "hashes": [ "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" ], "index": "pypi", "version": "==4.3.21" }, "lazy-object-proxy": { "hashes": [ "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653", "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61", "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2", "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837", "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3", "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43", "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726", "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3", "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587", "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8", "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a", "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd", "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f", "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad", "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4", "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b", "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf", "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981", "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741", "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e", "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93", "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b" ], "index": "pypi", "version": "==1.6.0" }, "mccabe": { "hashes": [ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" ], "index": "pypi", "version": "==0.6.1" }, "pbr": { "hashes": [ "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd", "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4" ], "markers": "python_version >= '2.6'", "version": "==5.6.0" }, "pipenv": { "hashes": [ "sha256:05958fadcd70b2de6a27542fcd2bd72dd5c59c6d35307fdac3e06361fb06e30e", "sha256:d180f5be4775c552fd5e69ae18a9d6099d9dafb462efe54f11c72cb5f4d5e977" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2021.5.29" }, "pipenv-to-requirements": { "hashes": [ "sha256:1c18682a4ec70eb07261d2b558df3ee22ea00192663a1b98fd1e45e22946c163", "sha256:cb70471a17a7d4658caffe989539413313d51df1b3a54838bcd7e7d3ab3fcc18" ], "index": "pypi", "version": "==0.9.0" }, "pycairo": { "hashes": [ "sha256:0d7a6754d410d911a46f00396bee4be96500ccd3d178e7e98aef1140e3dd67ae", "sha256:1ee72b035b21a475e1ed648e26541b04e5d7e753d75ca79de8c583b25785531b", "sha256:261c69850d4b2ec03346c9745bad2a835bb8124e4c6961b8ceac503d744eb3b3", "sha256:5525da2d8de912750dd157752aa96f1f0a42a437c5625e85b14c936b5c6305ae", "sha256:6db823a18e7be1eb2a29c28961f2f01e84d3b449f06be7338d05ac8f90592cd5", "sha256:736ffc618e851601e861a630293e5c910ef016b83b2d035a336f83a367bf56ab", "sha256:9a32e4a3574a104aa876c35d5e71485dfd6986b18d045534c6ec510c44d5d6a7", "sha256:b605151cdd23cedb31855b8666371b6e26b80f02753a52c8b8023a916b1df812", "sha256:c8c2bb933974d91c5d19e54b846d964de177e7bf33433bf34ac34c85f9b30e94", "sha256:e800486b51fffeb11ed867b4f2220d446e2a60a81a73b7c377123e0cbb72f49d", "sha256:f123d3818e30b77b7209d70a6dcfd5b4e34885f9fa539d92dd7ff3e4e2037213" ], "index": "pypi", "version": "==1.20.1" }, "pycodestyle": { "hashes": [ "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068", "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef" ], "index": "pypi", "version": "==2.7.0" }, "pyflakes": { "hashes": [ "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3", "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db" ], "index": "pypi", "version": "==2.3.1" }, "pygobject": { "hashes": [ "sha256:6fb599aa59ceb9dd05fafb0d72b3862943e7d5e85c8ef6c74856bc6d4321cbab" ], "index": "pypi", "version": "==3.40.1" }, "pygobject-stubs": { "hashes": [ "sha256:fa3b258cbe3e384355750639e7e28276c36805639ab31d461fea6062a1b3b9ab" ], "index": "pypi", "version": "==0.0.2" }, "pylint": { "hashes": [ "sha256:3db5468ad013380e987410a8d6956226963aed94ecb5f9d3a28acca6d9ac36cd", "sha256:886e6afc935ea2590b462664b161ca9a5e40168ea99e5300935f6591ad467df4" ], "index": "pypi", "version": "==2.4.4" }, "six": { "hashes": [ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], "index": "pypi", "version": "==1.16.0" }, "toml": { "hashes": [ "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.10.2" }, "virtualenv": { "hashes": [ "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467", "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4.7" }, "virtualenv-clone": { "hashes": [ "sha256:07e74418b7cc64f4fda987bf5bc71ebd59af27a7bc9e8a8ee9fd54b1f2390a27", "sha256:665e48dd54c84b98b71a657acb49104c54e7652bce9c1c4f6c6976ed4c827a29" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==0.5.4" }, "wrapt": { "hashes": [ "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" ], "index": "pypi", "version": "==1.12.1" } } } lutris-0.5.9.1/README.rst000066400000000000000000000121031413267435700147060ustar00rootroot00000000000000****** Lutris ****** |LiberaPayBadge|_ 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 be 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 -j, --json Display the list of games in JSON format --reinstall Reinstall game --display=DISPLAY X display to use 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. Planned features ================ Lutris is far from complete, and some features have yet to be implemented. Here's what to expect from future versions of Lutris: * TOSEC database integration * Management of personal game data (i.e. syncing games across devices using private cloud storage) * Community features (friends list, chat, multiplayer game scheduling, etc.) Support the project =================== Lutris is 100% community supported, to ensure a continuous development on the project, please consider donating to the project. Our main platform for supporting Lutris is Patreon: https://www.patreon.com/lutris but there are also other options available at https://lutris.net/donate Come with us! ============= Want to make Lutris better? Help implement features, fix bugs, test pre-releases, or simply chat with the developers? You can always reach us on: * Discord: https://discordapp.com/invite/Pnt5CuY * IRC: ircs://irc.libera.chat:6697/lutris * Github: https://github.com/lutris * Twitter: https://twitter.com/LutrisGaming .. |LiberaPayBadge| image:: http://img.shields.io/liberapay/receives/Lutris.svg?logo=liberapay .. _LiberaPayBadge: https://liberapay.com/Lutris/ lutris-0.5.9.1/bin/000077500000000000000000000000001413267435700137725ustar00rootroot00000000000000lutris-0.5.9.1/bin/lutris000077500000000000000000000036241413267435700152470ustar00rootroot00000000000000#!/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 sys import locale import gettext import os from os.path import realpath, dirname, normpath LAUNCH_PATH = dirname(realpath(__file__)) 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: sys.stderr.write("Unsupported locale setting. Fix your locales\n") try: # optional_settings does not exist if you don't use the meson build system from lutris import optional_settings try: locale.bindtextdomain("lutris", optional_settings.LOCALE_DIR) gettext.bindtextdomain("lutris", optional_settings.LOCALE_DIR) locale.textdomain("lutris") gettext.textdomain("lutris") except: sys.stderr.write( "Couldn't bind gettext domain, translations won't work.\n" "LOCALE_DIR: %s\n" % optional_settings.LOCALE_DIR ) except ImportError: pass from lutris.gui.application import Application # pylint: disable=no-name-in-module app = Application() # pylint: disable=invalid-name sys.exit(app.run(sys.argv)) lutris-0.5.9.1/debian/000077500000000000000000000000001413267435700144445ustar00rootroot00000000000000lutris-0.5.9.1/debian/changelog000066400000000000000000001333051413267435700163230ustar00rootroot00000000000000lutris (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.9.1/debian/clean000066400000000000000000000000121413267435700154420ustar00rootroot00000000000000builddir/ lutris-0.5.9.1/debian/control000066400000000000000000000026051413267435700160520ustar00rootroot00000000000000Source: 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-setproctitle, python3-magic, python3-distro, python3-dbus, 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, mesa-utils, Recommends: python3-evdev, gvfs-backends, libwine-development, winetricks, Suggests: gamemode, 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.9.1/debian/copyright000066400000000000000000000012211413267435700163730ustar00rootroot00000000000000Format: 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-2020 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.9.1/debian/rules000077500000000000000000000003071413267435700155240ustar00rootroot00000000000000#!/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 lutris-0.5.9.1/debian/source/000077500000000000000000000000001413267435700157445ustar00rootroot00000000000000lutris-0.5.9.1/debian/source/format000066400000000000000000000000151413267435700171530ustar00rootroot000000000000003.0 (native) lutris-0.5.9.1/docs/000077500000000000000000000000001413267435700141525ustar00rootroot00000000000000lutris-0.5.9.1/docs/installers.rst000066400000000000000000000762701413267435700171000ustar00rootroot00000000000000================== 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``) 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 / ResidualVM games. Can be looked up on the game compatibility list: https://www.scummvm.org/compatibility/ and https://www.residualvm.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`` Wine and other wine based runners ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ``arch``: Sets the architecture of a Wine prefix. By default it is set to ``win64``, the value can be set to ``win32`` to setup the game in a 32-bit prefix. ``prefix``: Path to the Wine prefix. For Wine games, it should be set to ``$GAMEDIR``. DRM free Steam ^^^^^^^^^^^^^^ Lutris has the ability to run Steam games without launching the Steam client. This is only possible with certain games lacking the Steam DRM. ``run_without_steam``: Activate the DRM free mode and no not launch Steam when the game runs. ``steamless_binary``: Used in conjonction with ``run_without_steam``. This allows to provide the path of the game executable if it's able to run without the Steam client. The game must not have the Steam DRM to use this feature. Example: ``steamless_binary: $GAMEDIR/System/GMDX.exe`` ScummVM ^^^^^^^ ``path``: Location of the game files. This should be set to ``$GAMEDIR`` in installer scripts. Runner configuration directives =============================== Runners can be customized in a section named after the runner identifier (``slug`` field in the API). A complete list of all runners is available at https://lutris.net/api/runners. Use the runner's slug as the runner identifier. Please keep the amount of runner customization to a minimum, only adding what is needed to make the game run correctly. A lot of runner options do not have their place in Lutris installers and are reserved for the user's preferences. The following sections will describe runner directives commonly used in installers. wine ---- ``version``: Set the Wine version to a specific build. Only set this if the game has known regressions with the current default build. Abusing this feature slows down the development of the Wine project. Example: ``version: staging-2.21-x86_64`` ``Desktop``: Run the game in a Wine virtual desktop. This should be used if the game has issues with Linux window managers such as crashes on Alt-Tab. Example: ``Desktop: true`` ``WineDesktop``: Set the resolution of the Wine virtual desktop. If not provided, the virtual desktop will take up the whole screen, which is likely the desired behavior. It is unlikely that you would add this directive in an installer but can be useful is a game is picky about the resolution it's running in. Example: ``WineDesktop: 1024x768`` ``dxvk``: Use this to disable DXVK if needed. (``dxvk: false``) ``esync``: Use this to enable esync. (``esync: true``) ``overrides``: Overrides for Wine DLLs. List your DLL overrides in a mapping with the following values: ``n,b`` = Try native and fallback to builtin if native doesn't work ``b,n`` = Try builtin and fallback to native if builtin doesn't work ``b`` = Use builtin ``n`` = Use native ``disabled`` = Disable library Example:: overrides: ddraw.dll: n d3d9: disabled winegstreamer: builtin System configuration directives =============================== Those directives are stored in the ``system`` section and allow for customization of system features. As with runner configuration options, system directives should be used carefully, only adding them when absolutely necessary to run a game. ``restore_gamma``: If the game doesn't restore the correct gamma on exit, you can use this option to call xgamma and reset the default values. This option won't work on Wayland. Example: ``restore_gamma: true`` ``terminal``: Run the game in a terminal if the game is a text based one. Do not use this option to get the console output of the game, this will result in a broken installer. **Only use this option for text based games.** ``env``: Sets environment variables before launching a game and during install. Do not **ever** use this directive to enable a framerate counter. Do not use this directive to override Wine DLLs. Variable substitution is available in values. Example:: env: __GL_SHADER_DISK_CACHE: 1 __GL_THREADED_OPTIMIZATIONS: '1' __GL_SHADER_DISK_CACHE_PATH: $GAMEDIR mesa_glthread: 'true' ``single_cpu``: Run the game on a single CPU core. Useful for some old games that handle multicore CPUs poorly. (``single_cpu: true``) ``disable_runtime``: **DO NOT DISABLE THE LUTRIS RUNTIME IN LUTRIS INSTALLERS** ``pulse_latency``: Set PulseAudio latency to 60 msecs. Can reduce audio stuttering. (``pulse_latency: true``) ``use_us_layout``: Change the keyboard layout to a standard US one while the game is running. Useful for games that handle other layouts poorly and don't have key remapping options. (``use_us_layou: true``) ``xephyr``: Run the game in Xephyr. This is useful for games only handling 256 color modes. To enable Xephyr, pass the desired bit per plane value. (``xephyr: 8bpp``) ``xephyr_resolution``: Used with the ``xephyr`` option, this sets the size of the Xephyr window. (``xephyr_resolution: 1024x768``) Fetching required files ======================= The ``files`` section of the installer references every file needed for installing the game. This section's keys are unique identifier used later in the ``installer`` section. The value can either be a string containing a URI pointing at the required file or a dictionary containing the ``filename`` and ``url`` keys. The ``url`` key is equivalent to passing only a string to the installer and the ``filename`` key will be used to give the local copy another name. If you need to set referer use ``referer`` key. If the game contains copyrighted files that cannot be redistributed, the value should begin with ``N/A``. When the installer encounter this value, it will prompt the user for the location of the file. To indicate to the user what file to select, append a message to ``N/A`` like this: ``N/A:Please select the installer for this game`` Examples:: files: - file1: https://example.com/gamesetup.exe - file2: "N/A:Select the game's setup file" - file3: url: https://example.com/url-that-doesnt-resolve-to-a-proper-filename filename: actual_local_filename.zip referer: www.mywebsite.com If the game makes use of Steam data, the value should be ``$STEAM:appid:path/to/data``. This will check that the data is available or install it otherwise. 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: zip, tgz, gzip, bz2, and gog (innoextract). 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, ``terminal`` (set to "true") to execute in a new terminal window, ``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 watched), ``include_processes`` (the opposite of ``exclude_processes``, is used to override Lutris' built-in exclude list) and ``disable_runtime`` (run a process without the Lutris Runtime, useful for running system binaries). Example:: - execute: args: --argh file: great_id terminal: true env: key: value 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``) 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, required when you created win64 prefix), ``blocking`` (if true, do not run the process in a thread), ``working_dir`` (optional working directory), ``include_processes`` (optional space-separated list of processes to include to being watched) ``exclude_processes`` (optional space-separated list of processes to exclude from being watched), ``env`` (optional environment variables), ``overrides`` (optional DLL overrides). Example:: - task: name: wineexec executable: drive_c/Program Files/Game/Game.exe 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 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, required when you created win64 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, required when you created win64 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, required when you created win64 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, required when you created win64 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.9.1/docs/steam.rst000066400000000000000000000012461413267435700160200ustar00rootroot00000000000000 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.9.1/lutris.spec000066400000000000000000000132611413267435700154230ustar00rootroot00000000000000%{!?__python3: %global __python3 /usr/bin/python3} %{!?python3_sitelib: %global python3_sitelib %(%{__python3} -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())")} %{!?py3_build: %global py3_build CFLAGS="%{optflags}" %{__python3} setup.py build} %{!?py3_install: %global py3_install %{__python3} setup.py install --skip-build --root %{buildroot}} %global appid net.lutris.Lutris Name: lutris Version: 0.5.9.1 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 # Common build dependencies BuildRequires: desktop-file-utils BuildRequires: python3-devel %if 0%{?fedora} BuildRequires: python3-gobject, python3-wheel, python3-setuptools Requires: python3-gobject, python3-PyYAML, cabextract Requires: gtk3, psmisc, xorg-x11-server-Xephyr, xorg-x11-server-utils Requires: python3-requests Requires: gnome-desktop3 Recommends: wine-core %endif %if 0%{?rhel} || 0%{?centos} BuildRequires: python3-gobject Requires: python3-gobject, python3-PyYAML, cabextract %endif %if 0%{?suse_version} BuildRequires: python3-gobject, python3-setuptools, typelib-1_0-Gtk-3_0 BuildRequires: update-desktop-files # Needed to workaround "directories not owned by a package" issue BuildRequires: hicolor-icon-theme BuildRequires: python3-setuptools Requires: (python3-gobject-Gdk or python3-gobject) Requires: python3-PyYAML, cabextract, typelib-1_0-Gtk-3_0 Requires: typelib-1_0-GnomeDesktop-3_0, typelib-1_0-WebKit2-4_0, typelib-1_0-Notify-0_7 Requires: fluid-soundfont-gm, python3-Pillow, python3-requests %endif %if 0%{?fedora} || 0%{?suse_version} BuildRequires: fdupes %ifarch x86_64 Requires: mesa-vulkan-drivers(x86-32) Requires: vulkan-loader(x86-32) %endif Requires: mesa-vulkan-drivers Requires: vulkan-loader Recommends: wine-core BuildRequires: fdupes %endif %if 0%{?fedora} %ifarch x86_64 Requires: mesa-libGL(x86-32) Requires: mesa-libGL %endif %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 %setup -q -n %{name} %build %py3_build %install %py3_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} Network FileTransfer %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 %{python3_sitelib}/%{name}-*.egg-info %{python3_sitelib}/%{name}/ %changelog * 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.9.1/lutris/000077500000000000000000000000001413267435700145445ustar00rootroot00000000000000lutris-0.5.9.1/lutris/__init__.py000066400000000000000000000000631413267435700166540ustar00rootroot00000000000000"""Main Lutris package""" __version__ = "0.5.9.1" lutris-0.5.9.1/lutris/api.py000066400000000000000000000162321413267435700156730ustar00rootroot00000000000000"""Functions to interact with the Lutris REST API""" import json import os import re import socket import urllib.error import urllib.parse import urllib.request from lutris import settings from lutris.util import http, system from lutris.util.log import logger API_KEY_FILE_PATH = os.path.join(settings.CACHE_DIR, "auth-token") USER_INFO_FILE_PATH = os.path.join(settings.CACHE_DIR, "user.json") USER_ICON_FILE_PATH = os.path.join(settings.CACHE_DIR, "user.png") 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") as token_file: api_string = token_file.read() try: username, token = api_string.split(":") except ValueError: logger.error("Unable to read Lutris token in %s", API_KEY_FILE_PATH) return None return {"token": token, "username": username} def connect(username, password): """Connect to the Lutris API""" credentials = urllib.parse.urlencode({"username": username, "password": password}).encode("utf-8") login_url = settings.SITE_URL + "/api/accounts/token" try: request = urllib.request.urlopen(login_url, credentials, 10) except (socket.timeout, urllib.error.URLError) as ex: logger.error("Unable to connect to server (%s): %s", login_url, ex) return False response = json.loads(request.read().decode()) if "token" in response: token = response["token"] with open(API_KEY_FILE_PATH, "w") as token_file: token_file.write(":".join((username, token))) get_user_info() return response["token"] return False def disconnect(): """Removes the API token, disconnecting the user""" for file_path in [API_KEY_FILE_PATH, USER_INFO_FILE_PATH]: if system.path_exists(file_path): os.remove(file_path) def get_user_info(): """Retrieves the user info to cache it locally""" credentials = read_api_key() if not credentials: return url = settings.SITE_URL + "/api/users/me" request = http.Request(url, headers={"Authorization": "Token " + credentials["token"]}) response = request.get() account_info = response.json if not account_info: logger.warning("Unable to fetch user info for %s", credentials["username"]) with open(USER_INFO_FILE_PATH, "w") as token_file: json.dump(account_info, token_file, indent=2) def get_runners(runner_name): """Return the available runners for a given runner name""" api_url = settings.SITE_URL + "/api/runners/" + runner_name response = http.Request(api_url).get() return response.json def get_http_response(url, payload): response = http.Request(url, headers={"Content-Type": "application/json"}) try: response.get(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_ids, page=1): """Read a single page of games from the API and return the response Args: game_ids (list): list of game IDs, the ID type is determined by `query_type` page (str): Page of results to get query_type (str): Type of the IDs in game_ids, by default 'games' queries games by their Lutris slug. 'gogid' can also be used. """ url = settings.SITE_URL + "/api/games" if int(page) > 1: url += "?page={}".format(page) if not game_ids: return [] payload = json.dumps({"games": game_ids, "page": page}).encode("utf-8") return get_http_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_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 search_games(query): if not query: return [] query = query.lower().strip()[:32] url = "/api/games?%s" % urllib.parse.urlencode({"search": query}) 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 None response_data = response.json return response_data.get("results", []) def get_bundle(bundle): """Retrieve a lutris bundle from the API""" url = "/api/bundles/%s" % bundle 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 bundle from API: %s", ex) return None response_data = response.json return response_data.get("games", []) def parse_installer_url(url): """ Parses `lutris:` urls, extracting any info necessary to install or run a game. """ action = None try: parsed_url = urllib.parse.urlparse(url, scheme="lutris") except Exception: # pylint: disable=broad-except logger.warning("Unable to parse url %s", url) return False if parsed_url.scheme != "lutris": return False url_path = parsed_url.path if not url_path: return False # urlparse can't parse if the path only contain numbers # workaround to remove the scheme manually: if url_path.startswith("lutris:"): url_path = url_path[7:] url_parts = url_path.split("/") if 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) 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} lutris-0.5.9.1/lutris/cache.py000066400000000000000000000020141413267435700161560ustar00rootroot00000000000000"""Module for handling the PGA cache""" import os import shutil from lutris import settings from lutris.util.log import logger from lutris.util.system import merge_folders def get_cache_path(): """Return the path of the PGA cache""" pga_cache_path = settings.read_setting("pga_cache_path") if pga_cache_path: return os.path.expanduser(pga_cache_path) return None def save_cache_path(path): """Saves the PGA cache path to the settings""" settings.write_setting("pga_cache_path", path) def save_to_cache(source, destination): """Copy a file or folder to the cache""" if not source: raise ValueError("Missing source") if os.path.dirname(source) == destination: logger.info("Skipping caching of %s, already cached in %s", source, destination) return if os.path.isdir(source): # Copy folder recursively merge_folders(source, destination) else: shutil.copy(source, destination) logger.debug("Cached %s to %s", source, destination) lutris-0.5.9.1/lutris/command.py000066400000000000000000000216511413267435700165410ustar00rootroot00000000000000"""Threading module, used to launch games while monitoring them.""" import contextlib import fcntl import io import os import shlex import subprocess import sys import uuid from gi.repository import GLib from lutris import runtime, settings from lutris.util import system from lutris.util.log import logger from lutris.util.shell import get_terminal_script def get_wrapper_script_location(): """Return absolute path of lutris-wrapper script""" wrapper_relpath = "share/lutris/bin/lutris-wrapper" candidates = [ os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), "..")), os.path.dirname(os.path.dirname(settings.__file__)), "/usr", "/usr/local", ] for candidate in candidates: wrapper_abspath = os.path.join(candidate, wrapper_relpath) if os.path.isfile(wrapper_abspath): return wrapper_abspath raise FileNotFoundError("Couldn't find lutris-wrapper script in any of the expected locations") WRAPPER_SCRIPT = get_wrapper_script_location() class MonitoredCommand: """Exexcutes a commmand while keeping track of its state""" fallback_cwd = "/tmp" def __init__( self, command, runner=None, env=None, term=None, cwd=None, include_processes=None, exclude_processes=None, log_buffer=None, title=None, ): # pylint: disable=too-many-arguments self.ready_state = True self.env = self.get_environment(env) self.command = command self.runner = runner self.stop_func = lambda: True self.game_process = None self.prevent_on_stop = False self.return_code = None self.terminal = term self.is_running = True self.error = None self.log_handlers = [ self.log_handler_stdout, self.log_handler_console_output, ] self.set_log_buffer(log_buffer) self.stdout_monitor = None self.include_processes = include_processes or [] self.exclude_processes = exclude_processes or [] self.cwd = self.get_cwd(cwd) self._stdout = io.StringIO() self._title = title if title else command[0] @property def stdout(self): return self._stdout.getvalue() def get_wrapper_command(self): """Return launch arguments for the wrapper script""" wrapper_command = [ WRAPPER_SCRIPT, self._title, str(len(self.include_processes)), str(len(self.exclude_processes)), ] + self.include_processes + self.exclude_processes if not self.terminal: return wrapper_command + self.command terminal_path = system.find_executable(self.terminal) if not terminal_path: raise RuntimeError("Couldn't find terminal %s" % self.terminal) script_path = get_terminal_script(self.command, self.cwd, self.env) return wrapper_command + [terminal_path, "-e", script_path] def set_log_buffer(self, log_buffer): """Attach a TextBuffer to this command enables the buffer handler""" if not log_buffer: return self.log_buffer = log_buffer if self.log_handler_buffer not in self.log_handlers: self.log_handlers.append(self.log_handler_buffer) def get_cwd(self, cwd): """Return the current working dir of the game""" if not cwd: cwd = self.runner.working_dir if self.runner else None return os.path.expanduser(cwd or "~") @staticmethod def get_environment(user_env): """Process the user provided environment variables for use as self.env""" env = user_env or {} # not clear why this needs to be added, the path is already added in # the wrappper script. env['PYTHONPATH'] = ':'.join(sys.path) # Drop bad values of environment keys, those will confuse the Python # interpreter. env["LUTRIS_GAME_UUID"] = str(uuid.uuid4()) return {key: value for key, value in env.items() if "=" not in key} def get_child_environment(self): """Returns the calculated environment for the child process.""" env = os.environ.copy() env.update(self.env) return env def start(self): """Run the thread.""" for key, value in self.env.items(): logger.debug("%s=\"%s\"", key, value) wrapper_command = self.get_wrapper_command() env = self.get_child_environment() self.game_process = self.execute_process(wrapper_command, env) if not self.game_process: logger.error("No game process available") return GLib.child_watch_add(self.game_process.pid, self.on_stop) # make stdout nonblocking. fileno = self.game_process.stdout.fileno() fcntl.fcntl(fileno, fcntl.F_SETFL, fcntl.fcntl(fileno, fcntl.F_GETFL) | os.O_NONBLOCK) self.stdout_monitor = GLib.io_add_watch( self.game_process.stdout, GLib.IO_IN | GLib.IO_HUP, self.on_stdout_output, ) def log_handler_stdout(self, line): """Add the line to this command's stdout attribute""" self._stdout.write(line) def log_handler_buffer(self, line): """Add the line to the associated LogBuffer object""" self.log_buffer.insert(self.log_buffer.get_end_iter(), line, -1) def log_handler_console_output(self, line): # pylint: disable=no-self-use """Print the line to stdout""" with contextlib.suppress(BlockingIOError): sys.stdout.write(line) sys.stdout.flush() def get_return_code(self): """Get the return code from the file written by the wrapper""" return_code_path = "/tmp/lutris-%s" % self.env["LUTRIS_GAME_UUID"] if os.path.exists(return_code_path): with open(return_code_path) 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( 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.9.1/lutris/config.py000066400000000000000000000223461413267435700163720ustar00rootroot00000000000000"""Handle the game, runner and global system configurations.""" import os import time import yaml from lutris import settings, sysoptions from lutris.runners import InvalidRunner, import_runner from lutris.util.log import logger from lutris.util.system import path_exists from lutris.util.yaml import read_yaml_from_file, write_yaml_to_file def make_game_config_id(game_slug): """Return an unique config id to avoid clashes between multiple games""" return "{}-{}".format(game_slug, int(time.time())) def write_game_config(game_slug, config): """Writes a game config to disk""" configpath = make_game_config_id(game_slug) config_filename = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % configpath) yaml_config = yaml.safe_dump(config, default_flow_style=False) with open(config_filename, "w") as config_file: logger.debug("Writing game config to %s", config_filename) config_file.write(yaml_config) return configpath 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=None, game_config_id=None, level=None): self.game_config_id = game_config_id if runner_slug: self.runner_slug = str(runner_slug) else: self.runner_slug = runner_slug # Cascaded config sections (for reading) self.game_config = {} self.runner_config = {} self.system_config = {} # Raw (non-cascaded) sections (for writing) self.raw_game_config = {} self.raw_runner_config = {} self.raw_system_config = {} self.raw_config = {} # Set config level self.level = level if not level: if game_config_id: self.level = "game" elif runner_slug: self.level = "runner" else: self.level = "system" self.initialize_config() def __repr__(self): return "LutrisConfig(level=%s, game_config_id=%s, runner=%s)" % ( self.level, self.game_config_id, self.runner_slug, ) @property def system_config_path(self): return os.path.join(settings.CONFIG_DIR, "system.yml") @property def runner_config_path(self): if not self.runner_slug: return None return os.path.join(settings.CONFIG_DIR, "runners/%s.yml" % self.runner_slug) @property def game_config_path(self): if not self.game_config_id: return None return os.path.join(settings.CONFIG_DIR, "games/%s.yml" % self.game_config_id) def initialize_config(self): """Init and load config files""" self.game_level = {"system": {}, self.runner_slug: {}, "game": {}} self.runner_level = {"system": {}, self.runner_slug: {}} self.system_level = {"system": {}} self.game_level.update(read_yaml_from_file(self.game_config_path)) self.runner_level.update(read_yaml_from_file(self.runner_config_path)) self.system_level.update(read_yaml_from_file(self.system_config_path)) self.update_cascaded_config() self.update_raw_config() def update_cascaded_config(self): if self.system_level.get("system") is None: self.system_level["system"] = {} self.system_config.clear() self.system_config.update(self.get_defaults("system")) self.system_config.update(self.system_level.get("system")) if self.level in ["runner", "game"] and self.runner_slug: if self.runner_level.get(self.runner_slug) is None: self.runner_level[self.runner_slug] = {} if self.runner_level.get("system") is None: self.runner_level["system"] = {} self.runner_config.clear() self.runner_config.update(self.get_defaults("runner")) self.runner_config.update(self.runner_level.get(self.runner_slug)) self.merge_to_system_config(self.runner_level.get("system")) if self.level == "game" and self.runner_slug: if self.game_level.get("game") is None: self.game_level["game"] = {} if self.game_level.get(self.runner_slug) is None: self.game_level[self.runner_slug] = {} if self.game_level.get("system") is None: self.game_level["system"] = {} self.game_config.clear() self.game_config.update(self.get_defaults("game")) self.game_config.update(self.game_level.get("game")) self.runner_config.update(self.game_level.get(self.runner_slug)) self.merge_to_system_config(self.game_level.get("system")) def merge_to_system_config(self, config): """Merge a configuration to the system configuation""" 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 self.game_config_path: raise RuntimeError("Tried to remove a non-existent config") if not path_exists(self.game_config_path): logger.debug("No config file at %s", self.game_config_path) return os.remove(self.game_config_path) logger.debug("Removed config %s", self.game_config_path) def save(self): """Save configuration file according to its type""" if self.level == "system": config = self.system_level config_path = self.system_config_path elif self.level == "runner": config = self.runner_level config_path = self.runner_config_path elif self.level == "game": config = self.game_level config_path = self.game_config_path else: raise ValueError("Invalid config level '%s'" % self.level) logger.debug("Saving %s config to %s", self, config_path) write_yaml_to_file(config, config_path) self.initialize_config() def get_defaults(self, options_type): """Return a dict of options' default value.""" options_dict = self.options_as_dict(options_type) defaults = {} for option, params in options_dict.items(): if "default" in params: defaults[option] = params["default"] return defaults def options_as_dict(self, options_type): """Convert the option list to a dict with option name as keys""" if options_type == "system": options = ( sysoptions.with_runner_overrides(self.runner_slug) if self.runner_slug else sysoptions.system_options ) else: if not self.runner_slug: return None attribute_name = options_type + "_options" try: runner = import_runner(self.runner_slug) except InvalidRunner: options = {} else: if not getattr(runner, attribute_name): runner = runner() options = getattr(runner, attribute_name) return dict((opt["option"], opt) for opt in options) lutris-0.5.9.1/lutris/database/000077500000000000000000000000001413267435700163105ustar00rootroot00000000000000lutris-0.5.9.1/lutris/database/__init__.py000066400000000000000000000000001413267435700204070ustar00rootroot00000000000000lutris-0.5.9.1/lutris/database/categories.py000066400000000000000000000035171413267435700210150ustar00rootroot00000000000000from lutris import settings from lutris.database import sql def get_categories(): """Get the list of every category in database.""" return sql.db_select(settings.PGA_DB, "categories",) def get_category(name): """Return a category by name""" categories = sql.db_select(settings.PGA_DB, "categories", condition=("name", name)) if categories: return categories[0] def get_game_ids_for_category(category_name): """Get the ids of games in database.""" query = ( "select game_id from games_categories " "JOIN categories ON categories.id = games_categories.category_id " "WHERE categories.name=?" ) return [ game["game_id"] for game in sql.db_query(settings.PGA_DB, query, (category_name, )) ] def get_categories_in_game(game_id): """Get the categories of a game in database.""" query = ( "select categories.name from categories " "JOIN games_categories ON categories.id = games_categories.category_id " "JOIN games ON games.id = games_categories.game_id " "WHERE games.id=?" ) return [ category["name"] for category in sql.db_query(settings.PGA_DB, query, (game_id,)) ] def add_category(category_name): """Add a category to the database""" return sql.db_insert(settings.PGA_DB, "categories", {"name": category_name}) def add_game_to_category(game_id, category_id): """Add a category to a game""" return sql.db_insert(settings.PGA_DB, "games_categories", {"game_id": game_id, "category_id": category_id}) def remove_category_from_game(game_id, category_id): """Remove a category from a game""" query = "DELETE FROM games_categories WHERE category_id=? AND game_id=?" with sql.db_cursor(settings.PGA_DB) as cursor: sql.cursor_execute(cursor, query, (category_id, game_id)) lutris-0.5.9.1/lutris/database/games.py000066400000000000000000000164431413267435700177660ustar00rootroot00000000000000import math import time from itertools import chain from lutris import settings from lutris.database import sql from lutris.util.log import logger from lutris.util.strings import slugify _SERVICE_CACHE = {} _SERVICE_CACHE_ACCESSED = False # Keep time of last access to have a self degrading cache def get_games( searches=None, filters=None, excludes=None, sorts=None ): return sql.filtered_query( settings.PGA_DB, "games", searches=searches, filters=filters, excludes=excludes, sorts=sorts ) def get_games_where(**conditions): """ Query games table based on conditions Args: conditions (dict): named arguments with each field matches its desired value. Special values for field names can be used: __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 == "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 limnited to a maximum of 999 parameters.") if value: condition_fields.append("{} in ({})".format(field, ", ".join("?" * len(value)) or "")) condition_values = list(chain(condition_values, value)) else: condition_fields.append("{} = ?".format(field)) condition_values.append(value) condition = " AND ".join(condition_fields) if condition: query = " WHERE ".join((query, condition)) else: # Inspect and document why we should return # an empty list when no condition is present. return [] return sql.db_query(settings.PGA_DB, query, tuple(condition_values)) def get_games_by_ids(game_ids): # sqlite limits the number of query parameters to 999, to # bypass that limitation, divide the query in chunks size = 999 return list( chain.from_iterable( [ get_games_where(id__in=list(game_ids)[page * size:page * size + size]) for page in range(math.ceil(len(game_ids) / size)) ] ) ) def get_game_for_service(service, appid): existing_games = get_games(filters={"service_id": appid, "service": service}) if existing_games: return existing_games[0] def get_service_games(service): """Return the list of all installed games for a service""" global _SERVICE_CACHE_ACCESSED previous_cache_accessed = _SERVICE_CACHE_ACCESSED or 0 _SERVICE_CACHE_ACCESSED = time.time() if service not in _SERVICE_CACHE or _SERVICE_CACHE_ACCESSED - previous_cache_accessed > 1: if service == "lutris": _SERVICE_CACHE[service] = [game["slug"] for game in get_games(filters={"installed": "1"})] else: _SERVICE_CACHE[service] = [ game["service_id"] for game in get_games(filters={"service": service, "installed": "1"}) ] return _SERVICE_CACHE[service] def get_game_by_field(value, field="slug"): """Query a game based on a database field""" if field not in ("slug", "installer_slug", "id", "configpath"): raise ValueError("Can't query by field '%s'" % field) game_result = sql.db_select(settings.PGA_DB, "games", condition=(field, value)) if game_result: return game_result[0] return {} def get_games_by_runner(runner): """Return all games using a specific runner""" return sql.db_select(settings.PGA_DB, "games", condition=("runner", runner)) def get_games_by_slug(slug): """Return all games using a specific slug""" return sql.db_select(settings.PGA_DB, "games", condition=("slug", slug)) def add_game(**game_data): """Add a game to the PGA database.""" game_data["installed_at"] = int(time.time()) if "slug" not in game_data: game_data["slug"] = slugify(game_data["name"]) return sql.db_insert(settings.PGA_DB, "games", game_data) def add_games_bulk(games): """ Add a list of games to the PGA database. The dicts must have an identical set of keys. Args: games (list): list of games in dict format Returns: list: List of inserted game ids """ return [sql.db_insert(settings.PGA_DB, "games", game) for game in games] def add_or_update(**params): """Add a game to the PGA or update an existing one If an 'id' is provided in the parameters then it will try to match it, otherwise it will try matching by slug, creating one when possible. """ game_id = get_matching_game(params) if game_id: params["id"] = game_id sql.db_update(settings.PGA_DB, "games", params, {"id": game_id}) return game_id return add_game(**params) def get_matching_game(params): """Tries to match given parameters with an existing game""" # Always match by ID if provided if params.get("id"): game = get_game_by_field(params["id"], "id") if game: return game["id"] logger.warning("Game ID %s provided but couldn't be matched", params["id"]) slug = params.get("slug") or slugify(params.get("name")) if not slug: raise ValueError("Can't add or update without an identifier") for game in get_games_by_slug(slug): if game["installed"]: if game["configpath"] == params.get("configpath"): return game["id"] else: if (game["runner"] == params.get("runner") or not all([params.get("runner"), game["runner"]])): return game["id"] return None def delete_game(game_id): """Delete a game from the PGA.""" sql.db_delete(settings.PGA_DB, "games", "id", game_id) def get_used_runners(): """Return a list of the runners in use by installed games.""" with sql.db_cursor(settings.PGA_DB) as cursor: query = "select distinct runner from games where runner is not null order by runner" rows = cursor.execute(query) results = rows.fetchall() return [result[0] for result in results if result[0]] def get_used_platforms(): """Return a list of platforms currently in use""" with sql.db_cursor(settings.PGA_DB) as cursor: query = ( "select distinct platform from games " "where platform is not null and platform is not '' order by platform" ) rows = cursor.execute(query) results = rows.fetchall() return [result[0] for result in results if result[0]] lutris-0.5.9.1/lutris/database/schema.py000066400000000000000000000126071413267435700201300ustar00rootroot00000000000000from 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": "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": "playtime", "type": "REAL" }, { "name": "hidden", "type": "INTEGER" }, { "name": "service", "type": "TEXT" }, { "name": "service_id", "type": "TEXT" } ], "service_games": [ { "name": "id", "type": "INTEGER", "indexed": True }, { "name": "service", "type": "TEXT" }, { "name": "appid", "type": "TEXT" }, { "name": "name", "type": "TEXT" }, { "name": "slug", "type": "TEXT" }, { "name": "icon", "type": "TEXT" }, { "name": "logo", "type": "TEXT" }, { "name": "url", "type": "TEXT" }, { "name": "details", "type": "TEXT" }, { "name": "lutris_slug", "type": "TEXT" }, ], "sources": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "uri", "type": "TEXT UNIQUE"}, ], "categories": [ {"name": "id", "type": "INTEGER", "indexed": True}, {"name": "name", "type": "TEXT", "unique": True}, ], "games_categories": [ {"name": "game_id", "type": "INTEGER", "indexed": False}, {"name": "category_id", "type": "INTEGER", "indexed": False}, ] } def get_schema(tablename): """ Fields: - position - name - type - not null - default - indexed """ tables = [] query = "pragma table_info('%s')" % tablename with sql.db_cursor(settings.PGA_DB) as cursor: for row in cursor.execute(query).fetchall(): field = { "name": row[1], "type": row[2], "not_null": row[3], "default": row[4], "indexed": row[5], } tables.append(field) return tables def field_to_string(name="", type="", indexed=False, unique=False): # pylint: disable=redefined-builtin """Converts a python based table definition to it's SQL statement""" field_query = "%s %s" % (name, type) if indexed: field_query += " PRIMARY KEY" if unique: field_query += " UNIQUE" return field_query def create_table(name, schema): """Creates a new table in the database""" fields = ", ".join([field_to_string(**f) for f in schema]) query = "CREATE TABLE IF NOT EXISTS %s (%s)" % (name, fields) logger.debug("[PGAQuery] %s", query) with sql.db_cursor(settings.PGA_DB) as cursor: cursor.execute(query) def migrate(table, schema): """Compare a database table with the reference model and make necessary changes This is very basic and only the needed features have been implemented (adding columns) Args: table (str): Name of the table to migrate schema (dict): Reference schema for the table Returns: list: The list of column names that have been added """ existing_schema = get_schema(table) migrated_fields = [] if existing_schema: columns = [col["name"] for col in existing_schema] for field in schema: if field["name"] not in columns: logger.info("Migrating %s field %s", table, field["name"]) migrated_fields.append(field["name"]) sql.add_field(settings.PGA_DB, table, field) else: create_table(table, schema) return migrated_fields def syncdb(): """Update the database to the current version, making necessary changes for backwards compatibility.""" for table in DATABASE: migrate(table, DATABASE[table]) lutris-0.5.9.1/lutris/database/services.py000066400000000000000000000016251413267435700205110ustar00rootroot00000000000000from lutris import settings from lutris.database import sql from lutris.util.log import logger class ServiceGameCollection: @classmethod def get_for_service(cls, service): if not service: raise ValueError("No service provided") return sql.filtered_query(settings.PGA_DB, "service_games", filters={"service": service}) @classmethod def get_game(cls, service, appid): """Return a single game refered by its appid""" if not service: raise ValueError("No service provided") if not appid: raise ValueError("No appid provided") results = sql.filtered_query(settings.PGA_DB, "service_games", filters={"service": service, "appid": appid}) if not results: return if len(results) > 1: logger.warning("More than one game found for %s on %s", appid, service) return results[0] lutris-0.5.9.1/lutris/database/sources.py000066400000000000000000000030341413267435700203450ustar00rootroot00000000000000import os from lutris import settings from lutris.database import sql from lutris.util import system from lutris.util.log import logger def add_source(uri): sql.db_insert(settings.PGA_DB, "sources", {"uri": uri}) def delete_source(uri): sql.db_delete(settings.PGA_DB, "sources", "uri", uri) def read_sources(): with sql.db_cursor(settings.PGA_DB) as cursor: rows = cursor.execute("select uri from sources") results = rows.fetchall() return [row[0] for row in results] def write_sources(sources): db_sources = read_sources() for uri in db_sources: if uri not in sources: sql.db_delete(settings.PGA_DB, "sources", "uri", uri) for uri in sources: if uri not in db_sources: sql.db_insert(settings.PGA_DB, "sources", {"uri": uri}) def check_for_file(game, file_id): for source in read_sources(): if source.startswith("file://"): source = source[7:] else: protocol = source[:7] logger.warning("PGA source protocol %s not implemented", protocol) continue if not system.path_exists(source): logger.info("PGA source %s unavailable", source) continue game_dir = os.path.join(source, game) if not system.path_exists(game_dir): continue for game_file in os.listdir(game_dir): game_base, _ext = os.path.splitext(game_file) if game_base == file_id: return os.path.join(game_dir, game_file) return False lutris-0.5.9.1/lutris/database/sql.py000066400000000000000000000116471413267435700174720ustar00rootroot00000000000000 import sqlite3 import threading from lutris.util.log import logger # Prevent multiple access to the database (SQLite limitation) DB_LOCK = threading.RLock() class db_cursor(object): def __init__(self, db_path): self.db_path = db_path self.db_conn = None def __enter__(self): self.db_conn = sqlite3.connect(self.db_path) cursor = self.db_conn.cursor() return cursor def __exit__(self, _type, value, traceback): self.db_conn.commit() self.db_conn.close() def cursor_execute(cursor, query, params=None): """Execute a SQL query, run it in a lock block""" params = params or () lock = DB_LOCK.acquire(timeout=1) if not lock: logger.error("Database is busy. Not executing %s", query) return results = cursor.execute(query, params) DB_LOCK.release() return results def db_insert(db_path, table, fields): columns = ", ".join(list(fields.keys())) placeholders = ("?, " * len(fields))[:-2] field_values = tuple(fields.values()) with db_cursor(db_path) as cursor: cursor_execute( cursor, "insert into {0}({1}) values ({2})".format(table, columns, placeholders), field_values, ) inserted_id = cursor.lastrowid return inserted_id def db_update(db_path, table, updated_fields, conditions): """Update `table` with the values given in the dict `values` on the condition given with the `row` tuple. """ columns = "=?, ".join(list(updated_fields.keys())) + "=?" field_values = tuple(updated_fields.values()) condition_field = " AND ".join(["%s=?" % field for field in conditions]) condition_value = tuple(conditions.values()) with db_cursor(db_path) as cursor: query = "UPDATE {0} SET {1} WHERE {2}".format(table, columns, condition_field) result = cursor_execute(cursor, query, field_values + condition_value) return result def db_delete(db_path, table, field, value): with db_cursor(db_path) as cursor: cursor_execute(cursor, "delete from {0} where {1}=?".format(table, field), (value, )) def db_select(db_path, table, fields=None, condition=None): if fields: columns = ", ".join(fields) else: columns = "*" with db_cursor(db_path) as cursor: query = "SELECT {} FROM {}" if condition: condition_field, condition_value = condition if isinstance(condition_value, (list, tuple, set)): condition_value = tuple(condition_value) placeholders = ", ".join("?" * len(condition_value)) where_condition = " where {} in (" + placeholders + ")" else: condition_value = (condition_value, ) where_condition = " where {}=?" query = query + where_condition query = query.format(columns, table, condition_field) params = condition_value else: query = query.format(columns, table) params = () cursor_execute(cursor, query, params) rows = cursor.fetchall() column_names = [column[0] for column in cursor.description] results = [] for row in rows: row_data = {} for index, column in enumerate(column_names): row_data[column] = row[index] results.append(row_data) return results def db_query(db_path, query, params=()): with db_cursor(db_path) as cursor: cursor_execute(cursor, query, params) rows = cursor.fetchall() column_names = [column[0] for column in cursor.description] results = [] for row in rows: row_data = {} for index, column in enumerate(column_names): row_data[column] = row[index] results.append(row_data) return results def add_field(db_path, tablename, field): query = "ALTER TABLE %s ADD COLUMN %s %s" % ( tablename, field["name"], field["type"], ) with db_cursor(db_path) as cursor: cursor.execute(query) def filtered_query( db_path, table, searches=None, filters=None, excludes=None, sorts=None ): query = "select * from %s" % table params = [] sql_filters = [] for field in searches or {}: sql_filters.append("%s LIKE ?" % field) params.append("%" + searches[field] + "%") for field in filters or {}: if filters[field]: 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.9.1/lutris/directories.py000066400000000000000000000005151413267435700174330ustar00rootroot00000000000000"""Directory management for runners""" class RunnerDirectory: """Class to reference and manipulate directories used by folders""" def __init__(self, path): self.path = path def __str__(self): return self.path # That's it for now. There's literally no code at all. Still figuring things # out here. lutris-0.5.9.1/lutris/discord.py000066400000000000000000000071061413267435700165510ustar00rootroot00000000000000"""Discord integration""" # Standard Library import asyncio import time # Lutris Modules from lutris.util.log import logger try: from pypresence import Presence as PyPresence from pypresence.exceptions import PyPresenceException except ImportError: PyPresence = None PyPresenceException = None class DiscordPresence(object): """Provide rich presence integration with Discord for games""" def __init__(self): self.available = bool(PyPresence) self.game_name = "" self.runner_name = "" self.last_rpc = 0 self.rpc_interval = 60 self.presence_connected = False self.rpc_client = None self.client_id = None def connect(self): """Make sure we are actually connected before trying to send requests""" if not self.presence_connected: self.rpc_client = PyPresence(self.client_id) try: self.rpc_client.connect() self.presence_connected = True except (ConnectionError, FileNotFoundError): logger.error("Could not connect to Discord") return self.presence_connected def disconnect(self): """Ensure we are definitely disconnected and fix broken event loop from pypresence That method is a huge mess of non-deterministic bs and should be nuked from orbit. """ if self.rpc_client: try: self.rpc_client.close() except Exception as e: logger.exception("Unable to close Discord RPC connection: %s", e) if self.rpc_client.sock_writer is not None: try: self.rpc_client.sock_writer.close() except Exception: logger.exception("Sock writer could not be closed.") try: logger.debug("Forcefully closing event loop.") self.rpc_client.loop.close() except Exception: logger.debug("Could not close event loop.") try: logger.debug("Forcefully replacing event loop.") self.rpc_client.loop = None asyncio.set_event_loop(asyncio.new_event_loop()) except Exception as e: logger.exception("Could not replace event loop: %s", e) try: logger.debug("Forcefully deleting RPC client.") self.rpc_client = None except Exception as ex: logger.exception(ex) self.rpc_client = None self.presence_connected = False def update_discord_rich_presence(self): """Dispatch a request to Discord to update presence""" if int(time.time()) - self.rpc_interval < self.last_rpc: logger.debug("Not enough time since last RPC") return self.last_rpc = int(time.time()) if not self.connect(): return try: self.rpc_client.update(details="Playing %s" % self.game_name, large_image="large_image", large_text=self.game_name, small_image="small_image") except PyPresenceException as ex: logger.error("Unable to update Discord: %s", ex) def clear_discord_rich_presence(self): """Dispatch a request to Discord to clear presence""" if self.connect(): try: self.rpc_client.clear() except PyPresenceException as ex: logger.error("Unable to clear Discord: %s", ex) self.disconnect() lutris-0.5.9.1/lutris/exceptions.py000066400000000000000000000031301413267435700172740ustar00rootroot00000000000000"""Exception handling module""" # Standard Library from functools import wraps from gettext import gettext as _ class LutrisError(Exception): """Base exception for Lutris related errors""" def __init__(self, message): super().__init__(message) self.message = message class GameConfigError(LutrisError): """Throw this error when the game configuration prevents the game from running properly. """ class UnavailableLibraries(RuntimeError): def __init__(self, libraries, arch=None): message = _( "The following {arch} libraries are required but are not installed on your system:\n{libs}" ).format( arch=arch if arch else "", libs=", ".join(libraries) ) super().__init__(message) self.libraries = libraries class AuthenticationError(Exception): """Raised when authentication to a service fails""" class UnavailableGame(Exception): """Raised when a game is available from a service""" class MultipleInstallerError(BaseException): """Current implementation doesn't know how to deal with multiple installers Raise this if a game returns more than 1 installer.""" def watch_lutris_errors(function): """Decorator used to catch LutrisError exceptions and send events""" @wraps(function) def wrapper(*args, **kwargs): """Catch all LutrisError exceptions and emit an event.""" try: return function(*args, **kwargs) except LutrisError as ex: game = args[0] game.emit("game-error", ex.message) return wrapper lutris-0.5.9.1/lutris/game.py000066400000000000000000000711561413267435700160410ustar00rootroot00000000000000"""Module that actually runs the games.""" # pylint: disable=too-many-public-methods import os import shlex import shutil import signal import subprocess import time from gettext import gettext as _ from gi.repository import GLib, GObject, Gtk from lutris import runtime, settings from lutris.command import MonitoredCommand from lutris.config import LutrisConfig from lutris.database import categories as categories_db from lutris.database import games as games_db from lutris.database import sql from lutris.exceptions import GameConfigError, watch_lutris_errors from lutris.gui import dialogs from lutris.runner_interpreter import export_bash_script, get_launch_parameters from lutris.runners import InvalidRunner, import_runner, wine from lutris.util import audio, jobs, linux, strings, system, xdgshortcuts from lutris.util.display import ( DISPLAY_MANAGER, SCREEN_SAVER_INHIBITOR, disable_compositing, enable_compositing, restore_gamma ) from lutris.util.graphics.xephyr import get_xephyr_command from lutris.util.graphics.xrandr import turn_off_except from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import LOG_BUFFERS, logger from lutris.util.process import Process from lutris.util.timer import Timer HEARTBEAT_DELAY = 2000 class Game(GObject.Object): """This class takes cares of loading the configuration for a game and running it. """ STATE_STOPPED = "stopped" STATE_LAUNCHING = "launching" STATE_RUNNING = "running" __gsignals__ = { "game-error": (GObject.SIGNAL_RUN_FIRST, None, (str, )), "game-launch": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-start": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-started": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-stop": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-stopped": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-removed": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-updated": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-install": (GObject.SIGNAL_RUN_FIRST, None, ()), "game-installed": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, game_id=None): super().__init__() self.id = game_id # pylint: disable=invalid-name self.runner = None self.config = None # 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.game_config_id = game_data.get("configpath") or "" self.is_installed = bool(game_data.get("installed") and self.game_config_id) self.is_hidden = bool(game_data.get("hidden")) self.platform = game_data.get("platform") or "" self.year = game_data.get("year") or "" self.lastplayed = game_data.get("lastplayed") or 0 self.has_custom_banner = bool(game_data.get("has_custom_banner")) self.has_custom_icon = bool(game_data.get("has_custom_icon")) self.service = game_data.get("service") self.appid = game_data.get("service_id") self.playtime = game_data.get("playtime") or 0.0 if self.game_config_id: self.load_config() self.game_uuid = None self.game_thread = None self.antimicro_thread = None self.prelaunch_pids = [] 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 def __repr__(self): return self.__str__() def __str__(self): value = self.name or "Game (no name)" if self.runner_name: value += " (%s)" % self.runner_name return value @property def is_favorite(self): """Return whether the game is in the user's favorites""" categories = categories_db.get_categories_in_game(self.id) for category in categories: if category == "favorite": return True return False def add_to_favorites(self): """Add the game to the 'favorite' category""" favorite = categories_db.get_category("favorite") if not favorite: favorite = categories_db.add_category("favorite") categories_db.add_game_to_category(self.id, favorite["id"]) self.emit("game-updated") def remove_from_favorites(self): """Remove game from favorites""" favorite = categories_db.get_category("favorite") categories_db.remove_category_from_game(self.id, favorite["id"]) self.emit("game-updated") def set_hidden(self, is_hidden): """Do not show this game in the UI""" self.is_hidden = is_hidden self.save() self.emit("game-updated") @property def log_buffer(self): """Access the log buffer object, creating it if necessary""" _log_buffer = LOG_BUFFERS.get(str(self.id)) if _log_buffer: return _log_buffer _log_buffer = Gtk.TextBuffer() _log_buffer.create_tag("warning", foreground="red") if self.game_thread: self.game_thread.set_log_buffer(self._log_buffer) _log_buffer.set_text(self.game_thread.stdout) LOG_BUFFERS[str(self.id)] = _log_buffer return _log_buffer @property def formatted_playtime(self): """Return a human readable formatted play time""" return strings.get_formatted_playtime(self.playtime) @staticmethod def show_error_message(message): """Display an error message based on the runner's output.""" if message["error"] == "CUSTOM": message_text = message["text"].replace("&", "&") dialogs.ErrorDialog(message_text) elif message["error"] == "RUNNER_NOT_INSTALLED": dialogs.ErrorDialog(_("Error the runner is not installed")) elif message["error"] == "NO_BIOS": dialogs.ErrorDialog(_("A bios file is required to run this game")) elif message["error"] == "FILE_NOT_FOUND": filename = message["file"] if filename: message_text = _("The file {} could not be found").format(filename.replace("&", "&")) else: message_text = _("This game has no executable set. The install process didn't finish properly.") dialogs.ErrorDialog(message_text) elif message["error"] == "NOT_EXECUTABLE": message_text = message["file"].replace("&", "&") dialogs.ErrorDialog(_("The file %s is not executable") % message_text) elif message["error"] == "PATH_NOT_SET": message_text = _("The path '%s' is not set. please set it in the options.") % message["path"] dialogs.ErrorDialog(message_text) else: dialogs.ErrorDialog(_("Unhandled error: %s") % message["error"]) def get_browse_dir(self): """Return the path to open with the Browse Files action.""" return self.runner.game_path def _get_runner(self): """Return the runner instance for this game's configuration""" try: runner_class = import_runner(self.runner_name) return runner_class(self.config) except InvalidRunner: logger.error("Unable to import runner %s for %s", self.runner_name, self.slug) def load_config(self): """Load the game's configuration.""" if not self.is_installed: return self.config = LutrisConfig(runner_slug=self.runner_name, game_config_id=self.game_config_id) self.runner = self._get_runner() def set_desktop_compositing(self, enable): """Enables or disables compositing""" if enable: if self.compositor_disabled: enable_compositing() self.compositor_disabled = False else: if not self.compositor_disabled: disable_compositing() self.compositor_disabled = True def remove(self, delete_files=False, no_signal=False): """Uninstall a game Params: delete_files (bool): Delete the game files no_signal (bool): Don't emit game-removed signal (if running in a thread) """ sql.db_update(settings.PGA_DB, "games", {"installed": 0, "runner": ""}, {"id": self.id}) if self.config: self.config.remove() xdgshortcuts.remove_launcher(self.slug, self.id, desktop=True, menu=True) if delete_files and self.runner: self.runner.remove_game_data(game_path=self.directory) self.is_installed = False self.runner = None if no_signal: return self.emit("game-removed") def delete(self): """Completely remove a game from the library""" if self.is_installed: raise RuntimeError("Uninstall the game before deleting") games_db.delete_game(self.id) self.emit("game-removed") def set_platform_from_runner(self): """Set the game's platform from the runner""" if not self.runner: logger.warning("Game has no runner, can't set platform") return self.platform = self.runner.get_platform() if not self.platform: logger.warning("The %s runner didn't provide a platform for %s", self.runner.human_name, self) def save(self, save_config=False): """ Save the game's config and metadata, if `save_config` is set to False, do not save the config. This is useful when exiting the game since the config might have changed and we don't want to override the changes. """ if self.config: logger.debug("Saving %s with config ID %s", self, self.config.game_config_id) configpath = self.config.game_config_id if save_config: self.config.save() else: logger.warning("Saving %s without a configuration", self) configpath = "" self.set_platform_from_runner() self.id = games_db.add_or_update( name=self.name, runner=self.runner_name, slug=self.slug, platform=self.platform, directory=self.directory, installed=self.is_installed, year=self.year, lastplayed=self.lastplayed, configpath=configpath, id=self.id, playtime=self.playtime, hidden=self.is_hidden, service=self.service, service_id=self.appid, ) self.emit("game-updated") def is_launchable(self): """Verify that the current game can be launched.""" if not self.runner.is_installed(): installed = self.runner.install_dialog() if not installed: return False if self.runner.use_runtime(): runtime_updater = runtime.RuntimeUpdater() if runtime_updater.is_updating(): logger.warning("Runtime updates: %s", runtime_updater.current_updates) dialogs.ErrorDialog(_("Runtime currently updating"), _("Game might not work as expected")) if ("wine" in self.runner_name and not wine.get_wine_version() and not LINUX_SYSTEM.is_flatpak): # TODO find a reference to the root window or better yet a way not # to have Gtk dependent code in this class. root_window = None dialogs.WineNotInstalledWarning(parent=root_window) return True def restrict_to_display(self, display): outputs = DISPLAY_MANAGER.get_config() if display == "primary": display = None for output in outputs: if output.primary: display = output.name break if not display: logger.warning("No primary display set") else: found = False for output in outputs: if output.name == display: found = True break if not found: logger.warning("Selected display %s not found", display) display = None if display: turn_off_except(display) time.sleep(3) return True return False def start_xephyr(self, display=":2"): """Start a monitored Xephyr instance""" if not system.find_executable("Xephyr"): raise GameConfigError("Unable to find Xephyr, install it or disable the Xephyr option") xephyr_command = get_xephyr_command(display, self.runner.system_config) xephyr_thread = MonitoredCommand(xephyr_command) xephyr_thread.start() time.sleep(3) return display def start_antimicrox(self, antimicro_config): """Start Antimicrox with a given config path""" antimicro_path = system.find_executable("antimicrox") if not antimicro_path: logger.warning("Antimicrox is not installed.") return logger.info("Starting Antic") antimicro_command = [antimicro_path, "--hidden", "--tray", "--profile", antimicro_config] self.antimicro_thread = MonitoredCommand(antimicro_command) self.antimicro_thread.start() @staticmethod def set_keyboard_layout(layout): setxkbmap_command = ["setxkbmap", "-model", "pc101", layout, "-print"] xkbcomp_command = ["xkbcomp", "-", os.environ.get("DISPLAY", ":0")] xkbcomp = subprocess.Popen(xkbcomp_command, stdin=subprocess.PIPE) subprocess.Popen(setxkbmap_command, env=os.environ, stdout=xkbcomp.stdin).communicate() xkbcomp.communicate() def start_prelaunch_command(self): """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 self.prelaunch_executor = MonitoredCommand( command_array, include_processes=[os.path.basename(command_array[0])], env=self.game_runtime_config["env"], cwd=self.directory, ) self.prelaunch_executor.start() logger.info("Running %s in the background", prelaunch_command) def get_terminal(self): """Return the terminal used to run the game into or None if the game is not run from a terminal. Remember that only games using text mode should use the terminal. """ if self.runner.system_config.get("terminal"): terminal = self.runner.system_config.get("terminal_app", linux.get_default_terminal()) if terminal and not system.find_executable(terminal): raise GameConfigError(_("The selected terminal application could not be launched:\n%s") % terminal) return terminal def get_killswitch(self): """Return the path to a file that is monitored during game execution. If the file stops existing, the game is stopped. """ killswitch = self.runner.system_config.get("killswitch") # Prevent setting a killswitch to a file that doesn't exists if killswitch and system.path_exists(self.killswitch): return killswitch def get_gameplay_info(self): """Return the information provided by a runner's play method. Checks for possible errors. """ if not self.runner: logger.warning("Trying to launch %s without a runner", self) return {} gameplay_info = self.runner.play() if "error" in gameplay_info: self.show_error_message(gameplay_info) self.state = self.STATE_STOPPED self.emit("game-stop") return return gameplay_info @watch_lutris_errors def configure_game(self, prelaunched, error=None): # noqa: C901 """Get the game ready to start, applying all the options This methods sets the game_runtime_config attribute. """ if error: logger.error(error) dialogs.ErrorDialog(str(error)) if not prelaunched: logger.error("Game prelaunch unsuccessful") dialogs.ErrorDialog(_("An error prevented the game from running")) self.state = self.STATE_STOPPED self.emit("game-stop") return gameplay_info = self.get_gameplay_info() if not gameplay_info: return command, env = get_launch_parameters(self.runner, gameplay_info) env["game_name"] = self.name # What is this used for?? self.game_runtime_config = { "args": command, "env": env, "terminal": self.get_terminal(), "include_processes": shlex.split(self.runner.system_config.get("include_processes", "")), "exclude_processes": shlex.split(self.runner.system_config.get("exclude_processes", "")), } # Audio control if self.runner.system_config.get("reset_pulse"): audio.reset_pulse() # Input control if self.runner.system_config.get("use_us_layout"): self.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() if self.runner.system_config.get("prelaunch_wait"): # Monitor the prelaunch command and wait until it has finished self.heartbeat = GLib.timeout_add(HEARTBEAT_DELAY, self.prelaunch_beat) else: self.start_game() def launch(self): """Request launching a game. The game may not be installed yet.""" if not self.is_installed: raise RuntimeError("Tried to launch a game that isn't installed") self.load_config() # Reload the config before launching it. if str(self.id) in LOG_BUFFERS: # Reset game logs on each launch LOG_BUFFERS.pop(str(self.id)) if not self.runner: dialogs.ErrorDialog(_("Invalid game configuration: Missing runner")) return if not self.is_launchable(): logger.error("Game is not launchable") return self.state = self.STATE_LAUNCHING self.prelaunch_pids = system.get_running_pid_list() self.emit("game-start") jobs.AsyncCall(self.runner.prelaunch, self.configure_game) def start_game(self): """Run a background command to lauch the game""" self.game_thread = MonitoredCommand( self.game_runtime_config["args"], title=self.name, runner=self.runner, 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") self.heartbeat = GLib.timeout_add(HEARTBEAT_DELAY, self.beat) def force_stop(self): """Forces termination of a running game""" pids = self.get_game_pids() if self.game_thread and self.game_thread.game_process: pids.add(self.game_thread.game_process.pid) for pid in pids: try: os.kill(int(pid), signal.SIGTERM) except ProcessLookupError as ex: logger.debug("Failed to kill game process: %s", ex) self.stop_game() def get_game_pids(self): """Return a list of processes belonging to the Lutris game""" new_pids = self.get_new_pids() game_pids = [] game_folder = self.runner.game_path or "" for pid in new_pids: cmdline = Process(pid).cmdline or "" # pressure-vessel: This could potentially pick up PIDs not started by lutris? if game_folder in cmdline or "pressure-vessel" in cmdline: game_pids.append(pid) return set(game_pids + [ pid for pid in new_pids if Process(pid).environ.get("LUTRIS_GAME_UUID") == self.game_uuid ]) def get_new_pids(self): """Return list of PIDs started since the game was launched""" return set(system.get_running_pid_list()) - set(self.prelaunch_pids) def stop_game(self): """Cleanup after a game as stopped""" duration = self.timer.duration logger.debug("%s has run for %s seconds", self, duration) if duration < 5: logger.warning("The game has run for a very short time, did it crash?") # Inspect why it could have crashed self.state = self.STATE_STOPPED self.emit("game-stop") if not self.timer.finished: self.timer.end() self.playtime += self.timer.duration / 3600 def prelaunch_beat(self): """Watch the prelaunch command""" if self.prelaunch_executor and self.prelaunch_executor.is_running: return True self.start_game() return False def beat(self): """Watch the game's process(es).""" if self.game_thread.error: dialogs.ErrorDialog(_("Error lauching the game:\n") + self.game_thread.error) self.on_game_quit() return False # 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() if 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: jobs.AsyncCall(self.game_thread.stop, None) 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() 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.directory, ) 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(save_config=False) 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.screen_saver_inhibitor_cookie is not None: SCREEN_SAVER_INHIBITOR.uninhibit(self.screen_saver_inhibitor_cookie) self.screen_saver_inhibitor_cookie = None if self.runner.system_config.get("use_us_layout"): subprocess.Popen(["setxkbmap"], env=os.environ).communicate() if self.runner.system_config.get("restore_gamma"): restore_gamma() self.process_return_codes() def process_return_codes(self): """Do things depending on how the game quitted.""" if self.game_thread.return_code == 127: # Error missing shared lib error = "error while loading shared lib" error_line = strings.lookup_string_in_text(error, self.game_thread.stdout) if error_line: dialogs.ErrorDialog(_("Error: Missing shared library.\n\n%s") % error_line) if self.game_thread.return_code == 1: # Error Wine version conflict error = "maybe the wrong wineserver" if strings.lookup_string_in_text(error, self.game_thread.stdout): dialogs.ErrorDialog(_("Error: A different Wine version is already using the same Wine prefix.")) def write_script(self, script_path): """Output the launch argument in a bash script""" gameplay_info = self.get_gameplay_info() if not gameplay_info: logger.error("Unable to retrieve game information for %s. Can't write a script", self) return export_bash_script(self.runner, gameplay_info, script_path) def move(self, new_location): logger.info("Moving %s to %s", self, new_location) new_config = "" old_location = self.directory if os.path.exists(old_location): game_directory = os.path.basename(old_location) target_directory = os.path.join(new_location, game_directory) else: target_directory = new_location self.directory = target_directory self.save() if not old_location: logger.info("Previous location wasn't set. Cannot continue moving") return target_directory with open(self.config.game_config_path) 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") as config_file: config_file.write(new_config) if not system.path_exists(old_location): logger.warning("Location %s doesn't exist, files already moved?", old_location) return target_directory if new_location.startswith(old_location): logger.warning("Can't move %s to one of its children %s", old_location, new_location) return target_directory try: shutil.move(old_location, new_location) except OSError as ex: logger.error( "Failed to move %s to %s, you may have to move files manually (Exception: %s)", old_location, new_location, ex ) return target_directory lutris-0.5.9.1/lutris/game_actions.py000066400000000000000000000225721413267435700175570ustar00rootroot00000000000000"""Handle game specific actions""" # Standard Library # pylint: disable=too-many-public-methods import os from gettext import gettext as _ from gi.repository import Gio from lutris.command import MonitoredCommand from lutris.game import Game from lutris.gui import dialogs from lutris.gui.config.add_game import AddGameDialog from lutris.gui.config.edit_game import EditGameConfigDialog from lutris.gui.dialogs.log import LogWindow from lutris.gui.dialogs.uninstall_game import RemoveGameDialog, UninstallGameDialog from lutris.gui.widgets.utils import open_uri from lutris.util import xdgshortcuts from lutris.util.log import LOG_BUFFERS, logger from lutris.util.system import path_exists class GameActions: """Regroup a list of callbacks for a game""" def __init__(self, application=None, window=None): self.application = application or Gio.Application.get_default() self.window = window self.game_id = None self._game = None @property def game(self): if not self._game: self._game = self.application.get_game_by_id(self.game_id) if not self._game: self._game = Game(self.game_id) self._game.connect("game-error", self.window.on_game_error) return self._game @property def is_game_running(self): return bool(self.application.get_game_by_id(self.game_id)) def set_game(self, game=None, game_id=None): if game: self._game = game self.game_id = game.id else: self._game = None self.game_id = game_id def get_game_actions(self): """Return a list of game actions and their callbacks""" return [ ("play", _("Play"), self.on_game_launch), ("stop", _("Stop"), self.on_game_stop), ("show_logs", _("Show logs"), self.on_show_logs), ("install", _("Install"), self.on_install_clicked), ("add", _("Add installed game"), self.on_add_manually), ("configure", _("Configure"), self.on_edit_game_configuration), ("favorite", _("Add to favorites"), self.on_add_favorite_game), ("deletefavorite", _("Remove from favorites"), self.on_delete_favorite_game), ("execute-script", _("Execute script"), self.on_execute_script_clicked), ("browse", _("Browse files"), self.on_browse_files), ( "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, ), ("install_more", _("Install another version"), self.on_install_clicked), ("remove", _("Remove"), self.on_remove_game), ("view", _("View on Lutris.net"), self.on_view_game), ("hide", _("Hide game from library"), self.on_hide_game), ("unhide", _("Unhide game from library"), self.on_unhide_game), ] def get_displayed_entries(self): """Return a dictionary of actions that should be shown for a game""" return { "add": not self.game.is_installed, "install": not self.game.is_installed, "play": self.game.is_installed and not self.is_game_running, "stop": self.is_game_running, "configure": bool(self.game.is_installed), "browse": self.game.is_installed and self.game.runner_name != "browser", "show_logs": self.game.is_installed, "favorite": not self.game.is_favorite, "deletefavorite": self.game.is_favorite, "install_more": not self.game.service and self.game.is_installed, "execute-script": bool(self.game.is_installed and self.game.runner.system_config.get("manual_command")), "desktop-shortcut": ( self.game.is_installed and not xdgshortcuts.desktop_launcher_exists(self.game.slug, self.game.id) ), "menu-shortcut": ( self.game.is_installed and not xdgshortcuts.menu_launcher_exists(self.game.slug, self.game.id) ), "rm-desktop-shortcut": bool( self.game.is_installed and xdgshortcuts.desktop_launcher_exists(self.game.slug, self.game.id) ), "rm-menu-shortcut": bool( self.game.is_installed and xdgshortcuts.menu_launcher_exists(self.game.slug, self.game.id) ), "remove": True, "view": True, "hide": self.game.is_installed and not self.game.is_hidden, "unhide": self.game.is_hidden, } def on_game_launch(self, *_args): """Launch a game""" self.game.launch() def get_running_game(self): ids = self.application.get_running_game_ids() for game_id in ids: if str(game_id) == str(self.game.id): return self.game logger.warning("Game %s not in %s", self.game_id, ids) def on_game_stop(self, _caller): """Stops the game""" game = self.get_running_game() if game: game.force_stop() def on_show_logs(self, _widget): """Display game log""" _buffer = LOG_BUFFERS.get(str(self.game.id)) if not _buffer: logger.info("No log for game %s", self.game) return LogWindow( title=_("Log for {}").format(self.game), buffer=_buffer, application=self.application ) def on_install_clicked(self, *_args): """Install a game""" # Install the currently selected game in the UI if not self.game.slug: raise RuntimeError("No game to install: %s" % self.game.id) self.game.emit("game-install") def on_locate_installed_game(self, _button, game): """Show the user a dialog to import an existing install to a DRM free service Params: game (Game): Game instance without a database ID, populated with a fields the service can provides """ AddGameDialog(self.window, game=game) def on_add_manually(self, _widget, *_args): """Callback that presents the Add game dialog""" return AddGameDialog(self.window, game=self.game, runner=self.game.runner_name) def on_edit_game_configuration(self, _widget): """Edit game preferences""" EditGameConfigDialog(self.window, self.game) def on_add_favorite_game(self, _widget): """Add to favorite Games list""" self.game.add_to_favorites() def on_delete_favorite_game(self, _widget): """delete from favorites""" self.game.remove_from_favorites() def on_hide_game(self, _widget): """Add a game to the list of hidden games""" self.game.set_hidden(True) def on_unhide_game(self, _widget): """Removes a game from the list of hidden games""" self.game.set_hidden(False) def on_execute_script_clicked(self, _widget): """Execute the game's associated script""" manual_command = self.game.runner.system_config.get("manual_command") if path_exists(manual_command): MonitoredCommand( [manual_command], include_processes=[os.path.basename(manual_command)], cwd=self.game.directory, ).start() logger.info("Running %s in the background", manual_command) def on_browse_files(self, _widget): """Callback to open a game folder in the file browser""" path = self.game.get_browse_dir() if not path: dialogs.NoticeDialog(_("This game has no installation directory")) elif path_exists(path): open_uri("file://%s" % path) else: dialogs.NoticeDialog(_("Can't open %s \nThe folder doesn't exist.") % path) def on_create_menu_shortcut(self, *_args): """Add the selected game to the system's Games menu.""" xdgshortcuts.create_launcher(self.game.slug, self.game.id, self.game.name, menu=True) def on_create_desktop_shortcut(self, *_args): """Create a desktop launcher for the selected game.""" xdgshortcuts.create_launcher(self.game.slug, self.game.id, self.game.name, desktop=True) def on_remove_menu_shortcut(self, *_args): """Remove an XDG menu shortcut""" xdgshortcuts.remove_launcher(self.game.slug, self.game.id, menu=True) def on_remove_desktop_shortcut(self, *_args): """Remove a .desktop shortcut""" xdgshortcuts.remove_launcher(self.game.slug, self.game.id, desktop=True) def on_view_game(self, _widget): """Callback to open a game on lutris.net""" open_uri("https://lutris.net/games/%s" % self.game.slug) def on_remove_game(self, *_args): """Callback that present the uninstall dialog to the user""" if self.game.is_installed: UninstallGameDialog(game_id=self.game.id, parent=self.window) else: RemoveGameDialog(game_id=self.game.id, parent=self.window) lutris-0.5.9.1/lutris/gui/000077500000000000000000000000001413267435700153305ustar00rootroot00000000000000lutris-0.5.9.1/lutris/gui/__init__.py000066400000000000000000000000331413267435700174350ustar00rootroot00000000000000""" Lutris GUI package """ lutris-0.5.9.1/lutris/gui/application.py000066400000000000000000000571051413267435700202150ustar00rootroot00000000000000# pylint: disable=wrong-import-position # # Copyright (C) 2021 Mathieu Comandon # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . import json import logging import os import signal import sys import tempfile from datetime import datetime, timedelta from gettext import gettext as _ import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") gi.require_version("GnomeDesktop", "3.0") from gi.repository import Gio, GLib, Gtk, GObject from lutris import settings from lutris.api import parse_installer_url from lutris.command import exec_command from lutris.database import games as games_db from lutris.game import Game from lutris.installer import get_installers from lutris.gui.dialogs import ErrorDialog, InstallOrPlayDialog, LutrisInitDialog from lutris.gui.dialogs.issue import IssueReportWindow from lutris.gui.installerwindow import InstallerWindow from lutris.gui.widgets.status_icon import LutrisStatusIcon from lutris.migrations import migrate from lutris.startup import init_lutris, run_all_checks, update_runtime from lutris.util import datapath, log from lutris.util.http import HTTPError, Request from lutris.util.log import logger from lutris.util.steam.appmanifest import AppManifest, get_appmanifests from lutris.util.steam.config import get_steamapps_paths from lutris.services import get_enabled_services from lutris.database.services import ServiceGameCollection from .lutriswindow import LutrisWindow class Application(Gtk.Application): def __init__(self): super().__init__( application_id="net.lutris.Lutris", flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE, ) GObject.add_emission_hook(Game, "game-launch", self.on_game_launch) GObject.add_emission_hook(Game, "game-start", self.on_game_start) GObject.add_emission_hook(Game, "game-stop", self.on_game_stop) GObject.add_emission_hook(Game, "game-install", self.on_game_install) GLib.set_application_name(_("Lutris")) self.window = None self.running_games = Gio.ListStore.new(Game) self.app_windows = {} self.tray = None self.css_provider = Gtk.CssProvider.new() self.run_in_background = False if os.geteuid() == 0: ErrorDialog(_("Running Lutris as root is not recommended and may cause unexpected issues")) 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( "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( "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") init_lutris() if os.environ.get("LUTRIS_SKIP_INIT"): logger.debug("Skipping initialization") return init_dialog = LutrisInitDialog(update_runtime) init_dialog.run() 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) if not self.run_in_background: self.window.present() else: # Reset run in background to False. Future calls will set it # accordingly self.run_in_background = False 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"] return str(kwargs) def show_window(self, window_class, **kwargs): """Instanciate a window keeping 1 instance max Params: window_class (Gtk.Window): class to create the instance from kwargs (dict): Additional arguments to pass to the instanciated window Returns: Gtk.Window: the existing window instance or a newly created one """ window_key = str(window_class) + self.get_window_key(**kwargs) if self.app_windows.get(window_key): self.app_windows[window_key].present() return self.app_windows[window_key] if issubclass(window_class, Gtk.Dialog): window_inst = window_class(parent=self.window, **kwargs) else: window_inst = window_class(application=self, **kwargs) window_inst.connect("destroy", self.on_app_window_destroyed, self.get_window_key(**kwargs)) self.app_windows[window_key] = window_inst logger.debug("Showing window %s", window_key) window_inst.show() return window_inst def show_installer_window(self, installers, service=None, appid=None): self.show_window( InstallerWindow, installers=installers, service=service, appid=appid ) 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__) + 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 """ game = Game(db_game["id"]) game.load_config() game.write_script(script_path) 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. # Use when targetting Python 3.7 minimum # console_handler.setStream(sys.stderr) # Until then... logger.removeHandler(log.console_handler) log.console_handler = logging.StreamHandler(stream=sys.stdout) log.console_handler.setFormatter(log.SIMPLE_FORMATTER) logger.addHandler(log.console_handler) # Set up logger if options.contains("debug"): log.console_handler.setFormatter(log.DEBUG_FORMATTER) logger.setLevel(logging.DEBUG) # 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 migrate() run_all_checks() # List game if options.contains("list-games"): game_list = games_db.get_games() if options.contains("installed"): game_list = [game for game in game_list if game["installed"]] if options.contains("json"): self.print_game_json(command_line, game_list) else: self.print_game_list(command_line, 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 # 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 try: url = options.lookup_value(GLib.OPTION_REMAINING) 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"] 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: if action == "rungameid": # Force db_game to use game id self.run_in_background = True db_game = games_db.get_game_by_field(game_slug, "id") elif action == "rungame": # Force db_game to use game slug self.run_in_background = True db_game = games_db.get_game_by_field(game_slug, "slug") elif action == "install": # Installers can use game or installer slugs self.run_in_background = 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.activate() self.set_tray_icon() 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_confirmed: action = None elif dlg.action == "play": action = "rungame" elif dlg.action == "install": action = "install" elif game_slug or installer_file: # No game found, default to install if a game_slug or # installer_file is provided action = "install" 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.do_shutdown() return 0 game = Game(db_game["id"]) self.on_game_launch(game) return 0 def on_game_launch(self, game): game.launch() return True # Return True to continue handling the emission hook 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_install(self, game): """Request installation of a game""" if game.service and game.service != "lutris": service = get_enabled_services()[game.service]() db_game = ServiceGameCollection.get_game(service.id, game.appid) try: game_id = service.install(db_game) except ValueError as e: logger.debug(e) game_id = None if game_id: game = Game(game_id) game.launch() else: ErrorDialog(message=_("Could not retrieve game installer."), parent=self.window) return True if not game.slug: raise ValueError("Invalid game passed: %s" % game) # return True installers = get_installers(game_slug=game.slug) if installers: self.show_installer_window(installers) else: logger.debug("Should generate automagical installer here but....") logger.debug("Wait? how did you get here?") return True def get_running_game_ids(self): ids = [] for i in range(self.running_games.get_n_items()): game = self.running_games.get_item(i) ids.append(str(game.id)) return ids def get_game_by_id(self, game_id): for i in range(self.running_games.get_n_items()): game = self.running_games.get_item(i) if str(game.id) == str(game_id): return game return None def on_game_stop(self, game): """Callback to remove the game from the running games""" ids = self.get_running_game_ids() if str(game.id) in ids: try: self.running_games.remove(ids.index(str(game.id))) except ValueError: pass else: logger.warning("%s not in %s", game.id, ids) game.emit("game-stopped") if settings.read_setting("hide_client_on_game_start") == "True": self.window.show() # Show launcher window elif not self.window.is_visible(): if self.running_games.get_n_items() == 0: self.quit() return True @staticmethod def get_lutris_action(url): installer_info = {"game_slug": None, "revision": None, "action": None} if url: url = url.get_strv() if url: url = url[0] installer_info = parse_installer_url(url) if installer_info is False: raise ValueError return installer_info def print_game_list(self, command_line, game_list): for game in game_list: self._print( command_line, "{:4} | {:<40} | {:<40} | {:<15} | {:<64}".format( game["id"], game["name"][:40], game["slug"][:40], game["runner"] or "-", game["directory"] or "-", ), ) def print_game_json(self, command_line, game_list): games = [ { "id": game["id"], "slug": game["slug"], "name": game["name"], "runner": game["runner"], "platform": game["platform"], "year": game["year"], "playtime": str(timedelta(hours=game["playtime"])), "lastplayed": str(datetime.fromtimestamp(game["lastplayed"])), "directory": game["directory"], } 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_paths() for platform in ("linux", "windows"): for path in steamapps_paths[platform]: 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} | {:10} | {}".format( appmanifest.steamid, appmanifest.name or "-", platform, ", ".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_paths() for platform in ("linux", "windows"): for path in steamapps_paths[platform]: self._print(command_line, path) def do_shutdown(self): # pylint: disable=arguments-differ logger.info("Shutting down Lutris") if self.window: settings.write_setting("selected_category", self.window.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) lutris-0.5.9.1/lutris/gui/config/000077500000000000000000000000001413267435700165755ustar00rootroot00000000000000lutris-0.5.9.1/lutris/gui/config/__init__.py000066400000000000000000000000471413267435700207070ustar00rootroot00000000000000DIALOG_WIDTH = 845 DIALOG_HEIGHT = 600 lutris-0.5.9.1/lutris/gui/config/add_game.py000066400000000000000000000016061413267435700206730ustar00rootroot00000000000000from gettext import gettext as _ from lutris.config import LutrisConfig from lutris.gui.config.common import GameDialogCommon class AddGameDialog(GameDialogCommon): """Add game dialog class.""" def __init__(self, parent, game=None, runner=None): super().__init__(_("Add a new game"), parent=parent) self.game = game self.saved = False if game: self.runner_name = game.runner_name self.slug = game.slug else: self.runner_name = runner self.slug = None self.lutris_config = LutrisConfig( runner_slug=self.runner_name, level="game", ) self.build_notebook() self.build_tabs("game") self.build_action_area(self.on_save) self.name_entry.grab_focus() self.connect("delete-event", self.on_cancel_clicked) self.show_all() lutris-0.5.9.1/lutris/gui/config/base_config_box.py000066400000000000000000000012711413267435700222570ustar00rootroot00000000000000from gi.repository import Gtk from lutris.gui.widgets.common import VBox class BaseConfigBox(VBox): def get_section_label(self, text): label = Gtk.Label(visible=True) label.set_markup("%s" % text) label.set_alignment(0, 0.5) label.set_margin_bottom(8) return label def get_description_label(self, text): label = Gtk.Label(visible=True) label.set_markup("%s" % text) label.set_alignment(0, 0.5) return label def __init__(self): super().__init__(visible=True) self.set_margin_top(50) self.set_margin_bottom(50) self.set_margin_right(80) self.set_margin_left(80) lutris-0.5.9.1/lutris/gui/config/boxes.py000066400000000000000000000652541413267435700203030ustar00rootroot00000000000000"""Widget generators and their signal handlers""" # Standard Library # pylint: disable=no-member,too-many-public-methods import os from gettext import gettext as _ # Third Party Libraries from gi.repository import Gdk, Gtk # Lutris Modules from lutris import settings, sysoptions from lutris.gui.widgets.common import EditableGrid, FileChooserEntry, Label, VBox from lutris.gui.widgets.searchable_combobox import SearchableCombobox from lutris.runners import InvalidRunner, import_runner from lutris.util.jobs import AsyncCall from lutris.util.log import logger class ConfigBox(VBox): """Dynamically generate a vbox built upon on a python dict.""" def __init__(self, game=None): super().__init__() self.options = [] self.game = game self.config = None self.raw_config = None self.option_widget = None self.wrapper = None self.tooltip_default = None self.files = [] self.files_list_store = None 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, config_section): # 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")) 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) return # Select config section. if config_section == "game": self.config = self.lutris_config.game_config self.raw_config = self.lutris_config.raw_game_config elif config_section == "runner": self.config = self.lutris_config.runner_config self.raw_config = self.lutris_config.raw_runner_config elif config_section == "system": self.config = self.lutris_config.system_config self.raw_config = self.lutris_config.raw_system_config # Go thru all options. for option in self.options: if "scope" in option: if config_section not in option["scope"]: continue option_key = option["option"] value = self.config.get(option_key) default = option.get("default") if callable(option.get("choices")) and option["type"] != "choice_with_search": option["choices"] = option["choices"]() if callable(option.get("condition")): option["condition"] = option["condition"]() self.wrapper = Gtk.Box() self.wrapper.set_spacing(12) self.wrapper.set_margin_bottom(6) # Set tooltip's "Default" part default = option.get("default") self.tooltip_default = default if isinstance(default, str) else None # Generate option widget self.option_widget = None self.call_widget_generator(option, option_key, value, default) # Reset button reset_btn = Gtk.Button.new_from_icon_name("edit-clear", Gtk.IconSize.MENU) 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, ) 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() hbox.set_margin_left(18) hbox.pack_end(placeholder, False, False, 5) # Grey out option if condition unmet if "condition" in option and not option["condition"]: hbox.set_sensitive(False) # Hide if advanced if option.get("advanced"): hbox.get_style_context().add_class("advanced") show_advanced = settings.read_setting("show_advanced_options") if show_advanced != "True": hbox.set_no_show_all(True) hbox.pack_start(self.wrapper, True, True, 0) self.pack_start(hbox, False, False, 0) def call_widget_generator(self, option, option_key, value, default): # noqa: C901 """Call the right generation method depending on option type.""" # pylint: disable=too-many-branches option_type = option["type"] option_size = option.get("size", None) if option_key in self.raw_config: self.set_style_property("font-weight", "bold", self.wrapper) elif value != default: self.set_style_property("font-style", "italic", self.wrapper) if option_type == "choice": self.generate_combobox(option_key, option["choices"], option["label"], value, default) elif option_type == "choice_with_entry": self.generate_combobox( option_key, option["choices"], option["label"], value, default, has_entry=True, ) elif option_type == "choice_with_search": self.generate_searchable_combobox( option_key, option["choices"], option["label"], value, default, ) elif option_type == "bool": self.generate_checkbox(option, value) self.tooltip_default = "Enabled" if default else "Disabled" elif option_type == "extended_bool": self.generate_checkbox_with_callback(option, value) self.tooltip_default = "Enabled" if default else "Disabled" elif option_type == "range": self.generate_range(option_key, option["min"], option["max"], option["label"], value) elif option_type == "string": if "label" not in option: raise ValueError("Option %s has no label" % option) self.generate_entry(option_key, option["label"], value, option_size) elif option_type == "directory_chooser": self.generate_directory_chooser(option, value) elif option_type == "file": self.generate_file_chooser(option, value) elif option_type == "multiple": self.generate_multiple_file_chooser(option_key, option["label"], value) elif option_type == "label": self.generate_label(option["label"]) elif option_type == "mapping": self.generate_editable_grid(option_key, label=option["label"], value=value) else: raise ValueError("Unknown widget type %s" % option_type) # Label def generate_label(self, text): """Generate a simple label.""" label = Label(text) label.set_use_markup(True) label.set_halign(Gtk.Align.START) label.set_valign(Gtk.Align.CENTER) self.wrapper.pack_start(label, True, True, 0) # Checkbox def generate_checkbox(self, option, value=None): """Generate a checkbox.""" label = Label(option["label"]) self.wrapper.pack_start(label, False, False, 0) switch = Gtk.Switch() if value is True: switch.set_active(value) switch.connect("notify::active", self.checkbox_toggle, option["option"]) switch.set_valign(Gtk.Align.CENTER) self.wrapper.pack_start(switch, False, False, 0) self.option_widget = switch # Checkbox with callback def generate_checkbox_with_callback(self, option, value=None): """Generate a checkbox. With callback""" label = Label(option["label"]) self.wrapper.pack_start(label, False, False, 0) checkbox = Gtk.Switch() checkbox.set_sensitive(option["active"] is True) if value is True: checkbox.set_active(value) checkbox.connect("notify::active", self._on_toggle_with_callback, option) checkbox.set_valign(Gtk.Align.CENTER) self.wrapper.pack_start(checkbox, False, False, 0) self.option_widget = checkbox def checkbox_toggle(self, widget, _gparam, option_name): """Action for the checkbox's toggled signal.""" self.option_changed(widget, option_name, widget.get_active()) def _on_toggle_with_callback(self, widget, _gparam, option): """Action for the checkbox's toggled signal. With callback method""" option_name = option["option"] callback = option["callback"] callback_on = option.get("callback_on") if widget.get_active() == callback_on or callback_on is None: AsyncCall(callback, self._on_callback_finished, widget, option, self.config) else: self.option_changed(widget, option_name, widget.get_active()) def _on_callback_finished(self, result, _error): widget, option, response = result if response: self.option_changed(widget, option["option"], widget.get_active()) else: widget.set_active(False) # Entry def generate_entry(self, option_name, label, value=None, option_size=None): """Generate an entry box.""" label = Label(label) self.wrapper.pack_start(label, False, False, 0) entry = Gtk.Entry() if value: entry.set_text(value) entry.connect("changed", self.entry_changed, option_name) expand = option_size != "small" self.wrapper.pack_start(entry, expand, expand, 0) self.option_widget = entry def entry_changed(self, entry, option_name): """Action triggered for entry 'changed' signal.""" self.option_changed(entry, option_name, entry.get_text()) def generate_searchable_combobox(self, option_name, choice_func, label, value, default): """Generate a searchable combo box""" combobox = SearchableCombobox(choice_func, value or default) combobox.connect("changed", self.on_searchable_entry_changed, option_name) self.wrapper.pack_start(Label(label), False, False, 0) self.wrapper.pack_start(combobox, True, True, 0) self.option_widget = combobox def on_searchable_entry_changed(self, combobox, value, key): self.option_changed(combobox, key, value) def _populate_combobox_choices(self, liststore, choices, default): for choice in choices: if isinstance(choice, str): choice = (choice, choice) if choice[1] == default: liststore.append((_("%s (default)") % choice[0], choice[1])) self.tooltip_default = choice[0] else: liststore.append(choice) # ComboBox def generate_combobox(self, option_name, choices, label, value=None, default=None, has_entry=False): """Generate a combobox (drop-down menu).""" liststore = Gtk.ListStore(str, str) self._populate_combobox_choices(liststore, choices, default) # With entry ("choice_with_entry" type) if has_entry: combobox = Gtk.ComboBox.new_with_model_and_entry(liststore) combobox.set_entry_text_column(0) if value: combobox.get_child().set_text(value) # No entry ("choice" type) else: combobox = Gtk.ComboBox.new_with_model(liststore) cell = Gtk.CellRendererText() combobox.pack_start(cell, True) combobox.add_attribute(cell, "text", 0) combobox.set_id_column(1) choices = list(v for k, v in choices) if value in choices: combobox.set_active_id(value) else: combobox.set_active_id(default) combobox.connect("changed", self.on_combobox_change, option_name) combobox.connect("scroll-event", self._on_combobox_scroll) label = Label(label) combobox.set_valign(Gtk.Align.CENTER) self.wrapper.pack_start(label, False, False, 0) self.wrapper.pack_start(combobox, True, True, 0) self.option_widget = combobox @staticmethod def _on_combobox_scroll(combobox, _event): """Prevents users from accidentally changing configuration values while scrolling down dialogs. """ combobox.stop_emission_by_name("scroll-event") return False def on_combobox_change(self, combobox, option): """Action triggered on combobox 'changed' signal.""" list_store = combobox.get_model() active = combobox.get_active() option_value = None if active < 0: if combobox.get_has_entry(): option_value = combobox.get_child().get_text() else: option_value = list_store[active][1] self.option_changed(combobox, option, option_value) # Range def generate_range(self, option_name, min_val, max_val, label, value=None): """Generate a ranged spin button.""" adjustment = Gtk.Adjustment(float(min_val), float(min_val), float(max_val), 1, 0, 0) spin_button = Gtk.SpinButton() spin_button.set_adjustment(adjustment) if value: spin_button.set_value(value) spin_button.connect("changed", self.on_spin_button_changed, option_name) label = Label(label) self.wrapper.pack_start(label, False, False, 0) self.wrapper.pack_start(spin_button, True, True, 0) self.option_widget = spin_button def on_spin_button_changed(self, spin_button, option): """Action triggered on spin button 'changed' signal.""" value = spin_button.get_value_as_int() self.option_changed(spin_button, option, value) # File chooser def generate_file_chooser(self, option, path=None): """Generate a file chooser button to select a file.""" option_name = option["option"] label = Label(option["label"]) default_path = option.get("default_path") or (self.runner.default_path if self.runner else "") file_chooser = FileChooserEntry( title=_("Select file"), action=Gtk.FileChooserAction.OPEN, path=path, default_path=default_path ) # file_chooser.set_size_request(200, 30) if "default_path" in option: default_path = self.lutris_config.system_config.get(option["default_path"]) if default_path and os.path.exists(default_path): file_chooser.entry.set_text(default_path) if 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.entry.connect("changed", self._on_chooser_file_set, option_name) def _on_chooser_file_set(self, entry, option): """Action triggered on file select dialog 'file-set' signal.""" if not os.path.isabs(entry.get_text()): entry.set_text(os.path.expanduser(entry.get_text())) self.option_changed(entry.get_parent(), option, entry.get_text()) # Directory chooser def generate_directory_chooser(self, option, path=None): """Generate a file chooser button to select a directory.""" label = Label(option["label"]) option_name = option["option"] default_path = None if not path and self.game and self.game.runner: default_path = self.game.runner.working_dir directory_chooser = FileChooserEntry( title=_("Select folder"), action=Gtk.FileChooserAction.SELECT_FOLDER, path=path, default_path=default_path ) directory_chooser.entry.connect("changed", self._on_chooser_dir_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_dir_set(self, entry, option): """Action triggered on file select dialog 'file-set' signal.""" self.option_changed(entry.get_parent(), option, entry.get_text()) # Editable grid def generate_editable_grid(self, option_name, label, value=None): """Adds an editable grid widget""" value = value or {} try: value = list(value.items()) except AttributeError: logger.error("Invalid value of type %s passed to grid widget: %s", type(value), value) value = {} label = Label(label) grid = EditableGrid(value, columns=["Key", "Value"]) grid.connect("changed", self._on_grid_changed, option_name) self.wrapper.pack_start(label, False, False, 0) self.wrapper.pack_start(grid, True, True, 0) self.option_widget = grid return grid def _on_grid_changed(self, grid, option): values = dict(grid.get_data()) self.option_changed(grid, option, values) # Multiple file selector def generate_multiple_file_chooser(self, option_name, label, value=None): """Generate a multiple file selector.""" vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) label = Label(label + ":") label.set_halign(Gtk.Align.START) button = Gtk.Button(_("Add files")) button.connect("clicked", self.on_add_files_clicked, option_name, value) button.set_margin_left(10) vbox.pack_start(label, False, False, 5) vbox.pack_end(button, False, False, 0) if value: if isinstance(value, str): self.files = [value] else: self.files = value else: self.files = [] self.files_list_store = Gtk.ListStore(str) for filename in self.files: self.files_list_store.append([filename]) cell_renderer = Gtk.CellRendererText() files_treeview = Gtk.TreeView(self.files_list_store) files_column = Gtk.TreeViewColumn(_("Files"), cell_renderer, text=0) files_treeview.append_column(files_column) files_treeview.connect("key-press-event", self.on_files_treeview_keypress, option_name) treeview_scroll = Gtk.ScrolledWindow() treeview_scroll.set_min_content_height(130) treeview_scroll.set_margin_left(10) treeview_scroll.set_shadow_type(Gtk.ShadowType.ETCHED_IN) treeview_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) treeview_scroll.add(files_treeview) vbox.pack_start(treeview_scroll, True, True, 0) self.wrapper.pack_start(vbox, True, True, 0) self.option_widget = self.files_list_store def on_add_files_clicked(self, _widget, option_name, value): """Create and run multi-file chooser dialog.""" dialog = Gtk.FileChooserDialog( title=_("Select files"), parent=None, action=Gtk.FileChooserAction.OPEN, buttons=( _("_Cancel"), Gtk.ResponseType.CANCEL, _("_Add"), Gtk.ResponseType.ACCEPT, ), ) 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 wrapper = widget.get_parent() hbox = wrapper.get_parent() # Dirty way to get the reset btn. I tried passing it through the # methods but got some strange unreliable behavior. reset_btn = hbox.get_children()[1].get_children()[0] reset_btn.set_visible(True) self.set_style_property("font-weight", "bold", wrapper) def on_reset_button_clicked(self, btn, option, _widget, wrapper): """Clear option (remove from config, reset option widget).""" option_key = option["option"] current_value = self.config[option_key] btn.set_visible(False) self.set_style_property("font-weight", "normal", wrapper) self.raw_config.pop(option_key) self.lutris_config.update_cascaded_config() reset_value = self.config.get(option_key) if current_value == reset_value: return # Destroy and recreate option widget self.wrapper = wrapper children = wrapper.get_children() for child in children: child.destroy() self.call_widget_generator(option, option_key, reset_value, option.get("default")) self.wrapper.show_all() @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 GameBox(ConfigBox): def __init__(self, lutris_config, game): ConfigBox.__init__(self, game) self.lutris_config = lutris_config if game.runner_name: if not game.runner: try: self.runner = import_runner(game.runner_name)() except InvalidRunner: self.runner = None else: self.runner = game.runner if self.runner: self.options = self.runner.game_options else: logger.warning("No runner in game supplied to GameBox") self.generate_widgets("game") class RunnerBox(ConfigBox): """Configuration box for runner specific options""" def __init__(self, lutris_config, game=None): ConfigBox.__init__(self, game) self.lutris_config = lutris_config try: self.runner = import_runner(self.lutris_config.runner_slug)() except InvalidRunner: self.runner = None if self.runner: self.options = self.runner.get_runner_options() if lutris_config.level == "game": self.generate_top_info_box(_( "If modified, these options supersede the same options from " "the base runner configuration." )) self.generate_widgets("runner") class SystemBox(ConfigBox): def __init__(self, lutris_config): ConfigBox.__init__(self) self.lutris_config = lutris_config self.runner = None runner_slug = self.lutris_config.runner_slug if runner_slug: self.options = sysoptions.with_runner_overrides(runner_slug) else: self.options = sysoptions.system_options if lutris_config.game_config_id and runner_slug: self.generate_top_info_box(_( "If modified, these options supersede the same options from " "the base runner configuration, which themselves supersede " "the global preferences." )) elif runner_slug: self.generate_top_info_box(_( "If modified, these options supersede the same options from " "the global preferences." )) self.generate_widgets("system") lutris-0.5.9.1/lutris/gui/config/common.py000066400000000000000000000501361413267435700204440ustar00rootroot00000000000000"""Shared config dialog stuff""" # pylint: disable=not-an-iterable import os from gettext import gettext as _ from gi.repository import 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, SystemBox from lutris.gui.dialogs import Dialog, DirectoryDialog, ErrorDialog, QuestionDialog from lutris.gui.widgets.common import Label, NumberEntry, SlugEntry, VBox from lutris.gui.widgets.notifications import send_notification from lutris.gui.widgets.utils import BANNER_SIZE, ICON_SIZE, get_pixbuf, get_pixbuf_for_game from lutris.runners import import_runner from lutris.util import resources, system from lutris.util.log import logger from lutris.util.strings import slugify # pylint: disable=too-many-instance-attributes, no-member class GameDialogCommon(Dialog): """Base class for config dialogs""" no_runner_label = _("Select a runner in the Game Info tab") def __init__(self, title, parent=None): super().__init__(title, parent=parent) self.set_default_size(DIALOG_WIDTH, DIALOG_HEIGHT) self.notebook = None self.name_entry = None self.runner_box = None self.timer_id = None self.game = None self.saved = None self.slug = None self.slug_entry = None self.directory_entry = None self.year_entry = None self.slug_change_button = None self.runner_dropdown = None self.banner_button = None self.icon_button = None self.game_box = None self.system_box = None self.runner_name = None self.runner_index = None self.lutris_config = None @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.vbox.pack_start(self.notebook, True, True, 10) def build_tabs(self, config_level): """Build tabs (for game and runner levels)""" self.timer_id = None if config_level == "game": self._build_info_tab() self._build_game_tab() self._build_runner_tab(config_level) self._build_system_tab(config_level) def _build_info_tab(self): info_box = VBox() if self.game: info_box.pack_start(self._get_banner_box(), False, False, 6) # Banner info_box.pack_start(self._get_name_box(), False, False, 6) # Game name self.runner_box = self._get_runner_box() info_box.pack_start(self.runner_box, False, False, 6) # Runner info_box.pack_start(self._get_year_box(), False, False, 6) # Year if self.game: info_box.pack_start(self._get_slug_box(), False, False, 6) info_box.pack_start(self._get_directory_box(), False, False, 6) info_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() if self.game: self.name_entry.set_text(self.game.name) box.pack_start(self.name_entry, True, True, 0) return box def _get_slug_box(self): slug_box = Gtk.Box(spacing=12, margin_right=12, margin_left=12) label = Label(_("Identifier")) slug_box.pack_start(label, False, False, 0) self.slug_entry = SlugEntry() self.slug_entry.set_text(self.game.slug) self.slug_entry.set_sensitive(False) self.slug_entry.connect("activate", self.on_slug_entry_activate) slug_box.pack_start(self.slug_entry, True, True, 0) self.slug_change_button = Gtk.Button(_("Change")) self.slug_change_button.connect("clicked", self.on_slug_change_clicked) slug_box.pack_start(self.slug_change_button, False, False, 0) return slug_box def _get_directory_box(self): """Return widget displaying the location of the game and allowing to move it""" box = Gtk.Box(spacing=12, margin_right=12, margin_left=12, visible=True) label = Label(_("Directory")) box.pack_start(label, False, False, 0) self.directory_entry = Gtk.Entry(visible=True) self.directory_entry.set_text(self.game.directory) self.directory_entry.set_sensitive(False) box.pack_start(self.directory_entry, True, True, 0) move_button = Gtk.Button(_("Move"), visible=True) move_button.connect("clicked", self.on_move_clicked) box.pack_start(move_button, False, False, 0) return box def _get_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.Box(spacing=12, margin_right=12, margin_left=12) label = Label("") banner_box.pack_start(label, False, False, 0) self.banner_button = Gtk.Button() self._set_image("banner") self.banner_button.connect("clicked", self.on_custom_image_select, "banner") banner_box.pack_start(self.banner_button, False, False, 0) reset_banner_button = Gtk.Button.new_from_icon_name("edit-clear", Gtk.IconSize.MENU) reset_banner_button.set_relief(Gtk.ReliefStyle.NONE) reset_banner_button.set_tooltip_text(_("Remove custom banner")) reset_banner_button.connect("clicked", self.on_custom_image_reset_clicked, "banner") banner_box.pack_start(reset_banner_button, False, False, 0) self.icon_button = Gtk.Button() self._set_image("icon") self.icon_button.connect("clicked", self.on_custom_image_select, "icon") banner_box.pack_start(self.icon_button, False, False, 0) reset_icon_button = Gtk.Button.new_from_icon_name("edit-clear", Gtk.IconSize.MENU) reset_icon_button.set_relief(Gtk.ReliefStyle.NONE) reset_icon_button.set_tooltip_text(_("Remove custom icon")) reset_icon_button.connect("clicked", self.on_custom_image_reset_clicked, "icon") banner_box.pack_start(reset_icon_button, False, False, 0) return banner_box 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() if self.game: self.year_entry.set_text(str(self.game.year or "")) box.pack_start(self.year_entry, True, True, 0) return box def _set_image(self, image_format): image = Gtk.Image() size = BANNER_SIZE if image_format == "banner" else ICON_SIZE game_slug = self.game.slug if self.game else "" image.set_from_pixbuf(get_pixbuf_for_game(game_slug, size)) if image_format == "banner": self.banner_button.set_image(image) else: self.icon_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): self.slug = self.slug_entry.get_text() self.slug_entry.set_sensitive(False) self.slug_change_button.set_label(_("Change")) def on_move_clicked(self, _button): new_location = DirectoryDialog("Select new location for the game", default_path=self.game.directory) if not new_location.folder or new_location.folder == self.game.directory: return move_dialog = dialogs.MoveDialog(self.game, new_location.folder) 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.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): if self.game and self.runner_name: self.game.runner_name = self.runner_name if not self.game.runner or self.game.runner.name != self.runner_name: try: self.game.runner = runners.import_runner(self.runner_name)() except runners.InvalidRunner: pass self.game_box = GameBox(self.lutris_config, self.game) game_sw = self.build_scrolled_window(self.game_box) elif self.runner_name: game = Game(None) game.runner_name = self.runner_name self.game_box = GameBox(self.lutris_config, game) game_sw = self.build_scrolled_window(self.game_box) else: game_sw = Gtk.Label(label=self.no_runner_label) self._add_notebook_tab(game_sw, _("Game options")) def _build_runner_tab(self, _config_level): if self.runner_name: self.runner_box = RunnerBox(self.lutris_config, self.game) runner_sw = self.build_scrolled_window(self.runner_box) else: runner_sw = Gtk.Label(label=self.no_runner_label) self._add_notebook_tab(runner_sw, _("Runner options")) def _build_system_tab(self, _config_level): if not self.lutris_config: raise RuntimeError("Lutris config not loaded yet") self.system_box = SystemBox(self.lutris_config) self._add_notebook_tab( self.build_scrolled_window(self.system_box), _("System options") ) def _add_notebook_tab(self, widget, label): self.notebook.append_page(widget, Gtk.Label(label=label)) def build_action_area(self, button_callback): self.action_area.set_layout(Gtk.ButtonBoxStyle.EDGE) # Advanced settings checkbox checkbox = Gtk.CheckButton(label=_("Show advanced options")) if settings.read_setting("show_advanced_options") == "True": checkbox.set_active(True) checkbox.connect("toggled", self.on_show_advanced_options_toggled) self.action_area.pack_start(checkbox, False, False, 5) # Buttons hbox = Gtk.Box() cancel_button = Gtk.Button(label=_("Cancel")) cancel_button.connect("clicked", self.on_cancel_clicked) hbox.pack_start(cancel_button, True, True, 10) save_button = Gtk.Button(label=_("Save")) save_button.connect("clicked", button_callback) hbox.pack_start(save_button, True, True, 0) self.action_area.pack_start(hbox, True, True, 0) def on_show_advanced_options_toggled(self, checkbox): value = bool(checkbox.get_active()) settings.write_setting("show_advanced_options", value) self._set_advanced_options_visible(value) def _set_advanced_options_visible(self, value): """Change visibility of advanced options across all config tabs.""" widgets = self.system_box.get_children() if self.runner_name: widgets += self.runner_box.get_children() if self.game: widgets += self.game_box.get_children() for widget in widgets: if widget.get_style_context().has_class("advanced"): widget.set_visible(value) if value: widget.set_no_show_all(not value) widget.show_all() 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( { "question": _("Are you sure you want to change the runner for this game ? " "This will reset the full configuration for this game and " "is not reversible."), "title": _("Confirm runner change"), } ) if dlg.result == Gtk.ResponseType.YES: self.runner_index = new_runner_index self._switch_runner(widget) else: # Revert the dropdown menu to the previously selected runner widget.set_active(self.runner_index) else: self.runner_index = new_runner_index self._switch_runner(widget) def _switch_runner(self, widget): """Rebuilds the UI on runner change""" current_page = self.notebook.get_current_page() if self.runner_index == 0: logger.info("No runner selected, resetting configuration") self.runner_name = None self.lutris_config = None else: runner_name = widget.get_model()[self.runner_index][1] if runner_name == self.runner_name: logger.debug("Runner unchanged, not creating a new config") return logger.info("Creating new configuration with runner %s", runner_name) self.runner_name = runner_name self.lutris_config = LutrisConfig(runner_slug=self.runner_name, level="game") self._rebuild_tabs() self.notebook.set_current_page(current_page) def _rebuild_tabs(self): for i in range(self.notebook.get_n_pages(), 1, -1): self.notebook.remove_page(i - 1) self._build_game_tab() self._build_runner_tab("game") self._build_system_tab("game") self.show_all() def on_cancel_clicked(self, _widget=None, _event=None): """Dialog destroy callback.""" if self.game: self.game.load_config() self.destroy() def is_valid(self): if not self.runner_name: ErrorDialog(_("Runner not provided")) return False if not self.name_entry.get_text(): ErrorDialog(_("Please fill in the name")) return False if (self.runner_name in ("steam", "winesteam") and self.lutris_config.game_config.get("appid") is None): ErrorDialog(_("Steam AppId not provided")) 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)) return False return True def on_save(self, _button): """Save game info and destroy widget. Return True if success.""" if not self.is_valid(): logger.warning(_("Current configuration is not valid, ignoring save request")) return False name = self.name_entry.get_text() if not self.slug: self.slug = slugify(name) if not self.game: self.game = Game() year = None if self.year_entry.get_text(): year = int(self.year_entry.get_text()) if not self.lutris_config.game_config_id: self.lutris_config.game_config_id = make_game_config_id(self.slug) runner_class = runners.import_runner(self.runner_name) runner = runner_class(self.lutris_config) self.game.name = name self.game.slug = self.slug self.game.year = year self.game.game_config_id = self.lutris_config.game_config_id self.game.runner = runner self.game.runner_name = self.runner_name self.game.is_installed = True self.game.config = self.lutris_config self.game.save(save_config=True) self.destroy() self.saved = True return True def on_custom_image_select(self, _widget, image_type): dialog = Gtk.FileChooserDialog( _("Please choose a custom image"), self, Gtk.FileChooserAction.OPEN, ( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK, ), ) 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.OK: image_path = dialog.get_filename() if image_type == "banner": self.game.has_custom_banner = True dest_path = os.path.join(settings.BANNER_PATH, "%s.jpg" % self.game.slug) size = BANNER_SIZE file_format = "jpeg" else: self.game.has_custom_icon = True dest_path = resources.get_icon_path(self.game.slug) size = ICON_SIZE file_format = "png" pixbuf = get_pixbuf(image_path, size) pixbuf.savev(dest_path, file_format, [], []) self._set_image(image_type) if image_type == "icon": system.update_desktop_icons() dialog.destroy() def on_custom_image_reset_clicked(self, _widget, image_type): if image_type == "banner": self.game.has_custom_banner = False dest_path = os.path.join(settings.BANNER_PATH, "%s.jpg" % self.game.slug) elif image_type == "icon": self.game.has_custom_icon = False dest_path = resources.get_icon_path(self.game.slug) else: raise ValueError("Unsupported image type %s" % image_type) os.remove(dest_path) self._set_image(image_type) lutris-0.5.9.1/lutris/gui/config/edit_game.py000066400000000000000000000011451413267435700210660ustar00rootroot00000000000000from gettext import gettext as _ from lutris.gui.config.common import GameDialogCommon class EditGameConfigDialog(GameDialogCommon): """Game config edit dialog.""" def __init__(self, parent, game): super().__init__(_("Configure %s") % game.name, parent=parent) self.game = game self.lutris_config = game.config self.slug = game.slug self.runner_name = game.runner_name self.build_notebook() self.build_tabs("game") self.build_action_area(self.on_save) self.connect("delete-event", self.on_cancel_clicked) self.show_all() lutris-0.5.9.1/lutris/gui/config/preferences_box.py000066400000000000000000000041551413267435700223250ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris import settings from lutris.gui.widgets.common import VBox class PreferencesBox(VBox): settings_options = { "hide_client_on_game_start": _("Minimize client when a game is launched"), "hide_text_under_icons": _("Hide text under icons (requires restart)"), "show_tray_icon": _("Show Tray Icon (requires restart)"), "dark_theme": _("Use dark theme (requires dark theme variant for Gtk)") } def _get_section_label(self, text): label = Gtk.Label(visible=True) label.set_markup("%s" % text) label.set_alignment(0, 0.5) return label def __init__(self): super().__init__(visible=True) self.set_margin_top(50) self.set_margin_bottom(50) self.set_margin_right(80) self.set_margin_left(80) self.add(self._get_section_label(_("Interface options"))) listbox = Gtk.ListBox(visible=True) self.pack_start(listbox, False, False, 12) for setting_key, label in self.settings_options.items(): list_box_row = Gtk.ListBoxRow(visible=True) list_box_row.set_selectable(False) list_box_row.set_activatable(False) list_box_row.add(self._get_setting_box(setting_key, label)) listbox.add(list_box_row) def _get_setting_box(self, setting_key, label): box = Gtk.Box( spacing=12, margin_top=12, margin_bottom=12, visible=True ) label = Gtk.Label(label, visible=True) label.set_alignment(0, 0.5) box.pack_start(label, True, True, 12) checkbox = Gtk.Switch(visible=True) if settings.read_setting(setting_key).lower() == "true": checkbox.set_active(True) checkbox.connect("state-set", self._on_setting_change, setting_key) box.pack_start(checkbox, False, False, 12) return box def _on_setting_change(self, widget, state, setting_key): """Save a setting when an option is toggled""" settings.write_setting(setting_key, state) lutris-0.5.9.1/lutris/gui/config/preferences_dialog.py000066400000000000000000000067171413267435700230020ustar00rootroot00000000000000"""Configuration dialog for client and system options""" from gettext import gettext as _ from gi.repository import Gtk from lutris.config import LutrisConfig from lutris.gui.config.boxes import SystemBox from lutris.gui.config.common import GameDialogCommon from lutris.gui.config.preferences_box import PreferencesBox from lutris.gui.config.runners_box import RunnersBox from lutris.gui.config.services_box import ServicesBox from lutris.gui.config.sysinfo_box import SysInfoBox # pylint: disable=no-member class PreferencesDialog(GameDialogCommon): def __init__(self, parent=None): super().__init__(_("Lutris settings"), parent=parent) self.set_border_width(0) self.set_default_size(960, 600) self.lutris_config = LutrisConfig() 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", "Services", "application-x-addon-symbolic")) sidebar.add(self.get_sidebar_button("sysinfo-stack", "Hardware information", "computer-symbolic")) sidebar.add(self.get_sidebar_button("system-stack", "Global options", "emblem-system-symbolic")) hbox.pack_start(sidebar, False, False, 0) self.stack = Gtk.Stack(visible=True) self.stack.set_vhomogeneous(False) self.stack.set_interpolate_size(True) hbox.add(self.stack) self.vbox.pack_start(hbox, True, True, 0) self.stack.add_named( self.build_scrolled_window(PreferencesBox()), "prefs-stack" ) self.stack.add_named( self.build_scrolled_window(RunnersBox()), "runners-stack" ) self.stack.add_named( self.build_scrolled_window(ServicesBox()), "services-stack" ) self.stack.add_named( self.build_scrolled_window(SysInfoBox()), "sysinfo-stack" ) self.system_box = SystemBox(self.lutris_config) self.system_box.show_all() self.stack.add_named( self.build_scrolled_window(self.system_box), "system-stack" ) self.build_action_area(self.on_save) self.action_area.set_margin_bottom(12) self.action_area.set_margin_right(12) self.action_area.set_margin_left(12) self.action_area.set_margin_top(12) def on_sidebar_activated(self, _listbox, row): if row.get_children()[0].stack_id == "system-stack": self.action_area.show_all() else: self.action_area.hide() self.stack.set_visible_child_name(row.get_children()[0].stack_id) 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() lutris-0.5.9.1/lutris/gui/config/runner.py000066400000000000000000000013201413267435700204540ustar00rootroot00000000000000from gettext import gettext as _ from lutris.config import LutrisConfig from lutris.gui.config.common import GameDialogCommon class RunnerConfigDialog(GameDialogCommon): """Runner config edit dialog.""" def __init__(self, runner, parent=None): super().__init__(_("Configure %s") % runner.human_name, parent=parent) self.runner_name = runner.__class__.__name__ self.saved = False self.lutris_config = LutrisConfig(runner_slug=self.runner_name) self.build_notebook() self.build_tabs("runner") self.build_action_area(self.on_save) self.show_all() def on_save(self, wigdet, data=None): self.lutris_config.save() self.destroy() lutris-0.5.9.1/lutris/gui/config/runner_box.py000066400000000000000000000127751413267435700213440ustar00rootroot00000000000000from 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 ErrorDialog, QuestionDialog from lutris.gui.dialogs.download import simple_downloader from lutris.gui.dialogs.runner_install import RunnerInstallDialog from lutris.gui.widgets.utils import ICON_SIZE, get_icon 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)() icon = get_icon(self.runner.name, icon_format='pixbuf', size=ICON_SIZE) if icon: runner_icon = Gtk.Image(visible=True) runner_icon.set_from_pixbuf(icon) else: runner_icon = Gtk.Image.new_from_icon_name("package-x-generic-symbolic", Gtk.IconSize.DND) runner_icon.show() 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_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_margin_right(12) self.configure_button.connect("clicked", self.on_configure_clicked) self.configure_button.show() self.pack_start(self.configure_button, False, False, 0) if not self.runner.is_installed(): self.runner_label_box.set_sensitive(False) 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("preferences-other-symbolic", Gtk.IconSize.BUTTON) _button.get_style_context().add_class("circular") _button.connect("clicked", self.on_versions_clicked) else: if self.runner.is_installed(): _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) else: _button = Gtk.Button.new_from_icon_name("system-software-install-symbolic", Gtk.IconSize.BUTTON) _button.get_style_context().add_class("circular") _button.connect("clicked", self.on_install_clicked) _button.show() return _button def on_versions_clicked(self, widget): RunnerInstallDialog( _("Manage %s versions") % self.runner.name, None, self.runner.name ) # connect a runner-installed signal from the above dialog? def on_install_clicked(self, widget): """Install a runner.""" logger.debug("Install of %s requested", self.runner) try: self.runner.install(downloader=simple_downloader) except ( runners.RunnerInstallationError, runners.NonInstallableRunnerError, ) as ex: logger.error(ex) ErrorDialog(ex.message) return if self.runner.is_installed(): self.emit("runner-installed") else: logger.error("Runner failed to install") def on_configure_clicked(self, widget): RunnerConfigDialog(self.runner) def on_remove_clicked(self, widget): dialog = QuestionDialog( { "title": _("Do you want to uninstall %s?") % self.runner.human_name, "question": _("This will remove %s and all associated data." % self.runner.human_name) } ) if Gtk.ResponseType.YES == dialog.result: self.runner.uninstall() self.emit("runner-removed") def on_runner_installed(self, widget): """Called after the runnner is installed""" self.runner_label_box.set_sensitive(True) self.configure_button.show() 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.configure_button.hide() self.action_alignment.get_children()[0].destroy() self.action_alignment.add(self.get_action_button()) lutris-0.5.9.1/lutris/gui/config/runners_box.py000066400000000000000000000024321413267435700215140ustar00rootroot00000000000000"""Add, remove and configure runners""" from gettext import gettext as _ from gi.repository import GLib, 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.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.runner_listbox = Gtk.ListBox(visible=True) self.pack_start(self.runner_listbox, False, False, 12) GLib.idle_add(self.populate_runners) def populate_runners(self): 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) @staticmethod def on_folder_clicked(_widget): open_uri("file://" + settings.RUNNER_DIR) lutris-0.5.9.1/lutris/gui/config/services_box.py000066400000000000000000000051151413267435700216440ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import GLib, GObject, Gtk from lutris import settings from lutris.gui.config.base_config_box import BaseConfigBox from lutris.gui.widgets.utils import ICON_SIZE, get_icon 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.listbox = Gtk.ListBox(visible=True) self.pack_start(self.listbox, False, False, 12) GLib.idle_add(self.populate_services) def populate_services(self): for service_key in SERVICES: list_box_row = Gtk.ListBoxRow(visible=True) list_box_row.set_selectable(False) list_box_row.set_activatable(False) list_box_row.add(self._get_service_box(service_key)) self.listbox.add(list_box_row) def _get_service_box(self, service_key): box = Gtk.Box( spacing=12, margin_right=12, margin_left=12, margin_top=12, margin_bottom=12, visible=True, ) service = SERVICES[service_key] pixbuf = get_icon(service.icon, icon_format="pixbuf", size=ICON_SIZE) if pixbuf: icon = Gtk.Image(visible=True) icon.set_from_pixbuf(pixbuf) else: icon = Gtk.Image.new_from_icon_name(service.id, Gtk.IconSize.DND) icon.show() box.pack_start(icon, False, False, 0) label = Gtk.Label(service.name, visible=True) label.set_alignment(0, 0.5) box.pack_start(label, 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.9.1/lutris/gui/config/sysinfo_box.py000066400000000000000000000033431413267435700215140ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gdk, Gtk from lutris.gui.widgets.log_text_view import LogTextView from lutris.util.linux import gather_system_info_str class SysInfoBox(Gtk.Fixed): settings_options = { "hide_client_on_game_start": _("Minimize client when a game is launched"), "hide_text_under_icons": _("Hide text under icons"), "show_tray_icon": _("Show Tray Icon"), } def __init__(self): super().__init__(visible=True) self.set_margin_top(40) self.set_margin_right(30) self.set_margin_left(30) sysinfo_frame = Gtk.Frame(visible=True) sysinfo_frame.set_size_request(550, 455) scrolled_window = Gtk.ScrolledWindow(visible=True) scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) sysinfo_view = LogTextView(autoscroll=False) sysinfo_view.set_cursor_visible(False) scrolled_window.add(sysinfo_view) sysinfo_frame.add(scrolled_window) sysinfo_str = gather_system_info_str() text_buffer = sysinfo_view.get_buffer() text_buffer.set_text(sysinfo_str) self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) self._clipboard_buffer = sysinfo_str button_copy = Gtk.Button(_("Copy to clipboard"), visible=True) button_copy.connect("clicked", self._copy_text) sysinfo_label = Gtk.Label(visible=True) sysinfo_label.set_markup("System information") self.put(sysinfo_label, 60, 0) self.put(sysinfo_frame, 60, 24) self.put(button_copy, 60, 486) def _copy_text(self, widget): # pylint: disable=unused-argument self.clipboard.set_text(self._clipboard_buffer, -1) lutris-0.5.9.1/lutris/gui/dialogs/000077500000000000000000000000001413267435700167525ustar00rootroot00000000000000lutris-0.5.9.1/lutris/gui/dialogs/__init__.py000066400000000000000000000323501413267435700210660ustar00rootroot00000000000000"""Commonly used dialogs""" import os from gettext import gettext as _ from gi.repository import GLib, GObject, Gtk from lutris import api, settings from lutris.gui.widgets.log_text_view import LogTextView from lutris.util import datapath from lutris.util.jobs import AsyncCall from lutris.util.log import logger class Dialog(Gtk.Dialog): def __init__(self, title=None, parent=None, flags=0, buttons=None): super().__init__(title, parent, flags, buttons) self.set_border_width(10) self.connect("delete-event", self.on_destroy) self.set_destroy_with_parent(True) def on_destroy(self, _widget, _data=None): self.destroy() 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, parent=None): super().__init__(buttons=Gtk.ButtonsType.OK, parent=parent) self.set_markup(message) self.run() self.destroy() class ErrorDialog(Gtk.MessageDialog): """Display an error message.""" def __init__(self, message, secondary=None, parent=None): super().__init__(buttons=Gtk.ButtonsType.OK, parent=parent) # Gtk doesn't wrap long labels containing no space correctly # the length of the message is limited to avoid display issues self.set_markup(message[:256]) if secondary: self.format_secondary_text(secondary[:256]) self.run() self.destroy() class QuestionDialog(Gtk.MessageDialog): """Ask the user a 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 "widgets" in dialog_settings: for widget in dialog_settings["widgets"]: self.get_message_area().add(widget) self.result = self.run() self.destroy() class DirectoryDialog(Gtk.FileChooserDialog): """Ask the user to select a directory.""" def __init__(self, message, default_path=None, parent=None): super().__init__( title=message, action=Gtk.FileChooserAction.SELECT_FOLDER, buttons=(_("_Cancel"), Gtk.ResponseType.CLOSE, _("_OK"), Gtk.ResponseType.OK), parent=parent, ) if default_path: self.set_current_folder(default_path) self.result = self.run() self.folder = self.get_current_folder() self.destroy() class FileDialog(Gtk.FileChooserDialog): """Ask the user to select a file.""" def __init__(self, message=None, default_path=None, mode="open"): self.filename = None if not message: message = _("Please choose a file") if mode == "save": action = Gtk.FileChooserAction.SAVE else: action = Gtk.FileChooserAction.OPEN super().__init__( message, None, action, (_("_Cancel"), Gtk.ResponseType.CANCEL, _("_OK"), Gtk.ResponseType.OK), ) if default_path and os.path.exists(default_path): self.set_current_folder(default_path) self.set_local_only(False) response = self.run() if response == Gtk.ResponseType.OK: self.filename = self.get_filename() self.destroy() class LutrisInitDialog(Gtk.Dialog): def __init__(self, init_lutris): super().__init__() self.set_size_request(320, 60) self.set_border_width(24) self.set_decorated(False) vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 12) label = Gtk.Label(_("Checking for runtime updates, please wait…")) vbox.add(label) self.progress = Gtk.ProgressBar(visible=True) self.progress.set_pulse_step(0.1) vbox.add(self.progress) self.get_content_area().add(vbox) GLib.timeout_add(125, self.show_progress) self.show_all() AsyncCall(self.initialize, self.init_cb, init_lutris) def show_progress(self): self.progress.pulse() return True def initialize(self, init_lutris, *args): init_lutris() def init_cb(self, _result, error): if error: ErrorDialog(str(error)) self.destroy() class InstallOrPlayDialog(Gtk.Dialog): def __init__(self, game_name): Gtk.Dialog.__init__(self, _("%s is already installed") % game_name) self.connect("delete-event", lambda *x: self.destroy()) self.action = "play" self.action_confirmed = False self.set_size_request(320, 120) self.set_border_width(12) 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) confirm_button = Gtk.Button(_("OK")) confirm_button.connect("clicked", self.on_confirm) vbox.pack_start(confirm_button, False, False, 0) self.show_all() self.run() def on_button_toggled(self, button, action): # pylint: disable=unused-argument logger.debug("Action set to %s", action) self.action = action def on_confirm(self, button): # pylint: disable=unused-argument logger.debug("Action %s confirmed", self.action) self.action_confirmed = True self.destroy() class ClientLoginDialog(GtkBuilderDialog): glade_file = "dialog-lutris-login.ui" dialog_object = "lutris-login" __gsignals__ = { "connected": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT, )), "cancel": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT, )), } def __init__(self, parent): super().__init__(parent=parent) self.parent = parent self.username_entry = self.builder.get_object("username_entry") self.password_entry = self.builder.get_object("password_entry") cancel_button = self.builder.get_object("cancel_button") cancel_button.connect("clicked", self.on_close) connect_button = self.builder.get_object("connect_button") connect_button.connect("clicked", self.on_connect) def get_credentials(self): username = self.username_entry.get_text() password = self.password_entry.get_text() return username, password def on_username_entry_activate(self, widget): # pylint: disable=unused-argument if all(self.get_credentials()): self.on_connect(None) else: self.password_entry.grab_focus() def on_password_entry_activate(self, widget): # pylint: disable=unused-argument if all(self.get_credentials()): self.on_connect(None) else: self.username_entry.grab_focus() def on_connect(self, widget): # pylint: disable=unused-argument username, password = self.get_credentials() token = api.connect(username, password) if not token: NoticeDialog(_("Login failed"), parent=self.parent) else: self.emit("connected", username) self.dialog.destroy() class InstallerSourceDialog(Gtk.Dialog): """Show install script source""" def __init__(self, code, name, parent): Gtk.Dialog.__init__(self, _("Install script for {}").format(name), parent=parent) self.set_size_request(500, 350) self.set_border_width(0) 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().add(self.scrolled_window) self.scrolled_window.add(source_box) close_button = Gtk.Button(_("OK")) close_button.connect("clicked", self.on_close) self.get_content_area().add(close_button) self.show_all() def on_close(self, *args): # pylint: disable=unused-argument self.destroy() class DontShowAgainDialog(Gtk.MessageDialog): """Display a message to the user and offer an option not to display this dialog again.""" def __init__( self, setting, message, secondary_message=None, parent=None, checkbox_message=None, ): # pylint: disable=no-member if settings.read_setting(setting) == "True": logger.info("Dialog %s dismissed by user", setting) return super().__init__(type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.OK, parent=parent) self.set_border_width(12) self.set_markup("%s" % message) if secondary_message: self.props.secondary_use_markup = True self.props.secondary_text = secondary_message if not checkbox_message: checkbox_message = _("Do not display this message again.") dont_show_checkbutton = Gtk.CheckButton(checkbox_message) dont_show_checkbutton.props.halign = Gtk.Align.CENTER dont_show_checkbutton.show() content_area = self.get_content_area() content_area.pack_start(dont_show_checkbutton, False, False, 0) self.run() if dont_show_checkbutton.get_active(): settings.write_setting(setting, True) self.destroy() class WineNotInstalledWarning(DontShowAgainDialog): """Display a warning if Wine is not detected on the system""" def __init__(self, parent=None): super().__init__( "hide-wine-systemwide-install-warning", _("Wine is not installed on your system."), secondary_message=_( "Having Wine installed on your system guarantees that " "Wine builds from Lutris will have all required dependencies.\n\nPlease " "follow the instructions given in the Lutris Wiki to " "install Wine." ), parent=parent, ) class MoveDialog(Gtk.Dialog): __gsignals__ = { "game-moved": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, game, destination): super().__init__() self.game = game self.destination = destination self.new_directory = None self.set_size_request(320, 60) self.set_border_width(24) self.set_decorated(False) vbox = Gtk.Box.new(Gtk.Orientation.VERTICAL, 12) label = Gtk.Label(_("Moving %s to %s..." % (game, destination))) vbox.add(label) self.progress = Gtk.ProgressBar(visible=True) self.progress.set_pulse_step(0.1) vbox.add(self.progress) self.get_content_area().add(vbox) GLib.timeout_add(125, self.show_progress) self.show_all() def move(self): AsyncCall(self._move_game, self.on_game_moved) def show_progress(self): self.progress.pulse() return True def _move_game(self): self.new_directory = self.game.move(self.destination) def on_game_moved(self, _result, error): if error: ErrorDialog(str(error)) self.emit("game-moved") self.destroy() lutris-0.5.9.1/lutris/gui/dialogs/cache.py000066400000000000000000000037311413267435700203730ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import GLib, Gtk from lutris.cache import get_cache_path, save_cache_path from lutris.gui.widgets.common import FileChooserEntry class CacheConfigurationDialog(Gtk.Dialog): def __init__(self): Gtk.Dialog.__init__(self, _("Cache configuration")) self.timer_id = None self.set_size_request(480, 150) self.set_border_width(12) self.get_content_area().add(self.get_cache_config()) self.show_all() 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) cache_path = get_cache_path() path_chooser = FileChooserEntry( title=_("Set the folder for the cache path"), action=Gtk.FileChooserAction.SELECT_FOLDER, path=cache_path ) path_chooser.entry.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): if self.timer_id: GLib.source_remove(self.timer_id) self.timer_id = GLib.timeout_add(1000, self.save_cache_setting, entry.get_text()) def save_cache_setting(self, value): save_cache_path(value) GLib.source_remove(self.timer_id) self.timer_id = None return False lutris-0.5.9.1/lutris/gui/dialogs/download.py000066400000000000000000000031661413267435700211410ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris.gui.widgets.download_progress_box import DownloadProgressBox class DownloadDialog(Gtk.Dialog): """Dialog showing a download in progress.""" def __init__(self, url=None, dest=None, title=None, label=None, downloader=None): Gtk.Dialog.__init__(self, title or _("Downloading file")) self.set_size_request(485, 104) self.set_border_width(12) params = {"url": url, "dest": dest, "title": label or _("Downloading %s") % url} self.dialog_progress_box = DownloadProgressBox(params, downloader=downloader) self.dialog_progress_box.connect("complete", self.download_complete) self.dialog_progress_box.connect("cancel", self.download_cancelled) self.connect("response", self.on_response) self.get_content_area().add(self.dialog_progress_box) self.show_all() self.dialog_progress_box.start() def download_complete(self, _widget, _data): self.response(Gtk.ResponseType.OK) self.destroy() def download_cancelled(self, _widget, data): self.response(Gtk.ResponseType.CANCEL) self.destroy() def on_response(self, _dialog, response): if response == Gtk.ResponseType.DELETE_EVENT: self.dialog_progress_box.downloader.cancel() self.destroy() def simple_downloader(url, destination, callback, callback_args=None): """Basic downloader with a DownloadDialog""" if not callback_args: callback_args = {} dialog = DownloadDialog(url, destination) dialog.run() return callback(**callback_args) lutris-0.5.9.1/lutris/gui/dialogs/issue.py000066400000000000000000000063311413267435700204570ustar00rootroot00000000000000"""GUI dialog for reporting issues""" # Standard Library import json import os from gettext import gettext as _ # Third Party Libraries from gi.repository import Gtk # Lutris Modules from lutris.gui.dialogs import NoticeDialog from lutris.gui.widgets.window import BaseApplicationWindow from lutris.util.linux import gather_system_info class IssueReportWindow(BaseApplicationWindow): """Window for collecting and sending issue reports""" def __init__(self, application): super().__init__(application) self.title_label = Gtk.Label(visible=True) self.vbox.add(self.title_label) title_label = Gtk.Label() title_label.set_markup(_("Submit an issue")) self.vbox.add(title_label) self.vbox.add(Gtk.HSeparator()) issue_entry_label = Gtk.Label(_( "Describe the problem you're having in the text box below. " "This information will be sent the Lutris team along with your system information. " "You can also save this information locally if you are offline." )) issue_entry_label.set_max_width_chars(80) issue_entry_label.set_property("wrap", True) self.vbox.add(issue_entry_label) self.textview = Gtk.TextView() self.textview.set_pixels_above_lines(12) self.textview.set_pixels_below_lines(12) self.textview.set_left_margin(12) self.textview.set_right_margin(12) self.vbox.pack_start(self.textview, True, True, 0) self.action_buttons = Gtk.Box(spacing=6) action_buttons_alignment = Gtk.Alignment.new(1, 0, 0, 0) action_buttons_alignment.add(self.action_buttons) self.vbox.pack_start(action_buttons_alignment, False, True, 0) cancel_button = self.get_action_button(_("C_ancel"), handler=self.on_destroy) self.action_buttons.add(cancel_button) save_button = self.get_action_button(_("_Save"), handler=self.on_save) self.action_buttons.add(save_button) self.show_all() def get_issue_info(self): buffer = self.textview.get_buffer() return { 'comment': buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), True), 'system': gather_system_info() } def on_save(self, _button): """Signal handler for the save button""" save_dialog = Gtk.FileChooserDialog( title=_("Select a location to save the issue"), transient_for=self, action=Gtk.FileChooserAction.SELECT_FOLDER, buttons=(_("_Cancel"), Gtk.ResponseType.CLOSE, _("_OK"), Gtk.ResponseType.OK), ) save_dialog.connect("response", self.on_folder_selected) save_dialog.run() def on_folder_selected(self, dialog, response): if response != Gtk.ResponseType.OK: return target_path = dialog.get_current_folder() 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") 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.9.1/lutris/gui/dialogs/log.py000066400000000000000000000045041413267435700201100ustar00rootroot00000000000000"""Window to show game logs""" import os from datetime import datetime 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, title=None, buffer=None, 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) window = builder.get_object("log_window") window.set_title(title) self.title = 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) window.connect("key-press-event", self.on_key_press_event) 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") as log_file: log_file.write(text) lutris-0.5.9.1/lutris/gui/dialogs/runner_install.py000066400000000000000000000260221413267435700223650ustar00rootroot00000000000000"""Dialog used to install versions of a runner""" # pylint: disable=no-member import gettext import os import random from collections import defaultdict from gettext import gettext as _ from gi.repository import GLib, Gtk from lutris import api, settings from lutris.database.games import get_games_by_runner from lutris.game import Game from lutris.gui.dialogs import Dialog, ErrorDialog, QuestionDialog from lutris.util import jobs, system from lutris.util.downloader import Downloader from lutris.util.extract import extract_archive from lutris.util.log import logger class RunnerInstallDialog(Dialog): """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 def __init__(self, title, parent, runner): super().__init__(title, parent, 0) self.add_buttons(_("_OK"), Gtk.ButtonsType.OK) self.runner = runner self.runner_info = {} self.installing = {} self.set_default_size(640, 480) self.renderer_progress = Gtk.CellRendererProgress() label = Gtk.Label.new(_("Waiting for response from %s") % (settings.SITE_URL)) self.vbox.pack_start(label, False, False, 18) spinner = Gtk.Spinner(visible=True) spinner.start() self.vbox.pack_start(spinner, False, False, 18) self.show_all() self.runner_store = Gtk.ListStore(str, str, str, bool, int, str) jobs.AsyncCall(api.get_runners, self.runner_fetch_cb, self.runner) def runner_fetch_cb(self, runner_info, error): """Clear the box and display versions from runner_info""" if error: logger.error(error) ErrorDialog(_("Unable to get runner versions: %s") % error) return self.runner_info = runner_info remote_versions = {(v["version"], v["architecture"]) for v in self.runner_info["versions"]} local_versions = self.get_installed_versions() for local_version in local_versions - remote_versions: self.runner_info["versions"].append({ "version": local_version[0], "architecture": local_version[1], "url": "", }) if not self.runner_info: ErrorDialog(_("Unable to get runner versions from lutris.net")) return for child_widget in self.vbox.get_children(): if child_widget.get_name() not in "GtkBox": child_widget.destroy() self.populate_store() label = Gtk.Label.new(_("%s version management") % self.runner_info["name"]) self.vbox.add(label) scrolled_window = Gtk.ScrolledWindow() treeview = self.get_treeview(self.runner_store) self.installing = {} self.connect("response", self.on_destroy) scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled_window.set_shadow_type(Gtk.ShadowType.ETCHED_OUT) scrolled_window.add(treeview) self.vbox.pack_start(scrolled_window, True, True, 14) self.show_all() def get_treeview(self, model): """Return TreeeView widget""" treeview = Gtk.TreeView(model=model) treeview.set_headers_visible(False) renderer_toggle = Gtk.CellRendererToggle() renderer_text = Gtk.CellRendererText() installed_column = Gtk.TreeViewColumn(None, renderer_toggle, active=3) renderer_toggle.connect("toggled", self.on_installed_toggled) treeview.append_column(installed_column) version_column = Gtk.TreeViewColumn(None, renderer_text) version_column.add_attribute(renderer_text, "text", self.COL_VER) version_column.set_property("min-width", 80) treeview.append_column(version_column) arch_column = Gtk.TreeViewColumn(None, renderer_text, text=self.COL_ARCH) arch_column.set_property("min-width", 50) treeview.append_column(arch_column) progress_column = Gtk.TreeViewColumn( None, self.renderer_progress, value=self.COL_PROGRESS, visible=self.COL_PROGRESS, ) progress_column.set_property("fixed-width", 120) progress_column.set_property("min-width", 120) progress_column.set_property("resizable", True) treeview.append_column(progress_column) usage_column = Gtk.TreeViewColumn(None, renderer_text, text=self.COL_USAGE) usage_column.set_property("min-width", 150) treeview.append_column(usage_column) return treeview def populate_store(self): """Return a ListStore populated with the runner versions""" version_usage = self.get_usage_stats() for version_info in reversed(self.runner_info["versions"]): is_installed = os.path.exists(self.get_runner_path(version_info["version"], version_info["architecture"])) games_using = version_usage.get("%(version)s-%(architecture)s" % version_info) usage_summary = gettext.ngettext( "In use by %d game", "In use by %d games", len(games_using)) % len(games_using) if games_using else _("Not in use") self.runner_store.append( [ version_info["version"], version_info["architecture"], version_info["url"], is_installed, 0, usage_summary if is_installed else "" ] ) def get_installed_versions(self): """List versions available locally""" runner_path = os.path.join(settings.RUNNER_DIR, self.runner) if not os.path.exists(runner_path): return set() return { tuple(p.rsplit("-", 1)) for p in os.listdir(runner_path) if "-" in p } def get_runner_path(self, version, arch): """Return the local path where the runner is/will be installed""" return os.path.join(settings.RUNNER_DIR, self.runner, "{}-{}".format(version, arch)) def get_dest_path(self, row): """Return temporary path where the runners should be downloaded to""" return os.path.join(settings.CACHE_DIR, os.path.basename(row[self.COL_URL])) def on_installed_toggled(self, _widget, path): row = self.runner_store[path] if row[self.COL_VER] in self.installing: confirm_dlg = QuestionDialog( { "question": _("Do you want to cancel the download?"), "title": _("Download starting"), } ) if confirm_dlg.result == confirm_dlg.YES: self.cancel_install(row) elif row[self.COL_INSTALLED]: self.uninstall_runner(row) else: self.install_runner(row) def cancel_install(self, row): """Cancel the installation of a runner version""" self.installing[row[self.COL_VER]].cancel() self.uninstall_runner(row) row[self.COL_PROGRESS] = 0 self.installing.pop(row[self.COL_VER]) def uninstall_runner(self, row): """Uninstall a runner version""" version = row[self.COL_VER] arch = row[self.COL_ARCH] system.remove_folder(self.get_runner_path(version, arch)) row[self.COL_INSTALLED] = False if self.runner == "wine": logger.debug("Clearing wine version cache") from lutris.util.wine.wine import get_wine_versions get_wine_versions.cache_clear() def install_runner(self, row): """Download and install a runner version""" dest_path = self.get_dest_path(row) url = row[self.COL_URL] if not url: ErrorDialog("Version %s is not longer available" % row[self.COL_VER]) return downloader = Downloader(row[self.COL_URL], dest_path, overwrite=True) GLib.timeout_add(100, self.get_progress, downloader, row) self.installing[row[self.COL_VER]] = downloader downloader.start() def get_progress(self, downloader, row): """Update progress bar with download progress""" if downloader.state == downloader.CANCELLED: return False if downloader.state == downloader.ERROR: self.cancel_install(row) return False downloader.check_progress() percent_downloaded = downloader.progress_percentage if percent_downloaded >= 1: row[self.COL_PROGRESS] = percent_downloaded self.renderer_progress.props.pulse = -1 self.renderer_progress.props.text = "%d %%" % int(percent_downloaded) else: row[self.COL_PROGRESS] = 1 self.renderer_progress.props.pulse = random.randint(1, 100) self.renderer_progress.props.text = _("Downloading…") if downloader.state == downloader.COMPLETED: row[self.COL_PROGRESS] = 99 self.renderer_progress.props.text = _("Extracting…") self.on_runner_downloaded(row) return False return True def get_usage_stats(self): """Return the usage for each version""" runner_games = get_games_by_runner(self.runner) version_usage = defaultdict(list) for db_game in runner_games: if not db_game["installed"]: continue game = Game(db_game["id"]) version = game.config.runner_config["version"] version_usage[version].append(db_game["id"]) return version_usage def on_runner_downloaded(self, row): """Handler called when a runner version is downloaded""" version = row[self.COL_VER] architecture = row[self.COL_ARCH] logger.debug("Runner %s for %s has finished downloading", version, architecture) src = self.get_dest_path(row) dst = self.get_runner_path(version, architecture) 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 os.remove(src) row[self.COL_PROGRESS] = 0 row[self.COL_INSTALLED] = True self.renderer_progress.props.text = "" self.installing.pop(row[self.COL_VER]) if self.runner == "wine": logger.debug("Clearing wine version cache") from lutris.util.wine.wine import get_wine_versions get_wine_versions.cache_clear() def on_destroy(self, _dialog, _data=None): """Override delete handler to prevent closing while downloads are active""" if self.installing: return True self.destroy() return True if __name__ == "__main__": import signal signal.signal(signal.SIGINT, signal.SIG_DFL) RunnerInstallDialog("test", None, "wine") Gtk.main() lutris-0.5.9.1/lutris/gui/dialogs/runners.py000066400000000000000000000000001413267435700210060ustar00rootroot00000000000000lutris-0.5.9.1/lutris/gui/dialogs/uninstall_game.py000066400000000000000000000135601413267435700223330ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk, Pango from lutris.database.games import get_games from lutris.game import Game from lutris.gui.dialogs import Dialog, QuestionDialog from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.strings import gtk_safe, human_size from lutris.util.system import get_disk_size, is_removeable, reverse_expanduser class UninstallGameDialog(Dialog): def __init__(self, game_id, parent=None): super().__init__(parent=parent) self.set_size_request(640, 128) self.game = Game(game_id) self.delete_files = False container = Gtk.VBox(visible=True) self.get_content_area().add(container) title_label = Gtk.Label(visible=True) title_label.set_line_wrap(True) title_label.set_alignment(0, 0.5) title_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) title_label.set_markup("Uninstall %s" % gtk_safe(self.game.name)) container.pack_start(title_label, False, False, 4) self.folder_label = Gtk.Label(visible=True) self.folder_label.set_alignment(0, 0.5) self.delete_button = Gtk.Button(_("Uninstall"), visible=True) self.delete_button.connect("clicked", self.on_delete_clicked) if not self.game.directory: self.folder_label.set_markup("No file will be deleted") elif len(get_games(filters={"directory": self.game.directory})) > 1: self.folder_label.set_markup("The folder %s is used by other games and will be kept." % self.game.directory) elif is_removeable(self.game.directory): self.delete_button.set_sensitive(False) self.folder_label.set_markup("Calculating size…") AsyncCall(get_disk_size, self.folder_size_cb, self.game.directory) else: self.folder_label.set_markup( "Content of %s are protected and will not be deleted." % reverse_expanduser(self.game.directory) ) container.pack_start(self.folder_label, False, False, 4) self.confirm_delete_button = Gtk.CheckButton() self.confirm_delete_button.set_active(True) container.pack_start(self.confirm_delete_button, False, False, 4) button_box = Gtk.HBox(visible=True) button_box.set_margin_top(30) style_context = button_box.get_style_context() style_context.add_class("linked") cancel_button = Gtk.Button(_("Cancel"), visible=True) cancel_button.connect("clicked", self.on_close) button_box.add(cancel_button) button_box.add(self.delete_button) container.pack_end(button_box, False, False, 0) self.show() def folder_size_cb(self, folder_size, error): if error: logger.error(error) return self.delete_files = True self.delete_button.set_sensitive(True) self.folder_label.hide() self.confirm_delete_button.show() self.confirm_delete_button.set_label( "Delete %s (%s)" % ( reverse_expanduser(self.game.directory), human_size(folder_size) ) ) def on_close(self, _button): self.destroy() def on_delete_clicked(self, button): button.set_sensitive(False) if not self.confirm_delete_button.get_active(): self.delete_files = False if self.delete_files and not hasattr(self.game.runner, "no_game_remove_warning"): dlg = QuestionDialog( { "question": _( "Please confirm.\nEverything under %s\n" "will be deleted." ) % gtk_safe(self.game.directory), "title": _("Permanently delete files?"), } ) if dlg.result != Gtk.ResponseType.YES: button.set_sensitive(True) return if self.delete_files: self.folder_label.set_markup("Uninstalling game and deleting files...") else: self.folder_label.set_markup("Uninstalling game...") self.game.remove(self.delete_files) self.destroy() class RemoveGameDialog(Dialog): def __init__(self, game_id, parent=None): super().__init__(parent=parent) self.set_size_request(640, 128) self.game = Game(game_id) container = Gtk.VBox(visible=True) self.get_content_area().add(container) title_label = Gtk.Label(visible=True) title_label.set_line_wrap(True) title_label.set_alignment(0, 0.5) title_label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) title_label.set_markup("Remove %s" % gtk_safe(self.game.name)) container.pack_start(title_label, False, False, 4) self.delete_label = Gtk.Label(visible=True) self.delete_label.set_alignment(0, 0.5) self.delete_label.set_markup("Completely remove %s from the library?\nAll play time will be lost." % self.game) container.pack_start(self.delete_label, False, False, 4) button_box = Gtk.HBox(visible=True) button_box.set_margin_top(30) style_context = button_box.get_style_context() style_context.add_class("linked") cancel_button = Gtk.Button(_("Cancel"), visible=True) cancel_button.connect("clicked", self.on_close) button_box.add(cancel_button) self.remove_button = Gtk.Button(_("Remove"), visible=True) self.remove_button.connect("clicked", self.on_remove_clicked) button_box.add(self.remove_button) container.pack_end(button_box, False, False, 0) self.show() def on_close(self, _button): self.destroy() def on_remove_clicked(self, button): button.set_sensitive(False) self.game.delete() self.destroy() lutris-0.5.9.1/lutris/gui/dialogs/webconnect_dialog.py000066400000000000000000000104551413267435700227770ustar00rootroot00000000000000"""isort:skip_file""" import os from gettext import gettext as _ import gi gi.require_version("WebKit2", "4.0") from gi.repository import WebKit2 from lutris.gui.dialogs import Dialog class WebConnectDialog(Dialog): """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_border_width(0) self.set_default_size(390, 500) 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.pack_start(self.webview, True, True, 0) # pylint: disable=no-member webkit_settings = self.webview.get_settings() # Allow popups (Doesn't work...) webkit_settings.set_enable_write_console_messages_to_stdout(True) webkit_settings.set_allow_modal_dialogs(True) # Enable developer options for troubleshooting (Can be disabled in # releases) webkit_settings.set_javascript_can_open_windows_automatically(True) webkit_settings.set_enable_developer_extras(True) self.show_all() def enable_inspector(self): """If you want a full blown Webkit inspector, call this""" 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.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) view.load_uri(uri) popup_dialog = WebPopupDialog(view, parent=self) popup_dialog.set_modal(True) popup_dialog.show() return view class WebPopupDialog(Dialog): """Dialog for handling web popups""" def __init__(self, webview, parent=None): # pylint: disable=no-member self.parent = parent super(WebPopupDialog, self).__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.vbox.pack_start(self.webview, True, True, 0) self.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, parent=self) dialog.set_modal(True) dialog.show() return view def on_webview_close(self, webview): self.destroy() lutris-0.5.9.1/lutris/gui/installer/000077500000000000000000000000001413267435700173255ustar00rootroot00000000000000lutris-0.5.9.1/lutris/gui/installer/__init__.py000066400000000000000000000000001413267435700214240ustar00rootroot00000000000000lutris-0.5.9.1/lutris/gui/installer/file_box.py000066400000000000000000000267631413267435700215040ustar00rootroot00000000000000"""Widgets for the installer window""" import os from gettext import gettext as _ from urllib.parse import urlparse from gi.repository import GObject, Gtk from lutris.cache import save_to_cache from lutris.gui.installer.widgets import InstallerLabel from lutris.gui.widgets.common import FileChooserEntry from lutris.gui.widgets.download_progress_box import DownloadProgressBox from lutris.installer.steam_installer import SteamInstaller from lutris.util import system from lutris.util.log import logger from lutris.util.strings import add_url_tags, 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.provider self.popover = self.get_popover() 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""" if ( self.provider in ("user", "pga") and not system.path_exists(self.installer_file.dest_file) ): return False return True def get_download_progress(self): """Return the widget for the download progress bar""" download_progress = DownloadProgressBox({ "url": self.installer_file.url, "dest": self.installer_file.dest_file, "referer": self.installer_file.referer }) download_progress.connect("complete", self.on_download_complete) download_progress.connect("cancel", self.on_download_cancelled) download_progress.show() if ( not self.installer_file.uses_pga_cache() and system.path_exists(self.installer_file.dest_file) ): os.remove(self.installer_file.dest_file) 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.get_file_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 if self.provider == "steam": 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_file_label(self): """Return a human readable label for installer files""" url = self.installer_file.url if url.startswith("http"): parsed = urlparse(url) label = "%s on %s" % (self.installer_file.filename, parsed.netloc) elif url.startswith("N/A"): label = url[3:].lstrip(":") else: label = url return add_url_tags(gtk_safe(label)) def get_popover(self): """Return the popover widget to select file source""" popover = Gtk.Popover() popover.add(self.get_popover_menu()) popover.set_position(Gtk.PositionType.BOTTOM) return popover def get_source_radiobutton(self, last_widget, label, source): """Return a radio button for the popover menu""" button = Gtk.RadioButton.new_with_label_from_widget(last_widget, label) if self.provider == source: button.set_active(True) button.connect("toggled", self.on_source_changed, source) return button def get_popover_menu(self): """Create the menu going into the popover""" vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) last_widget = None if "download" in self.installer_file.providers: download_button = self.get_source_radiobutton(last_widget, _("Download"), "download") vbox.pack_start(download_button, False, True, 10) last_widget = download_button if "pga" in self.installer_file.providers: pga_button = self.get_source_radiobutton(last_widget, _("Use cache"), "pga") vbox.pack_start(pga_button, False, True, 10) last_widget = pga_button user_button = self.get_source_radiobutton(last_widget, _("Select file"), "user") vbox.pack_start(user_button, False, True, 10) return vbox 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, _button, source): """Change the source to a new provider, emit a new state""" if source == self.provider or not hasattr(self, "popover"): return self.provider = source self.replace_file_provider_widget() self.popover.popdown() button = self.popover.get_relative_to() if button: button.set_label(self.get_source_button_label()) if self.provider == "user": self.emit("file-unready") else: self.emit("file-ready") def get_source_button_label(self): """Return the label for the source button""" provider_labels = { "download": _("Download"), "pga": _("Cache"), "user": _("Local"), "steam": _("Steam"), } if self.provider in provider_labels: return provider_labels[self.provider] raise ValueError("Unsupported provider %s" % self.provider) 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.get_file_label()) label.props.can_focus = True box.pack_start(label, False, False, 0) location_entry = FileChooserEntry( self.installer_file.human_url, Gtk.FileChooserAction.OPEN, path=None ) location_entry.entry.connect("changed", self.on_location_changed) location_entry.show() box.pack_start(location_entry, False, False, 0) if self.installer_file.uses_pga_cache(create=True): 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.get_file_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) source_box.pack_start(InstallerLabel(_("Source:")), False, False, 0) button = Gtk.Button.new_with_label(self.get_source_button_label()) button.connect("clicked", self.on_file_source_select) source_box.pack_start(button, False, False, 0) return box def on_file_source_select(self, button): """Open the popover to switch to a different source""" self.popover.set_relative_to(button) self.popover.show_all() self.popover.popup() 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.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: save_to_cache(self.installer_file.dest_file, self.installer_file.cache_path) 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.9.1/lutris/gui/installer/files_box.py000066400000000000000000000100541413267435700216510ustar00rootroot00000000000000from 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, installer, parent): super().__init__() self.parent = parent self.installer = installer self.installer_files = installer.files self.ready_files = set() self.available_files = set() self.installer_files_boxes = {} self._file_queue = [] 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""" started_downloads = 0 for file_id in self.installer_files_boxes: if self.installer_files_boxes[file_id].provider == "download": started_downloads += 1 if started_downloads <= self.max_downloads: self.installer_files_boxes[file_id].start() else: self._file_queue.append(file_id) else: self.installer_files_boxes[file_id].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""" logger.debug("Files are ready? %s", self.is_ready) 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.remove(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""" return { installer_file.id: installer_file.dest_file for installer_file in self.installer_files } lutris-0.5.9.1/lutris/gui/installer/picker.py000066400000000000000000000016741413267435700211640ustar00rootroot00000000000000from 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.9.1/lutris/gui/installer/script_box.py000066400000000000000000000055661413267435700220670ustar00rootroot00000000000000from gettext import gettext as _ from gi.repository import Gtk from lutris.gui.installer.widgets import InstallerLabel from lutris.util.strings import add_url_tags, gtk_safe class InstallerScriptBox(Gtk.VBox): """Box displaying the details of a script, with associated action buttons""" def __init__(self, script, parent=None, revealed=False): super().__init__() self.script = script self.parent = parent self.revealer = None self.set_margin_left(12) self.set_margin_right(12) box = Gtk.Box(spacing=12, margin_top=6, margin_bottom=6) box.pack_start(self.get_infobox(), True, True, 0) box.add(self.get_install_button()) self.add(box) self.add(self.get_revealer(revealed)) def get_rating(self): """Return a string representation of the API rating""" try: rating = int(self.script["rating"]) except (ValueError, TypeError, KeyError): return "" return "⭐" * rating def get_infobox(self): """Return the central information box""" info_box = Gtk.VBox(spacing=6) title_box = Gtk.HBox(spacing=6) title_box.add(InstallerLabel("%s" % gtk_safe(self.script["version"]))) title_box.pack_start(InstallerLabel(""), True, True, 0) rating_label = InstallerLabel(self.get_rating()) rating_label.set_alignment(1, 0.5) title_box.pack_end(rating_label, False, False, 0) info_box.add(title_box) info_box.add(InstallerLabel(add_url_tags(self.script["description"]))) return info_box def get_revealer(self, revealed): """Return the revelaer widget""" self.revealer = Gtk.Revealer() self.revealer.add(self.get_notes()) 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) 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() notes_label = InstallerLabel(notes) notes_label.set_margin_top(12) notes_label.set_margin_bottom(12) notes_label.set_margin_right(12) notes_label.set_margin_left(12) return notes_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["slug"]) lutris-0.5.9.1/lutris/gui/installer/widgets.py000066400000000000000000000010431413267435700213430ustar00rootroot00000000000000from gi.repository import Gtk, Pango class InstallerLabel(Gtk.Label): """A label for installers""" def __init__(self, text, wrap=True): super().__init__() if wrap: self.set_line_wrap(True) self.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) else: self.set_property("ellipsize", Pango.EllipsizeMode.MIDDLE) self.set_alignment(0, 0.5) self.set_margin_right(12) self.set_markup(text) self.props.can_focus = False self.set_tooltip_text(text) lutris-0.5.9.1/lutris/gui/installerwindow.py000066400000000000000000000527571413267435700211470ustar00rootroot00000000000000"""Window used for game installers""" import os from gettext import gettext as _ from gi.repository import GLib, Gtk from lutris.exceptions import UnavailableGame from lutris.game import Game from lutris.gui.dialogs import DirectoryDialog, InstallerSourceDialog, QuestionDialog from lutris.gui.dialogs.cache import CacheConfigurationDialog from lutris.gui.installer.files_box import InstallerFilesBox from lutris.gui.installer.picker import InstallerPicker from lutris.gui.widgets.common import FileChooserEntry, InstallerLabel from lutris.gui.widgets.log_text_view import LogTextView from lutris.gui.widgets.window import BaseApplicationWindow from lutris.installer import interpreter from lutris.installer.errors import MissingGameDependency, ScriptingError from lutris.util import xdgshortcuts from lutris.util.log import logger from lutris.util.strings import add_url_tags, gtk_safe, human_size class InstallerWindow(BaseApplicationWindow): # pylint: disable=too-many-public-methods """GUI for the install process.""" def __init__( self, installers, service=None, appid=None, application=None, ): super().__init__(application=application) self.set_default_size(540, 320) self.installers = installers self.service = service self.appid = appid self.install_in_progress = False self.interpreter = None self.log_buffer = None self.log_textview = None self._cancel_files_func = None self.title_label = InstallerLabel() self.title_label.set_selectable(False) self.vbox.add(self.title_label) self.status_label = InstallerLabel() self.status_label.set_max_width_chars(80) self.status_label.set_property("wrap", True) self.status_label.set_selectable(True) self.vbox.add(self.status_label) self.widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.vbox.pack_start(self.widget_box, True, True, 0) self.vbox.add(Gtk.HSeparator()) button_box = Gtk.Box() self.cache_button = Gtk.Button(_("Cache")) self.cache_button.connect("clicked", self.on_cache_clicked) button_box.add(self.cache_button) self.action_buttons = Gtk.Box(spacing=6) action_buttons_alignment = Gtk.Alignment.new(1, 0, 0, 0) action_buttons_alignment.add(self.action_buttons) button_box.pack_end(action_buttons_alignment, True, True, 0) self.vbox.pack_start(button_box, False, True, 0) self.cancel_button = self.add_button( _("C_ancel"), self.cancel_installation, tooltip=_("Abort and revert the installation") ) self.eject_button = self.add_button(_("_Eject"), self.on_eject_clicked) self.source_button = self.add_button(_("_View source"), self.on_source_clicked) self.install_button = self.add_button(_("_Install"), self.on_install_clicked) self.continue_button = self.add_button(_("_Continue")) self.play_button = self.add_button(_("_Launch"), self.launch_game) self.close_button = self.add_button(_("_Close"), self.on_destroy) self.continue_handler = None self.clean_widgets() self.show_all() self.close_button.hide() self.play_button.hide() self.install_button.hide() self.source_button.hide() self.eject_button.hide() self.continue_button.hide() self.install_in_progress = True self.widget_box.show() self.title_label.show() self.choose_installer() self.present() def add_button(self, label, handler=None, tooltip=None): """Add a button to the action buttons box""" button = Gtk.Button.new_with_mnemonic(label) if tooltip: button.set_tooltip_text(tooltip) if handler: button.connect("clicked", handler) self.action_buttons.add(button) return button def validate_scripts(self): """Auto-fixes some script aspects and checks for mandatory fields""" for script in self.installers: for item in ["description", "notes"]: script[item] = script.get(item) or "" for item in ["name", "runner", "version"]: if item not in script: logger.error("Invalid script: %s", script) raise ScriptingError('Missing field "%s" in install script' % item) def choose_installer(self): """Stage where we choose an install script.""" self.validate_scripts() base_script = self.installers[0] self.title_label.set_markup(_("Install %s") % gtk_safe(base_script["name"])) installer_picker = InstallerPicker(self.installers) installer_picker.connect("installer-selected", self.on_installer_selected) scrolledwindow = Gtk.ScrolledWindow( hexpand=True, vexpand=True, child=installer_picker, visible=True ) scrolledwindow.set_shadow_type(Gtk.ShadowType.ETCHED_IN) self.widget_box.pack_end(scrolledwindow, True, True, 10) def get_script_from_slug(self, script_slug): """Return a installer script from its slug, raise an error if one isn't found""" for script in self.installers: if script["slug"] == script_slug: return script def on_cache_clicked(self, _button): """Open the cache configuration dialog""" CacheConfigurationDialog() def on_installer_selected(self, _widget, installer_slug): """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. """ self.clean_widgets() try: self.interpreter = interpreter.ScriptInterpreter( self.get_script_from_slug(installer_slug), self ) except MissingGameDependency as ex: dlg = QuestionDialog( { "question": _("This game requires %s. Do you want to install it?") % ex.slug, "title": _("Missing dependency"), } ) if dlg.result == Gtk.ResponseType.YES: InstallerWindow( installers=self.installers, service=self.service, appid=self.appid, application=self.application, ) self.destroy() return self.title_label.set_markup(_(u"Installing {}").format(gtk_safe(self.interpreter.installer.game_name))) self.select_install_folder() def select_install_folder(self): """Stage where we select the install directory.""" if not self.interpreter.installer.creates_game_folder: self.on_install_clicked(self.install_button) return self.set_message(_("Select installation directory")) default_path = self.interpreter.get_default_target() self.set_install_destination(default_path) if self.continue_handler: self.continue_button.disconnect(self.continue_handler) self.continue_button.hide() self.source_button.show() self.install_button.grab_focus() self.install_button.show() # self.manual_button.hide() def on_target_changed(self, text_entry, _data=None): """Set the installation target for the game.""" self.interpreter.target_path = os.path.expanduser(text_entry.get_text()) def on_install_clicked(self, button): """Let the interpreter take charge of the next stages.""" button.hide() self.source_button.hide() self.interpreter.connect("runners-installed", self.on_runners_ready) GLib.idle_add(self.interpreter.launch_install) def set_install_destination(self, default_path=None): """Display the destination chooser.""" self.install_button.set_visible(False) self.continue_button.show() self.continue_button.set_sensitive(False) location_entry = FileChooserEntry( "Select folder", Gtk.FileChooserAction.SELECT_FOLDER, path=default_path, warn_if_non_empty=True, warn_if_ntfs=True ) location_entry.entry.connect("changed", self.on_target_changed) self.widget_box.pack_start(location_entry, False, False, 0) def ask_for_disc(self, message, callback, requires): """Ask the user to do insert a CD-ROM.""" self.clean_widgets() label = InstallerLabel(message) label.show() self.widget_box.add(label) buttons_box = Gtk.Box() buttons_box.show() buttons_box.set_margin_top(40) buttons_box.set_margin_bottom(40) self.widget_box.add(buttons_box) autodetect_button = Gtk.Button(label=_("Autodetect")) autodetect_button.connect("clicked", callback, requires) autodetect_button.grab_focus() autodetect_button.show() buttons_box.pack_start(autodetect_button, True, True, 40) browse_button = Gtk.Button(label=_("Browse…")) callback_data = {"callback": callback, "requires": requires} browse_button.connect("clicked", self.on_browse_clicked, callback_data) browse_button.show() buttons_box.pack_start(browse_button, True, True, 40) 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() def input_menu(self, alias, options, preselect, has_entry, callback): """Display an input request as a dropdown menu with options.""" self.clean_widgets() 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_active_id(preselect) combobox.set_halign(Gtk.Align.CENTER) self.widget_box.pack_start(combobox, True, False, 100) combobox.connect("changed", self.on_input_menu_changed) combobox.show() if self.continue_handler: self.continue_button.disconnect(self.continue_handler) self.continue_handler = self.continue_button.connect("clicked", callback, alias, combobox) self.continue_button.grab_focus() self.continue_button.show() self.on_input_menu_changed(combobox) def on_input_menu_changed(self, widget): """Enable continue button if a non-empty choice is selected""" self.continue_button.set_sensitive(bool(widget.get_active_id())) def on_runners_ready(self, _widget=None): """The runners are ready, proceed with file selection""" if self.interpreter.extras is None: extras = self.interpreter.get_extras() if extras: self.show_extras(extras) return try: self.interpreter.installer.prepare_game_files() except UnavailableGame as ex: raise ScriptingError(str(ex)) if not self.interpreter.installer.files: logger.debug("Installer doesn't require files") self.interpreter.launch_installer_commands() return self.show_installer_files_screen() def show_installer_files_screen(self): """Show installer screen with the file picker / downloader""" self.clean_widgets() self.set_status(_("Please review the files needed for the installation then click 'Continue'")) installer_files_box = InstallerFilesBox(self.interpreter.installer, self) installer_files_box.connect("files-available", self.on_files_available) installer_files_box.connect("files-ready", self.on_files_ready) self._cancel_files_func = installer_files_box.stop_all scrolledwindow = Gtk.ScrolledWindow( hexpand=True, vexpand=True, child=installer_files_box, visible=True ) scrolledwindow.set_shadow_type(Gtk.ShadowType.ETCHED_IN) self.widget_box.pack_end(scrolledwindow, True, True, 10) self.continue_button.show() self.continue_button.set_sensitive(installer_files_box.is_ready) if self.continue_handler: self.continue_button.disconnect(self.continue_handler) self.continue_handler = self.continue_button.connect( "clicked", self.on_files_confirmed, installer_files_box ) 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 show_extras(self, extras): """Show installer screen with the extras picker""" self.clean_widgets() extra_liststore = Gtk.ListStore( bool, # is selected? str, # id str, # label ) for extra in extras: extra_liststore.append((False, extra["id"], self.get_extra_label(extra))) treeview = Gtk.TreeView(extra_liststore) treeview.set_headers_visible(False) renderer_toggle = Gtk.CellRendererToggle() renderer_toggle.connect("toggled", self.on_extra_toggled, extra_liststore) renderer_text = Gtk.CellRendererText() installed_column = Gtk.TreeViewColumn(None, renderer_toggle, active=0) treeview.append_column(installed_column) label_column = Gtk.TreeViewColumn(None, renderer_text) label_column.add_attribute(renderer_text, "text", 2) label_column.set_property("min-width", 80) treeview.append_column(label_column) scrolledwindow = Gtk.ScrolledWindow( hexpand=True, vexpand=True, child=treeview, visible=True ) scrolledwindow.set_shadow_type(Gtk.ShadowType.ETCHED_IN) scrolledwindow.show_all() self.widget_box.pack_end(scrolledwindow, True, True, 10) self.continue_button.show() self.continue_button.set_sensitive(True) if self.continue_handler: self.continue_button.disconnect(self.continue_handler) self.continue_handler = self.continue_button.connect("clicked", self.on_extras_confirmed, extra_liststore) def on_extra_toggled(self, _widget, path, store): row = store[path] row[0] = not row[0] def on_extras_confirmed(self, _button, extra_store): """Resume install when user has selected extras to download""" selected_extras = [] for extra in extra_store: if extra[0]: selected_extras.append(extra[1]) self.interpreter.extras = selected_extras GLib.idle_add(self.on_runners_ready) def on_files_ready(self, _widget, files_ready): """Toggle state of continue button based on ready state""" logger.debug("Files are ready: %s", files_ready) self.continue_button.set_sensitive(files_ready) def on_files_confirmed(self, _button, file_box): """Call this when the user confirms the install files This will start the downloads. """ self.set_status("") self.continue_button.set_sensitive(False) try: file_box.start_all() self.continue_button.disconnect(self.continue_handler) except PermissionError as ex: self.continue_button.set_sensitive(True) raise ScriptingError("Unable to get files: %s" % ex) def on_files_available(self, widget): """All files are available, continue the install""" logger.info("All files are available, continuing install") self._cancel_files_func = None self.continue_button.hide() self.interpreter.game_files = widget.get_game_files() self.clean_widgets() self.interpreter.launch_installer_commands() def on_install_finished(self): self.clean_widgets() self.install_in_progress = False desktop_shortcut_button = Gtk.Button(_("Create desktop shortcut"), visible=True) desktop_shortcut_button.connect("clicked", self.on_create_desktop_shortcut_clicked) self.widget_box.pack_start(desktop_shortcut_button, False, False, 5) menu_shortcut_button = Gtk.Button(_("Create application menu shortcut"), visible=True) menu_shortcut_button.connect("clicked", self.on_create_menu_shortcut_clicked) self.widget_box.pack_start(menu_shortcut_button, False, False, 5) self.widget_box.show() self.eject_button.hide() self.cancel_button.hide() self.continue_button.hide() self.install_button.hide() self.play_button.show() self.close_button.grab_focus() self.close_button.show() if not self.is_active(): self.set_urgency_hint(True) # Blink in taskbar self.connect("focus-in-event", self.on_window_focus) def on_window_focus(self, _widget, *_args): """Remove urgency hint (flashing indicator) when window receives focus""" self.set_urgency_hint(False) def on_install_error(self, message): self.clean_widgets() self.set_status(message) self.cancel_button.grab_focus() def launch_game(self, widget, _data=None): """Launch a game after it's been installed.""" widget.set_sensitive(False) self.on_destroy(widget) game = Game(self.interpreter.installer.game_id) game.emit("game-launch") def on_destroy(self, _widget, _data=None): """destroy event handler""" if self.install_in_progress: abort_close = self.cancel_installation() if abort_close: return True else: if self.interpreter: self.interpreter.cleanup() self.destroy() def on_create_desktop_shortcut_clicked(self, _widget): self.create_shortcut(desktop=True) def on_create_menu_shortcut_clicked(self, _widget): self.create_shortcut() 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) def cancel_installation(self, _widget=None): """Ask a confirmation before cancelling the install""" remove_checkbox = Gtk.CheckButton.new_with_label(_("Remove game files")) if self.interpreter: remove_checkbox.set_active(self.interpreter.game_dir_created) remove_checkbox.show() confirm_cancel_dialog = QuestionDialog( { "question": _("Are you sure you want to cancel the installation?"), "title": _("Cancel installation?"), "widgets": [remove_checkbox] } ) if confirm_cancel_dialog.result != Gtk.ResponseType.YES: logger.debug("User aborted installation cancellation") return True if self._cancel_files_func: self._cancel_files_func() if self.interpreter: self.interpreter.revert() self.interpreter.cleanup() self.destroy() def on_source_clicked(self, _button): InstallerSourceDialog( self.interpreter.installer.script_pretty, self.interpreter.installer.game_name, self ) def clean_widgets(self): """Cleanup before displaying the next stage.""" for child_widget in self.widget_box.get_children(): child_widget.destroy() def set_status(self, text): """Display a short status text.""" self.status_label.set_text(text) def set_message(self, message): """Display a message.""" label = InstallerLabel() label.set_markup("%s" % add_url_tags(message)) label.show() self.widget_box.pack_start(label, False, False, 18) def add_spinner(self): """Show a spinner in the middle of the view""" self.clean_widgets() spinner = Gtk.Spinner() self.widget_box.pack_start(spinner, False, False, 18) spinner.show() spinner.start() def attach_logger(self, command): """Creates a TextBuffer and attach it to a command""" self.log_buffer = Gtk.TextBuffer() command.set_log_buffer(self.log_buffer) self.log_textview = LogTextView(self.log_buffer) scrolledwindow = Gtk.ScrolledWindow(hexpand=True, vexpand=True, child=self.log_textview) scrolledwindow.set_shadow_type(Gtk.ShadowType.ETCHED_IN) self.widget_box.pack_end(scrolledwindow, True, True, 10) scrolledwindow.show() self.log_textview.show() lutris-0.5.9.1/lutris/gui/lutriswindow.py000066400000000000000000001033051413267435700204560ustar00rootroot00000000000000"""Main window for the Lutris interface.""" import os from collections import namedtuple from gettext import gettext as _ from gi.repository import Gdk, Gio, GLib, GObject, Gtk from lutris import api, services, settings from lutris.database import categories as categories_db from lutris.database import games as games_db from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.game_actions import GameActions from lutris.gui import dialogs from lutris.gui.config.add_game import AddGameDialog from lutris.gui.config.preferences_dialog import PreferencesDialog from lutris.gui.views import COL_ID, COL_NAME from lutris.gui.views.grid import GameGridView from lutris.gui.views.list import GameListView from lutris.gui.views.media_loader import download_icons from lutris.gui.views.store import GameStore from lutris.gui.widgets.contextual_menu import ContextualMenu 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 # pylint: disable=no-member from lutris.services.base import BaseService from lutris.services.lutris import LutrisBanner, LutrisIcon, LutrisService from lutris.util import datapath from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.system import update_desktop_icons @GtkTemplate(ui=os.path.join(datapath.get(), "ui", "lutris-window.ui")) class LutrisWindow(Gtk.ApplicationWindow): # pylint: disable=too-many-public-methods """Handler class for main window signals.""" default_view_type = "grid" default_width = 800 default_height = 600 __gtype_name__ = "LutrisWindow" __gsignals__ = { "view-updated": (GObject.SIGNAL_RUN_FIRST, None, ()), } tabs_box = GtkTemplate.Child() games_scrollwindow = 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() library_button = GtkTemplate.Child() website_button = 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 = 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)) self.threads_stoppers = [] self.window_size = (width, height) self.maximized = settings.read_setting("maximized") == "True" self.service = None self.game_actions = GameActions(application=application, window=self) self.search_timer_id = None self.selected_category = settings.read_setting("selected_category", default="runner:all") 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.view = Gtk.Box() self.connect("delete-event", self.on_window_delete) self.connect("configure-event", self.on_window_configure) self.connect("realize", self.on_load) if self.maximized: self.maximize() self.init_template() self._init_actions() self.set_dark_theme() self.set_viewtype_icon(self.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, selected=self.selected_category) self.sidebar.connect("selected-rows-changed", self.on_sidebar_changed) self.sidebar_scrolled.add(self.sidebar) self.sidebar_revealer.set_reveal_child(self.side_panel_visible) self.sidebar_revealer.set_transition_duration(300) self.tabs_box.hide() self.game_bar = None self.revealer_box = Gtk.HBox(visible=True) self.game_revealer.add(self.revealer_box) self.connect("view-updated", self.update_store) GObject.add_emission_hook(BaseService, "service-login", self.on_service_login) GObject.add_emission_hook(BaseService, "service-logout", self.on_service_logout) GObject.add_emission_hook(BaseService, "service-games-loaded", self.on_service_games_updated) GObject.add_emission_hook(Game, "game-updated", self.on_game_updated) GObject.add_emission_hook(Game, "game-removed", self.on_game_collection_changed) def _init_actions(self): Action = namedtuple("Action", ("callback", "type", "enabled", "default", "accel")) Action.__new__.__defaults__ = (None, None, True, 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="h", ), "toggle-viewtype": Action(self.on_toggle_viewtype), "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), "view-sorting-ascending": Action( self.on_view_sorting_direction_change, type="b", default=self.view_sorting_ascending, ), "show-side-panel": Action( self.on_side_panel_state_change, type="b", default=self.side_panel_visible, accel="F9", ), "show-hidden-games": Action( self.hidden_state_change, type="b", default=self.show_hidden_games, ), "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 = {} 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 is False: action.props.enabled = False self.add_action(action) if value.accel: app.add_accelerator(value.accel, "win." + name) @property def service_media(self): return self.get_service_media(self.load_icon_type()) def on_load(self, widget, data=None): """Finish initializing the view""" self._bind_zoom_adjustment() self.view.grab_focus() self.view.contextual_menu = ContextualMenu(self.game_actions.get_game_actions()) def load_filters(self): """Load the initial filters when creating the view""" category, value = self.selected_category.split(":") filters = { category: value } # Type of filter corresponding to the selected sidebar element filters["hidden"] = settings.read_setting("show_hidden_games").lower() == "true" filters["installed"] = settings.read_setting("filter_installed").lower() == "true" return filters def hidden_state_change(self, action, value): """Hides or shows the hidden games""" action.set_state(value) settings.write_setting("show_hidden_games", str(value).lower(), section="lutris") self.filters["hidden"] = value self.emit("view-updated") @property def current_view_type(self): """Returns which kind of view is currently presented (grid or list)""" return settings.read_setting("view_type") or "grid" @property def filter_installed(self): return settings.read_setting("filter_installed").lower() == "true" @property def side_panel_visible(self): return settings.read_setting("side_panel_visible").lower() != "false" @property def show_tray_icon(self): """Setting to hide or show status icon""" return settings.read_setting("show_tray_icon", default="false").lower() == "true" @property def view_sorting(self): value = settings.read_setting("view_sorting") or "name" if value.endswith("_text"): value = value[:-5] return value @property def view_sorting_ascending(self): return settings.read_setting("view_sorting_ascending").lower() != "false" @property def show_hidden_games(self): return settings.read_setting("show_hidden_games").lower() == "true" @property def sort_params(self): _sort_params = [("installed", "COLLATE NOCASE DESC")] _sort_params.append(( self.view_sorting, "COLLATE NOCASE ASC" if self.view_sorting_ascending else "COLLATE NOCASE DESC" )) return _sort_params def get_running_games(self): """Return a list of currently running games""" return games_db.get_games_by_ids([game.id for game in self.application.running_games]) def get_recent_games(self): """Return a list of currently running games""" searches, _filters, excludes = self.get_sql_filters() games = games_db.get_games(searches=searches, filters={'installed': '1'}, excludes=excludes) return sorted( games, key=lambda game: max(game["installed_at"] or 0, game["lastplayed"] or 0), reverse=True ) def get_api_games(self): """Return games from the lutris API""" if not self.filters.get("text"): return [] api_games = api.search_games(self.filters["text"]) if "icon" in self.icon_type: api_field = "icon_url" _service_media = LutrisIcon else: api_field = "banner_url" _service_media = LutrisBanner AsyncCall( download_icons, self.icons_download_cb, {g["slug"]: g[api_field] for g in api_games}, _service_media() ) return api_games def icons_download_cb(self, result, error): if error: logger.error("Failed to download icons: %s", error) return self.game_store.update_icons(result) def game_matches(self, game): if self.filters.get("installed"): if game["appid"] not in games_db.get_service_games(self.service.id): return False if not self.filters.get("text"): return True return self.filters["text"] in game["name"].lower() def set_service(self, service_name): if self.service and self.service.id == service_name: return self.service if not service_name: self.service = None return try: self.service = services.SERVICES[service_name]() except KeyError: logger.error("Non existent service '%s'", service_name) self.service = None return self.service @staticmethod def combine_games(service_game, lutris_game): """Inject lutris game information into a service game""" if lutris_game and service_game["appid"] == lutris_game["service_id"]: for field in ("platform", "runner", "year", "installed_at", "lastplayed", "playtime", "installed"): service_game[field] = lutris_game[field] return service_game def get_service_games(self, service_name): """Switch the current service to service_name and return games if available""" service_games = ServiceGameCollection.get_for_service(service_name) if service_name == "lutris": lutris_games = {g["slug"]: g for g in games_db.get_games()} else: lutris_games = {g["service_id"]: g for g in games_db.get_games(filters={"service": self.service.id})} def get_sort_value(game): sort_defaults = { "name": "", "year": 0, "lastplayed": 0.0, "installed_at": 0.0, "playtime": 0.0, } lutris_game = lutris_games.get(game["appid"]) if not lutris_game: return sort_defaults[self.view_sorting] value = lutris_game[self.view_sorting] if value: return value return sort_defaults[self.view_sorting] return [ self.combine_games(game, lutris_games.get(game["appid"])) for game in sorted( service_games, key=get_sort_value, reverse=not self.view_sorting_ascending ) if self.game_matches(game) ] def get_games_from_filters(self): service_name = self.filters.get("service") self.tabs_box.hide() if service_name in services.SERVICES: if service_name == "lutris": self.tabs_box.show() # Only the lutris service has the ability to search through all games. if self.website_button.props.active: return self.get_api_games() if self.service.online and not self.service.is_authenticated(): self.show_label(_("Connect your %s account to access your games") % self.service.name) return [] return self.get_service_games(service_name) dynamic_categories = { "recent": self.get_recent_games, "running": self.get_running_games, } if self.filters.get("dynamic_category") in dynamic_categories: return dynamic_categories[self.filters["dynamic_category"]]() if self.filters.get("category") and self.filters["category"] != "all": game_ids = categories_db.get_game_ids_for_category(self.filters["category"]) return games_db.get_games_by_ids(game_ids) searches, filters, excludes = self.get_sql_filters() return games_db.get_games( searches=searches, filters=filters, excludes=excludes, sorts=self.sort_params ) def get_sql_filters(self): """Return the current filters for the view""" sql_filters = {} sql_excludes = {} if self.filters.get("runner"): sql_filters["runner"] = self.filters["runner"] if self.filters.get("platform"): sql_filters["platform"] = self.filters["platform"] if self.filters.get("installed"): sql_filters["installed"] = "1" if self.filters.get("text"): searches = {"name": self.filters["text"]} else: searches = None if not self.filters.get("hidden"): sql_excludes["hidden"] = 1 return searches, sql_filters, sql_excludes def get_service_media(self, icon_type): """Return the ServiceMedia class used for this view""" service = self.service if self.service else LutrisService medias = service.medias if icon_type in medias: return medias[icon_type]() return medias[service.default_format]() def update_revealer(self, game=None): if game: if self.game_bar: self.game_bar.destroy() self.game_bar = GameBar(game, self.game_actions, self.application) self.revealer_box.pack_start(self.game_bar, True, True, 0) elif self.game_bar: # The game bar can't be destroyed here because the game gets unselected on Wayland # whenever the game bar is interacted with. Instead, we keep the current game bar open # when the game gets unselected, which is somewhat closer to what the intended behavior # should be anyway. Might require closing the game bar manually in some cases. pass # self.game_bar.destroy() if self.revealer_box.get_children(): self.game_revealer.set_reveal_child(True) else: self.game_revealer.set_reveal_child(False) def show_empty_label(self): """Display a label when the view is empty""" if self.filters.get("text"): self.show_label(_("No games matching '%s' found ") % self.filters["text"]) elif self.view.service == "lutris" and self.website_button.props.active: self.show_label(_("Use search to find games on lutris.net")) else: if self.filters.get("category") == "favorite": self.show_label(_("Add games to your favorites to see them here.")) elif self.filters.get("installed"): self.show_label(_("No installed games found. Press Ctrl+H so show all games.")) else: self.show_label(_("No games found")) def update_store(self, *_args, **_kwargs): self.game_store.store.clear() for child in self.blank_overlay.get_children(): child.destroy() games = self.get_games_from_filters() logger.debug("Showing %d games", len(games)) self.view.service = self.service.id if self.service else None GLib.idle_add(self.update_revealer) for game in games: self.game_store.add_game(game) if not games: self.show_empty_label() self.search_timer_id = None return False def set_dark_theme(self): """Enables or disables dark theme""" gtksettings = Gtk.Settings.get_default() gtksettings.set_property( "gtk-application-prefer-dark-theme", settings.read_setting("dark_theme", default="false").lower() == "true" ) 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: self.save_icon_type(icon_type) self.show_spinner() def show_label(self, message): """Display a label in the middle of the UI""" for child in self.blank_overlay.get_children(): child.destroy() label = Gtk.Label(message, visible=True) self.blank_overlay.add(label) self.blank_overlay.props.visible = True def show_spinner(self): spinner = Gtk.Spinner(visible=True) spinner.start() for child in self.blank_overlay.get_children(): child.destroy() self.blank_overlay.add(spinner) self.blank_overlay.props.visible = True def hide_overlay(self): self.blank_overlay.props.visible = False for child in self.blank_overlay.get_children(): child.destroy() @property def view_type(self): """Return the type of view saved by the user""" view_type = settings.read_setting("view_type") if view_type in ["grid", "list"]: return view_type return self.default_view_type def do_key_press_event(self, event): # pylint: disable=arguments-differ # XXX: This block of code below is to enable searching on type. # Enabling this feature steals focus from other entries so it needs # some kind of focus detection before enabling library search. # Probably not ideal for non-english, but we want to limit # which keys actually start searching if event.keyval == Gdk.KEY_Escape: self.search_entry.set_text("") self.view.grab_focus() return Gtk.ApplicationWindow.do_key_press_event(self, event) if ( # pylint: disable=too-many-boolean-expressions not Gdk.KEY_0 <= event.keyval <= Gdk.KEY_z or event.state & Gdk.ModifierType.CONTROL_MASK or event.state & Gdk.ModifierType.SHIFT_MASK or event.state & Gdk.ModifierType.META_MASK or event.state & Gdk.ModifierType.MOD1_MASK or self.search_entry.has_focus() ): return Gtk.ApplicationWindow.do_key_press_event(self, event) self.search_entry.grab_focus() return self.search_entry.do_key_press_event(self.search_entry, event) def load_icon_type(self): """Return the icon style depending on the type of view.""" setting_key = "icon_type_%sview" % self.current_view_type if self.service and self.service.id != "lutris": setting_key += "_%s" % self.service.id self.icon_type = settings.read_setting(setting_key) return self.icon_type def save_icon_type(self, icon_type): """Save icon type to settings""" self.icon_type = icon_type setting_key = "icon_type_%sview" % self.current_view_type if self.service and self.service.id != "lutris": setting_key += "_%s" % self.service.id settings.write_setting(setting_key, self.icon_type) self.redraw_view() def redraw_view(self): """Completely reconstruct the main view""" if not self.game_store: logger.error("No game store yet") return if self.view: self.view.destroy() self.game_store = GameStore(self.service, self.service_media) if self.view_type == "grid": self.view = GameGridView( self.game_store, self.game_store.service_media, hide_text=settings.read_setting("hide_text_under_icons") == "True" ) else: self.view = GameListView(self.game_store, self.game_store.service_media) self.view.connect("game-selected", self.on_game_selection_changed) self.view.connect("game-activated", self.on_game_activated) self.view.contextual_menu = ContextualMenu(self.game_actions.get_game_actions()) for child in self.games_scrollwindow.get_children(): child.destroy() self.games_scrollwindow.add(self.view) self.view.show_all() self.view.grab_focus() GLib.idle_add(self.update_store) 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 on_service_games_updated(self, service): """Request a view update when service games are loaded""" if self.service and service.id == self.service.id: self.emit("view-updated") return True def on_service_login(self, service): AsyncCall(service.reload, None) return True def on_service_logout(self, service): if self.service and service.id == self.service.id: self.emit("view-updated") return True def on_dark_theme_state_change(self, action, value): """Callback for theme switching action""" action.set_state(value) settings.write_setting("dark_theme", value.get_boolean()) self.set_dark_theme() @GtkTemplate.Callback def on_resize(self, widget, *_args): """Size-allocate signal. Updates stored window size and maximized state. """ if not widget.get_window(): return self.maximized = widget.is_maximized() size = widget.get_size() if not self.maximized: self.window_size = size self.search_entry.set_size_request(min(max(50, size[0] - 470), 800), -1) def on_window_delete(self, *_args): if self.application.running_games.get_n_items(): self.hide() return True def on_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() # Save 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) @GtkTemplate.Callback def on_preferences_activate(self, *_args): """Callback when preferences is activated.""" self.application.show_window(PreferencesDialog) def on_show_installed_state_change(self, action, value): """Callback to handle uninstalled game filter switch""" action.set_state(value) self.set_show_installed_state(value.get_boolean()) self.emit("view-updated") @GtkTemplate.Callback 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() if self.service and self.service.id == "lutris" and self.website_button.props.active: delay = 1250 # Big delay to make sure user has stopped typing before sending a search else: delay = 150 self.search_timer_id = GLib.timeout_add(delay, 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.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.view.set_cursor(Gtk.TreePath('0'), None, False) # needed for both view types self.view.grab_focus() @GtkTemplate.Callback def on_about_clicked(self, *_args): """Open the about dialog.""" dialogs.AboutDialog(parent=self) def on_game_error(self, game, error): """Called when a game has sent the 'game-error' signal""" logger.error("%s crashed", game) dialogs.ErrorDialog(error, parent=self) @GtkTemplate.Callback def on_add_game_button_clicked(self, *_args): """Add a new game manually with the AddGameDialog.""" if "runner" in self.filters: runner = self.filters["runner"] else: runner = None AddGameDialog(self, runner=runner) 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() 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.emit("view-updated") def on_view_sorting_direction_change(self, action, value): self.actions["view-sorting-ascending"].set_state(value) settings.write_setting("view_sorting_ascending", bool(value)) self.emit("view-updated") 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 = widget.get_selected_row() if row: self.selected_category = "%s:%s" % (row.type, row.id) self.filters[row.type] = row.id service_name = self.filters.get("service") self.set_service(service_name) self._bind_zoom_adjustment() self.redraw_view() def on_game_selection_changed(self, view, selection): if not selection: GLib.idle_add(self.update_revealer) return False game_id = view.get_model().get_value(selection, COL_ID) if not game_id: GLib.idle_add(self.update_revealer) return False if self.service: game = ServiceGameCollection.get_game(self.service.id, game_id) else: game = games_db.get_game_by_field(int(game_id), "id") if not game: game = { "id": game_id, "appid": game_id, "name": view.get_model().get_value(selection, COL_NAME), "slug": game_id, "service": self.service.id if self.service else None, } logger.warning("No game found. Replacing with placeholder %s", game) GLib.idle_add(self.update_revealer, game) return False def is_game_displayed(self, game): """Return whether a game should be displayed on the view""" if game.is_hidden and not self.show_hidden_games: return False return True def on_game_updated(self, game): """Updates an individual entry in the view when a game is updated""" if game.appid and self.service: db_game = ServiceGameCollection.get_game(self.service.id, game.appid) else: db_game = games_db.get_game_by_field(game.id, "id") if not self.is_game_displayed(game): self.game_store.remove_game(db_game["id"]) return True updated = self.game_store.update(db_game) if not updated: self.game_store.add_game(db_game) return True def on_game_collection_changed(self, _sender): """Simple method used to refresh the view""" self.emit("view-updated") return True def on_game_activated(self, view, game_id): """Handles view activations (double click, enter press)""" initial_game_id = game_id if self.service: logger.debug("Looking up %s game %s", self.service.id, game_id) db_game = games_db.get_game_for_service(self.service.id, game_id) if self.service.id == "lutris": if not db_game: self.service.install(game_id) return if not db_game["installed"]: self.service.install(game_id) return game_id = db_game["id"] else: if db_game and db_game["installed"]: game_id = db_game["id"] else: service_game = ServiceGameCollection.get_game(self.service.id, game_id) if not service_game: logger.error("No game %s found for %s", game_id, self.service.id) return game_id = self.service.install(service_game) else: logger.debug("No service for view") if game_id: game = Game(game_id) if game.is_installed: game.emit("game-launch") else: game.emit("game-install") else: logger.warning("No game found for %s", initial_game_id) lutris-0.5.9.1/lutris/gui/views/000077500000000000000000000000001413267435700164655ustar00rootroot00000000000000lutris-0.5.9.1/lutris/gui/views/__init__.py000066400000000000000000000011131413267435700205720ustar00rootroot00000000000000"""Common values used for views""" ( COL_ID, COL_SLUG, COL_NAME, COL_ICON, 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(15)) 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.9.1/lutris/gui/views/base.py000066400000000000000000000043201413267435700177500ustar00rootroot00000000000000from gi.repository import Gdk, GObject, Gtk from lutris.database.games import get_game_for_service from lutris.game import Game from lutris.game_actions import GameActions from lutris.gui.views import COL_ID class GameView: # pylint: disable=no-member __gsignals__ = { "game-selected": (GObject.SIGNAL_RUN_FIRST, None, (Gtk.TreeIter, )), "game-activated": (GObject.SIGNAL_RUN_FIRST, None, (str, )), "remove-game": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self): self.service = None # Stores the service.id in a string self.current_path = None self.contextual_menu = None def connect_signals(self): """Signal handlers common to all views""" self.connect("button-press-event", self.popup_contextual_menu) self.connect("key-press-event", self.handle_key_press) def popup_contextual_menu(self, view, event): """Contextual menu.""" if event.button != 3: return view.current_path = view.get_path_at_pos(event.x, event.y) if view.current_path: view.select() _iter = self.get_model().get_iter(view.current_path[0]) if not _iter: return selected_id = self.get_selected_id(_iter) game_row = self.game_store.get_row_by_id(selected_id) game_id = None if self.service: game = get_game_for_service(self.service, game_row[COL_ID]) if game: game_id = game["id"] else: game_id = game_row[COL_ID] if not game_id: return game = Game(game_id) game_actions = GameActions() game_actions.set_game(game=game) self.contextual_menu.popup(event, game_actions) def get_selected_id(self, selected_item): return self.get_model().get_value(selected_item, COL_ID) def select(self): """Selects the object pointed by current_path""" raise NotImplementedError def handle_key_press(self, widget, event): # pylint: disable=unused-argument key = event.keyval if key == Gdk.KEY_Delete: self.emit("remove-game") lutris-0.5.9.1/lutris/gui/views/grid.py000066400000000000000000000043411413267435700177660ustar00rootroot00000000000000"""Grid view for the main window""" # pylint: disable=no-member from gi.repository import Gtk from lutris.gui.views import COL_ICON, COL_NAME from lutris.gui.views.base import GameView from lutris.gui.widgets.cellrenderers import 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, service_media, hide_text=False): self.game_store = store self.service_media = service_media self.model = self.game_store.store super().__init__(model=self.game_store.store) GameView.__init__(self) self.service = None self.set_column_spacing(6) self.set_pixbuf_column(COL_ICON) self.set_item_padding(1) self.cell_width = max(service_media.size[0], self.min_width) if hide_text: self.cell_renderer = None else: self.cell_renderer = GridViewCellRendererText(self.cell_width) self.pack_end(self.cell_renderer, False) self.add_attribute(self.cell_renderer, "markup", COL_NAME) self.connect_signals() self.connect("item-activated", self.on_item_activated) self.connect("selection-changed", self.on_selection_changed) def select(self): self.select_path(self.current_path) def get_selected_item(self): """Return the currently selected game's id.""" selection = self.get_selected_items() if not selection: return self.current_path = selection[0] return self.get_model().get_iter(self.current_path) def on_item_activated(self, _view, _path): """Handles double clicks""" selected_item = self.get_selected_item() if selected_item: selected_id = self.get_selected_id(selected_item) else: selected_id = None logger.debug("Item activated: %s", selected_id) self.emit("game-activated", selected_id) def on_selection_changed(self, _view): """Handles selection changes""" selected_items = self.get_selected_item() if selected_items: self.emit("game-selected", selected_items) lutris-0.5.9.1/lutris/gui/views/list.py000066400000000000000000000147411413267435700200210ustar00rootroot00000000000000"""TreeView based game list""" from gettext import gettext as _ # Third Party Libraries # pylint: disable=no-member from gi.repository import Gtk, Pango # Lutris Modules from lutris import settings from lutris.gui.views import ( COL_ICON, COL_INSTALLED_AT, COL_INSTALLED_AT_TEXT, COL_LASTPLAYED, COL_LASTPLAYED_TEXT, COL_NAME, COL_PLATFORM, COL_PLAYTIME, COL_PLAYTIME_TEXT, COL_RUNNER_HUMAN_NAME, COL_YEAR, COLUMN_NAMES ) from lutris.gui.views.base import GameView from lutris.gui.views.store import sort_func class GameListView(Gtk.TreeView, GameView): """Show the main list of games.""" __gsignals__ = GameView.__gsignals__ def __init__(self, store, service_media): self.game_store = store self.service_media = service_media self.model = self.game_store.store super().__init__(model=self.model) GameView.__init__(self) self.set_rules_hint(True) # Icon column image_cell = Gtk.CellRendererPixbuf() column = Gtk.TreeViewColumn("", image_cell, pixbuf=COL_ICON) column.set_reorderable(True) column.set_sort_indicator(False) self.append_column(column) # 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_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_sort_with_column(COL_LASTPLAYED_TEXT, COL_LASTPLAYED) self.set_column(default_text_cell, _("Installed At"), COL_INSTALLED_AT_TEXT, 120) self.set_sort_with_column(COL_INSTALLED_AT_TEXT, COL_INSTALLED_AT) self.set_column(default_text_cell, _("Play Time"), COL_PLAYTIME_TEXT, 100) self.set_sort_with_column(COL_PLAYTIME_TEXT, COL_PLAYTIME) self.get_selection().set_mode(Gtk.SelectionMode.SINGLE) self.connect_signals() self.connect("row-activated", self.on_row_activated) self.get_selection().connect("changed", self.on_cursor_changed) @staticmethod def set_text_cell(): text_cell = Gtk.CellRendererText() text_cell.set_padding(10, 0) text_cell.set_property("ellipsize", Pango.EllipsizeMode.END) return text_cell def set_column(self, cell, header, column_id, default_width, always_visible=False, sort_id=None): column = Gtk.TreeViewColumn(header, cell, markup=column_id) column.set_sort_indicator(True) column.set_sort_column_id(column_id if sort_id is None else sort_id) self.set_column_sort(column_id if sort_id is None else sort_id) column.set_resizable(True) column.set_reorderable(True) width = settings.read_setting("%s_column_width" % COLUMN_NAMES[column_id], "list view") is_visible = settings.read_setting("%s_visible" % COLUMN_NAMES[column_id], "list view") column.set_fixed_width(int(width) if width else default_width) column.set_visible(is_visible == "True" or always_visible if is_visible else True) self.append_column(column) column.connect("notify::width", self.on_column_width_changed) column.get_button().connect('button-press-event', self.on_column_header_button_pressed) return column def set_column_sort(self, col): """Sort a column and fallback to sorting by name and runner.""" model = self.get_model() if model: model.set_sort_func(col, sort_func, col) def set_sort_with_column(self, col, sort_col): """Sort a column by using another column's data""" self.model.set_sort_func(col, sort_func, sort_col) def get_selected_item(self): """Return the currently selected game's id.""" selection = self.get_selection() if not selection: return None _model, select_iter = selection.get_selected() if select_iter: return select_iter def select(self): self.set_cursor(self.current_path[0]) def set_selected_game(self, game_id): row = self.game_store.get_row_by_id(game_id, filtered=True) if row: self.set_cursor(row.path) def on_column_header_button_pressed(self, button, event): """Handles column header button press events""" if event.button == 3: 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_item = self.get_selected_item() if selected_item: selected_id = self.get_selected_id(selected_item) else: selected_id = None self.emit("game-activated", selected_id) def on_cursor_changed(self, widget, _line=None, _column=None): selected_item = self.get_selected_item() self.emit("game-selected", selected_item) @staticmethod def on_column_width_changed(col, *args): col_name = col.get_title() if col_name: settings.write_setting( col_name.replace(" ", "") + "_column_width", col.get_fixed_width(), "list view", ) class GameListColumnToggleMenu(Gtk.Menu): def __init__(self, columns): super().__init__() self.columns = columns self.column_map = {} self.create_menuitems() self.show_all() def create_menuitems(self): for column in self.columns: title = column.get_title() if title == "": continue checkbox = Gtk.CheckMenuItem(title) checkbox.set_active(column.get_visible()) if title == _("Name"): checkbox.set_sensitive(False) else: checkbox.connect("toggled", self.on_toggle_column) self.column_map[checkbox] = column self.append(checkbox) def on_toggle_column(self, check_menu_item): column = self.column_map[check_menu_item] is_visible = check_menu_item.get_active() column.set_visible(is_visible) settings.write_setting( column.get_title().replace(" ", "") + "_visible", str(is_visible), "list view", ) lutris-0.5.9.1/lutris/gui/views/media_loader.py000066400000000000000000000020551413267435700214460ustar00rootroot00000000000000"""Loads game media in parallel""" import concurrent.futures from lutris.util import system from lutris.util.log import logger def download_icons(media_urls, service_media): """Download a list of media files concurrently. Limits the number of simultaneous downloads to avoid API throttling and UI being overloaded with signals. """ icons = {} num_workers = 5 with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: future_downloads = { executor.submit(service_media.download, slug, url): slug for slug, url in media_urls.items() if url } for future in concurrent.futures.as_completed(future_downloads): slug = future_downloads[future] try: path = future.result() except Exception as ex: # pylint: disable=broad-except logger.exception('%r failed: %s', slug, ex) path = None if system.path_exists(path): icons[slug] = path return icons lutris-0.5.9.1/lutris/gui/views/store.py000066400000000000000000000146051413267435700202010ustar00rootroot00000000000000"""Store object for a list of games""" # pylint: disable=not-an-iterable import time from gi.repository import GLib, GObject, Gtk from gi.repository.GdkPixbuf import Pixbuf from lutris import settings from lutris.database import sql from lutris.database.games import get_games from lutris.gui.views.store_item import StoreItem from lutris.gui.widgets.utils import get_pixbuf from lutris.util.strings import gtk_safe from . import ( COL_ICON, COL_ID, COL_INSTALLED, COL_INSTALLED_AT, COL_INSTALLED_AT_TEXT, COL_LASTPLAYED, COL_LASTPLAYED_TEXT, COL_NAME, COL_PLATFORM, COL_PLAYTIME, COL_PLAYTIME_TEXT, COL_RUNNER, COL_RUNNER_HUMAN_NAME, COL_SLUG, 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_NAME)) value2 = try_lower(model.get_value(row2, COL_NAME)) try: diff = -1 if value1 < value2 else 0 if value1 == value2 else 1 except TypeError: diff = 0 if diff == 0: value1 = try_lower(model.get_value(row1, COL_RUNNER_HUMAN_NAME)) value2 = try_lower(model.get_value(row2, COL_RUNNER_HUMAN_NAME)) try: return -1 if value1 < value2 else 0 if value1 == value2 else 1 except TypeError: return 0 class GameStore(GObject.Object): __gsignals__ = { "icons-changed": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, service, service_media): super().__init__() self.service = service self.service_media = service_media self._installed_games = [] self._installed_games_accessed = False self._icon_updates = {} self.store = Gtk.ListStore( str, str, str, Pixbuf, str, str, str, str, int, str, bool, int, str, float, str, ) @property def installed_game_slugs(self): previous_access = self._installed_games_accessed or 0 self._installed_games_accessed = time.time() if self._installed_games_accessed - previous_access > 1: self._installed_games = [g["slug"] for g in get_games(filters={"installed": "1"})] return self._installed_games def add_games(self, games): """Add games to the store""" for game in list(games): GLib.idle_add(self.add_game, game) def get_row_by_slug(self, slug): for model_row in self.store: if model_row[COL_SLUG] == slug: return model_row def get_row_by_id(self, _id): if not _id: return for model_row in self.store: try: if model_row[COL_ID] == str(_id): return model_row except TypeError: return def remove_game(self, _id): """Remove a game from the view.""" row = self.get_row_by_id(_id) if row: self.store.remove(row.iter) def update(self, db_game): """Update game informations Return whether a row was updated """ store_item = StoreItem(db_game, self.service_media) row = self.get_row_by_id(store_item.id) if not row: row = self.get_row_by_id(db_game["service_id"]) if not row: return False row[COL_ID] = str(store_item.id) row[COL_SLUG] = store_item.slug row[COL_NAME] = gtk_safe(store_item.name) row[COL_ICON] = store_item.get_pixbuf() row[COL_YEAR] = store_item.year row[COL_RUNNER] = store_item.runner row[COL_RUNNER_HUMAN_NAME] = gtk_safe(store_item.runner_text) row[COL_PLATFORM] = gtk_safe(store_item.platform) row[COL_LASTPLAYED] = store_item.lastplayed row[COL_LASTPLAYED_TEXT] = store_item.lastplayed_text row[COL_INSTALLED] = store_item.installed row[COL_INSTALLED_AT] = store_item.installed_at row[COL_INSTALLED_AT_TEXT] = store_item.installed_at_text row[COL_PLAYTIME] = store_item.playtime row[COL_PLAYTIME_TEXT] = store_item.playtime_text return True def add_game(self, db_game): """Add a PGA game to the store""" game = StoreItem(db_game, self.service_media) self.store.append( ( str(game.id), game.slug, game.name, game.get_pixbuf(), game.year, game.runner, game.runner_text, gtk_safe(game.platform), game.lastplayed, game.lastplayed_text, game.installed, game.installed_at, game.installed_at_text, game.playtime, game.playtime_text, ) ) def on_game_updated(self, game): if self.service: db_games = sql.filtered_query( settings.PGA_DB, "service_games", filters=({ "service": self.service_media.service, "appid": game.appid }) ) else: db_games = sql.filtered_query( settings.PGA_DB, "games", filters=({ "id": game.id }) ) for db_game in db_games: GLib.idle_add(self.update, db_game) return True def update_icons(self, icon_updates): """Updates the store with new icon paths keyed by slug""" for slug in icon_updates: row = self.get_row_by_slug(slug) if not row: continue installed = slug in self.installed_game_slugs row[COL_ICON] = get_pixbuf(icon_updates[slug], self.service_media.size, is_installed=installed) lutris-0.5.9.1/lutris/gui/views/store_item.py000066400000000000000000000116431413267435700212160ustar00rootroot00000000000000"""Game representation for views""" import time from lutris.database.games import get_service_games from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.gui.widgets.utils import get_pixbuf, get_pixbuf_for_game from lutris.runners import RUNNER_NAMES from lutris.util import system 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.service_media = service_media def __str__(self): return self.name def __repr__(self): return "" % (self.id, self.slug) @property def id(self): # pylint: disable=invalid-name """Game internal ID""" # Return an unique identifier for the game. # Since service games are not related to lutris, use the appid if "service_id" not in self._game_data: if "appid" in self._game_data: return self._game_data["appid"] return self._game_data["slug"] return self._game_data["id"] @property def service(self): return gtk_safe(self._game_data.get("service")) @property def slug(self): """Slug identifier""" return gtk_safe(self._game_data["slug"]) @property def name(self): """Name""" return gtk_safe(self._game_data["name"]) @property def year(self): """Year""" return str(self._game_data.get("year") or "") @property def runner(self): """Runner slug""" return gtk_safe(self._game_data.get("runner")) or "" @property def runner_text(self): """Runner name""" return gtk_safe(RUNNER_NAMES.get(self.runner)) @property def platform(self): """Platform""" _platform = self._game_data.get("platform") if not _platform and not self.service and self.installed: game_inst = Game(self._game_data["id"]) if game_inst.platform: _platform = game_inst.platform return gtk_safe(_platform) @property def installed(self): """Game is installed""" if "service_id" not in self._game_data: return self.id in get_service_games(self.service) if not self._game_data.get("runner"): return False return self._game_data.get("installed") def get_pixbuf(self): """Pixbuf varying on icon type""" if self._game_data.get("icon"): image_path = self._game_data["icon"] else: image_path = self.service_media.get_absolute_path(self.slug) if not system.path_exists(image_path): service = self._game_data.get("service") appid = self._game_data.get("service_id") if appid: service_game = ServiceGameCollection.get_game(service, appid) else: service_game = None if service_game: image_path = self.service_media.get_absolute_path(service_game["slug"]) if system.path_exists(image_path): return get_pixbuf(image_path, self.service_media.size, is_installed=self.installed) return get_pixbuf_for_game( self._game_data["slug"], self.service_media.size, self.installed ) @property def installed_at(self): """Date of install""" return self._game_data.get("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._game_data.get("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._game_data.get("playtime", 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.9.1/lutris/gui/widgets/000077500000000000000000000000001413267435700167765ustar00rootroot00000000000000lutris-0.5.9.1/lutris/gui/widgets/__init__.py000066400000000000000000000000001413267435700210750ustar00rootroot00000000000000lutris-0.5.9.1/lutris/gui/widgets/cellrenderers.py000066400000000000000000000007641413267435700222100ustar00rootroot00000000000000from gi.repository import Gtk, Pango class GridViewCellRendererText(Gtk.CellRendererText): """CellRendererText adjusted for grid view display, removes extra padding""" def __init__(self, width, *args, **kwargs): super(GridViewCellRendererText, self).__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.props.wrap_width = width lutris-0.5.9.1/lutris/gui/widgets/common.py000066400000000000000000000256231413267435700206500ustar00rootroot00000000000000"""Misc widgets used in the GUI.""" # Standard Library import os from gettext import gettext as _ # Third Party Libraries from gi.repository import GObject, Gtk, Pango # Lutris Modules from lutris.gui.widgets.utils import get_stock_icon from lutris.util import system from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger 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. def __init__( self, title=_("Select file"), action=Gtk.FileChooserAction.OPEN, path=None, default_path=None, warn_if_non_empty=False, warn_if_ntfs=False ): super().__init__( orientation=Gtk.Orientation.VERTICAL, spacing=0, visible=True ) self.title = title self.action = action self.path = os.path.expanduser(path) if path else None self.default_path = os.path.expanduser(default_path) if default_path else path self.warn_if_non_empty = warn_if_non_empty self.warn_if_ntfs = warn_if_ntfs self.path_completion = Gtk.ListStore(str) self.entry = Gtk.Entry(visible=True) self.entry.set_completion(self.get_completion()) self.entry.connect("changed", self.on_entry_changed) if path: self.entry.set_text(path) browse_button = Gtk.Button(_("Browse..."), visible=True) browse_button.connect("clicked", self.on_browse_clicked) box = Gtk.Box(spacing=6, visible=True) box.pack_start(self.entry, True, True, 0) box.add(browse_button) self.pack_start(box, False, False, 0) def get_text(self): """Return the entry's text""" return self.entry.get_text() def get_filename(self): """Deprecated""" logger.warning("Just use get_text") return self.get_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 FileChooserDialog configured for this widget""" dialog = Gtk.FileChooserDialog(title=self.title, transient_for=None, action=self.action) dialog.add_buttons(_("_Cancel"), Gtk.ResponseType.CLOSE, _("_OK"), Gtk.ResponseType.OK) dialog.set_create_folders(True) dialog.set_current_folder(self.get_default_folder()) dialog.connect("response", self.on_select_file) return dialog def get_default_folder(self): """Return the default folder for the file picker""" default_path = self.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() file_chooser_dialog.run() def on_entry_changed(self, widget): """Entry changed callback""" self.clear_warnings() path = widget.get_text() if not path: return path = os.path.expanduser(path) self.update_completion(path) 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_pixbuf(get_stock_icon("dialog-warning", 32)) 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 usually 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 might not work properly." )) self.pack_end(non_empty_label, False, False, 10) parent = system.get_existing_parent(path) if parent is not None and not os.access(parent, os.W_OK): non_writable_destination_label = Gtk.Label(visible=True) non_writable_destination_label.set_markup(_( "Warning The destination folder " "is not writable by the current user." )) self.pack_end(non_writable_destination_label, False, False, 10) def on_select_file(self, dialog, response): """FileChooserDialog response callback""" if response == Gtk.ResponseType.OK: target_path = dialog.get_filename() if target_path: dialog.set_current_folder(target_path) self.entry.set_text(system.reverse_expanduser(target_path)) dialog.hide() 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): """Custom init of label.""" super().__init__(label=message) self.set_line_wrap(True) self.set_max_width_chars(22) self.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) self.set_size_request(230, -1) self.set_alignment(0, 0.5) self.set_justify(Gtk.Justification.LEFT) class InstallerLabel(Gtk.Label): """Label for installer window""" def __init__(self, message=None): super().__init__(label=message) self.set_max_width_chars(80) self.set_property("wrap", True) self.set_use_markup(True) self.set_selectable(True) self.set_alignment(0.5, 0) class VBox(Gtk.Box): def __init__(self, **kwargs): super().__init__(orientation=Gtk.Orientation.VERTICAL, margin_top=18, **kwargs) class EditableGrid(Gtk.Grid): __gsignals__ = {"changed": (GObject.SIGNAL_RUN_FIRST, None, ())} def __init__(self, data, columns): self.columns = columns super().__init__() self.set_column_homogeneous(True) self.set_row_homogeneous(True) self.set_row_spacing(10) self.set_column_spacing(10) self.liststore = Gtk.ListStore(str, str) for item in data: self.liststore.append([str(value) for value in item]) self.treeview = Gtk.TreeView.new_with_model(self.liststore) self.treeview.set_grid_lines(Gtk.TreeViewGridLines.BOTH) for i, column_title in enumerate(self.columns): renderer = Gtk.CellRendererText() renderer.set_property("editable", True) renderer.connect("edited", self.on_text_edited, i) column = Gtk.TreeViewColumn(column_title, renderer, text=i) column.set_resizable(True) column.set_min_width(100) column.set_sort_column_id(0) self.treeview.append_column(column) self.buttons = [] self.add_button = Gtk.Button(_("Add")) self.buttons.append(self.add_button) self.add_button.connect("clicked", self.on_add) self.delete_button = Gtk.Button(_("Delete")) self.buttons.append(self.delete_button) self.delete_button.connect("clicked", self.on_delete) self.scrollable_treelist = Gtk.ScrolledWindow() self.scrollable_treelist.set_vexpand(True) self.scrollable_treelist.add(self.treeview) self.attach(self.scrollable_treelist, 0, 0, 5, 5) self.attach(self.add_button, 5 - len(self.buttons), 6, 1, 1) for i, button in enumerate(self.buttons[1:]): self.attach_next_to(button, self.buttons[i], Gtk.PositionType.RIGHT, 1, 1) self.show_all() def on_add(self, widget): # pylint: disable=unused-argument self.liststore.append(["", ""]) row_position = len(self.liststore) - 1 self.treeview.set_cursor(row_position, None, False) self.treeview.scroll_to_cell(row_position, None, False, 0.0, 0.0) self.emit("changed") def on_delete(self, widget): # pylint: disable=unused-argument selection = self.treeview.get_selection() _, iteration = selection.get_selected() self.liststore.remove(iteration) self.emit("changed") def on_text_edited(self, widget, path, text, field): # pylint: disable=unused-argument self.liststore[path][field] = text.strip() # pylint: disable=unsubscriptable-object self.emit("changed") def get_data(self): # pylint: disable=arguments-differ model_data = [] for row in self.liststore: # pylint: disable=not-an-iterable model_data.append(row) return model_data lutris-0.5.9.1/lutris/gui/widgets/contextual_menu.py000066400000000000000000000035041413267435700225640ustar00rootroot00000000000000from gi.repository import Gtk from lutris import runners 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 action = Gtk.Action(name=name, label=label) action.connect("activate", callback) menu_item = action.create_menu_item() menu_item.action_id = name self.append(menu_item) return menu_item def get_runner_entries(self, game): if not game: return None try: runner = runners.import_runner(game.runner_name)(game.config) except runners.InvalidRunner: return None return runner.context_menu_entries def popup(self, event, game_actions, game=None, service=None): for item in self.get_children(): self.remove(item) for entry in self.main_entries: self.add_menuitem(entry) if game_actions.game.runner_name and game_actions.game.is_installed: runner_entries = self.get_runner_entries(game) if runner_entries: self.append(Gtk.SeparatorMenuItem()) for entry in runner_entries: self.add_menuitem(entry) self.show_all() displayed = game_actions.get_displayed_entries() for menuitem in self.get_children(): if not isinstance(menuitem, Gtk.ImageMenuItem): continue menuitem.set_visible(displayed.get(menuitem.action_id, True)) super().popup(None, None, None, None, event.button, event.time) lutris-0.5.9.1/lutris/gui/widgets/download_progress_box.py000066400000000000000000000124121413267435700237530ustar00rootroot00000000000000from 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 class DownloadProgressBox(Gtk.Box): """Progress bar used to monitor a file download.""" __gsignals__ = { "complete": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT, )), "cancel": (GObject.SignalFlags.RUN_LAST, None, ()), "error": (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT, )), } def __init__(self, params, cancelable=True, downloader=None): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.downloader = downloader self.is_complete = False self.url = params.get("url") self.dest = params.get("dest") self.referer = params.get("referer") self.main_label = Gtk.Label(self.get_title()) self.main_label.set_alignment(0, 0) self.main_label.set_property("wrap", True) self.main_label.set_margin_bottom(10) # self.main_label.set_max_width_chars(70) self.main_label.set_selectable(True) self.main_label.set_property("ellipsize", Pango.EllipsizeMode.MIDDLE) self.pack_start(self.main_label, True, True, 0) progress_box = Gtk.Box() self.progressbar = Gtk.ProgressBar() self.progressbar.set_margin_top(5) self.progressbar.set_margin_bottom(5) self.progressbar.set_margin_right(10) progress_box.pack_start(self.progressbar, True, True, 0) self.cancel_button = Gtk.Button.new_with_mnemonic(_("_Cancel")) self.cancel_cb_id = self.cancel_button.connect("clicked", self.on_cancel_clicked) if not cancelable: self.cancel_button.set_sensitive(False) progress_box.pack_end(self.cancel_button, False, False, 0) self.pack_start(progress_box, False, False, 0) self.progress_label = Gtk.Label() self.progress_label.set_alignment(0, 0) self.pack_start(self.progress_label, True, True, 0) self.show_all() self.cancel_button.hide() def get_title(self): """Return the main label text for the widget""" parsed = urlparse(self.url) return "%s%s" % (parsed.netloc, parsed.path) def start(self): """Start downloading a file.""" if not self.downloader: try: self.downloader = Downloader(self.url, self.dest, referer=self.referer, overwrite=True) except RuntimeError as ex: from lutris.gui.dialogs import ErrorDialog ErrorDialog(ex.args[0]) self.emit("cancel") return None timer_id = GLib.timeout_add(500, self._progress) self.cancel_button.show() self.cancel_button.set_sensitive(True) if not self.downloader.state == self.downloader.DOWNLOADING: self.downloader.start() return timer_id def set_retry_button(self): """Transform the cancel button into a retry button""" self.cancel_button.set_label(_("Retry")) self.cancel_button.disconnect(self.cancel_cb_id) self.cancel_cb_id = self.cancel_button.connect("clicked", self.on_retry_clicked) self.cancel_button.set_sensitive(True) def on_retry_clicked(self, button): logger.debug("Retrying download") button.set_label(_("Cancel")) button.disconnect(self.cancel_cb_id) self.cancel_cb_id = button.connect("clicked", self.on_cancel_clicked) self.downloader.reset() self.start() def on_cancel_clicked(self, _widget=None): """Cancel the current download.""" logger.debug("Download cancel requested") if self.downloader: self.downloader.cancel() self.cancel_button.set_sensitive(False) self.emit("cancel") def _progress(self): """Show download progress.""" progress = min(self.downloader.check_progress(), 1) if self.downloader.state in [self.downloader.CANCELLED, self.downloader.ERROR]: self.progressbar.set_fraction(0) if self.downloader.state == self.downloader.CANCELLED: self._set_text(_("Download interrupted")) self.emit("cancel") else: self._set_text(str(self.downloader.error)[:80]) return False self.progressbar.set_fraction(progress) megabytes = 1024 * 1024 progress_text = _( "{downloaded:0.2f} / {size:0.2f}MB ({speed:0.2f}MB/s), {time} remaining" ).format( downloaded=float(self.downloader.downloaded_size) / megabytes, size=float(self.downloader.full_size) / megabytes, 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 = u"{}".format(gtk_safe(text)) self.progress_label.set_markup(markup) lutris-0.5.9.1/lutris/gui/widgets/game_bar.py000066400000000000000000000252171413267435700211140ustar00rootroot00000000000000from 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_by_field, get_game_for_service from lutris.game import Game from lutris.gui.widgets.utils import get_link_button, get_pixbuf_for_game from lutris.util.strings import gtk_safe class GameBar(Gtk.Fixed): play_button_position = (12, 42) def __init__(self, db_game, game_actions, application): """Create the game bar with a database row""" super().__init__(visible=True) GObject.add_emission_hook(Game, "game-start", self.on_game_state_changed) GObject.add_emission_hook(Game, "game-started", self.on_game_state_changed) GObject.add_emission_hook(Game, "game-stopped", self.on_game_state_changed) GObject.add_emission_hook(Game, "game-updated", self.on_game_state_changed) GObject.add_emission_hook(Game, "game-removed", self.on_game_state_changed) GObject.add_emission_hook(Game, "game-installed", self.on_game_state_changed) self.set_margin_bottom(12) self.game_actions = game_actions 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"] if self.service.id == "lutris": game = get_game_by_field(self.appid, field="slug") else: 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) or Game(game_id) else: self.game = Game() self.game.name = db_game["name"] self.game.slug = db_game["slug"] self.game.appid = self.appid self.game.service = self.service.id if self.service else None game_actions.set_game(self.game) self.update_view() def clear_view(self): """Clears all widgets from the container""" for child in self.get_children(): child.destroy() def update_view(self): """Populate the view with widgets""" self.put(self.get_game_name_label(), 16, 8) x_offset = 140 y_offset = 40 if self.game.is_installed: self.put(self.get_runner_button(), x_offset, y_offset + 2) x_offset += 80 self.put(self.get_platform_label(), x_offset, y_offset) x_offset += 120 if self.game.lastplayed: self.put(self.get_last_played_label(), x_offset, y_offset) x_offset += 120 if self.game.playtime: self.put(self.get_playtime_label(), x_offset, y_offset) self.play_button = self.get_play_button() self.put(self.play_button, self.play_button_position[0], self.play_button_position[1]) def get_popover(self, buttons, parent): """Return the popover widget containing a list of link buttons""" if not buttons: return None popover = Gtk.Popover() vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, visible=True) for action in buttons: vbox.pack_end(buttons[action], False, False, 1) popover.add(vbox) popover.set_position(Gtk.PositionType.TOP) popover.set_constrain_to(Gtk.PopoverConstraint.NONE) popover.set_relative_to(parent) return popover def get_icon(self): """Return the game icon""" icon = Gtk.Image.new_from_pixbuf(get_pixbuf_for_game(self.game.slug, (32, 32))) icon.show() return icon def get_game_name_label(self): """Return the label with the game's title""" title_label = Gtk.Label(visible=True) title_label.set_markup("%s" % gtk_safe(self.game.name)) return title_label def get_runner_button(self): icon_name = self.game.runner.name + "-symbolic" runner_icon = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU) runner_icon.show() box = Gtk.HBox(visible=True) runner_button = Gtk.Button(visible=True) popover = self.get_popover(self.get_runner_buttons(), runner_button) if popover: runner_button.set_image(runner_icon) popover_button = Gtk.MenuButton(visible=True) popover_button.set_size_request(32, 32) popover_button.props.direction = Gtk.ArrowType.UP popover_button.set_popover(popover) runner_button.connect("clicked", lambda _x: popover_button.emit("clicked")) box.add(runner_button) box.add(popover_button) style_context = box.get_style_context() style_context.add_class("linked") else: runner_icon.set_margin_top(8) runner_icon.set_margin_left(48) box.add(runner_icon) return box def get_platform_label(self): platform_label = Gtk.Label(visible=True) platform_label.set_size_request(120, -1) platform_label.set_alignment(0, 0.5) platform = gtk_safe(self.game.platform) platform_label.set_tooltip_markup(platform) platform_label.set_markup(_("Platform:\n%s") % platform) platform_label.set_property("ellipsize", Pango.EllipsizeMode.END) return platform_label def get_playtime_label(self): """Return the label containing the playtime info""" playtime_label = Gtk.Label(visible=True) playtime_label.set_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) lastplayed = datetime.fromtimestamp(self.game.lastplayed) last_played_label.set_markup(_("Last played:\n%s") % lastplayed.strftime("%x")) return last_played_label def get_popover_button(self): """Return the popover button+menu for the Play button""" popover_button = Gtk.MenuButton(visible=True) popover_button.set_size_request(32, 32) popover_button.props.direction = Gtk.ArrowType.UP return popover_button def get_popover_box(self): """Return a container for a button + a popover button attached to it""" box = Gtk.HBox(visible=True) style_context = box.get_style_context() style_context.add_class("linked") return box def get_locate_installed_game_button(self): """Return a button to locate an existing install""" button = get_link_button("Locate installed game") button.show() button.connect("clicked", self.game_actions.on_locate_installed_game, self.game) return {"locate": button} def get_play_button(self): """Return the widget for install/play/stop and game config""" button = Gtk.Button(visible=True) button.set_size_request(120, 32) box = self.get_popover_box() popover_button = self.get_popover_button() if self.game.is_installed: if self.game.state == self.game.STATE_STOPPED: button.set_label(_("Play")) button.connect("clicked", self.game_actions.on_game_launch) elif self.game.state == self.game.STATE_LAUNCHING: button.set_label(_("Launching")) button.set_sensitive(False) else: button.set_label(_("Stop")) button.connect("clicked", self.game_actions.on_game_stop) else: button.set_label(_("Install")) button.connect("clicked", self.game_actions.on_install_clicked) if self.service: if self.service.local: # Local services don't show an install dialog, they can be launched directly button.set_label(_("Play")) if self.service.drm_free: button.set_size_request(84, 32) box.add(button) popover = self.get_popover(self.get_locate_installed_game_button(), popover_button) popover_button.set_popover(popover) box.add(popover_button) return box return button button.set_size_request(84, 32) box.add(button) popover = self.get_popover(self.get_game_buttons(), popover_button) popover_button.set_popover(popover) box.add(popover_button) return box def get_game_buttons(self): """Return a dictionary of buttons to use in the panel""" displayed = self.game_actions.get_displayed_entries() buttons = {} for action in self.game_actions.get_game_actions(): action_id, label, callback = action if action_id in ("play", "stop", "install"): continue button = get_link_button(label) if displayed.get(action_id): button.show() else: button.hide() buttons[action_id] = button button.connect("clicked", self.on_link_button_clicked, callback) return buttons def get_runner_buttons(self): buttons = {} if self.game.runner_name and self.game.is_installed: runner = runners.import_runner(self.game.runner_name)(self.game.config) for entry in runner.context_menu_entries: name, label, callback = entry button = get_link_button(label) button.show() button.connect("clicked", self.on_link_button_clicked, callback) buttons[name] = 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 ( game.id == self.game.id or game.appid == self.appid ): self.game = game else: return True self.clear_view() self.update_view() return True lutris-0.5.9.1/lutris/gui/widgets/gi_composites.py000066400000000000000000000226611413267435700222230ustar00rootroot00000000000000"""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.9.1/lutris/gui/widgets/log_text_view.py000066400000000000000000000064621413267435700222370ustar00rootroot00000000000000# Third Party Libraries from gi.repository import Gtk class LogTextView(Gtk.TextView): # pylint: disable=no-member def __init__(self, buffer=None, autoscroll=True): 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(Gtk.WrapMode.CHAR) 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.9.1/lutris/gui/widgets/notifications.py000066400000000000000000000011541413267435700222220ustar00rootroot00000000000000import gi from lutris.util.log import logger NOTIFY_SUPPORT = True try: gi.require_version('Notify', '0.7') from gi.repository import Notify except ImportError: NOTIFY_SUPPORT = False if NOTIFY_SUPPORT: Notify.init("lutris") else: logger.warning("Notifications are disabled, please install GObject bindings for 'Notify' to enable them.") def send_notification(title, text, file_path_to_icon="lutris"): if NOTIFY_SUPPORT: notification = Notify.Notification.new(title, text, file_path_to_icon) notification.show() else: logger.info(title) logger.info(text) lutris-0.5.9.1/lutris/gui/widgets/searchable_combobox.py000066400000000000000000000063611413267435700233370ustar00rootroot00000000000000"""Extended combobox with search""" # pylint: disable=unsubscriptable-object from gi.repository import GLib, GObject, Gtk from lutris.util.jobs import AsyncCall class SearchableCombobox(Gtk.Bin): """Combox box with autocompletion. Well fitted for large lists. """ __gsignals__ = { "changed": (GObject.SIGNAL_RUN_FIRST, None, (str, )), } def __init__(self, choice_func, initial=None): super().__init__() self.initial = initial self.liststore = Gtk.ListStore(str, str) self.combobox = Gtk.ComboBox.new_with_model_and_entry(self.liststore) self.combobox.set_entry_text_column(0) self.combobox.set_id_column(1) self.combobox.set_valign(Gtk.Align.CENTER) completion = Gtk.EntryCompletion() completion.set_model(self.liststore) completion.set_text_column(0) completion.set_match_func(self.search_store) completion.connect("match-selected", self.set_id_from_completion) entry = self.combobox.get_child() entry.set_icon_from_icon_name(Gtk.EntryIconPosition.PRIMARY, "content-loading-symbolic") entry.set_completion(completion) self.combobox.connect("changed", self.on_combobox_change) self.combobox.connect("scroll-event", self._on_combobox_scroll) self.add(self.combobox) GLib.idle_add(self._populate_combobox_choices, choice_func) def get_model(self): """Proxy to the liststore""" return self.liststore def get_active(self): """Proxy to the get_active method""" return self.combobox.get_active() @staticmethod def get_has_entry(): """The entry present is not for editing custom values, only search""" return False def search_store(self, _completion, string, _iter): """Return true if any word of a string is present in a row""" for word in string.split(): if word not in self.liststore[_iter][0].lower(): # search is always lower case return False return True def set_id_from_completion(self, _completion, model, _iter): """Sets the active ID to the appropriate ID column in the model otherwise the value is set to the entry's value. """ self.combobox.set_active_id(model[_iter][1]) def _populate_combobox_choices(self, choice_func): AsyncCall(self._do_populate_combobox_choices, None, choice_func) def _do_populate_combobox_choices(self, choice_func): for choice in choice_func(): self.liststore.append(choice) entry = self.combobox.get_child() entry.set_icon_from_icon_name(Gtk.EntryIconPosition.PRIMARY, None) self.combobox.set_active_id(self.initial) @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.9.1/lutris/gui/widgets/sidebar.py000066400000000000000000000357251413267435700207750ustar00rootroot00000000000000"""Sidebar for the main window""" from gettext import gettext as _ from gi.repository import GLib, GObject, Gtk, Pango from lutris import runners, services from lutris.database import categories as categories_db from lutris.database import games as games_db from lutris.game import Game 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.services.base import AuthTokenExpired, BaseService from lutris.util.jobs import AsyncCall TYPE = 0 SLUG = 1 ICON = 2 LABEL = 3 GAMECOUNT = 4 class SidebarRow(Gtk.ListBoxRow): """A row in the sidebar containing possible action buttons""" MARGIN = 9 SPACING = 6 def __init__(self, id_, type_, name, icon, application=None): """Initialize the row Parameters: id_: identifier of the row type: type of row to display (still used?) name (str): Text displayed on the row icon (GtkImage): icon displayed next to the label application (GtkApplication): reference to the running application """ super().__init__() self.application = application self.type = type_ self.id = id_ self.runner = None self.name = name self.is_updating = False self.buttons = {} self.box = Gtk.Box(spacing=self.SPACING, margin_start=self.MARGIN, margin_end=self.MARGIN) self.connect("realize", self.on_realize) self.add(self.box) if not icon: icon = Gtk.Box(spacing=self.SPACING, margin_start=self.MARGIN, margin_end=self.MARGIN) self.box.add(icon) label = Gtk.Label( label=name, halign=Gtk.Align.START, hexpand=True, margin_top=self.SPACING, margin_bottom=self.SPACING, ellipsize=Pango.EllipsizeMode.END, ) self.box.pack_start(label, True, True, 0) self.btn_box = Gtk.Box(spacing=3, no_show_all=True, valign=Gtk.Align.CENTER, homogeneous=True) self.box.pack_end(self.btn_box, False, False, 0) self.spinner = Gtk.Spinner() self.box.pack_end(self.spinner, False, False, 0) 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, Gtk.Image.new_from_icon_name(service.icon, Gtk.IconSize.MENU) ) self.service = service def get_actions(self): """Return the definition of buttons to be added to the row""" return [ ("view-refresh-symbolic", _("Reload"), self.on_refresh_clicked, "refresh") ] 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 AsyncCall(self.service.reload, self.service_load_cb) def service_load_cb(self, _result, error): if error: if isinstance(error, AuthTokenExpired): self.service.logout() self.service.login() else: ErrorDialog(str(error)) 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 { "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() if self.service.is_authenticated(): return [buttons["refresh"], buttons["disconnect"]] return [buttons["connect"]] def on_connect_clicked(self, button): button.set_sensitive(False) if self.service.is_authenticated(): self.service.logout() else: self.service.login() 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. self.runner = runners.import_runner(self.id)() if self.runner.multiple_versions: entries.append(( "system-software-install-symbolic", _("Manage Versions"), self.on_manage_versions, "manage-versions" )) if self.runner.runnable_alone: entries.append(("media-playback-start-symbolic", _("Run"), self.runner.run, "run")) entries.append(("emblem-system-symbolic", _("Configure"), self.on_configure_runner, "configure")) return entries def on_configure_runner(self, *_args): """Show runner configuration""" self.application.show_window(RunnerConfigDialog, runner=self.runner) def on_manage_versions(self, *_args): """Manage runner versions""" dlg_title = _("Manage %s versions") % self.runner.name RunnerInstallDialog(dlg_title, self.get_toplevel(), self.runner.name) class SidebarHeader(Gtk.Box): """Header shown on top of each sidebar section""" def __init__(self, name): super().__init__(orientation=Gtk.Orientation.VERTICAL) 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, selected=None): super().__init__() self.set_size_request(200, -1) self.application = application self.get_style_context().add_class("sidebar") self.installed_runners = [] self.service_rows = {} self.active_platforms = None self.runners = None self.platforms = None self.categories = None # A dummy objects that allows inspecting why/when we have a show() call on the object. self.running_row = DummyRow() if selected: self.selected_row_type, self.selected_row_id = selected.split(":") else: self.selected_row_type, self.selected_row_id = ("category", "all") self.row_headers = { "library": SidebarHeader(_("Library")), "sources": SidebarHeader(_("Sources")), "runners": SidebarHeader(_("Runners")), "platforms": SidebarHeader(_("Platforms")), } GObject.add_emission_hook(RunnerBox, "runner-installed", self.update) GObject.add_emission_hook(RunnerBox, "runner-removed", self.update) GObject.add_emission_hook(ServicesBox, "services-changed", self.on_services_changed) GObject.add_emission_hook(Game, "game-start", self.on_game_start) GObject.add_emission_hook(Game, "game-stop", self.on_game_stop) GObject.add_emission_hook(Game, "game-updated", self.update) GObject.add_emission_hook(Game, "game-removed", self.update) GObject.add_emission_hook(BaseService, "service-login", self.on_service_auth_changed) GObject.add_emission_hook(BaseService, "service-logout", self.on_service_auth_changed) GObject.add_emission_hook(BaseService, "service-games-load", self.on_service_games_updating) GObject.add_emission_hook(BaseService, "service-games-loaded", self.on_service_games_updated) self.connect("realize", self.on_realize) self.set_filter_func(self._filter_func) self.set_header_func(self._header_func) self.show_all() def get_sidebar_icon(self, icon_name): return Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.MENU) def on_realize(self, widget): self.active_platforms = games_db.get_used_platforms() self.runners = sorted(runners.__all__) self.platforms = sorted(runners.RUNNER_PLATFORMS) self.categories = categories_db.get_categories() self.add( SidebarRow( "all", "category", _("Games"), Gtk.Image.new_from_icon_name("applications-games-symbolic", Gtk.IconSize.MENU) ) ) self.add( SidebarRow( "recent", "dynamic_category", _("Recent"), Gtk.Image.new_from_icon_name("document-open-recent-symbolic", Gtk.IconSize.MENU) ) ) self.add( SidebarRow( "favorite", "category", _("Favorites"), Gtk.Image.new_from_icon_name("favorite-symbolic", Gtk.IconSize.MENU) ) ) self.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) service_classes = services.get_enabled_services() for service_name in service_classes: service = service_classes[service_name]() row_class = OnlineServiceSidebarRow if service.online else ServiceSidebarRow service_row = row_class(service) self.service_rows[service_name] = service_row self.add(service_row) for runner_name in self.runners: icon_name = runner_name.lower().replace(" ", "") + "-symbolic" runner = runners.import_runner(runner_name)() self.add(RunnerSidebarRow( runner_name, "runner", runner.human_name, self.get_sidebar_icon(icon_name), application=self.application )) for platform in self.platforms: icon_name = (platform.lower().replace(" ", "").replace("/", "_") + "-symbolic") self.add(SidebarRow(platform, "platform", platform, self.get_sidebar_icon(icon_name))) self.update() for row in self.get_children(): if row.type == self.selected_row_type and row.id == self.selected_row_id: self.select_row(row) break self.show_all() self.running_row.hide() def _filter_func(self, row): if not row or not row.id or row.type in ("category", "dynamic_category", "service"): return True if row.type == "runner": if row.id is None: return True # 'All' return row.id in self.installed_runners return row.id in self.active_platforms def _header_func(self, row, before): if row.get_header(): return if not before: row.set_header(self.row_headers["library"]) elif before.type in ("category", "dynamic_category") and row.type == "service": row.set_header(self.row_headers["sources"]) elif before.type == "service" and row.type == "runner": row.set_header(self.row_headers["runners"]) elif before.type == "runner" and row.type == "platform": row.set_header(self.row_headers["platforms"]) def update(self, *_args): self.installed_runners = [runner.name for runner in runners.get_installed()] self.active_platforms = games_db.get_used_platforms() self.invalidate_filter() return True def on_game_start(self, _game): """Show the "running" section when a game start""" self.running_row.show() return True def on_game_stop(self, _game): """Hide the "running" section when no games are running""" if not self.application.running_games.get_n_items(): self.running_row.hide() return True def on_service_auth_changed(self, service): self.service_rows[service.id].create_button_box() self.service_rows[service.id].update_buttons() return True def on_service_games_updating(self, service): self.service_rows[service.id].is_updating = True self.service_rows[service.id].update_buttons() return True def on_service_games_updated(self, service): self.service_rows[service.id].is_updating = False self.service_rows[service.id].update_buttons() return True def on_services_changed(self, _widget): for child in self.get_children(): child.destroy() self.on_realize(self) return True lutris-0.5.9.1/lutris/gui/widgets/status_icon.py000066400000000000000000000075661413267435700217210ustar00rootroot00000000000000"""AppIndicator based tray icon""" from gettext import gettext as _ import gi from gi.repository import Gtk from lutris.database.games import get_games from lutris.game import Game try: gi.require_version('AppIndicator3', '0.1') from gi.repository import AppIndicator3 as AppIndicator APP_INDICATOR_SUPPORTED = True except (ImportError, ValueError): APP_INDICATOR_SUPPORTED = False class LutrisStatusIcon: def __init__(self, application): self.application = application self.icon = self.create() self.menu = self.get_menu() self.set_visible(True) if APP_INDICATOR_SUPPORTED: self.icon.set_menu(self.menu) else: self.icon.connect("activate", self.on_activate) self.icon.connect("popup-menu", self.on_menu_popup) def create(self): """Create an appindicator""" if APP_INDICATOR_SUPPORTED: return AppIndicator.Indicator.new( "net.lutris.Lutris", "lutris", AppIndicator.IndicatorCategory.APPLICATION_STATUS ) return LutrisTray(self.application) def is_visible(self): """Whether the icon is visible""" if APP_INDICATOR_SUPPORTED: return self.icon.get_status() != AppIndicator.IndicatorStatus.PASSIVE return self.icon.is_visible() def set_visible(self, value): """Set the visibility of the icon""" if APP_INDICATOR_SUPPORTED: if value: visible = AppIndicator.IndicatorStatus.ACTIVE else: visible = AppIndicator.IndicatorStatus.ACTIVE self.icon.set_status(visible) else: self.icon.set_visible(value) def get_menu(self): """Instanciates the menu attached to the tray icon""" menu = Gtk.Menu() installed_games = self.add_games() number_of_games_in_menu = 10 for game in installed_games[:number_of_games_in_menu]: menu.append(self._make_menu_item_for_game(game)) menu.append(Gtk.SeparatorMenuItem()) present_menu = Gtk.ImageMenuItem() present_menu.set_image(Gtk.Image.new_from_icon_name("lutris", Gtk.IconSize.MENU)) present_menu.set_label(_("Show Lutris")) present_menu.connect("activate", self.on_activate) menu.append(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 on_activate(self, _status_icon, _event=None): """Callback to show or hide the window""" self.application.window.present() 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.do_shutdown() def _make_menu_item_for_game(self, game): menu_item = Gtk.MenuItem() menu_item.set_label(game["name"]) menu_item.connect("activate", self.on_game_selected, game["id"]) return menu_item @staticmethod def add_games(): """Adds installed games in order of last use""" installed_games = get_games(filters={"installed": 1}) installed_games.sort( key=lambda game: max(game["lastplayed"] or 0, game["installed_at"] or 0), reverse=True, ) return installed_games def on_game_selected(self, _widget, game_id): Game(game_id).launch() class LutrisTray(Gtk.StatusIcon): """Lutris tray icon""" def __init__(self, application, **_kwargs): super().__init__() self.set_tooltip_text(_("Lutris")) self.set_visible(True) self.application = application self.set_from_icon_name("lutris") lutris-0.5.9.1/lutris/gui/widgets/utils.py000066400000000000000000000173151413267435700205170ustar00rootroot00000000000000"""Various utilities using the GObject framework""" import array import os from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk from lutris import settings from lutris.util import datapath, system from lutris.util.log import logger try: from PIL import Image except ImportError: Image = None ICON_SIZE = (32, 32) BANNER_SIZE = (184, 69) 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.reset_library_preloads() try: Gtk.show_uri(None, uri, Gdk.CURRENT_TIME) except GLib.Error as ex: logger.exception("Failed to open URI %s: %s, falling back to xdg-open", uri, ex) system.execute(["xdg-open", uri]) def get_pixbuf(image, size, fallback=None, is_installed=True): """Return a pixbuf from file `image` at `size` or fallback to `fallback`""" width, height = size pixbuf = None if system.path_exists(image, exclude_empty=True): try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(image, width, height) pixbuf = pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.NEAREST) except GLib.GError: logger.error("Unable to load icon from image %s", image) else: if not fallback: fallback = get_default_icon(size) if system.path_exists(fallback): pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(fallback, width, height) if is_installed: pixbuf = pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.NEAREST) return pixbuf overlay = os.path.join(datapath.get(), "media/unavailable.png") transparent_pixbuf = get_overlay(overlay, size).copy() pixbuf.composite( transparent_pixbuf, 0, 0, size[0], size[1], 0, 0, 1, 1, GdkPixbuf.InterpType.NEAREST, 100, ) return transparent_pixbuf def get_stock_icon(name, size): """Return a pixbuf from a stock icon name""" theme = Gtk.IconTheme.get_default() try: return theme.load_icon(name, size, Gtk.IconLookupFlags.GENERIC_FALLBACK) except GLib.GError: logger.error("Failed to read icon %s", name) return None def get_icon(icon_name, icon_format="image", size=None, icon_type="runner"): """Return an icon based on the given name, format, size and type. Keyword arguments: icon_name -- The name of the icon to retrieve format -- The format of the icon, which should be either 'image' or 'pixbuf' (default 'image') size -- The size for the desired image (default None) icon_type -- Retrieve either a 'runner' or 'platform' icon (default 'runner') """ filename = icon_name.lower().replace(" ", "") + ".png" icon_path = os.path.join(settings.RUNTIME_DIR, "icons/hicolor/64x64/apps", filename) if not os.path.exists(icon_path): return None if icon_format == "image": icon = Gtk.Image() if size: icon.set_from_pixbuf(get_pixbuf(icon_path, size)) else: icon.set_from_file(icon_path) return icon if icon_format == "pixbuf" and size: return get_pixbuf(icon_path, size) raise ValueError("Invalid arguments") def get_overlay(overlay_path, size): width, height = size transparent_pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(overlay_path, width, height) transparent_pixbuf = transparent_pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.NEAREST) return transparent_pixbuf def get_default_icon(size): if size[0] == size[1]: return os.path.join(datapath.get(), "media/default_icon.png") return os.path.join(datapath.get(), "media/default_banner.png") def get_pixbuf_for_game(image_abspath, size, is_installed=True): return get_pixbuf(image_abspath, size, fallback=get_default_icon(size), is_installed=is_installed) def convert_to_background(background_path, target_size=(320, 1080)): """Converts a image to a pane background""" coverart = Image.open(background_path) coverart = coverart.convert("RGBA") target_width, target_height = target_size image_height = int(target_height * 0.80) # 80% of the mask is visible orig_width, orig_height = coverart.size # Resize and crop coverart width = int(orig_width * (image_height / orig_height)) offset = int((width - target_width) / 2) coverart = coverart.resize((width, image_height), resample=Image.BICUBIC) coverart = coverart.crop((offset, 0, target_width + offset, image_height)) # Resize canvas of coverart by putting transparent pixels on the bottom coverart_bg = Image.new('RGBA', (target_width, target_height), (0, 0, 0, 0)) coverart_bg.paste(coverart, (0, 0, target_width, image_height)) # Apply a tint to the base image # tint = Image.new('RGBA', (target_width, target_height), (0, 0, 0, 255)) # coverart = Image.blend(coverart, tint, 0.6) # Paste coverart on transparent image while applying a gradient mask background = Image.new('RGBA', (target_width, target_height), (0, 0, 0, 0)) mask = Image.open(os.path.join(datapath.get(), "media/mask.png")) background.paste(coverart_bg, mask=mask) return background def thumbnail_image(base_image, target_size): base_width, base_height = base_image.size base_ratio = base_width / base_height target_width, target_height = target_size target_ratio = target_width / target_height # Resize and crop coverart if base_ratio >= target_ratio: width = int(base_width * (target_height / base_height)) height = target_height else: width = target_width height = int(base_height * (target_width / base_width)) x_offset = int((width - target_width) / 2) y_offset = int((height - target_height) / 2) base_image = base_image.resize((width, height), resample=Image.BICUBIC) base_image = base_image.crop((x_offset, y_offset, width - x_offset, height - y_offset)) return base_image def paste_overlay(base_image, overlay_image, position=0.7): base_width, base_height = base_image.size overlay_width, overlay_height = overlay_image.size offset_x = int((base_width - overlay_width) / 2) offset_y = int((base_height - overlay_height) / 2) base_image.paste( overlay_image, ( offset_x, offset_y, overlay_width + offset_x, overlay_height + offset_y ), mask=overlay_image ) return base_image def image2pixbuf(image): """Converts a PIL Image to a GDK Pixbuf""" image_array = array.array('B', image.tobytes()) width, height = image.size return GdkPixbuf.Pixbuf.new_from_data(image_array, GdkPixbuf.Colorspace.RGB, True, 8, width, height, width * 4) def get_link_button(text): """Return a transparent text button for the side panels""" button = Gtk.Button(text, visible=True) button.props.relief = Gtk.ReliefStyle.NONE button.get_children()[0].set_alignment(0, 0.5) button.get_style_context().add_class("panel-button") button.set_size_request(-1, 24) return button 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.9.1/lutris/gui/widgets/window.py000066400000000000000000000030651413267435700206630ustar00rootroot00000000000000# Third Party Libraries from gi.repository import Gtk class BaseApplicationWindow(Gtk.ApplicationWindow): """Window used to guide the user through a issue reporting process""" def __init__(self, application): Gtk.ApplicationWindow.__init__(self, icon_name="lutris", application=application) self.application = application self.set_show_menubar(False) self.set_position(Gtk.WindowPosition.CENTER) self.connect("delete-event", self.on_destroy) self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12, visible=True) self.vbox.set_margin_top(18) self.vbox.set_margin_bottom(18) self.vbox.set_margin_right(18) self.vbox.set_margin_left(18) self.add(self.vbox) self.action_buttons = Gtk.Box(spacing=6) self.vbox.pack_end(self.action_buttons, False, False, 0) def get_action_button(self, label, handler=None, tooltip=None): """Returns a button that can be used for the action bar""" button = Gtk.Button.new_with_mnemonic(label) if handler: button.connect("clicked", handler) if tooltip: button.set_tooltip_text(tooltip) return button def on_destroy(self, _widget=None, _data=None): """Destroy callback""" self.destroy() def present(self): # pylint: disable=arguments-differ """The base implementation doesn't always work, this one does.""" self.set_keep_above(True) super().present() self.set_keep_above(False) super().present() lutris-0.5.9.1/lutris/installer/000077500000000000000000000000001413267435700165415ustar00rootroot00000000000000lutris-0.5.9.1/lutris/installer/__init__.py000066400000000000000000000030631413267435700206540ustar00rootroot00000000000000"""Install script interpreter package.""" import yaml from lutris import settings from lutris.util import system from lutris.util.http import Request from lutris.util.log import logger AUTO_ELF_EXE = "_xXx_AUTO_ELF_xXx_" AUTO_WIN32_EXE = "_xXx_AUTO_WIN32_xXx_" def fetch_script(game_slug, revision=None): """Download install script(s) for matching game_slug.""" 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 = Request(installer_url) request.get() response = request.json if response is None: raise RuntimeError("Couldn't get installer at %s" % installer_url) if not revision: return response["results"] # Revision requests return a single installer return [response] def read_script(filename): """Return scripts from a local file""" logger.debug("Loading script(s) from %s", filename) script = yaml.safe_load(open(filename, "r").read()) if isinstance(script, list): return script if "results" in script: return script["results"] return [script] def get_installers(game_slug, installer_file=None, revision=None): # check if installer is local or online if system.path_exists(installer_file): return read_script(installer_file) return fetch_script(game_slug=game_slug, revision=revision) lutris-0.5.9.1/lutris/installer/commands.py000066400000000000000000000610741413267435700207240ustar00rootroot00000000000000"""Commands for installer scripts""" import glob import json import multiprocessing import os import shlex import shutil from gettext import gettext as _ from gi.repository import GLib from lutris import runtime from lutris.cache import get_cache_path from lutris.command import MonitoredCommand from lutris.installer.errors import ScriptingError from lutris.runners import import_task from lutris.util import extract, linux, selective_merge, system from lutris.util.fileio import EvilConfigParser, MultiOrderedDict from lutris.util.log import logger from lutris.util.wine.wine import WINE_DEFAULT_ARCH, get_wine_version_exe class CommandsMixin: """The directives for the `installer:` part of the install script.""" def __init__(self): if isinstance(self, CommandsMixin): raise RuntimeError("This class is a mixin") def _get_runner_version(self): """Return the version of the runner used for the installer""" if ( self.installer.runner in ("wine", "winesteam") and self.installer.script.get(self.installer.runner) ): return self.installer.script[self.installer.runner].get("version") if self.installer.runner == "libretro": return self.installer.script["game"]["core"] return None @staticmethod def _check_required_params(params, command_data, command_name): """Verify presence of a list of parameters required by a command.""" if isinstance(params, str): params = [params] for param in params: if isinstance(param, tuple): param_present = False for key in param: if key in command_data: param_present = True if not param_present: raise ScriptingError( "One of %s parameter is mandatory for the %s command" % (" or ".join(param), command_name), command_data, ) else: if param not in command_data: raise ScriptingError( "The %s parameter is mandatory for the %s command" % (param, command_name), command_data, ) @staticmethod def _is_cached_file(file_path): """Return whether a file referenced by file_id is stored in the cache""" pga_cache_path = get_cache_path() if not pga_cache_path: return False return file_path.startswith(pga_cache_path) def chmodx(self, filename): """Make filename executable""" filename = self._substitute(filename) if not system.path_exists(filename): raise ScriptingError("Invalid file '%s'. Can't make it executable" % filename) system.make_executable(filename) def execute(self, data): """Run an executable file.""" args = [] terminal = None working_dir = None env = {} if isinstance(data, dict): self._check_required_params([("file", "command")], data, "execute") if "command" in data and "file" in data: raise ScriptingError( "Parameters file and command can't be used " "at the same time for the execute command", data, ) file_ref = 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: file_ref = "bash" args = ["-c", self._get_file(command.strip())] include_processes.append("bash") else: # Determine whether 'file' value is a file id or a path file_ref = self._get_file(file_ref) if system.path_exists(file_ref) and not system.is_executable(file_ref): logger.warning("Making %s executable", file_ref) system.make_executable(file_ref) exec_path = system.find_executable(file_ref) if not exec_path: raise ScriptingError("Unable to find executable %s" % file_ref) 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_path] + args, env=env, term=terminal, cwd=working_dir, include_processes=include_processes, exclude_processes=exclude_processes, ) command.start() GLib.idle_add(self.parent.attach_logger, 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(src_param) 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) GLib.idle_add(self.parent.set_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 has_entry = data.get("entry") options = data["options"] preselect = self._substitute(data.get("preselect", "")) GLib.idle_add( self.parent.input_menu, alias, options, preselect, has_entry, self._on_input_menu_validated, ) return "STOP" def _on_input_menu_validated(self, _widget, *args): alias = args[0] menu = args[1] choosen_option = menu.get_active_id() if choosen_option: self.user_inputs.append({"alias": alias, "value": choosen_option}) GLib.idle_add(self.parent.continue_button.hide) self._iter_commands() 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 ) if self.installer.runner == "wine": GLib.idle_add(self.parent.eject_button.show) GLib.idle_add(self.parent.ask_for_disc, message, self._find_matching_disc, requires) 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 required_abspath: logger.debug("Found %s on cdrom %s", requires, drive) self.game_disc = drive self._iter_commands() break 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) if not os.path.exists(dst): os.makedirs(dst) 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: raise ScriptingError("Can't move %s \nto destination %s" % (src, dst)) 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: raise ScriptingError("Missing parameter src") 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): """Subsitute 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") as source_file: with open(tmp_filename, "w") as dest_file: line = "." while line: line = source_file.readline() line = self._substitute(line) dest_file.write(line) os.rename(tmp_filename, filename) def _get_task_runner_and_name(self, task_name): if "." in task_name: # Run a task from a different runner # than the one for this installer runner_name, task_name = task_name.split(".") else: runner_name = self.installer.runner return runner_name, task_name def get_wine_path(self): """Return absolute path of wine version used during the install""" wine_version = self._get_runner_version() return get_wine_version_exe(wine_version) 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") if self.parent: GLib.idle_add(self.parent.cancel_button.set_sensitive, False) runner_name, task_name = self._get_task_runner_and_name(data.pop("name")) if runner_name.startswith("wine"): wine_path = self.get_wine_path() if wine_path: data["wine_path"] = wine_path data["prefix"] = data.get("prefix") \ or self.installer.script.get("game", {}).get("prefix") \ or "$GAMEDIR" data["arch"] = data.get("arch") \ or self.installer.script.get("game", {}).get("arch") \ or WINE_DEFAULT_ARCH if task_name == "wineexec": data["env"] = self.script_env for key in data: value = data[key] if isinstance(value, dict): for inner_key in value: value[inner_key] = self._substitute(value[inner_key]) elif isinstance(value, list): for index, elem in enumerate(value): value[index] = self._substitute(elem) else: value = self._substitute(data[key]) data[key] = value task = import_task(runner_name, task_name) command = task(**data) GLib.idle_add(self.parent.cancel_button.set_sensitive, True) if isinstance(command, MonitoredCommand): # Monitor thread and continue when task has executed GLib.idle_add(self.parent.attach_logger, 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 != "0": raise ScriptingError("Command exited with code %s" % command.return_code) self._iter_commands() return False return True 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(params["file"]) # Create dir if necessary basedir = os.path.dirname(dest_file_path) if not os.path.exists(basedir): os.makedirs(basedir) 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) 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(params["file"]) # Create dir if necessary basedir = os.path.dirname(filename) if not os.path.exists(basedir): os.makedirs(basedir) merge = params.get("merge", True) if not os.path.exists(filename): # create an empty file with open(filename, "a+"): pass with open(filename, "r+" if merge else "w") as json_file: json_data = {} if merge: try: json_data = json.load(json_file) except ValueError: logger.error("Failed to parse JSON from file %s", filename) json_data = selective_merge(json_data, params.get("data", {})) json_file.seek(0) json_file.write(json.dumps(json_data, indent=2)) def write_config(self, params): """Write a key-value pair into an INI type config file.""" if params.get("data"): self._check_required_params(["file", "data"], params, "write_config") else: self._check_required_params(["file", "section", "key", "value"], params, "write_config") # Get file config_file_path = self._get_file(params["file"]) # Create dir if necessary basedir = os.path.dirname(config_file_path) if not os.path.exists(basedir): os.makedirs(basedir) 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(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.""" process = multiprocessing.Pool(1) result_obj = process.apply_async(func, args, kwargs) self.abort_current_task = process.terminate result = result_obj.get() # Wait process end & reraise 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) 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"] logger.info("ScummVM arguments from GOG: %s", arguments) if "-c " in arguments: config_path = arguments.split("\"")[1].replace("..\\", "") config_section = arguments.split()[-1] else: raise RuntimeError("Unable to read config path from arguments: '%s'" % arguments) parser = EvilConfigParser(allow_no_value=True, dict_type=MultiOrderedDict, strict=False) parser.optionxform = str # Preserve text case base_dir = os.path.dirname(gog_config_path) scummvm_config_path = os.path.join(base_dir, config_path) if not system.path_exists(scummvm_config_path): raise RuntimeError("ScummVM config file %s not found" % scummvm_config_path) parser.read(scummvm_config_path) game_id = parser.get(config_section, "gameid") return { "game_id": game_id, "path": base_dir, "arguments": "-c \"%s\"" % config_path } def autosetup_gog_game(self, file_id): """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 for filename in file_list: if "dosbox/dosbox.exe" in filename.lower(): dosbox_found = True if "scummvm/scummvm.exe" in filename.lower(): scummvm_found = True if dosbox_found: self._extract_gog_game(file_id) dosbox_config = { "working_dir": "$GAMEDIR/DOSBOX", } for filename in os.listdir(self.target_path): if filename.endswith("_single.conf"): dosbox_config["main_file"] = filename elif filename.endswith(".conf"): dosbox_config["config_file"] = filename 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: return self.task({ "name": "wineexec", "prefix": "$GAMEDIR", "executable": file_id, "args": "/SP- /NOCANCEL" }) lutris-0.5.9.1/lutris/installer/errors.py000066400000000000000000000027051413267435700204330ustar00rootroot00000000000000"""Installer specific exceptions""" import sys from lutris.gui.dialogs import ErrorDialog from lutris.util.log import logger from lutris.util.strings import gtk_safe class ScriptingError(Exception): """Custom exception for scripting errors, can be caught by modifying excepthook.""" def __init__(self, message, faulty_data=None): self.message = message self.faulty_data = faulty_data super(ScriptingError, self).__init__() logger.error(self.__str__()) def __str__(self): faulty_data = repr(self.faulty_data) return self.message + "\n%s" % faulty_data if faulty_data else "" def __repr__(self): return self.message class FileNotAvailable(Exception): """Raised when a file has to be provided by the user""" class MissingGameDependency(Exception): """Raise when a game requires another game that isn't installed""" def __init__(self, slug=None): self.slug = slug super().__init__() _excepthook = sys.excepthook # pylint: disable=invalid-name def error_handler(error_type, value, traceback): """Intercept all possible exceptions and raise them as ScriptingErrors""" if error_type == ScriptingError: message = value.message if value.faulty_data: message += "\n%s" % gtk_safe(value.faulty_data) ErrorDialog(message) else: _excepthook(error_type, value, traceback) sys.excepthook = error_handler lutris-0.5.9.1/lutris/installer/installer.py000066400000000000000000000264711413267435700211220ustar00rootroot00000000000000"""Lutris installer class""" import json import os from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_or_update, get_game_by_field from lutris.game import Game from lutris.installer import AUTO_ELF_EXE, AUTO_WIN32_EXE from lutris.installer.errors import ScriptingError from lutris.installer.installer_file import InstallerFile from lutris.installer.legacy import get_game_launcher from lutris.runners import import_runner from lutris.services import SERVICES from lutris.util.game_finder import find_linux_game_executable, find_windows_game_executable from lutris.util.log import logger 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.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 = installer.get("variables", {}) self.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.requires = self.script.get("requires") self.extends = self.script.get("extends") self.game_id = self.get_game_id() def get_service(self, initial=None): if initial: return initial if "steam" in self.runner: return SERVICES["steam"]() version = self.version.lower() if "humble" in version: return SERVICES["humblebundle"]() if "gog" in version: return SERVICES["gog"]() def get_appid(self, installer, initial=None): if initial: return initial if not self.service: return if self.service.id == "steam": return installer.get("steamid") game_config = self.script.get("game", {}) if self.service.id == "gog": return game_config.get("gogid") or installer.get("gogid") if self.service.id == "humblebundle": return game_config.get("humbleid") or installer.get("humblestoreid") @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 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: # Game is an extension of an existing game, folder exists return False if self.runner in ("steam", "winesteam"): # Steam games installs in their steamapps directory return False if ( self.files or self.script.get("game", {}).get("gog") or self.script.get("game", {}).get("prefix") ): return True command_names = [list(c.keys())[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 in ("steam", "winesteam"): 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 pop_user_provided_file(self): """Return and remove the first user provided file, which is used for game stores""" for index, file in enumerate(self.files): if file.url.startswith("N/A"): self.files.pop(index) return file.id def prepare_game_files(self): """Gathers necessary files before iterating through them.""" if not self.files: return if self.service: if self.service.online and not self.service.is_connected(): logger.info("Not authenticated to %s", self.service.id) return installer_file_id = self.pop_user_provided_file() if not installer_file_id: logger.warning("Could not find a file for this service") return if self.service.has_extras: self.service.selected_extras = self.interpreter.extras installer_files = self.service.get_installer_files(self, installer_file_id) for installer_file in installer_files: self.files.append(installer_file) if not installer_files: # Failed to get the service game, put back a user provided file self.files.insert(0, "N/A: Provider installer file") 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 isinstance(value, list): config[key] = [self.interpreter._substitute(i) for i in value] elif isinstance(value, dict): config[key] = {k: self.interpreter._substitute(v) for (k, v) in value.items()} elif isinstance(value, bool): config[key] = value else: config[key] = self.interpreter._substitute(value) return config def get_game_config(self): """Return the game configuration""" if self.requires: # Load the base game config required_game = get_game_by_field(self.requires, field="installer_slug") if not required_game: required_game = get_game_by_field(self.requires, field="slug") if not required_game: raise ValueError("No game matched '%s' on installer_slug or slug" % self.requires) base_config = LutrisConfig( runner_slug=self.runner, game_config_id=required_game["configpath"] ) config = base_config.game_level else: config = {"game": {}} # Config update if "system" in self.script: config["system"] = self._substitute_config(self.script["system"]) if self.runner in self.script and self.script[self.runner]: config[self.runner] = self._substitute_config(self.script[self.runner]) launcher, launcher_config = self.get_game_launcher_config(self.interpreter.game_files) if launcher: config["game"][launcher] = launcher_config if "game" in self.script: try: config["game"].update(self.script["game"]) except ValueError: raise ScriptingError("Invalid 'game' section", self.script["game"]) 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) 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 configpath = write_game_config(self.slug, self.get_game_config()) runner_inst = import_runner(self.runner)() if self.service: service_id = self.service.id else: service_id = None self.game_id = add_or_update( name=self.game_name, runner=self.runner, slug=self.game_slug, platform=runner_inst.get_platform(), directory=self.interpreter.target_path, installed=1, hidden=0, installer_slug=self.slug, parent_slug=self.requires, year=self.year, configpath=configpath, service=service_id, service_id=self.service_appid, id=self.game_id, ) # This is a bit redundant but used to trigger the game-updated signal game = Game(self.game_id) game.save() def get_game_launcher_config(self, game_files): """Game options such as exe or main_file can be added at the root of the script as a shortcut, this integrates them into the game config properly This should be deprecated. Game launchers should go in the game section. """ launcher, launcher_value = get_game_launcher(self.script) if isinstance(launcher_value, list): launcher_values = [] for game_file in launcher_value: if game_file in game_files: launcher_values.append(game_files[game_file]) else: launcher_values.append(game_file) return launcher, launcher_values if launcher_value: if launcher_value in game_files: launcher_value = game_files[launcher_value] elif self.interpreter.target_path and os.path.exists( os.path.join(self.interpreter.target_path, launcher_value) ): launcher_value = os.path.join(self.interpreter.target_path, launcher_value) return launcher, launcher_value lutris-0.5.9.1/lutris/installer/installer_file.py000066400000000000000000000143261413267435700221150ustar00rootroot00000000000000"""Manipulates installer files""" import os from urllib.parse import urlparse from lutris import cache, settings from lutris.installer.errors import ScriptingError from lutris.util import system from lutris.util.log import logger class InstallerFile: """Representation of a file in the `files` sections of an installer""" def __init__(self, game_slug, file_id, file_meta): self.game_slug = game_slug self.id = file_id.replace("-", "_") # pylint: disable=invalid-name self._file_meta = file_meta self._dest_file = None # Used to override the destination @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 @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 if self.url.startswith("$WINESTEAM"): raise ScriptingError("Usage of $WINESTEAM location is deprecated") 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 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 __str__(self): return "%s/%s" % (self.game_slug, self.id) @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 @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 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, create=False): """Determines whether the installer files are stored in a PGA cache Params: create (bool): If a cache is active, auto create directories if needed Returns: bool """ cache_path = cache.get_cache_path() if not cache_path: return False if system.path_exists(cache_path): return True if create: try: logger.debug("Creating cache path %s", self.cache_path) os.makedirs(self.cache_path) except (OSError, PermissionError) as ex: logger.error("Failed to created cache path: %s", ex) return False return True logger.warning("Cache path %s does not exist", cache_path) return False @property def cache_path(self): """Return the directory used as a cache for the duration of the installation""" _cache_path = cache.get_cache_path() if not _cache_path: _cache_path = os.path.join(settings.CACHE_DIR, "installer") url_parts = urlparse(self.url) if url_parts.netloc.endswith("gog.com"): folder = "gog" else: folder = self.id return os.path.join(_cache_path, self.game_slug, folder) def prepare(self): """Prepare the file for download""" if not system.path_exists(self.cache_path): os.makedirs(self.cache_path) 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: raise ScriptingError("Invalid checksum, expected format (type:hash) ", self.checksum) if system.get_file_checksum(self.dest_file, hash_type) != expected_hash: raise ScriptingError(hash_type.capitalize() + " checksum mismatch ", self.checksum) @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) lutris-0.5.9.1/lutris/installer/interpreter.py000066400000000000000000000431751413267435700214700ustar00rootroot00000000000000"""Install a game by following its install script.""" import os from gettext import gettext as _ from gi.repository import GLib, GObject from lutris import settings from lutris.config import LutrisConfig from lutris.database.games import get_game_by_field from lutris.gui.dialogs import WineNotInstalledWarning from lutris.gui.dialogs.download import simple_downloader from lutris.installer.commands import CommandsMixin from lutris.installer.errors import MissingGameDependency, ScriptingError from lutris.installer.installer import LutrisInstaller from lutris.installer.legacy import get_game_launcher from lutris.runners import InvalidRunner, NonInstallableRunnerError, RunnerInstallationError, import_runner, steam, wine from lutris.services.lutris import download_lutris_media from lutris.util import system from lutris.util.display import DISPLAY_MANAGER from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.strings import unpack_dependencies from lutris.util.wine.wine import get_wine_version, get_wine_version_exe class ScriptInterpreter(GObject.Object, CommandsMixin): """Control the execution of an installer""" __gsignals__ = { "runners-installed": (GObject.SIGNAL_RUN_FIRST, None, ()), } def __init__(self, installer, parent): super().__init__() self.target_path = None self.parent = parent self.service = parent.service if parent else None self.appid = parent.appid if parent else None self.game_dir_created = False # Whether a game folder was created during the install # Extra files for installers, either None if the extras haven't been checked yet. # Or a list of IDs of extras to be downloaded during the install self.extras = None 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.installer = LutrisInstaller(installer, self, service=self.service, appid=self.appid) if not self.installer.script: raise ScriptingError("This installer doesn't have a 'script' section") script_errors = self.installer.get_errors() if script_errors: raise ScriptingError( "Invalid script: \n{}".format("\n".join(script_errors)), self.installer.script ) self.current_resolution = DISPLAY_MANAGER.get_current_resolution() self._check_binary_dependencies() self._check_dependency() if self.installer.creates_game_folder: self.target_path = self.get_default_target() def get_default_target(self): """Return default installation dir""" config = LutrisConfig(runner_slug=self.installer.runner) games_dir = config.system_config.get("game_path", os.path.expanduser("~")) if self.service: service_dir = self.service.id else: service_dir = "" return os.path.expanduser(os.path.join(games_dir, service_dir, self.installer.game_slug)) @property def cache_path(self): """Return the directory used as a cache for the duration of the installation""" return os.path.join(settings.CACHE_DIR, "installer/%s" % self.installer.game_slug) @property def script_env(self): """Return the script's own environment variable with values susbtituted. This value can be used to provide the same environment variable as set for the game during the install process. """ return { key: self._substitute(value) for key, value in self.installer.script.get('system', {}).get('env', {}).items() } @staticmethod def _get_installed_dependency(dependency): """Return whether a dependency is installed""" game = get_game_by_field(dependency, field="installer_slug") if not game: game = get_game_by_field(dependency, "slug") if bool(game) and bool(game["directory"]): return game def _check_binary_dependencies(self): """Check if all required binaries are installed on the system. This reads a `require-binaries` entry in the script, parsed the same way as the `requires` entry. """ binary_dependencies = unpack_dependencies(self.installer.script.get("require-binaries")) for dependency in binary_dependencies: if isinstance(dependency, tuple): installed_binaries = { dependency_option: bool(system.find_executable(dependency_option)) for dependency_option in dependency } if not any(installed_binaries.values()): raise ScriptingError("This installer requires %s on your system" % " or ".join(dependency)) else: if not system.find_executable(dependency): raise ScriptingError("This installer requires %s on your system" % dependency) def _check_dependency(self): """When a game is a mod or an extension of another game, check that the base game is installed. If the game is available, install the game in the base game folder. The first game available listed in the dependencies is the one picked to base the installed on. """ if self.installer.extends: dependencies = [self.installer.extends] else: dependencies = unpack_dependencies(self.installer.requires) error_message = "You need to install {} before" for index, dependency in enumerate(dependencies): if isinstance(dependency, tuple): installed_games = [dep for dep in [self._get_installed_dependency(dep) for dep in dependency] if dep] if not installed_games: if len(dependency) == 1: raise MissingGameDependency(slug=dependency) raise ScriptingError(error_message.format(" or ".join(dependency))) if index == 0: self.target_path = installed_games[0]["directory"] self.requires = installed_games[0]["installer_slug"] else: game = self._get_installed_dependency(dependency) if not game: raise MissingGameDependency(slug=dependency) if index == 0: self.target_path = game["directory"] self.requires = game["installer_slug"] def get_extras(self): """Get extras and store them to move them at the end of the install""" if not self.service or not self.service.has_extras: self.extras = [] return self.extras self.extras = self.service.get_extras(self.appid) return self.extras def launch_install(self): """Launch the install process""" self.runners_to_install = self.get_runners_to_install() self.install_runners() self.create_game_folder() 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: raise ScriptingError( "Lutris does not have the necessary permissions to install to path:", self.target_path, ) 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 (wine for winesteam) or runners used for installer tasks. """ runners_to_install = [] required_runners = [] runner = self.get_runner_class(self.installer.runner) required_runners.append(runner()) for command in self.installer.script.get("installer", []): command_name, command_params = self._get_command_name_and_params(command) if command_name == "task": runner_name, _task_name = self._get_task_runner_and_name(command_params["name"]) runner_names = [r.name for r in required_runners] if runner_name not in runner_names: required_runners.append(self.get_runner_class(runner_name)()) for runner in required_runners: params = {} if self.installer.runner == "libretro": params["core"] = self.installer.script["game"]["core"] if self.installer.runner.startswith("wine"): # Force the wine version to be installed params["fallback"] = False params["min_version"] = wine.MIN_SAFE_VERSION version = self._get_runner_version() if version: params["version"] = version else: # Looking up default wine version default_wine = runner.get_runner_version() or {} if "version" in default_wine: logger.debug("Default wine version is %s", default_wine["version"]) # Set the version to both the is_installed params and # the script itself so the version gets saved at the # end of the install. if self.installer.runner not in self.installer.script: self.installer.script[self.installer.runner] = {} version = "{}-{}".format(default_wine["version"], default_wine["architecture"]) params["version"] = \ self.installer.script[self.installer.runner]["version"] = version else: logger.error("Failed to get default wine version (got %s)", default_wine) if not runner.is_installed(**params): logger.info("Runner %s needs to be installed", runner) runners_to_install.append(runner) if self.installer.runner.startswith("wine") and not get_wine_version(): WineNotInstalledWarning(parent=self.parent) return runners_to_install def install_runners(self): """Install required runners for a game""" if self.runners_to_install: self.install_runner(self.runners_to_install.pop(0)) return self.emit("runners-installed") def install_runner(self, runner): """Install runner required by the install script""" logger.debug("Installing %s", runner.name) try: runner.install( version=self._get_runner_version(), downloader=simple_downloader, callback=self.install_runners, ) except (NonInstallableRunnerError, RunnerInstallationError) as ex: logger.error(ex.message) raise ScriptingError(ex.message) def get_runner_class(self, runner_name): """Runner the runner class from its name""" try: runner = import_runner(runner_name) except InvalidRunner: GLib.idle_add(self.parent.cancel_button.set_sensitive, True) raise ScriptingError("Invalid runner provided %s" % runner_name) return runner def launch_installer_commands(self): """Run the pre-installation steps and launch install.""" if self.target_path and os.path.exists(self.target_path): os.chdir(self.target_path) if not os.path.exists(self.cache_path): os.mkdir(self.cache_path) # Copy extras to game folder for extra in self.extras: self.installer.script["installer"].append( {"copy": {"src": extra, "dst": "$GAMEDIR/extras"}} ) self._iter_commands() def _iter_commands(self, result=None, exception=None): if result == "STOP" or self.cancelled: return self.parent.set_status(_("Installing game data")) self.parent.add_spinner() self.parent.continue_button.hide() commands = self.installer.script.get("installer", []) if exception: self.parent.on_install_error(repr(exception)) elif self.current_command < len(commands): try: command = commands[self.current_command] except KeyError: raise ScriptingError("Installer commands are not formatted correctly") 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.parent.set_status(status_text) logger.debug("Installer command: %s", command) AsyncCall(method, self._iter_commands, params) else: self._finish_install() @staticmethod def _get_command_name_and_params(command_data): if isinstance(command_data, dict): command_name = list(command_data.keys())[0] command_params = command_data[command_name] else: command_name = command_data command_params = {} command_name = command_name.replace("-", "_") 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 = self.installer.script.get("game") launcher_value = None if game: _launcher, launcher_value = get_game_launcher(self.installer.script) path = None if launcher_value: path = self._substitute(launcher_value) if not os.path.isabs(path) and self.target_path: path = os.path.join(self.target_path, path) self.installer.save() if path and not os.path.isfile(path) and self.installer.runner not in ("web", "browser"): self.parent.set_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: install_complete_text = (self.installer.script.get("install_complete_text") or _("Installation completed!")) self.parent.set_status(install_complete_text) download_lutris_media(self.installer.game_slug) self.parent.on_install_finished() def cleanup(self): """Clean up install dir after a successful install""" os.chdir(os.path.expanduser("~")) system.remove_folder(self.cache_path) def revert(self): """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.game_dir_created: system.remove_folder(self.target_path) def _substitute(self, template_string): """Replace path aliases with real paths.""" if template_string is None: logger.warning("No template string given") return "" 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._get_last_user_input(), "VERSION": self.installer.version, "RESOLUTION": "x".join(self.current_resolution), "RESOLUTION_WIDTH": self.current_resolution[0], "RESOLUTION_HEIGHT": self.current_resolution[1], "WINEBIN": self.get_wine_path(), } 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) if str(template_string).replace("-", "_") in self.game_files: template_string = template_string.replace("-", "_") return system.substitute(template_string, replacements) def _get_last_user_input(self): return self.user_inputs[-1]["value"] if self.user_inputs else "" def eject_wine_disc(self): """Use Wine to eject a CD, otherwise Wine can have problems detecting disc changes""" wine_path = get_wine_version_exe(self._get_runner_version()) wine.eject_disc(wine_path, self.target_path) lutris-0.5.9.1/lutris/installer/legacy.py000066400000000000000000000013461413267435700203630ustar00rootroot00000000000000from lutris.util import linux def get_game_launcher(script): """Return the key and value of the launcher exe64 can be provided to specify an executable for 64bit systems This should be deprecated when support for multiple binaries has been added. """ launcher_value = None exe = "exe64" if "exe64" in script and linux.LINUX_SYSTEM.is_64_bit else "exe" for launcher in (exe, "iso", "rom", "disk", "main_file"): if launcher not in script: continue launcher_value = script[launcher] if launcher == "exe64": launcher = "exe" # If exe64 is used, rename it to exe break if not launcher_value: launcher = None return launcher, launcher_value lutris-0.5.9.1/lutris/installer/steam_installer.py000066400000000000000000000100111413267435700222720ustar00rootroot00000000000000"""Collection of installer files""" import os import time 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: raise ScriptingError("Malformed steam path: %s" % self.steam_uri) 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.9.1/lutris/migrations/000077500000000000000000000000001413267435700167205ustar00rootroot00000000000000lutris-0.5.9.1/lutris/migrations/__init__.py000066400000000000000000000016211413267435700210310ustar00rootroot00000000000000import importlib from lutris import settings from lutris.util.log import logger MIGRATION_VERSION = 10 # Never decrease this number # Replace deprecated migrations with empty lists MIGRATIONS = [ [], [], [], [], [], [], [], ["mess_to_mame"], ["migrate_hidden_ids"], ["migrate_steam_appids"], ] 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.9.1/lutris/migrations/mess_to_mame.py000066400000000000000000000007071413267435700217460ustar00rootroot00000000000000"""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.9.1/lutris/migrations/migrate_hidden_ids.py000066400000000000000000000015301413267435700230730ustar00rootroot00000000000000"""Move hidden games from settings to database""" from lutris import settings from lutris.game import Game def get_hidden_ids(): """Return a list of game IDs to be excluded from the library view""" # Load the ignore string and filter out empty strings to prevent issues ignores_raw = settings.read_setting("library_ignores", section="lutris", default="").split(",") ignores = [ignore for ignore in ignores_raw if not ignore == ""] # Turn the strings into integers return [int(game_id) for game_id in ignores] def migrate(): """Run migration""" try: game_ids = get_hidden_ids() except: print("Failed to read hidden game IDs") return [] for game_id in game_ids: game = Game(game_id) game.set_hidden(True) settings.write_setting("library_ignores", '', section="lutris") lutris-0.5.9.1/lutris/migrations/migrate_steam_appids.py000066400000000000000000000010521413267435700234510ustar00rootroot00000000000000"""Set service ID for Steam games""" from lutris import settings from lutris.database.games import get_games, sql def migrate(): """Run migration""" for game in get_games(): if not game.get("steamid"): continue if game["runner"] and game["runner"] != "steam": continue print("Migrating Steam game %s" % game["name"]) sql.db_update( settings.PGA_DB, "games", {"service": "steam", "service_id": game["steamid"]}, {"id": game["id"]} ) lutris-0.5.9.1/lutris/runner_interpreter.py000066400000000000000000000122511413267435700210530ustar00rootroot00000000000000"""Transform runner parameters to data usable for runtime execution""" import os import shlex import stat from lutris.util import system from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger def get_mangohud_conf(system_config): """Return correct launch arguments and environment variables for Mangohud.""" env = {"MANGOHUD": "1"} mango_args = [] mangohud = system_config.get("mangohud") or "" if mangohud and system.find_executable("mangohud"): if mangohud == "gl64": mango_args = ["mangohud"] env["MANGOHUD_DLSYM"] = "1" elif mangohud == "gl32": mango_args = ["mangohud.x86"] env["MANGOHUD_DLSYM"] = "1" else: mango_args = ["mangohud"] return mango_args, env def get_launch_parameters(runner, gameplay_info): system_config = runner.system_config launch_arguments = gameplay_info["command"] env = { "DISABLE_LAYER_AMD_SWITCHABLE_GRAPHICS_1": "1" } # Steam compatibility if os.environ.get("SteamAppId"): logger.info("Game launched from steam (AppId: %s)", os.environ["SteamAppId"]) env["LC_ALL"] = "" # Optimus optimus = system_config.get("optimus") if optimus == "primusrun" and system.find_executable("primusrun"): launch_arguments.insert(0, "primusrun") elif optimus == "optirun" and system.find_executable("optirun"): launch_arguments.insert(0, "virtualgl") launch_arguments.insert(0, "-b") launch_arguments.insert(0, "optirun") elif optimus == "pvkrun" and system.find_executable("pvkrun"): launch_arguments.insert(0, "pvkrun") mango_args, mango_env = get_mangohud_conf(system_config) if mango_args: launch_arguments = mango_args + launch_arguments env.update(mango_env) # Libstrangle fps_limit = system_config.get("fps_limit") or "" if fps_limit: strangle_cmd = system.find_executable("strangle") if strangle_cmd: launch_arguments = [strangle_cmd, fps_limit] + launch_arguments else: logger.warning("libstrangle is not available on this system, FPS limiter disabled") prefix_command = system_config.get("prefix_command") or "" if prefix_command: launch_arguments = (shlex.split(os.path.expandvars(prefix_command)) + launch_arguments) single_cpu = system_config.get("single_cpu") or False if single_cpu: logger.info("The game will run on a single CPU core") launch_arguments.insert(0, "0") 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_libary_path = gameplay_info.get("ld_library_path") if game_ld_libary_path: ld_library_path = env.get("LD_LIBRARY_PATH") if not ld_library_path: ld_library_path = "$LD_LIBRARY_PATH" env["LD_LIBRARY_PATH"] = ":".join([game_ld_libary_path, ld_library_path]) # Feral gamemode gamemode = system_config.get("gamemode") and LINUX_SYSTEM.gamemode_available() if gamemode: launch_arguments.insert(0, "gamemoderun") # Gamescope gamescope = system_config.get("gamescope") and system.find_executable("gamescope") if gamescope: launch_arguments = get_gamescope_args(launch_arguments, system_config) return launch_arguments, env def get_gamescope_args(launch_arguments, system_config): """Insert gamescope at the start of the launch arguments""" launch_arguments.insert(0, "--") launch_arguments.insert(0, "-f") if system_config.get("gamescope_output_res"): output_width, output_height = system_config["gamescope_output_res"].lower().split("x") launch_arguments.insert(0, output_height) launch_arguments.insert(0, "-H") launch_arguments.insert(0, output_width) launch_arguments.insert(0, "-W") if system_config.get("gamescope_game_res"): game_width, game_height = system_config["gamescope_game_res"].lower().split("x") launch_arguments.insert(0, game_height) launch_arguments.insert(0, "-h") launch_arguments.insert(0, game_width) launch_arguments.insert(0, "-w") launch_arguments.insert(0, "gamescope") return launch_arguments def export_bash_script(runner, gameplay_info, script_path): """Convert runner configuration into a bash script""" 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 env_var in env: script_content += "export %s=\"%s\"\n" % (env_var, env[env_var]) script_content += "\n# Command\n" script_content += " ".join([shlex.quote(c) for c in command]) with open(script_path, "w") as script_file: script_file.write(script_content) os.chmod(script_path, os.stat(script_path).st_mode | stat.S_IEXEC) lutris-0.5.9.1/lutris/runners/000077500000000000000000000000001413267435700162405ustar00rootroot00000000000000lutris-0.5.9.1/lutris/runners/__init__.py000066400000000000000000000054631413267435700203610ustar00rootroot00000000000000"""Runner loaders""" from collections import defaultdict __all__ = [ # Native "linux", "steam", "web", # Microsoft based "wine", "winesteam", "dosbox", # Multi-system "easyrpg", "mame", "mednafen", "scummvm", "residualvm", "libretro", # Commodore "fsuae", "vice", # Atari "atari800", "hatari", # Nintendo "snes9x", "mupen64plus", "dolphin", "ryujinx", "yuzu", # Sony "pcsx2", "rpcs3", # Sega "osmose", "reicast", "redream", # Fantasy consoles "pico8", # Misc legacy systems "jzintv", "o2em", "zdoom", ] ADDON_RUNNERS = {} RUNNER_PLATFORMS = {} class InvalidRunner(Exception): def __init__(self, message): super().__init__(message) self.message = message class RunnerInstallationError(Exception): def __init__(self, message): super().__init__(message) self.message = message class NonInstallableRunnerError(Exception): def __init__(self, message): super().__init__(message) self.message = message def get_runner_module(runner_name): if runner_name not in __all__: raise InvalidRunner("Invalid runner name '%s'" % runner_name) return __import__("lutris.runners.%s" % runner_name, globals(), locals(), [runner_name], 0) def import_runner(runner_name): """Dynamically import a runner class.""" if runner_name in ADDON_RUNNERS: return ADDON_RUNNERS[runner_name] runner_module = get_runner_module(runner_name) if not runner_module: return None return getattr(runner_module, runner_name) def import_task(runner, task): """Return a runner task.""" runner_module = get_runner_module(runner) if not runner_module: return None return getattr(runner_module, task) def get_installed(sort=True): """Return a list of installed runners (class instances).""" installed = [] for runner_name in __all__: runner = import_runner(runner_name)() if runner.is_installed(): installed.append(runner) return sorted(installed) if sort else installed def inject_runners(runners): for runner_name in runners: ADDON_RUNNERS[runner_name] = runners[runner_name] __all__.append(runner_name) def get_runner_names(): return { runner: import_runner(runner)().human_name for runner in __all__ } def get_platforms(): """Return a dictionary of all supported platforms with their runners""" platforms = defaultdict(list) for runner_name in __all__: runner = import_runner(runner_name)() for platform in runner.platforms: platforms[platform].append(runner_name) return platforms RUNNER_NAMES = {} # This needs to be initialized at startup with get_runner_names lutris-0.5.9.1/lutris/runners/atari800.py000066400000000000000000000126561413267435700201540ustar00rootroot00000000000000# Standard Library import logging import os.path from gettext import gettext as _ # Lutris Modules from lutris.config import LutrisConfig from lutris.gui.dialogs import ErrorDialog from lutris.gui.dialogs.download import DownloadDialog 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 # pylint: disable=C0103 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, "label": _("Fullscreen"), }, { "option": "resolution", "type": "choice", "choices": get_resolutions(), "default": "desktop", "label": _("Fullscreen resolution"), }, ] def install(self, version=None, downloader=None, callback=None): def on_runner_installed(*args): # pylint: disable=unused-argument config_path = system.create_folder("~/.atari800") bios_archive = os.path.join(config_path, "atari800-bioses.zip") dlg = DownloadDialog(self.bios_url, bios_archive) dlg.run() if not system.path_exists(bios_archive): ErrorDialog(_("Could not download Atari 800 BIOS archive")) return 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(atari800, self).install(version, downloader, on_runner_installed) def find_good_bioses(self, bios_path): """ Check for correct bios files """ good_bios = {} for filename in os.listdir(bios_path): real_hash = system.get_md5_hash(os.path.join(bios_path, filename)) for bios_file, checksum in self.bios_checksums.items(): if real_hash == checksum: logging.debug("%s Checksum : OK", filename) good_bios[bios_file] = filename return good_bios def play(self): arguments = [self.get_executable()] if self.runner_config.get("fullscreen"): arguments.append("-fullscreen") else: arguments.append("-windowed") resolution = self.runner_config.get("resolution") if resolution: if resolution == "desktop": width, height = display.DISPLAY_MANAGER.get_current_resolution() else: width, height = resolution.split("x") arguments += ["-fs-width", "%s" % width, "-fs-height", "%s" % height] if self.runner_config.get("machine"): arguments.append("-%s" % self.runner_config["machine"]) bios_path = self.runner_config.get("bios_path") if not system.path_exists(bios_path): return {"error": "NO_BIOS"} good_bios = self.find_good_bioses(bios_path) for bios, filename in good_bios.items(): arguments.append("-%s" % bios) arguments.append(os.path.join(bios_path, filename)) rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": rom} arguments.append(rom) return {"command": arguments} lutris-0.5.9.1/lutris/runners/commands/000077500000000000000000000000001413267435700200415ustar00rootroot00000000000000lutris-0.5.9.1/lutris/runners/commands/__init__.py000066400000000000000000000000001413267435700221400ustar00rootroot00000000000000lutris-0.5.9.1/lutris/runners/commands/dosbox.py000066400000000000000000000033161413267435700217140ustar00rootroot00000000000000"""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") 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.9.1/lutris/runners/commands/wine.py000066400000000000000000000332051413267435700213600ustar00rootroot00000000000000"""Wine commands for installers""" # pylint: disable=too-many-arguments import os import shlex import time from lutris import runtime, settings from lutris.command import MonitoredCommand from lutris.config import LutrisConfig from lutris.runners import import_runner from lutris.util import linux, system from lutris.util.log import logger from lutris.util.shell import get_shell_command from lutris.util.strings import split_arguments from lutris.util.wine.cabinstall import CabInstaller from lutris.util.wine.prefix import WinePrefixManager from lutris.util.wine.wine import ( WINE_DEFAULT_ARCH, WINE_DIR, detect_arch, detect_prefix_arch, get_overrides_env, get_real_executable, use_lutris_runtime ) 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") as reg_file: reg_file.write('REGEDIT4\n\n[%s]\n"%s"=%s\n' % (path, key, formatted_value[type])) logger.debug("Setting [%s]:%s=%s", path, key, formatted_value[type]) set_regedit_file(reg_path, wine_path=wine_path, prefix=prefix, arch=arch) os.remove(reg_path) def set_regedit_file(filename, wine_path=None, prefix=None, arch=WINE_DEFAULT_ARCH): """Apply a regedit file to the Windows registry.""" if arch == "win64" and wine_path and system.path_exists(wine_path + "64"): # Use wine64 by default if set to a 64bit prefix. Using regular wine # will prevent some registry keys from being created. Most likely to be # a bug in Wine. see: https://github.com/lutris/lutris/issues/804 wine_path = wine_path + "64" wineexec( "regedit", args="/S '%s'" % filename, wine_path=wine_path, prefix=prefix, arch=arch, blocking=True, ) def delete_registry_key(key, wine_path=None, prefix=None, arch=WINE_DEFAULT_ARCH): """Deletes a registry key from a Wine prefix""" wineexec( "regedit", args='/S /D "%s"' % key, wine_path=wine_path, prefix=prefix, arch=arch, blocking=True, ) def create_prefix( # noqa: C901 prefix, wine_path=None, arch=WINE_DEFAULT_ARCH, overrides=None, install_gecko=None, install_mono=None, ): """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") 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): os.rmdir(prefix) if not wine_path: wine = import_runner("wine") wine_path = wine().get_executable() if not wine_path: logger.error("Wine not found, can't create prefix") return wineboot_path = os.path.join(os.path.dirname(wine_path), "wineboot") if not system.path_exists(wineboot_path): logger.error( "No wineboot executable found in %s, " "your wine installation is most likely broken", wine_path, ) return if install_gecko == "False": overrides["mshtml"] = "disabled" if install_mono == "False": overrides["mscoree"] = "disabled" wineenv = { "WINEARCH": arch, "WINEPREFIX": prefix, "WINEDLLOVERRIDES": get_overrides_env(overrides), } system.execute([wineboot_path], env=wineenv) for loop_index in range(50): time.sleep(0.25) if system.path_exists(os.path.join(prefix, "user.reg")): break if loop_index == 20: 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() if 'steamapps/common' in prefix.lower(): from lutris.runners.winesteam import winesteam runner = winesteam() logger.info("Transfering Steam information from default prefix to new prefix") dest_path = '/tmp/steam.reg' default_prefix = runner.get_default_prefix(runner.default_arch) wineexec("regedit", args=r"/E '%s' 'HKEY_CURRENT_USER\Software\Valve\Steam'" % dest_path, prefix=default_prefix) set_regedit_file(dest_path, wine_path=wine_path, prefix=prefix, arch=arch) try: os.remove(dest_path) except FileNotFoundError: logger.error("File %s was already removed", dest_path) steam_drive_path = os.path.join(prefix, 'dosdevices', 's:') if not system.path_exists(steam_drive_path): logger.info("Linking Steam default prefix to drive S:") os.symlink(os.path.join(default_prefix, 'drive_c'), steam_drive_path) def winekill(prefix, arch=WINE_DEFAULT_ARCH, wine_path=None, env=None, initial_pids=None): """Kill processes in Wine prefix.""" initial_pids = initial_pids or [] if not wine_path: wine = import_runner("wine") wine_path = wine().get_executable() wine_root = os.path.dirname(wine_path) if not env: env = {"WINEARCH": arch, "WINEPREFIX": prefix} command = [os.path.join(wine_root, "wineserver"), "-k"] logger.debug("Killing all wine processes: %s", command) logger.debug("\tWine prefix: %s", prefix) logger.debug("\tWine arch: %s", arch) if initial_pids: logger.debug("\tInitial pids: %s", initial_pids) system.execute(command, env=env, quiet=True) logger.debug("Waiting for wine processes to terminate") # Wineserver needs time to terminate processes num_cycles = 0 while True: num_cycles += 1 running_processes = [pid for pid in initial_pids if system.path_exists("/proc/%s" % pid)] if not running_processes: break if num_cycles > 20: logger.warning( "Some wine processes are still running: %s", ", ".join(running_processes), ) break time.sleep(0.1) logger.debug("Done waiting.") # pragma pylint: disable=too-many-locals def wineexec( # noqa: C901 executable, args="", wine_path=None, prefix=None, arch=None, working_dir=None, winetricks_wine="", blocking=False, config=None, include_processes=None, exclude_processes=None, disable_runtime=False, env=None, overrides=None, ): """ 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) 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 wine_path: wine = import_runner("wine") wine_path = wine().get_executable() if not wine_path: raise RuntimeError("Wine is not installed") if not working_dir: if os.path.isfile(executable): working_dir = os.path.dirname(executable) executable, _args, working_dir = get_real_executable(executable, working_dir) if _args: args = '{} "{}"'.format(_args[0], _args[1]) # Create prefix if necessary if arch not in ("win32", "win64"): arch = detect_arch(prefix, wine_path) if not detect_prefix_arch(prefix): wine_bin = winetricks_wine if winetricks_wine else wine_path create_prefix(prefix, wine_path=wine_bin, arch=arch) wineenv = {"WINEARCH": arch} if winetricks_wine: wineenv["WINE"] = winetricks_wine else: wineenv["WINE"] = wine_path if prefix: wineenv["WINEPREFIX"] = prefix wine_config = config or LutrisConfig(runner_slug="wine") disable_runtime = disable_runtime or wine_config.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_config.system_config["prefer_system_libs"], wine_path=wine_root_path, ) ) if overrides: wineenv["WINEDLLOVERRIDES"] = get_overrides_env(overrides) if env: wineenv.update(env) command_parameters = [wine_path] if executable: command_parameters.append(executable) command_parameters += split_arguments(args) if blocking: return system.execute(command_parameters, env=wineenv, cwd=working_dir) wine = import_runner("wine") command = MonitoredCommand( command_parameters, runner=wine(), env=wineenv, cwd=working_dir, include_processes=include_processes, exclude_processes=exclude_processes, ) command.start() return command # pragma pylint: enable=too-many-locals def winetricks( app, prefix=None, arch=None, silent=True, wine_path=None, config=None, env=None, disable_runtime=False, ): """Execute winetricks.""" wine_config = config or LutrisConfig(runner_slug="wine") winetricks_path = os.path.join(settings.RUNTIME_DIR, "winetricks/winetricks") if (wine_config.runner_config.get("system_winetricks") or not system.path_exists(winetricks_path)): winetricks_path = system.find_executable("winetricks") if not winetricks_path: raise RuntimeError("No installation of winetricks found") if wine_path: winetricks_wine = wine_path else: wine = import_runner("wine") winetricks_wine = wine().get_executable() if arch not in ("win32", "win64"): arch = detect_arch(prefix, winetricks_wine) args = app if str(silent).lower() in ("yes", "on", "true"): args = "--unattended " + args return wineexec( None, prefix=prefix, winetricks_wine=winetricks_wine, wine_path=winetricks_path, arch=arch, args=args, config=config, env=env, disable_runtime=disable_runtime, ) def winecfg(wine_path=None, prefix=None, arch=WINE_DEFAULT_ARCH, config=None, env=None): """Execute winecfg.""" if not wine_path: logger.debug("winecfg: Reverting to default wine") wine = import_runner("wine") wine_path = wine().get_executable() winecfg_path = os.path.join(os.path.dirname(wine_path), "winecfg") logger.debug("winecfg: %s", winecfg_path) return wineexec( None, prefix=prefix, winetricks_wine=winecfg_path, wine_path=winecfg_path, arch=arch, config=config, env=env, include_processes=["winecfg.exe"], ) 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): aliases = { "wine": wine_path, "winecfg": wine_path + "cfg", "wineserver": wine_path + "server", "wineboot": wine_path + "boot", } env["WINEPREFIX"] = prefix shell_command = get_shell_command(prefix, env, aliases) terminal = terminal or linux.get_default_terminal() system.execute([linux.get_default_terminal(), "-e", shell_command]) lutris-0.5.9.1/lutris/runners/dolphin.py000066400000000000000000000037651413267435700202620ustar00rootroot00000000000000"""Dolphin runner""" from gettext import gettext as _ # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class dolphin(Runner): description = _("GameCube and Wii emulator") human_name = _("Dolphin") platforms = [_("Nintendo GameCube"), _("Nintendo Wii")] runnable_alone = True runner_executable = "dolphin/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": "nogui", "type": "bool", "label": _("No GUI"), "default": False, "help": _("Disable the graphical user interface."), }, { "option": "batch", "type": "bool", "label": _("Batch"), "default": False, "help": _("Exit Dolphin with emulator."), }, ] def get_platform(self): selected_platform = self.game_config.get("platform") if selected_platform: return self.platforms[int(selected_platform)] return "" def play(self): # Find the executable executable = self.get_executable() if self.runner_config.get("nogui"): executable += "-nogui" command = [executable] # Batch isn't available in nogui if self.runner_config.get("batch") and not self.runner_config.get("nogui"): command.append("--batch") # Retrieve the path to the file iso = self.game_config.get("main_file") or "" if not system.path_exists(iso): return {"error": "FILE_NOT_FOUND", "file": iso} command.extend(["-e", iso]) return {"command": command} lutris-0.5.9.1/lutris/runners/dosbox.py000066400000000000000000000121631413267435700201130ustar00rootroot00000000000000# Standard Library import os import shlex from gettext import gettext as _ # 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/bin/dosbox" require_libs = ["libopusfile.so.0", ] game_options = [ { "option": "main_file", "type": "file", "label": _("Main file"), "help": _( "The CONF, EXE, COM or BAT file to launch.\n" "It can be left blank if the launch of the executable is " "managed in the config file." ), }, { "option": "config_file", "type": "file", "label": _("Configuration file"), "help": _( "Start DOSBox with the options specified in this file. \n" "It can have a section in which you can put commands " "to execute on startup. Read DOSBox's documentation " "for more information." ), }, { "option": "args", "type": "string", "label": _("Command line arguments"), "help": _("Command line arguments used when launching DOSBox"), "validator": shlex.split, }, { "option": "working_dir", "type": "directory_chooser", "label": _("Working directory"), "help": _( "The location where the game is run from.\n" "By default, Lutris uses the directory of the " "executable." ), }, ] scaler_modes = [ (_("none"), "none"), ("normal2x", "normal2x"), ("normal3x", "normal3x"), ("hq2x", "hq2x"), ("hq3x", "hq3x"), ("advmame2x", "advmame2x"), ("advmame3x", "advmame3x"), ("2xsai", "2xsai"), ("super2xsai", "super2xsai"), ("supereagle", "supereagle"), ("advinterp2x", "advinterp2x"), ("advinterp3x", "advinterp3x"), ("tv2x", "tv2x"), ("tv3x", "tv3x"), ("rgb2x", "rgb2x"), ("rgb3x", "rgb3x"), ("scan2x", "scan2x"), ("scan3x", "scan3x"), ] runner_options = [ { "option": "scaler", "label": _("Graphic scaler"), "type": "choice", "choices": scaler_modes, "default": "normal3x", "help": _("The algorithm used to scale up the game's base " "resolution, resulting in different visual styles. "), }, { "option": "exit", "label": _("Exit DOSBox with the game"), "type": "bool", "default": True, "help": _("Shut down DOSBox when the game is quit."), }, { "option": "fullscreen", "label": _("Open game in fullscreen"), "type": "bool", "default": False, "help": _("Tells DOSBox to launch the game in fullscreen."), }, ] def make_absolute(self, path): """Return a guaranteed absolute path""" if not path: return "" if os.path.isabs(path): return path if self.game_data.get("directory"): return os.path.join(self.game_data.get("directory"), path) return "" @property def main_file(self): return self.make_absolute(self.game_config.get("main_file")) @property def 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(dosbox, self).working_dir def play(self): main_file = self.main_file if not system.path_exists(main_file): return {"error": "FILE_NOT_FOUND", "file": main_file} args = shlex.split(self.game_config.get("args") or "") command = [self.get_executable()] if main_file.endswith(".conf"): command.append("-conf") command.append(main_file) else: command.append(main_file) # Options if self.game_config.get("config_file"): command.append("-conf") command.append(self.make_absolute(self.game_config["config_file"])) scaler = self.runner_config.get("scaler") if scaler and scaler != "none": command.append("-scaler") command.append(self.runner_config["scaler"]) if self.runner_config.get("fullscreen"): command.append("-fullscreen") if self.runner_config.get("exit"): command.append("-exit") if args: command.extend(args) return {"command": command} lutris-0.5.9.1/lutris/runners/easyrpg.py000066400000000000000000000322711413267435700202710ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ from os import path # Lutris Modules from lutris.runners.runner import Runner class easyrpg(Runner): human_name = _("EasyRPG Player") description = _("Runs RPG Maker 2000/2003 games") platforms = [_("Linux")] runnable_alone = True entry_point_option = "project_path" runner_executable = "easyrpg-player" download_url = "https://easyrpg.org/downloads/player/0.6.2.3/easyrpg-player-0.6.2.3-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": "string", "label": _("Encoding"), "help": _( "Instead of auto detecting the encoding or using the " "one in RPG_RT.ini, the specified encoding is used. " "Use 'auto' for automatic detection." ) }, { "option": "engine", "type": "choice", "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": "save_path", "type": "directory_chooser", "label": _("Save path"), "help": _( "Instead of storing save files in the game directory they " "are stored in the specified path. The directory must exist." ) }, { "option": "new_game", "type": "bool", "label": _("New game"), "help": _("Skip the title scene and start a new game directly."), "default": False }, { "option": "load_game_id", "type": "range", "label": _("Load game ID"), "help": _( "Skip the title scene and load SaveXX.lsd. " "Set to '0' to disable." ), "min": 0, "max": 99, "default": 0 }, { "option": "start_map_id", "type": "range", "label": _("Start map ID"), "help": _( "Overwrite the map used for new games and use " "MapXXXX.lmu instead. Set to '0' to disable. " "\n\nIncompatible with 'Load game ID'." ), "min": 0, "max": 9999, "default": 0 }, { "option": "start_position", "type": "string", "label": _("Start position"), "help": _( "Overwrite the party start position and " "move the party to the specified position. " "Provide two numbers separated by a space. " "\n\nIncompatible with 'Load game ID'." ) }, { "option": "start_party", "type": "string", "label": _("Start party"), "help": _( "Overwrite the starting party members with " "the actors with the specified IDs. Provide " "one to four numbers separated by spaces. " "\n\nIncompatible with 'Load game ID'." ) }, { "option": "battle_test", "type": "string", "label": _("Monster party"), "help": _("Start a battle test with the specified monster party.") }, { "option": "record_input", "type": "string", "label": _("Record input"), "help": _("Records all button input to the specified log file.") }, { "option": "replay_input", "type": "file", "label": _("Replay input"), "help": _( "Replays button input from the specified log file, " "as generated by 'Record input'. 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." ) }, ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "help": _("Start in fullscreen mode."), "default": False }, { "option": "audio", "type": "bool", "label": _("Enable audio"), "help": _( "Switch off to disable audio " "(in case you prefer your own music)." ), "default": True }, { "option": "mouse", "type": "bool", "label": _("Enable mouse"), "help": _( "Use mouse click for decision and scroll wheel for lists." ), "default": False }, { "option": "touch", "type": "bool", "label": _("Enable touch"), "help": _("Use one/two finger tap for decision/cancel."), "default": False }, { "option": "hide_title", "type": "bool", "label": _("Hide title"), "help": _( "Hide the title background image and center the command menu." ), "default": False }, { "option": "vsync", "type": "bool", "label": _("Enable VSync"), "help": _( "Switch off to disable VSync and use the FPS limit. " "VSync may or may not be supported on all platforms." ), "default": True }, { "option": "fps_limit", "type": "string", "label": _("FPS limit"), "help": _( "Set a custom frames per second limit. If unspecified, " "the default is 60 FPS. Set to '0' to disable the frame " "limiter. This option may not be supported on all platforms." ) }, { "option": "show_fps", "type": "choice", "label": _("Show FPS"), "help": _("Enable frames per second counter."), "choices": [ (_("Disabled"), "off"), (_("Fullscreen & title bar"), "on"), (_("Fullscreen, title bar & window"), "full") ], "default": "off" }, { "option": "seed", "type": "string", "label": _("RNG seed"), "help": _("Seeds the random number generator") }, { "option": "test_play", "type": "bool", "label": _("Test play"), "help": _("Enable TestPlay mode."), "default": False }, { "option": "rtp", "type": "bool", "label": _("Enable RTP"), "help": _( "Switch off to disable support for the Runtime Package (RTP)." ), "default": True }, { "option": "rpg2k_rtp_path", "type": "directory_chooser", "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", "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", "label": _("Fallback RTP location"), "help": _("Full path to a directory containing a combined RTP.") }, ] @property def game_path(self): game_path = self.game_data.get("directory") if game_path: return game_path # Default to the directory of the entry point entry_point = self.game_config.get(self.entry_point_option) if entry_point: return path.expanduser(entry_point) return "" def get_env(self, os_env=False): env = super().get_env(os_env) rpg2k_rtp_path = self.runner_config.get("rpg2k_rtp_path") if rpg2k_rtp_path: env["RPG2K_RTP_PATH"] = rpg2k_rtp_path rpg2k3_rtp_path = self.runner_config.get("rpg2k3_rtp_path") if rpg2k3_rtp_path: env["RPG2K3_RTP_PATH"] = rpg2k3_rtp_path rpg_rtp_path = self.runner_config.get("rpg_rtp_path") if rpg_rtp_path: env["RPG_RTP_PATH"] = rpg_rtp_path return env def get_runner_command(self): cmd = [self.get_executable()] if self.runner_config["fullscreen"]: cmd.append("--fullscreen") else: cmd.append("--window") if not self.runner_config["audio"]: cmd.append("--disable-audio") if self.runner_config["mouse"]: cmd.append("--enable-mouse") if self.runner_config["touch"]: cmd.append("--enable-touch") if self.runner_config["hide_title"]: cmd.append("--hide-title") if not self.runner_config["vsync"]: cmd.append("--no-vsync") fps_limit = self.runner_config.get("fps_limit") if fps_limit: cmd.extend(("--fps-limit", fps_limit)) show_fps = self.runner_config.get("show_fps") if show_fps != "off": cmd.append("--show-fps") if show_fps == "full": cmd.append("--fps-render-window") if self.runner_config["test_play"]: cmd.append("--test-play") seed = self.runner_config.get("seed") if seed: cmd.extend(("--seed", seed)) if not self.runner_config["rtp"]: cmd.append("--disable-rtp") return cmd def get_run_data(self): cmd = self.get_runner_command() if self.default_path: game_path = path.expanduser(self.default_path) cmd.extend(("--project-path", game_path)) return {"command": cmd, "env": self.get_env()} def play(self): if not self.game_path: return {"error": "CUSTOM", "text": _("No game directory provided")} if not path.isdir(self.game_path): return self.directory_not_found(self.game_path) cmd = self.get_runner_command() cmd.extend(("--project-path", self.game_path)) encoding = self.game_config.get("encoding") if encoding: cmd.extend(("--encoding", encoding)) engine = self.game_config.get("engine") if engine: cmd.extend(("--engine", engine)) save_path = self.game_config.get("save_path") if save_path: save_path = path.expanduser(save_path) if not path.isdir(save_path): return self.directory_not_found(save_path) cmd.extend(("--save-path", save_path)) record_input = self.game_config.get("record_input") if record_input: record_input = path.expanduser(record_input) cmd.extend(("--record-input", record_input)) replay_input = self.game_config.get("replay_input") if replay_input: replay_input = path.expanduser(replay_input) if not path.isfile(replay_input): return {"error": "FILE_NOT_FOUND", "file": replay_input} cmd.extend(("--replay-input", replay_input)) load_game_id = self.game_config.get("load_game_id") if load_game_id: cmd.extend(("--load-game-id", str(load_game_id))) start_map_id = self.game_config.get("start_map_id") if start_map_id: cmd.extend(("--start-map-id", str(start_map_id))) start_position = self.game_config.get("start_position") if start_position: cmd.extend(("--start-position", *start_position.split())) start_party = self.game_config.get("start_party") if start_party: cmd.extend(("--start-party", *start_party.split())) battle_test = self.game_config.get("battle_test") if battle_test: cmd.extend(("--battle-test", battle_test)) return {"command": cmd} @staticmethod def directory_not_found(directory): error = _( "The directory {} could not be found" ).format(directory.replace("&", "&")) return {"error": "CUSTOM", "text": error} lutris-0.5.9.1/lutris/runners/fsuae.py000066400000000000000000000325211413267435700177200ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris.runners.runner import Runner from lutris.util.display import DISPLAY_MANAGER class fsuae(Runner): human_name = _("FS-UAE") description = _("Amiga emulator") platforms = [ _("Amiga 500"), _("Amiga 500+"), _("Amiga 600"), _("Amiga 1000"), _("Amiga 1200"), _("Amiga 1200"), _("Amiga 4000"), _("Amiga CD32"), _("Commodore CDTV"), ] model_choices = [ (_("Amiga 500"), "A500"), (_("Amiga 500+ with 1 MB chip RAM"), "A500+"), (_("Amiga 600 with 1 MB chip RAM"), "A600"), (_("Amiga 1000 with 512 KB chip RAM"), "A1000"), (_("Amiga 1200 with 2 MB chip RAM"), "A1200"), (_("Amiga 1200 but with 68020 processor"), "A1200/020"), (_("Amiga 4000 with 2 MB chip RAM and a 68040"), "A4000/040"), (_("Amiga CD32"), "CD32"), (_("Commodore CDTV"), "CDTV"), ] 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", "type": "multiple", "label": _("Additionnal floppies"), "default_path": "game_path", "help": _("The additional floppy disk image(s)."), }, { "option": "cdrom_image", "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", "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", "label": _("Extended Kickstart location"), "type": "file", "advanced": True, "help": _("Location of extended Kickstart used for CD32"), }, { "option": "gfx_fullscreen_amiga", "label": _("Fullscreen (F12 + S to switch)"), "type": "bool", "default": False, }, { "option": "scanlines", "label": _("Scanlines display style"), "type": "bool", "default": False, "help": _("Activates a display filter adding scanlines to imitate " "the displays of yesteryear."), }, { "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", "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", "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": "grafixcard", "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", "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": "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, }, ] def get_platform(self): model = self.runner_config.get("model") if model: for index, machine in enumerate(self.model_choices): if machine[1] == model: return self.platforms[index] return "" def get_absolute_path(self, path): """Return the absolute path for a file""" return path if os.path.isabs(path) else os.path.join(self.game_path, path) def insert_floppies(self): disks = [] main_disk = self.game_config.get("main_file") if main_disk: disks.append(main_disk) game_disks = self.game_config.get("disks") or [] for disk in game_disks: if disk not in disks: disks.append(disk) # Make all paths absolute disks = [self.get_absolute_path(disk) for disk in disks] drives = [] floppy_images = [] for drive, disk_path in enumerate(disks): disk_param = self.get_disk_param(disk_path) drives.append("--%s_%d=%s" % (disk_param, drive, disk_path)) if disk_param == "floppy_drive": floppy_images.append("--floppy_image_%d=%s" % (drive, disk_path)) cdrom_image = self.game_config.get("cdrom_image") if cdrom_image: drives.append("--cdrom_drive_0=%s" % self.get_absolute_path(cdrom_image)) return drives + floppy_images def get_disk_param(self, disk_path): amiga_model = self.runner_config.get("model") if amiga_model in ("CD32", "CDTV"): return "cdrom_drive" if disk_path.lower().endswith(".hdf"): return "hard_drive" return "floppy_drive" def get_params(self): # pylint: disable=too-many-branches params = [] option_params = { "kickstart_file": "--kickstart_file=%s", "kickstart_ext_file": "--kickstart_ext_file=%s", "model": "--amiga_model=%s", "cpumodel": "--cpu=%s", "fmemory": "--fast_memory=%s", "ziiimem": "--zorro_iii_memory=%s", "fdvolume": "--floppy_drive_volume=%s", "fdspeed": "--floppy_drive_speed=%s", "grafixcard": "--graphics_card=%s", "grafixmemory": "--graphics_memory=%s", } for option, param in option_params.items(): option_value = self.runner_config.get(option) if option_value: params.append(param % option_value) if self.runner_config.get("gfx_fullscreen_amiga"): width = int(DISPLAY_MANAGER.get_current_resolution()[0]) params.append("--fullscreen") # params.append("--fullscreen_mode=fullscreen-window") params.append("--fullscreen_mode=fullscreen") params.append("--fullscreen_width=%d" % width) if self.runner_config.get("jitcompiler"): params.append("--jit_compiler=1") if self.runner_config.get("bsdsocket"): params.append("--bsdsocket_library=1") if self.runner_config.get("gamemode"): params.append("--game_mode=0") if self.runner_config.get("govwarning"): params.append("--governor_warning=0") if self.runner_config.get("scanlines"): params.append("--scanlines=1") return params def play(self): return {"command": [self.get_executable()] + self.get_params() + self.insert_floppies()} lutris-0.5.9.1/lutris/runners/hatari.py000066400000000000000000000144171413267435700200710ustar00rootroot00000000000000# Standard Library import os import shutil from gettext import gettext as _ # Lutris Modules from lutris.config import LutrisConfig from lutris.gui.dialogs import FileDialog, QuestionDialog 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 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", "label": _("Fullscreen"), "default": False, }, { "option": "zoom", "type": "bool", "label": _("Scale up display by 2 (Atari ST/STE)"), "default": True, "help": _("Double the screen size in windowed mode."), }, { "option": "borders", "type": "bool", "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", "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", "label": _("Joystick 1"), "choices": joystick_choices, "default": "none", }, { "option": "joy1", "type": "choice", "label": _("Joystick 2"), "choices": joystick_choices, "default": "none", }, ] def install(self, version=None, downloader=None, callback=None): def on_runner_installed(*args): bios_path = system.create_folder("~/.hatari/bios") dlg = QuestionDialog( { "question": _("Do you want to select an Atari ST BIOS file?"), "title": _("Use BIOS file?"), } ) if dlg.result == dlg.YES: bios_dlg = FileDialog(_("Select a BIOS file")) bios_filename = bios_dlg.filename if not bios_filename: return 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(hatari, self).install(version=version, downloader=downloader, callback=on_runner_installed) def play(self): # pylint: disable=too-many-branches params = [self.get_executable()] if self.runner_config.get("fullscreen"): params.append("--fullscreen") else: params.append("--window") params.append("--zoom") if self.runner_config.get("zoom"): params.append("2") else: params.append("1") params.append("--borders") if self.runner_config.get("borders"): params.append("true") else: params.append("false") params.append("--statusbar") if self.runner_config.get("status"): params.append("true") else: params.append("false") if self.runner_config.get("joy0"): params.append("--joy0") params.append(self.runner_config["joy0"]) if self.runner_config.get("joy1"): params.append("--joy1") params.append(self.runner_config["joy1"]) if system.path_exists(self.runner_config.get("bios_file", "")): params.append("--tos") params.append(self.runner_config["bios_file"]) else: return {"error": "NO_BIOS"} diska = self.game_config.get("disk-a") if not system.path_exists(diska): return {"error": "FILE_NOT_FOUND", "file": diska} params.append("--disk-a") params.append(diska) return {"command": params} lutris-0.5.9.1/lutris/runners/json.py000066400000000000000000000054271413267435700175730ustar00rootroot00000000000000"""Base class and utilities for JSON based runners""" import json import os from lutris import settings from lutris.runners.runner import Runner from lutris.util import datapath, system JSON_RUNNER_DIRS = [ os.path.join(datapath.get(), "json"), os.path.join(settings.RUNNER_DIR, "json"), ] class JsonRunner(Runner): json_path = None def __init__(self, config=None): super().__init__(config) if not self.json_path: raise RuntimeError("Create subclasses of JsonRunner with the json_path attribute set") with open(self.json_path) 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") def play(self): """Return a launchable command constructed from the options""" arguments = [self.get_executable()] 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"])) else: raise RuntimeError("Unhandled type %s" % option["type"]) main_file = self.game_config.get(self.entry_point_option) if not system.path_exists(main_file): return {"error": "FILE_NOT_FOUND", "file": main_file} arguments.append(main_file) return {"command": arguments} def load_json_runners(): json_runners = {} for json_dir in JSON_RUNNER_DIRS: if not os.path.exists(json_dir): continue for json_path in os.listdir(json_dir): if not json_path.endswith(".json"): continue runner_name = json_path[:-5] runner_class = type( runner_name, (JsonRunner, ), {'json_path': os.path.join(json_dir, json_path)} ) json_runners[runner_name] = runner_class return json_runners lutris-0.5.9.1/lutris/runners/jzintv.py000066400000000000000000000053371413267435700201460ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class jzintv(Runner): human_name = _("jzIntv") description = _("Intellivision Emulator") platforms = [_("Intellivision")] runner_executable = "jzintv/bin/jzintv" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "default_path": "game_path", "help": _( "The game data, commonly called a ROM image. \n" "Supported formats: ROM, BIN+CFG, INT, ITV \n" "The file extension must be lower-case." ), } ] runner_options = [ { "option": "bios_path", "type": "directory_chooser", "label": _("Bios location"), "help": _( "Choose the folder containing the Intellivision BIOS " "files (exec.bin and grom.bin).\n" "These files contain code from the original hardware " "necessary to the emulation." ), }, { "option": "fullscreen", "type": "bool", "label": _("Fullscreen") }, { "option": "resolution", "type": "choice", "label": _("Resolution"), "choices": ( ("320 x 200", "0"), ("640 x 480", "1"), ("800 x 400", "5"), ("800 x 600", "2"), ("1024 x 768", "3"), ("1680 x 1050", "4"), ("1600 x 1200", "6"), ), "default": "0" }, ] def play(self): """Run Intellivision game""" arguments = [self.get_executable()] selected_resolution = self.runner_config.get("resolution") if selected_resolution: arguments = arguments + ["-z%s" % selected_resolution] if self.runner_config.get("fullscreen"): arguments = arguments + ["-f"] bios_path = self.runner_config.get("bios_path", "") if system.path_exists(bios_path): arguments.append("--execimg=%s/exec.bin" % bios_path) arguments.append("--gromimg=%s/grom.bin" % bios_path) else: return {"error": "NO_BIOS"} rom_path = self.game_config.get("main_file") or "" if not system.path_exists(rom_path): return {"error": "FILE_NOT_FOUND", "file": rom_path} romdir = os.path.dirname(rom_path) romfile = os.path.basename(rom_path) arguments += ["--rom-path=%s/" % romdir] arguments += [romfile] return {"command": arguments} lutris-0.5.9.1/lutris/runners/libretro.py000066400000000000000000000262441413267435700204440ustar00rootroot00000000000000"""libretro runner""" import os from gettext import gettext as _ from operator import itemgetter from zipfile import ZipFile import requests from lutris import settings from lutris.runners.runner import Runner from lutris.util import system from lutris.util.libretro import RetroConfig from lutris.util.log import logger def get_default_config_path(path=""): return os.path.join(settings.RUNNER_DIR, "retroarch", path) def get_libretro_cores(): cores = [] runner_path = get_default_config_path() if os.path.exists(runner_path): # 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) if req.status_code == requests.codes.ok: # pylint: disable=no-member open(get_default_config_path('info.zip'), 'wb').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)) if not cores: logger.warning("No cores found") 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" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file") }, { "option": "core", "type": "choice", "label": _("Core"), "choices": get_core_choices(), }, ] runner_options = [ { "option": "config_file", "type": "file", "label": _("Config file"), "default": get_default_config_path("retroarch.cfg"), }, { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": True, }, { "option": "verbose", "type": "bool", "label": _("Verbose logging"), "default": False, }, ] @property def platforms(self): return [core[2] for core in LIBRETRO_CORES] def get_platform(self): game_core = self.game_config.get("core") if not game_core: logger.warning("Game don't have a core set") return for core in LIBRETRO_CORES: if core[1] == game_core: return core[2] logger.warning("'%s' not found in Libretro cores", game_core) return "" def get_core_path(self, core): return os.path.join(settings.RUNNER_DIR, "retroarch", "cores", "{}_libretro.so".format(core)) def get_version(self, use_default=True): return self.game_config["core"] def is_retroarch_installed(self): return system.path_exists(self.get_executable()) def is_installed(self, core=None): if self.game_config.get("core") and core is None: core = self.game_config["core"] if not core or self.runner_config.get("runner_executable"): return self.is_retroarch_installed() is_core_installed = system.path_exists(self.get_core_path(core)) return self.is_retroarch_installed() and is_core_installed def install(self, version=None, downloader=None, callback=None): def install_core(): if not version: if callback: callback() else: super(libretro, self).install(version, downloader, callback) if not self.is_retroarch_installed(): super(libretro, self).install(version=None, downloader=downloader, callback=install_core) else: super(libretro, self).install(version, downloader, callback) def get_run_data(self): return { "command": [self.get_executable()] + 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): f = open(config_file, "w") 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 return True 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_executable()] command += self.get_runner_parameters() # Core core = self.game_config.get("core") if not core: return { "error": "CUSTOM", "text": _("No core has been selected for this game"), } command.append("--libretro={}".format(self.get_core_path(core))) # Ensure the core is available if not self.is_installed(core): self.install(core) # Main file file = self.game_config.get("main_file") if not file: return {"error": "CUSTOM", "text": _("No game file specified")} if not system.path_exists(file): return {"error": "FILE_NOT_FOUND", "file": file} command.append(file) return {"command": command} # Checks whether the retroarch or libretro directories can be uninstalled. def can_uninstall(self): retroarch_path = os.path.join(settings.RUNNER_DIR, 'retroarch') return os.path.isdir(retroarch_path) or super(libretro, self).can_uninstall() # Remove the `retroarch` directory. def uninstall(self): retroarch_path = os.path.join(settings.RUNNER_DIR, 'retroarch') if os.path.isdir(retroarch_path): system.remove_folder(retroarch_path) super(libretro, self).uninstall() lutris-0.5.9.1/lutris/runners/linux.py000066400000000000000000000106131413267435700177520ustar00rootroot00000000000000"""Runner for Linux games""" # Standard Library import os import stat from gettext import gettext as _ # Lutris Modules 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(linux, self).__init__(config) self.ld_preload = None @property def game_exe(self): """Return the game's executable's path.""" exe = self.game_config.get("exe") if not exe: return 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 get_relative_exe(self): """Return a relative path if a working dir is set in the options Some games such as Unreal Gold fail to run if given the absolute path """ exe_path = self.game_exe working_dir = self.game_config.get("working_dir") if working_dir: parts = exe_path.split(os.path.expanduser(working_dir)) if len(parts) == 2: return "." + parts[1] 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(linux, self).working_dir def is_installed(self): """Well of course Linux is installed, you're using Linux right ?""" return True def play(self): """Run native game.""" launch_info = {} if not self.game_exe or not system.path_exists(self.game_exe): return {"error": "FILE_NOT_FOUND", "file": self.game_exe} # Quit if the file is not executable mode = os.stat(self.game_exe).st_mode if not mode & stat.S_IXUSR: return {"error": "NOT_EXECUTABLE", "file": self.game_exe} if not system.path_exists(self.game_exe): return {"error": "FILE_NOT_FOUND", "file": self.game_exe} ld_preload = self.game_config.get("ld_preload") if ld_preload: launch_info["ld_preload"] = ld_preload ld_library_path = self.game_config.get("ld_library_path") if ld_library_path: launch_info["ld_library_path"] = os.path.expanduser(ld_library_path) command = [self.get_relative_exe()] 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.9.1/lutris/runners/mame.py000066400000000000000000000276771413267435700175540ustar00rootroot00000000000000"""Runner for MAME""" import os from gettext import gettext as _ from lutris import runtime, settings from lutris.runners.runner import Runner from lutris.util import system from lutris.util.jobs import AsyncCall from lutris.util.log import logger from lutris.util.mame.database import get_supported_systems from lutris.util.strings import split_arguments MAME_CACHE_DIR = os.path.join(settings.CACHE_DIR, "mame") MAME_XML_PATH = os.path.join(MAME_CACHE_DIR, "mame.xml") def write_mame_xml(force=False): if not system.path_exists(MAME_CACHE_DIR): system.create_folder(MAME_CACHE_DIR) if system.path_exists(MAME_XML_PATH, exclude_empty=True) and not force: return False logger.info("Writing full game list from MAME to %s", MAME_XML_PATH) mame_inst = mame() mame_inst.write_xml_list() if system.get_disk_size(MAME_XML_PATH) == 0: logger.warning("MAME did not write anything to %s", MAME_XML_PATH) return False return True def notify_mame_xml(result, error): if error: logger.error("Failed writing MAME XML") elif result: logger.info("Finished writing MAME XML") def get_system_choices(include_year=True): """Return list of systems for inclusion in dropdown""" if not system.path_exists(MAME_XML_PATH, exclude_empty=True): mame_inst = mame() if mame_inst.is_installed(): AsyncCall(write_mame_xml, notify_mame_xml) return [] for system_id, info in sorted( get_supported_systems(MAME_XML_PATH).items(), key=lambda sys: (sys[1]["manufacturer"], sys[1]["description"]), ): if info["description"].startswith(info["manufacturer"]): template = "" else: template = "%(manufacturer)s " template += "%(description)s" if include_year: template += " %(year)s" system_name = template % info system_name = system_name.replace("", "").strip() yield (system_name, system_id) class mame(Runner): # pylint: disable=invalid-name """MAME runner""" human_name = _("MAME") description = _("Arcade game emulator") runner_executable = "mame/mame" runnable_alone = True config_dir = os.path.expanduser("~/.mame") cache_dir = os.path.join(settings.CACHE_DIR, "mame") xml_path = os.path.join(cache_dir, "mame.xml") _platforms = [] game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), }, { "option": "machine", "type": "choice_with_search", "label": _("Machine"), "choices": get_system_choices, "help": _("The emulated machine.") }, { "option": "device", "type": "choice_with_entry", "label": _("Storage type"), "choices": [ (_("Floppy disk"), "flop"), (_("Floppy drive 1"), "flop1"), (_("Floppy drive 2"), "flop2"), (_("Floppy drive 3"), "flop3"), (_("Floppy drive 4"), "flop4"), (_("Cassette (tape)"), "cass"), (_("Cassette 1 (tape)"), "cass1"), (_("Cassette 2 (tape)"), "cass2"), (_("Cartridge"), "cart"), (_("Cartridge 1"), "cart1"), (_("Cartridge 2"), "cart2"), (_("Cartridge 3"), "cart3"), (_("Cartridge 4"), "cart4"), (_("Snapshot"), "snapshot"), (_("Hard Disk"), "hard"), (_("Hard Disk 1"), "hard1"), (_("Hard Disk 2"), "hard2"), (_("CD-ROM"), "cdrm"), (_("CD-ROM 1"), "cdrm1"), (_("CD-ROM 2"), "cdrm2"), (_("Snapshot"), "dump"), (_("Quickload"), "quickload"), (_("Memory Card"), "memc"), (_("Cylinder"), "cyln"), (_("Punch Tape 1"), "ptap1"), (_("Punch Tape 2"), "ptap2"), (_("Print Out"), "prin"), ], }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _("Command line arguments used when launching the game"), }, { "option": "autoboot_command", "type": "string", "label": _("Autoboot command"), "help": _("Autotype this command when the system has started, " "an enter keypress is automatically added."), }, { "option": "autoboot_delay", "type": "range", "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", "label": _("Fullscreen"), "default": True, }, { "option": "crt", "type": "bool", "label": _("CRT effect ()"), "help": _("Applies a CRT effect to the screen." "Requires OpenGL renderer."), "default": False, }, { "option": "video", "type": "choice", "label": _("Video backend"), "choices": ( (_("Auto"), ""), ("OpenGL", "opengl"), ("BGFX", "bgfx"), ("SDL2", "accel"), (_("Software"), "soft"), ), "default": "opengl", }, { "option": "waitvsync", "type": "bool", "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, version=None, downloader=None, callback=None): def on_runner_installed(*args): AsyncCall(write_mame_xml, notify_mame_xml) super().install(version=version, downloader=downloader, 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""" if not os.path.exists(self.cache_dir): os.makedirs(self.cache_dir) output = system.execute( [self.get_executable(), "-listxml"], env=runtime.get_env() ) if output: with open(self.xml_path, "w") as xml_file: xml_file.write(output) logger.info("MAME XML list written to %s", self.xml_path) else: logger.warning("Couldn't get any output for mame -listxml") def get_platform(self): selected_platform = self.game_config.get("platform") if selected_platform: return self.platforms[int(selected_platform)] if self.game_config.get("machine"): machine_mapping = {choice[1]: choice[0] for choice in get_system_choices(include_year=False)} return machine_mapping[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_executable(), "-createconfig", "-inipath", self.config_dir], env=runtime.get_env(), cwd=self.working_dir ) return True 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_executable(), "-skip_gameinfo", "-inipath", self.config_dir] if self.runner_config.get("video"): command += ["-video", self.runner_config["video"]] if not self.runner_config.get("fullscreen"): command.append("-window") if self.runner_config.get("waitvsync"): command.append("-waitvsync") if self.runner_config.get("uimodekey"): command += ["-uimodekey", self.runner_config["uimodekey"]] if self.runner_config.get("crt"): command += self.get_shader_params("CRT-geom", ["Gaussx", "Gaussy", "CRT-geom-halation"]) command += ["-nounevenstretch"] if self.game_config.get("machine"): rompath = self.runner_config.get("rompath") if rompath: command += ["-rompath", rompath] command.append(self.game_config["machine"]) device = self.game_config.get("device") if not device: return {'error': "CUSTOM", "text": "No device is set for machine %s" % self.game_config["machine"]} rom = self.game_config.get("main_file") if rom: command += ["-" + device, rom] else: rompath = os.path.dirname(self.game_config.get("main_file")) if not rompath: rompath = self.runner_config.get("rompath") rom = os.path.basename(self.game_config.get("main_file")) if not rompath: return {'error': 'PATH_NOT_SET', 'path': 'rompath'} command += ["-rompath", rompath, rom] if self.game_config.get("autoboot_command"): command += ["-autoboot_command", self.game_config["autoboot_command"] + "\\n"] if self.game_config.get("autoboot_delay"): command += ["-autoboot_delay", str(self.game_config["autoboot_delay"])] for arg in split_arguments(self.game_config.get("args")): command.append(arg) return {"command": command} lutris-0.5.9.1/lutris/runners/mednafen.py000066400000000000000000000412261413267435700203740ustar00rootroot00000000000000# Standard Library import subprocess from gettext import gettext as _ # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system from lutris.util.display import DISPLAY_MANAGER from lutris.util.joypad import get_controller_mappings from lutris.util.log import logger DEFAULT_MEDNAFEN_SCALER = "nn4x" class mednafen(Runner): human_name = _("Mednafen") description = _("Multi-system emulator: NES, PC Engine, PSX…") platforms = [ _("Nintendo Game Boy (Color)"), _("Nintendo Game Boy Advance"), _("Sega Game Gear"), _("Sega Genesis/Mega Drive"), _("Atari Lynx"), _("Sega Master System"), _("SNK Neo Geo Pocket (Color)"), _("Nintendo NES"), _("NEC PC Engine TurboGrafx-16"), _("NEC PC-FX"), _("Sony PlayStation"), _("Sega Saturn"), _("Nintendo SNES"), _("Bandai WonderSwan"), _("Nintendo Virtual Boy"), ] machine_choices = ( (_("Game Boy (Color)"), "gb"), (_("Game Boy Advance"), "gba"), (_("Game Gear"), "gg"), (_("Genesis/Mega Drive"), "md"), (_("Lynx"), "lynx"), (_("Master System"), "sms"), (_("Neo Geo Pocket (Color)"), "gnp"), (_("NES"), "nes"), (_("PC Engine"), "pce_fast"), (_("PC-FX"), "pcfx"), (_("PlayStation"), "psx"), (_("Saturn"), "ss"), (_("SNES"), "snes"), (_("WonderSwan"), "wswan"), (_("Virtual Boy"), "vb"), ) runner_executable = "mednafen/bin/mednafen" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image. \n" "Mednafen supports GZIP and ZIP compressed ROMs."), }, { "option": "machine", "type": "choice", "label": _("Machine type"), "choices": machine_choices, "help": _("The emulated machine."), }, ] runner_options = [ { "option": "fs", "type": "bool", "label": _("Fullscreen"), "default": False }, { "option": "stretch", "type": "choice", "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", "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 [] output = subprocess.Popen( [self.get_executable(), "dummy"], stdout=subprocess.PIPE, universal_newlines=True, ).communicate()[0] ouput = output.split("\n") found = False joy_list = [] for line in ouput: if found and "Joystick" in line: joy_list.append(line) else: found = False if "Initializing joysticks" in line: found = True for joy in joy_list: index = joy.find("Unique ID:") joy_id = joy[index + 11:] logger.debug("Joystick found id %s ", joy_id) joy_ids.append(joy_id) return joy_ids @staticmethod def set_joystick_controls(joy_ids, machine): """ Setup joystick mappings per machine """ # Get the controller mappings controller_mappings = get_controller_mappings() if not controller_mappings: logger.warning("No controller detected for joysticks %s.", joy_ids) return [] # TODO currently only supports the first controller. Add support for other controllers. mapping = controller_mappings[0][1] # Construct a dictionnary of button codes to parse to mendafen map_code = { "a": "", "b": "", "c": "", "x": "", "y": "", "z": "", "back": "", "start": "", "leftshoulder": "", "rightshoulder": "", "lefttrigger": "", "righttrigger": "", "leftstick": "", "rightstick": "", "select": "", "shoulder_l": "", "shoulder_r": "", "i": "", "ii": "", "iii": "", "iv": "", "v": "", "vi": "", "run": "", "ls": "", "rs": "", "fire1": "", "fire2": "", "option_1": "", "option_2": "", "cross": "", "circle": "", "square": "", "triangle": "", "r1": "", "r2": "", "l1": "", "l2": "", "option": "", "l": "", "r": "", "right-x": "", "right-y": "", "left-x": "", "left-y": "", "up-x": "", "up-y": "", "down-x": "", "down-y": "", "up-l": "", "up-r": "", "down-l": "", "down-r": "", "left-l": "", "left-r": "", "right-l": "", "right-r": "", "lstick_up": "0000c001", "lstick_down": "00008001", "lstick_right": "00008000", "lstick_left": "0000c000", "rstick_up": "0000c003", "rstick_down": "00008003", "rstick_left": "0000c002", "rstick_right": "00008002", "dpup": "0000c005", "dpdown": "00008005", "dpleft": "0000c004", "dpright": "00008004", } # Insert the button mapping number into the map_codes for button in mapping.keys: bttn_id = mapping.keys[button] if bttn_id[0] == "b": # it's a button map_code[button] = "000000" + bttn_id[1:].zfill(2) # Duplicate button names that are emulated in mednanfen map_code["up"] = map_code["dpup"] # map_code["down"] = map_code["dpdown"] # map_code["left"] = map_code["dpleft"] # Multiple systems map_code["right"] = map_code["dpright"] map_code["select"] = map_code["back"] # map_code["shoulder_r"] = map_code["rightshoulder"] # GBA map_code["shoulder_l"] = map_code["leftshoulder"] # map_code["i"] = map_code["b"] # map_code["ii"] = map_code["a"] # map_code["iii"] = map_code["leftshoulder"] map_code["iv"] = map_code["y"] # PCEngine and PCFX map_code["v"] = map_code["x"] # map_code["vi"] = map_code["rightshoulder"] map_code["run"] = map_code["start"] # map_code["ls"] = map_code["leftshoulder"] # map_code["rs"] = map_code["rightshoulder"] # Saturn map_code["c"] = map_code["righttrigger"] # map_code["z"] = map_code["lefttrigger"] # map_code["fire1"] = map_code["a"] # Master System map_code["fire2"] = map_code["b"] # map_code["option_1"] = map_code["x"] # Lynx map_code["option_2"] = map_code["y"] # map_code["r1"] = map_code["rightshoulder"] # map_code["r2"] = map_code["righttrigger"] # map_code["l1"] = map_code["leftshoulder"] # map_code["l2"] = map_code["lefttrigger"] # PlayStation map_code["cross"] = map_code["a"] # map_code["circle"] = map_code["b"] # map_code["square"] = map_code["x"] # map_code["triangle"] = map_code["y"] # map_code["option"] = map_code["select"] # NeoGeo pocket map_code["l"] = map_code["leftshoulder"] # SNES map_code["r"] = map_code["rightshoulder"] # map_code["right-x"] = map_code["dpright"] # map_code["left-x"] = map_code["dpleft"] # map_code["up-x"] = map_code["dpup"] # map_code["down-x"] = map_code["dpdown"] # Wonder Swan map_code["right-y"] = map_code["lstick_right"] map_code["left-y"] = map_code["lstick_left"] # map_code["up-y"] = map_code["lstick_up"] # map_code["down-y"] = map_code["lstick_down"] # map_code["up-l"] = map_code["dpup"] # map_code["down-l"] = map_code["dpdown"] # map_code["left-l"] = map_code["dpleft"] # map_code["right-l"] = map_code["dpright"] # map_code["up-r"] = map_code["rstick_up"] # map_code["down-r"] = map_code["rstick_down"] # Virtual boy map_code["left-r"] = map_code["rstick_left"] # map_code["right-r"] = map_code["rstick_right"] # map_code["lt"] = map_code["leftshoulder"] # map_code["rt"] = map_code["rightshoulder"] # # Define which buttons to use for each machine layout = { "nes": ["a", "b", "start", "select", "up", "down", "left", "right"], "gb": ["a", "b", "start", "select", "up", "down", "left", "right"], "gba": [ "a", "b", "shoulder_r", "shoulder_l", "start", "select", "up", "down", "left", "right", ], "pce": [ "i", "ii", "iii", "iv", "v", "vi", "run", "select", "up", "down", "left", "right", ], "ss": [ "a", "b", "c", "x", "y", "z", "ls", "rs", "start", "up", "down", "left", "right", ], "gg": ["button1", "button2", "start", "up", "down", "left", "right"], "md": [ "a", "b", "c", "x", "y", "z", "start", "up", "down", "left", "right", ], "sms": ["fire1", "fire2", "up", "down", "left", "right"], "lynx": ["a", "b", "option_1", "option_2", "up", "down", "left", "right"], "psx": [ "cross", "circle", "square", "triangle", "l1", "l2", "r1", "r2", "start", "select", "lstick_up", "lstick_down", "lstick_right", "lstick_left", "rstick_up", "rstick_down", "rstick_left", "rstick_right", "up", "down", "left", "right", ], "pcfx": [ "i", "ii", "iii", "iv", "v", "vi", "run", "select", "up", "down", "left", "right", ], "ngp": ["a", "b", "option", "up", "down", "left", "right"], "snes": [ "a", "b", "x", "y", "l", "r", "start", "select", "up", "down", "left", "right", ], "wswan": [ "a", "b", "right-x", "right-y", "left-x", "left-y", "up-x", "up-y", "down-x", "down-y", "start", ], "vb": [ "up-l", "down-l", "left-l", "right-l", "up-r", "down-r", "left-r", "right-r", "a", "b", "lt", "rt", ], } # Select a the gamepad type controls = [] if machine in ["gg", "lynx", "wswan", "gb", "gba", "vb"]: gamepad = "builtin.gamepad" elif machine in ["md"]: gamepad = "port1.gamepad6" controls.append("-md.input.port1") controls.append("gamepad6") elif machine in ["psx"]: gamepad = "port1.dualshock" controls.append("-psx.input.port1") controls.append("dualshock") else: gamepad = "port1.gamepad" # Construct the controlls options for button in layout[machine]: controls.append("-{}.input.{}.{}".format(machine, gamepad, button)) controls.append("joystick {} {}".format(joy_ids[0], map_code[button])) return controls def play(self): """Runs the game""" rom = self.game_config.get("main_file") or "" machine = self.game_config.get("machine") or "" fullscreen = self.runner_config.get("fs") or "0" if fullscreen is True: fullscreen = "1" elif fullscreen is False: fullscreen = "0" stretch = self.runner_config.get("stretch") or "0" scaler = self.runner_config.get("scaler") or DEFAULT_MEDNAFEN_SCALER sound_device = self.runner_config.get("sound_device") xres, yres = DISPLAY_MANAGER.get_current_resolution() options = [ "-fs", fullscreen, "-force_module", machine, "-sound.device", sound_device, "-" + machine + ".xres", xres, "-" + machine + ".yres", yres, "-" + machine + ".stretch", stretch, "-" + machine + ".special", scaler, "-" + machine + ".videoip", "1", ] joy_ids = self.find_joysticks() dont_map_controllers = self.runner_config.get("dont_map_controllers") if joy_ids and not dont_map_controllers: controls = self.set_joystick_controls(joy_ids, machine) for control in controls: options.append(control) if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": rom} command = [self.get_executable()] for option in options: command.append(option) command.append(rom) return {"command": command} lutris-0.5.9.1/lutris/runners/mupen64plus.py000066400000000000000000000030751413267435700210210ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.runners.runner import Runner from lutris.util import system class mupen64plus(Runner): human_name = _("Mupen64Plus") description = _("Nintendo 64 emulator") platforms = [_("Nintendo 64")] runner_executable = "mupen64plus/mupen64plus" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image."), } ] runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": True, }, { "option": "hideosd", "type": "bool", "label": _("Hide OSD"), "default": True }, ] @property def working_dir(self): return os.path.join(settings.RUNNER_DIR, "mupen64plus") def play(self): arguments = [self.get_executable()] if self.runner_config.get("hideosd"): arguments.append("--noosd") else: arguments.append("--osd") if self.runner_config.get("fullscreen"): arguments.append("--fullscreen") else: arguments.append("--windowed") rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": rom} arguments.append(rom) return {"command": arguments} lutris-0.5.9.1/lutris/runners/o2em.py000066400000000000000000000074751413267435700174710ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class o2em(Runner): human_name = _("O2EM") description = _("Magnavox Osyssey² 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, "label": _("First controller"), "default": "2", }, { "option": "controller2", "type": "choice", "choices": controller_choices, "label": _("Second controller"), "default": "1", }, { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": False, }, { "option": "scanlines", "type": "bool", "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, version=None, downloader=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(o2em, self).install(version, downloader, on_runner_installed) def play(self): arguments = ["-biosdir=%s" % self.bios_path] if self.runner_config.get("fullscreen"): arguments.append("-fullscreen") if self.runner_config.get("scanlines"): arguments.append("-scanlines") if "controller1" in self.runner_config: arguments.append("-s1=%s" % self.runner_config["controller1"]) if "controller2" in self.runner_config: arguments.append("-s2=%s" % self.runner_config["controller2"]) rom_path = self.game_config.get("main_file") or "" if not system.path_exists(rom_path): return {"error": "FILE_NOT_FOUND", "file": rom_path} romdir = os.path.dirname(rom_path) romfile = os.path.basename(rom_path) arguments.append("-romdir=%s/" % romdir) arguments.append(romfile) return {"command": [self.get_executable()] + arguments} lutris-0.5.9.1/lutris/runners/openmsx.py000066400000000000000000000013461413267435700203070ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class openmsx(Runner): human_name = _("openMSX") description = _("MSX computer emulator") platforms = [_("MSX, MSX2, MSX2+, MSX turboR")] game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "help": _("The game data, commonly called a ROM image."), } ] def play(self): rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": rom} return {"command": [self.get_executable(), rom]} lutris-0.5.9.1/lutris/runners/osmose.py000066400000000000000000000025411413267435700201210ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class osmose(Runner): human_name = _("Osmose") description = _("Sega Master System Emulator") platforms = [_("Sega Master System")] runner_executable = "osmose/osmose" game_options = [ { "option": "main_file", "type": "file", "label": _("ROM file"), "default_path": "game_path", "help": _( "The game data, commonly called a ROM image.\n" "Supported formats: SMS and GG files. ZIP compressed " "ROMs are supported." ), } ] runner_options = [{ "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": False, }] def play(self): """Run Sega Master System game""" arguments = [self.get_executable()] rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": rom} arguments.append(rom) if self.runner_config.get("fullscreen"): arguments.append("-fs") arguments.append("-bilinear") return {"command": arguments} lutris-0.5.9.1/lutris/runners/pcsx2.py000066400000000000000000000042241413267435700176530ustar00rootroot00000000000000from gettext import gettext as _ 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" arch = "i386" require_libs = ["libOpenGL.so.0", "libgdk-x11-2.0.so.0", "libEGL.so.1"] 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 }, { "option": "config_file", "type": "file", "label": _("Custom config file"), "advanced": True, }, { "option": "config_path", "type": "directory_chooser", "label": _("Custom config path"), "advanced": True, }, ] def play(self): arguments = [self.get_executable()] if self.runner_config.get("fullscreen"): arguments.append("--fullscreen") if self.runner_config.get("full_boot"): arguments.append("--fullboot") if self.runner_config.get("nogui"): arguments.append("--nogui") if self.runner_config.get("config_file"): arguments.append("--cfg={}".format(self.runner_config["config_file"])) if self.runner_config.get("config_path"): arguments.append("--cfgpath={}".format(self.runner_config["config_path"])) iso = self.game_config.get("main_file") or "" if not system.path_exists(iso): return {"error": "FILE_NOT_FOUND", "file": iso} arguments.append(iso) return {"command": arguments} lutris-0.5.9.1/lutris/runners/pico8.py000066400000000000000000000233111413267435700176340ustar00rootroot00000000000000"""Runner for the PICO-8 fantasy console""" import json import math import os import shutil from gettext import gettext as _ from time import sleep from lutris import settings from lutris.database.games import get_game_by_field from lutris.runners.runner import Runner from lutris.util import system from lutris.util.downloader import Downloader from lutris.util.log import logger from lutris.util.strings import split_arguments DOWNLOAD_URL = "https://github.com/daniel-j/lutris-pico-8-runner/archive/master.tar.gz" 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": "string", "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", "label": _("Fullscreen"), "default": True, "help": _("Launch in fullscreen."), }, { "option": "window_size", "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(pico8, self).__init__(config) self.runnable_alone = self.is_native def __repr__(self): return _("PICO-8 runner (%s)") % self.config def install(self, version=None, downloader=None, callback=None): opts = {} if callback: opts["callback"] = callback opts["dest"] = settings.RUNNER_DIR + "/pico8" opts["merge_single"] = True if downloader: opts["downloader"] = downloader else: raise RuntimeError("Unsupported download for this runner") self.download_and_extract(DOWNLOAD_URL, **opts) @property def is_native(self): return self.runner_config.get("runner_executable", "") != "" @property def engine_path(self): engine = self.runner_config.get("engine") if not engine.lower().endswith(".js") and not os.path.exists(engine): engine = os.path.join( settings.RUNNER_DIR, "pico8/web/engines", self.runner_config.get("engine") + ".js", ) return engine @property def cart_path(self): main_file = self.game_config.get("main_file") if self.is_native and main_file.startswith("http"): return os.path.join(settings.RUNNER_DIR, "pico8/cartridges", "tmp.p8.png") if not os.path.exists(main_file) and main_file.isdigit(): return os.path.join(settings.RUNNER_DIR, "pico8/cartridges", main_file + ".p8.png") return main_file @property def launch_args(self): if self.is_native: args = [self.get_executable()] args.append("-windowed") args.append("0" if self.runner_config.get("fullscreen") else "1") if self.runner_config.get("splore"): args.append("-splore") size = self.runner_config.get("window_size").split("x") if len(size) == 2: args.append("-width") args.append(size[0]) args.append("-height") args.append(size[1]) extra_args = self.runner_config.get("args", "") for arg in split_arguments(extra_args): args.append(arg) else: args = [ self.get_executable(), os.path.join(settings.RUNNER_DIR, "pico8/web/player.html"), "--window-size", self.runner_config.get("window_size"), ] return args def get_run_data(self): return {"command": self.launch_args, "env": self.get_env(os_env=False)} def is_installed(self, version=None, fallback=True, min_version=None): """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)) downloadCompleted = False def on_downloaded_engine(): nonlocal downloadCompleted downloadCompleted = True dl = Downloader(downloadUrl, enginePath, True, callback=on_downloaded_engine) dl.start() dl.thread.join() # Doesn't actually wait until finished # Waits for download to complete while not os.path.exists(enginePath): if downloadCompleted or dl.state == Downloader.ERROR: logger.error("Could not download engine from %s", downloadUrl) return False sleep(0.1) return True def play(self): launch_info = {} launch_info["env"] = self.get_env(os_env=False) game_data = get_game_by_field(self.config.game_config_id, "configpath") command = self.launch_args if self.is_native: if not self.runner_config.get("splore"): command.append("-run") cartPath = self.cart_path if not os.path.exists(cartPath): return {"error": "FILE_NOT_FOUND", "file": cartPath} command.append(cartPath) else: command.append("--name") command.append(game_data.get("name") + " - PICO-8") # icon = datapath.get_icon_path(game_data.get("slug")) # if icon: # command.append("--icon") # command.append(icon) webargs = { "cartridge": self.cart_path, "engine": self.engine_path, "fullscreen": self.runner_config.get("fullscreen") is True, } command.append("--execjs") command.append("load_config(" + json.dumps(webargs) + ")") launch_info["command"] = command return launch_info lutris-0.5.9.1/lutris/runners/redream.py000066400000000000000000000115051413267435700202330ustar00rootroot00000000000000import os import shutil from gettext import gettext as _ from lutris import settings from lutris.gui.dialogs import FileDialog, QuestionDialog 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\n" "Supported formats: GDI, CDI, CHD"), } ] runner_options = [ {"option": "fs", "type": "bool", "label": _("Fullscreen"), "default": False}, { "option": "ar", "type": "choice", "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, version=None, downloader=None, callback=None): def on_runner_installed(*args): dlg = QuestionDialog( { "question": _("Do you want to select a premium license file?"), "title": _("Use premium version?"), } ) if dlg.result == dlg.YES: license_dlg = FileDialog(_("Select a license file")) license_filename = license_dlg.filename if not license_filename: return shutil.copy( license_filename, os.path.join(settings.RUNNER_DIR, "redream") ) super(redream, self).install( version=version, downloader=downloader, callback=on_runner_installed ) def play(self): command = [self.get_executable()] if self.runner_config.get("fs") is True: command.append("--fullscreen=1") else: command.append("--fullscreen=0") if self.runner_config.get("ar"): command.append("--aspect=" + self.runner_config.get("ar")) if self.runner_config.get("region"): command.append("--region=" + self.runner_config.get("region")) if self.runner_config.get("language"): command.append("--language=" + self.runner_config.get("language")) if self.runner_config.get("broadcast"): command.append("--broadcast=" + self.runner_config.get("broadcast")) if self.runner_config.get("time_sync"): command.append("--time_sync=" + self.runner_config.get("time_sync")) if self.runner_config.get("int_res"): command.append("--res=" + self.runner_config.get("int_res")) command.append(self.game_config.get("main_file")) return {"command": command} lutris-0.5.9.1/lutris/runners/reicast.py000066400000000000000000000123621413267435700202500ustar00rootroot00000000000000# 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.gui.dialogs import NoticeDialog 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" 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(reicast, self).__init__(config) self.runner_options = [ { "option": "fullscreen", "type": "bool", "label": _("Fullscreen"), "default": False, }, { "option": "device_id_1", "type": "choice", "label": _("Gamepad 1"), "choices": self.get_joypads, "default": "-1", }, { "option": "device_id_2", "type": "choice", "label": _("Gamepad 2"), "choices": self.get_joypads, "default": "-1", }, { "option": "device_id_3", "type": "choice", "label": _("Gamepad 3"), "choices": self.get_joypads, "default": "-1", }, { "option": "device_id_4", "type": "choice", "label": _("Gamepad 4"), "choices": self.get_joypads, "default": "-1", }, ] def install(self, version=None, downloader=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") NoticeDialog(_("You have to copy valid BIOS files to ~/.reicast/data before playing")) super(reicast, self).install(version, downloader, 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") 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") 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") command = [self.get_executable(), "-config", "config:image={}".format(iso)] return {"command": command} lutris-0.5.9.1/lutris/runners/residualvm.py000066400000000000000000000072451413267435700207750ustar00rootroot00000000000000"""ResidualVM runner""" # Standard Library import os import subprocess from gettext import gettext as _ # Lutris Modules from lutris.runners.runner import Runner RESIDUALVM_CONFIG_FILE = os.path.join(os.path.expanduser("~"), ".residualvmrc") class residualvm(Runner): human_name = _("ResidualVM") platforms = [_("Linux")] # TODO description = _("3D point-and-click adventure games engine") runner_executable = "residualvm/residualvm" game_options = [ { "option": "game_id", "type": "string", "label": _("Game identifier") }, { "option": "path", "type": "directory_chooser", "label": _("Game files location") }, { "option": "subtitles", "label": _("Enable subtitles (if the game has voice)"), "type": "bool", "default": False, }, ] runner_options = [ { "option": "fullscreen", "label": _("Fullscreen"), "type": "bool", "default": False, }, { "option": "renderer", "label": _("Renderer"), "type": "choice", "choices": ( ("OpenGL", "opengl"), (_("OpenGL shaders"), "opengl_shaders"), (_("Software"), "software"), ), "default": "opengl", }, { "option": "show-fps", "label": _("Display FPS information"), "type": "bool", "default": False, }, ] @property def game_path(self): return self.game_config.get("path") def get_residualvm_data_dir(self): root_dir = os.path.dirname(self.get_executable()) return os.path.join(root_dir, "data") def play(self): command = [ self.get_executable(), "--extrapath=%s" % self.get_residualvm_data_dir(), "--themepath=%s" % self.get_residualvm_data_dir(), ] # Options if self.game_config.get("subtitles"): command.append("--subtitles") if self.runner_config.get("fullscreen"): command.append("--fullscreen") else: command.append("--no-fullscreen") renderer = self.runner_config.get("renderer") if renderer: command.append("--renderer=%s" % renderer) if self.runner_config.get("show-fps"): command.append("--show-fps") else: command.append("--no-show-fps") # /Options command.append("--path=%s" % self.game_path) command.append(self.game_config.get("game_id")) launch_info = {"command": command} return launch_info def get_game_list(self): """Return the entire list of games supported by ResidualVM.""" residual_output = subprocess.Popen([self.get_executable(), "--list-games"], stdout=subprocess.PIPE).communicate()[0] game_list = str.split(residual_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.9.1/lutris/runners/rpcs3.py000066400000000000000000000022171413267435700176460ustar00rootroot00000000000000# Standard Library from gettext import gettext as _ # Lutris Modules from lutris.runners.runner import Runner from lutris.util import system class rpcs3(Runner): human_name = _("RPCS3") description = _("PlayStation 3 emulator") platforms = [_("Sony PlayStation 3")] runnable_alone = True runner_executable = "rpcs3/rpcs3" 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_executable()] if self.runner_config.get("nogui"): arguments.append("--no-gui") eboot = self.game_config.get("main_file") or "" if not system.path_exists(eboot): return {"error": "FILE_NOT_FOUND", "file": eboot} arguments.append(eboot) return {"command": arguments} lutris-0.5.9.1/lutris/runners/runner.py000066400000000000000000000361111413267435700201250ustar00rootroot00000000000000"""Base module for runners""" import os from gettext import gettext as _ from gi.repository import Gtk from lutris import runtime, settings from lutris.command import MonitoredCommand from lutris.config import LutrisConfig from lutris.database.games import get_game_by_field from lutris.exceptions import UnavailableLibraries from lutris.gui import dialogs from lutris.runners import RunnerInstallationError from lutris.util import system from lutris.util.extract import ExtractFailure, extract_archive from lutris.util.http import Request 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 def __init__(self, config=None): """Initialize runner.""" self.config = config if config: self.game_data = get_game_by_field(self.config.game_config_id, "configpath") else: self.game_data = {} def __lt__(self, other): return self.name < other.name @property def description(self): """Return the class' docstring as the description.""" return self.__doc__ @description.setter def description(self, value): """Leave the ability to override the docstring.""" self.__doc__ = value # What the shit @property def name(self): return self.__class__.__name__ @property def default_config(self): return LutrisConfig(runner_slug=self.name) @property def game_config(self): """Return the cascaded game config as a dict.""" return self.config.game_config if self.config else {} @property def runner_config(self): """Return the cascaded runner config as a dict.""" if self.config: return self.config.runner_config return self.default_config.runner_config @property def system_config(self): """Return the cascaded system config as a dict.""" if self.config: return self.config.system_config return self.default_config.system_config @property def default_path(self): """Return the default path where games are installed.""" return self.system_config.get("game_path") @property def game_path(self): """Return the directory where the game is installed.""" game_path = self.game_data.get("directory") if game_path: return game_path # 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 "" @property def library_folders(self): """Return a list of paths where a game might be installed""" return [] @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 discord_client_id(self): if self.game_data.get("discord_client_id"): return self.game_data.get("discord_client_id") def get_platform(self): return self.platforms[0] def get_runner_options(self): runner_options = self.runner_options[:] if self.runner_executable: runner_options.append( { "option": "runner_executable", "type": "file", "label": _("Custom executable for the runner"), "advanced": True, } ) return runner_options def get_executable(self): if "runner_executable" in self.runner_config: runner_executable = self.runner_config["runner_executable"] if os.path.isfile(runner_executable): return runner_executable if not self.runner_executable: raise ValueError("runner_executable not set for {}".format(self.name)) return os.path.join(settings.RUNNER_DIR, self.runner_executable) def get_env(self, os_env=False): """Return environment variables used for a game.""" env = {} if os_env: env.update(os.environ.copy()) # 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") as controllerdb_file: sdl_gamecontrollerconfig = controllerdb_file.read() env["SDL_GAMECONTROLLERCONFIG"] = sdl_gamecontrollerconfig # Set monitor to use for SDL 1 games if self.system_config.get("sdl_video_fullscreen"): env["SDL_VIDEO_FULLSCREEN_DISPLAY"] = self.system_config["sdl_video_fullscreen"] # DRI Prime if self.system_config.get("dri_prime"): env["DRI_PRIME"] = "1" # Prime vars prime = self.system_config.get("prime") if prime: env["__NV_PRIME_RENDER_OFFLOAD"] = "1" env["__GLX_VENDOR_LIBRARY_NAME"] = "nvidia" env["__VK_LAYER_NV_optimus"] = "NVIDIA_only" # Set PulseAudio latency to 60ms if self.system_config.get("pulse_latency"): env["PULSE_LATENCY_MSEC"] = "60" # Vulkan ICD files vk_icd = self.system_config.get("vk_icd") if vk_icd and vk_icd != "off" and system.path_exists(vk_icd): env["VK_ICD_FILENAMES"] = vk_icd runtime_ld_library_path = None if self.use_runtime(): runtime_env = self.get_runtime_env() if "LD_LIBRARY_PATH" in runtime_env: runtime_ld_library_path = runtime_env["LD_LIBRARY_PATH"] if runtime_ld_library_path: ld_library_path = env.get("LD_LIBRARY_PATH") if not ld_library_path: ld_library_path = "$LD_LIBRARY_PATH" env["LD_LIBRARY_PATH"] = ":".join([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 prelaunch(self): """Run actions before running the game, override this method in runners""" 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 UnavailableLibraries(unavailable_libs, self.arch) return True 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_executable()], "env": self.get_env()} def run(self, *args): """Run the runner alone.""" if not self.runnable_alone: return if not self.is_installed(): if not self.install_dialog(): logger.info("Runner install cancelled") return command_data = self.get_run_data() command = command_data.get("command") env = (command_data.get("env") or {}).copy() if hasattr(self, "prelaunch"): 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): """Ask the user if they want to install the runner. Return success of runner installation. """ dialog = dialogs.QuestionDialog( { "question": _("The required runner is not installed.\n" "Do you wish to install it now?"), "title": _("Required runner unavailable"), } ) if Gtk.ResponseType.YES == dialog.result: from lutris.gui.dialogs import ErrorDialog from lutris.gui.dialogs.download import simple_downloader try: if hasattr(self, "get_version"): version = self.get_version(use_default=False) # pylint: disable=no-member self.install(downloader=simple_downloader, version=version) else: self.install(downloader=simple_downloader) except RunnerInstallationError as ex: ErrorDialog(ex.message) return self.is_installed() return False def is_installed(self): """Return whether the runner is installed""" return system.path_exists(self.get_executable()) def get_runner_version(self, version=None): """Get the appropriate version for a runner Params: version (str): Optional version to lookup, will return this one if found Returns: dict: Dict containing version, architecture and url for the runner """ logger.info( "Getting runner information for %s%s", self.name, " (version: %s)" % version if version else "", ) request = Request("{}/api/runners/{}".format(settings.SITE_URL, self.name)) runner_info = request.get().json if not runner_info: logger.error("Failed to get runner information") return versions = runner_info.get("versions") or [] arch = LINUX_SYSTEM.arch if version: if version.endswith("-i386") or version.endswith("-x86_64"): version, arch = version.rsplit("-", 1) versions = [v for v in versions if v["version"] == version] versions_for_arch = [v for v in versions if v["architecture"] == arch] if len(versions_for_arch) == 1: return versions_for_arch[0] if len(versions_for_arch) > 1: default_version = [v for v in versions_for_arch if v["default"] is True] if default_version: return default_version[0] elif len(versions) == 1 and LINUX_SYSTEM.is_64_bit: return versions[0] elif len(versions) > 1 and LINUX_SYSTEM.is_64_bit: default_version = [v for v in versions if v["default"] is True] if default_version: return default_version[0] # If we didn't find a proper version yet, return the first available. if len(versions_for_arch) >= 1: return versions_for_arch[0] def install(self, version=None, downloader=None, callback=None): """Install runner using package management systems.""" logger.debug( "Installing %s (version=%s, downloader=%s, callback=%s)", self.name, version, downloader, callback, ) opts = {"downloader": downloader, "callback": callback} if self.download_url: opts["dest"] = os.path.join(settings.RUNNER_DIR, self.name) return self.download_and_extract(self.download_url, **opts) runner = self.get_runner_version(version) if not runner: raise RunnerInstallationError("Failed to retrieve {} ({}) information".format(self.name, version)) if not downloader: raise RuntimeError("Missing mandatory downloader for runner %s" % self) if "wine" in self.name: opts["merge_single"] = True opts["dest"] = os.path.join( settings.RUNNER_DIR, self.name, "{}-{}".format(runner["version"], runner["architecture"]) ) if self.name == "libretro" and version: opts["merge_single"] = False opts["dest"] = os.path.join(settings.RUNNER_DIR, "retroarch/cores") self.download_and_extract(runner["url"], **opts) def download_and_extract(self, url, dest=None, **opts): downloader = opts["downloader"] 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 downloader( 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): raise RunnerInstallationError("Failed to extract {}".format(archive)) try: extract_archive(archive, dest, merge_single=merge_single) except ExtractFailure as ex: logger.error("Failed to extract the archive %s file may be corrupt", archive) raise RunnerInstallationError("Failed to extract {}: {}".format(archive, ex)) os.remove(archive) if self.name == "wine": logger.debug("Clearing wine version cache") from lutris.util.wine.wine import get_wine_versions get_wine_versions.cache_clear() if callback: callback() @staticmethod def remove_game_data(game_path=None): system.remove_folder(game_path) def can_uninstall(self): runner_path = os.path.join(settings.RUNNER_DIR, self.name) return os.path.isdir(runner_path) def uninstall(self): runner_path = os.path.join(settings.RUNNER_DIR, self.name) if os.path.isdir(runner_path): system.remove_folder(runner_path) def find_option(self, options_group, option_name): """Retrieve an option dict if it exists in the group""" if options_group not in ['game_options', 'runner_options']: return None output = None for item in getattr(self, options_group): if item["option"] == option_name: output = item break return output lutris-0.5.9.1/lutris/runners/ryujinx.py000066400000000000000000000060521413267435700203250ustar00rootroot00000000000000# Standard Library import filecmp import os from gettext import gettext as _ from shutil import copyfile # Lutris Modules 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 for LDN build visit https://ryujinx.org/") runnable_alone = True runner_executable = "ryujinx/publish/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 path: return path[:-len("nand")] def play(self): """Run the game.""" arguments = [self.get_executable()] rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": rom} arguments.append(rom) 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) return True lutris-0.5.9.1/lutris/runners/scummvm.py000066400000000000000000000126611413267435700203070ustar00rootroot00000000000000# Standard Library import os import subprocess from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.runners.runner import Runner from lutris.util import system from lutris.util.strings import split_arguments class scummvm(Runner): description = _("Runs various 2D point-and-click adventure games.") human_name = _("ScummVM") platforms = [_("Linux")] runnable_alone = True runner_executable = "scummvm/bin/scummvm" game_options = [ { "option": "game_id", "type": "string", "label": _("Game identifier") }, { "option": "path", "type": "directory_chooser", "label": _("Game files location") }, { "option": "subtitles", "label": _("Enable subtitles (if the game has voice)"), "type": "bool", "default": False, }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _("Command line arguments used when launching the game"), }, ] runner_options = [ { "option": "fullscreen", "label": _("Fullscreen"), "type": "bool", "default": True, }, { "option": "aspect", "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": "gfx-mode", "label": _("Graphic scaler"), "type": "choice", "default": "3x", "choices": [ ("1x", "1x"), ("2x", "2x"), ("3x", "3x"), ("hq2x", "hq2x"), ("hq3x", "hq3x"), ("advmame2x", "advmame2x"), ("advmame3x", "advmame3x"), ("2xsai", "2xsai"), ("super2xsai", "super2xsai"), ("supereagle", "supereagle"), ("tv2x", "tv2x"), ("dotmatrix", "dotmatrix"), ], "help": _("The algorithm used to scale up the game's base " "resolution, resulting in different visual styles. "), }, { "option": "datadir", "label": _("Data directory"), "type": "directory_chooser", "help": _("Defaults to share/scummvm if unspecified."), "advanced": True, }, ] @property def game_path(self): return self.game_config.get("path") @property def libs_dir(self): path = os.path.join(settings.RUNNER_DIR, "scummvm/lib") return path if system.path_exists(path) else "" def get_command(self): return [ self.get_executable(), "--extrapath=%s" % self.get_scummvm_data_dir(), "--themepath=%s" % self.get_scummvm_data_dir(), ] def get_scummvm_data_dir(self): data_dir = self.runner_config.get("datadir") if data_dir is None: root_dir = os.path.dirname(os.path.dirname(self.get_executable())) data_dir = os.path.join(root_dir, "share/scummvm") return data_dir def get_run_data(self): env = {"LD_LIBRARY_PATH": "%s;$LD_LIBRARY_PATH" % self.libs_dir} return {"env": env, "command": self.get_command()} def play(self): command = self.get_command() # Options if self.runner_config.get("aspect"): command.append("--aspect-ratio") if self.game_config.get("subtitles"): command.append("--subtitles") if self.runner_config.get("fullscreen"): command.append("--fullscreen") else: command.append("--no-fullscreen") mode = self.runner_config.get("gfx-mode") if mode: command.append("--gfx-mode=%s" % mode) # /Options 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")) launch_info = {"command": command, "ld_library_path": self.libs_dir} return launch_info def get_game_list(self): """Return the entire list of games supported by ScummVM.""" scumm_output = subprocess.Popen([self.get_executable(), "--list-games"], stdout=subprocess.PIPE).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.9.1/lutris/runners/snes9x.py000066400000000000000000000053531413267435700200510ustar00rootroot00000000000000# Standard Library import os import subprocess import xml.etree.ElementTree as etree from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger SNES9X_DIR = os.path.join(settings.DATA_DIR, "runners/snes9x") class snes9x(Runner): description = _("Super Nintendo emulator") human_name = _("Snes9x") platforms = [_("Nintendo SNES")] runnable_alone = True runner_executable = "snes9x/bin/snes9x-gtk" 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", "label": _("Fullscreen"), "default": "1" }, { "option": "maintain_aspect_ratio", "type": "bool", "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): subprocess.Popen([self.get_executable(), "-help"]) if not system.path_exists(config_file): logger.error("Snes9x config file creation failed") return tree = etree.parse(config_file) node = tree.find("./preferences/option[@name='%s']" % option) if value.__class__.__name__ == "bool": value = "1" if value else "0" node.attrib["value"] = value tree.write(config_file) def play(self): for option_name in self.config.runner_config: self.set_option(option_name, self.runner_config.get(option_name)) rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": rom} return {"command": [self.get_executable(), rom]} lutris-0.5.9.1/lutris/runners/steam.py000066400000000000000000000321331413267435700177250ustar00rootroot00000000000000"""Steam for Linux runner""" import os import subprocess import time from gettext import gettext as _ from lutris.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 STEAM_DATA_DIRS, get_default_acf, get_steam_dir, read_config, read_library_folders from lutris.util.steam.vdf import to_vdf from lutris.util.strings import split_arguments def shutdown(): """Cleanly quit Steam.""" logger.debug("Shutting down Steam") if is_running(): subprocess.call(["steam", "-shutdown"]) def get_steam_pid(): """Return pid of Steam process.""" return system.get_pid("steam$") def kill(): """Force quit Steam.""" system.kill_pid(get_steam_pid()) 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" 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": "quit_steam_on_exit", "label": _("Stop Steam after game exits"), "type": "bool", "default": False, "help": _( "Shut down Steam after the game has quit\n" "(only if Steam was started by Lutris)" ), }, { "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}] def __init__(self, config=None): super(steam, self).__init__(config) self.own_game_remove_method = _("Remove game data (through Steam)") self.no_game_remove_warning = True self.original_steampid = None @property def runnable_alone(self): return not linux.LINUX_SYSTEM.is_flatpak @property def appid(self): return self.game_config.get("appid") or "" def get_steam_config(self): """Return the "Steam" part of Steam's config.vdf as a dict.""" return read_config(self.steam_data_dir) def get_library_config(self): """Return the "libraryfolders" part of Steam's libraryfolders.vdf as a dict """ return read_library_folders(self.steam_data_dir) @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() @property def library_folders(self): """Return a list Steam library paths""" return self.get_steamapps_dirs() def get_appmanifest(self): """Return an AppManifest instance for the game""" appmanifests = [] for apps_path in self.get_steamapps_dirs(): appmanifest = get_appmanifest_from_appid(apps_path, self.appid) if appmanifest: appmanifests.append(appmanifest) if len(appmanifests) > 1: logger.warning("More than one AppManifest for %s returning only 1st", self.appid) if appmanifests: return appmanifests[0] def get_executable(self): if linux.LINUX_SYSTEM.is_flatpak: # Use xdg-open for Steam URIs in Flatpak return system.find_executable("xdg-open") if self.runner_config.get("lsi_steam") and system.find_executable("lsi-steam"): return system.find_executable("lsi-steam") runner_executable = self.runner_config.get("runner_executable") if runner_executable and os.path.isfile(runner_executable): return runner_executable return system.find_executable(self.runner_executable) @property def working_dir(self): """Return the working directory to use when running the game.""" if self.game_config.get("run_without_steam"): steamless_binary = self.game_config.get("steamless_binary") if steamless_binary and os.path.isfile(steamless_binary): return os.path.dirname(steamless_binary) return super().working_dir @property def launch_args(self): """Provide launch arguments for Steam""" args = [self.get_executable()] if linux.LINUX_SYSTEM.is_flatpak: return args if self.runner_config.get("start_in_big_picture"): args.append("-bigpicture") return args + split_arguments(self.runner_config.get("args") or "") def get_game_path_from_appid(self, appid): """Return the game directory.""" for apps_path in self.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) def get_steamapps_dirs(self): """Return a list of the Steam library main + custom folders.""" dirs = [] # Extra colon-separated compatibility tools dirs environment variable if 'STEAM_EXTRA_COMPAT_TOOLS_PATHS' in os.environ: dirs += os.getenv('STEAM_EXTRA_COMPAT_TOOLS_PATHS').split(':') # Main steamapps dir and compatibilitytools.d dir for data_dir in STEAM_DATA_DIRS: for _dir in ["steamapps", "compatibilitytools.d"]: abs_dir = os.path.join(os.path.expanduser(data_dir), _dir) abs_dir = system.fix_path_case(abs_dir) if abs_dir and os.path.isdir(abs_dir): dirs.append(abs_dir) # Custom dirs steam_config = self.get_steam_config() if steam_config: i = 1 while "BaseInstallFolder_%s" % i in steam_config: path = steam_config["BaseInstallFolder_%s" % i] + "/steamapps" path = system.fix_path_case(path) if path and os.path.isdir(path): dirs.append(path) i += 1 # New Custom dirs library_config = self.get_library_config() if library_config: paths = [] for entry in library_config.values(): if "mounted" in entry: if entry.get("path") and entry.get("mounted") == "1": path = system.fix_path_case(entry.get("path") + "/steamapps") paths.append(path) else: path = system.fix_path_case(entry.get("path") + "/steamapps") paths.append(path) for path in paths: if path and os.path.isdir(path): dirs.append(path) return system.list_unique_folders(dirs) def get_default_steamapps_path(self): steamapps_paths = self.get_steamapps_dirs() if steamapps_paths: return steamapps_paths[0] def install(self, version=None, downloader=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 RuntimeError("Could not find Steam path, is Steam installed?") acf_path = os.path.join(steamapps_path, "appmanifest_%s.acf" % appid) with open(acf_path, "w") as acf_file: acf_file.write(acf_content) if is_running(): shutdown() time.sleep(5) command = [self.get_executable(), "steam://install/%s" % appid] subprocess.Popen(command) def prelaunch(self): def has_steam_shutdown(times=10): for __ in range(times): time.sleep(1) if not is_running(): return True # If using primusrun, shutdown existing Steam first if self.system_config.get("optimus") != "off" and is_running(): shutdown() if not has_steam_shutdown(): logger.info("Forcing Steam shutdown") kill() if not has_steam_shutdown(5): logger.error("Failed to shut down Steam :(") return False return True def get_run_data(self): return {"command": self.launch_args, "env": self.get_env()} def play(self): game_args = self.game_config.get("args") or "" binary_path = self.game_config.get("steamless_binary") if self.game_config.get("run_without_steam") and binary_path: # Start without steam if not system.path_exists(binary_path): return {"error": "FILE_NOT_FOUND", "file": binary_path} self.original_steampid = None 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(), } # Get current steam pid to act as the root pid instead of lutris self.original_steampid = get_steam_pid() 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 stop(self): if self.runner_config.get("quit_steam_on_exit") and not self.original_steampid: shutdown() return True return False def remove_game_data(self, appid=None, **kwargs): if not self.is_installed(): return False command = MonitoredCommand( [self.get_executable(), "steam://uninstall/%s" % (appid or self.appid)], runner=self, env=self.get_env(), ) command.start() lutris-0.5.9.1/lutris/runners/vice.py000066400000000000000000000147561413267435700175550ustar00rootroot00000000000000# Standard Library import os from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.runners.runner import Runner from lutris.util import system from lutris.util.log import logger class vice(Runner): description = _("Commodore Emulator") human_name = _("Vice") 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", "label": _("Fullscreen"), "default": False, }, { "option": "double", "type": "bool", "label": _("Scale up display by 2"), "default": True, }, { "option": "aspect_ratio", "type": "bool", "label": _("Preserve aspect ratio"), "default": True, }, { "option": "drivesound", "type": "bool", "label": _("Enable sound emulation of disk drives"), "default": False, }, { "option": "renderer", "type": "choice", "label": _("Graphics renderer"), "choices": [("OpenGL", "opengl"), (_("Software"), "software")], "default": "opengl", }, { "option": "machine", "type": "choice", "label": _("Machine"), "choices": machine_choices, "default": "c64", }, ] def get_platform(self): machine = self.game_config.get("machine") if machine: for index, choice in enumerate(self.machine_choices): if choice[1] == machine: return self.platforms[index] return self.platforms[0] # Default to C64 def get_executable(self, machine=None): if not machine: machine = "c64" executables = { "c64": "x64", "c128": "x128", "vic20": "xvic", "pet": "xpet", "plus4": "xplus4", "cbmii": "xcbm2", } try: executable = executables[machine] except KeyError: raise ValueError("Invalid machine '%s'" % machine) return os.path.join(settings.RUNNER_DIR, "vice/bin/%s" % executable) def install(self, version=None, downloader=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(vice, self).install(version, downloader, on_runner_installed) def get_roms_path(self, machine=None): if not machine: machine = "c64" paths = { "c64": "C64", "c128": "C128", "vic20": "VIC20", "pet": "PET", "plus4": "PLUS4", "cmbii": "CBM-II", } root_dir = os.path.dirname(os.path.dirname(self.get_executable())) return os.path.join(root_dir, "lib64/vice", paths[machine]) @staticmethod def get_option_prefix(machine): prefixes = { "c64": "VICII", "c128": "VICII", "vic20": "VIC", "pet": "CRTC", "plus4": "TED", "cmbii": "CRTC", } return prefixes[machine] @staticmethod def get_joydevs(machine): joydevs = {"c64": 2, "c128": 2, "vic20": 1, "pet": 0, "plus4": 2, "cmbii": 0} return joydevs[machine] @staticmethod def get_rom_args(machine, rom): args = [] if rom.endswith(".crt"): crt_option = { "c64": "-cartcrt", "c128": "-cartcrt", "vic20": "-cartgeneric", "pet": None, "plus4": "-cart", "cmbii": None, } if crt_option[machine]: args.append(crt_option[machine]) args.append(rom) return args def play(self): machine = self.runner_config.get("machine") rom = self.game_config.get("main_file") if not rom: return {"error": "CUSTOM", "text": "No rom provided"} if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": rom} params = [self.get_executable(machine)] rom_dir = os.path.dirname(rom) params.append("-chdir") params.append(rom_dir) option_prefix = self.get_option_prefix(machine) if self.runner_config.get("fullscreen"): params.append("-{}full".format(option_prefix)) if self.runner_config.get("double"): params.append("-{}dsize".format(option_prefix)) if self.runner_config.get("renderer"): params.append("-sdl2renderer") params.append(self.runner_config["renderer"]) if not self.runner_config.get("aspect_ratio", True): params.append("-sdlaspectmode") params.append("0") if self.runner_config.get("drivesound"): params.append("-drivesound") if self.runner_config.get("joy"): for dev in range(self.get_joydevs(machine)): params += ["-joydev{}".format(dev + 1), "4"] params.extend(self.get_rom_args(machine, rom)) return {"command": params} lutris-0.5.9.1/lutris/runners/web.py000066400000000000000000000211341413267435700173700ustar00rootroot00000000000000"""Run web based games""" import os import string from gettext import gettext as _ from urllib.parse import urlparse from lutris import settings from lutris.database.games import get_game_by_field from lutris.runners.runner import Runner from lutris.util import datapath, linux, resources, system from lutris.util.strings import split_arguments DEFAULT_ICON = os.path.join(datapath.get(), "media/default_icon.png") class web(Runner): human_name = _("Web") description = _("Runs web based games") platforms = [_("Web")] game_options = [ { "option": "main_file", "type": "string", "label": _("Full URL or HTML file path"), "help": _("The full address of the game's web page or path to a HTML file."), } ] runner_options = [ { "option": "fullscreen", "label": _("Open in fullscreen"), "type": "bool", "default": False, "help": _("Launch the game in fullscreen."), }, { "option": "maximize_window", "label": _("Open window maximized"), "type": "bool", "default": False, "help": _("Maximizes the window when game starts."), }, { "option": "window_size", "label": _("Window size"), "type": "choice_with_entry", "choices": [ "640x480", "800x600", "1024x768", "1280x720", "1280x1024", "1920x1080", ], "default": "800x600", "help": _("The initial size of the game window when not opened."), }, { "option": "disable_resizing", "label": _("Disable window resizing (disables fullscreen and maximize)"), "type": "bool", "default": False, "help": _("You can't resize this window."), }, { "option": "frameless", "label": _("Borderless window"), "type": "bool", "default": False, "help": _("The window has no borders/frame."), }, { "option": "disable_menu_bar", "label": _("Disable menu bar and default shortcuts"), "type": "bool", "default": False, "help": _("This also disables default keyboard shortcuts, " "like copy/paste and fullscreen toggling."), }, { "option": "disable_scrolling", "label": _("Disable page scrolling and hide scrollbars"), "type": "bool", "default": False, "help": _("Disables scrolling on the page."), }, { "option": "hide_cursor", "label": _("Hide mouse cursor"), "type": "bool", "default": False, "help": _("Prevents the mouse cursor from showing " "when hovering above the window."), }, { "option": "open_links", "label": _("Open links in game window"), "type": "bool", "default": False, "help": _( "Enable this option if you want clicked links to open inside the " "game window. By default all links open in your default web browser." ), }, { "option": "remove_margin", "label": _("Remove default margin & padding"), "type": "bool", "default": False, "help": _("Sets margin and padding to zero " "on <html> and <body> elements."), }, { "option": "enable_flash", "label": _("Enable Adobe Flash Player"), "type": "bool", "default": False, "help": _("Enable Adobe Flash Player."), }, { "option": "user_agent", "label": _("Custom User-Agent"), "type": "string", "default": "", "help": _("Overrides the default User-Agent header used by the runner."), "advanced": True, }, { "option": "devtools", "label": _("Debug with Developer Tools"), "type": "bool", "default": False, "help": _("Let's you debug the page."), "advanced": True, }, { "option": "external_browser", "label": _("Open in web browser (old behavior)"), "type": "bool", "default": False, "help": _("Launch the game in a web browser."), }, { "option": "custom_browser_executable", "label": _("Custom web browser executable"), "type": "file", "help": _( "Select the executable of a browser on your system.\n" "If left blank, Lutris will launch your default browser (xdg-open)." ), }, { "option": "custom_browser_args", "label": _("Web browser arguments"), "type": "string", "default": '"$GAME"', "help": _( "Command line arguments to pass to the executable.\n" "$GAME or $URL inserts the game url.\n\n" 'For Chrome/Chromium app mode use: --app="$GAME"' ), }, ] system_options_override = [{"option": "disable_runtime", "default": True}] runner_executable = "web/electron/electron" def get_env(self, os_env=True): env = super(web, self).get_env(os_env) enable_flash_player = self.runner_config.get("enable_flash") env["ENABLE_FLASH_PLAYER"] = "1" if enable_flash_player else "0" return env def play(self): url = self.game_config.get("main_file") if not url: return { "error": "CUSTOM", "text": _("The web address is empty, \n" "verify the game's configuration."), } # check if it's an url or a file is_url = urlparse(url).scheme != "" if not is_url: if not system.path_exists(url): return { "error": "CUSTOM", "text": _("The file %s does not exist, \n" "verify the game's configuration.") % url, } url = "file://" + url game_data = get_game_by_field(self.config.game_config_id, "configpath") # keep the old behavior from browser runner, but with support for extra arguments! if self.runner_config.get("external_browser"): # is it possible to disable lutris runtime here? browser = self.runner_config.get("custom_browser_executable") or "xdg-open" args = self.runner_config.get("custom_browser_args") args = args or '"$GAME"' arguments = string.Template(args).safe_substitute({"GAME": url, "URL": url}) command = [browser] for arg in split_arguments(arguments): command.append(arg) return {"command": command} icon = resources.get_icon_path(game_data.get("slug")) if not system.path_exists(icon): icon = DEFAULT_ICON command = [ self.get_executable(), os.path.join(settings.RUNNER_DIR, "web/electron/resources/app.asar"), url, "--name", game_data.get("name"), "--icon", icon, ] for key in [ "fullscreen", "frameless", "devtools", "disable_resizing", "disable_menu_bar", "maximize_window", "disable_scrolling", "hide_cursor", "open_links", "remove_margin", ]: if self.runner_config.get(key): converted_opt_name = key.replace("_", "-") command.append("--{option}".format(option=converted_opt_name)) if self.runner_config.get("window_size"): command.append("--window-size") command.append(self.runner_config.get("window_size")) if self.runner_config.get("user_agent"): command.append("--user-agent") command.append(self.runner_config.get("user_agent")) if linux.LINUX_SYSTEM.is_flatpak: command.append("--no-sandbox") return {"command": command, "env": self.get_env(False)} lutris-0.5.9.1/lutris/runners/wine.py000066400000000000000000001041061413267435700175560ustar00rootroot00000000000000"""Wine runner""" # pylint: disable=too-many-lines import os import shlex from gettext import gettext as _ from lutris import runtime 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 from lutris.util.graphics.vkquery import is_vulkan_supported from lutris.util.jobs import thread_safe_call from lutris.util.log import logger from lutris.util.strings import parse_version, split_arguments from lutris.util.wine.d3d_extras import D3DExtrasManager from lutris.util.wine.dxvk import DXVKManager from lutris.util.wine.dxvk_nvapi import DXVKNVAPIManager 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 ( POL_PATH, WINE_DIR, WINE_PATHS, detect_arch, display_vulkan_error, esync_display_limit_warning, esync_display_version_warning, fsync_display_support_warning, fsync_display_version_warning, get_default_version, get_overrides_env, get_proton_paths, get_real_executable, get_wine_version, get_wine_versions, is_esync_limit_set, is_fsync_supported, is_gstreamer_build, is_version_esync, is_version_fsync ) MIN_SAFE_VERSION = "5.0" # Wine installers must run with at least this version 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"), "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): # noqa: C901 super(wine, self).__init__(config) self.dll_overrides = DEFAULT_DLL_OVERRIDES 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_wine_versions() for version in versions: if version in labels.keys(): version_number = get_wine_version(WINE_PATHS[version]) label = labels[version].format(version_number) else: label = version version_choices.append((label, version)) return version_choices def esync_limit_callback(widget, option, config): limits_set = is_esync_limit_set() wine_path = self.get_path_for_version(config["version"]) wine_ver = is_version_esync(wine_path) response = True if not wine_ver: response = thread_safe_call(esync_display_version_warning) if not limits_set: thread_safe_call(esync_display_limit_warning) response = False return widget, option, response def fsync_support_callback(widget, option, config): fsync_supported = is_fsync_supported() wine_path = self.get_path_for_version(config["version"]) wine_ver = is_version_fsync(wine_path) response = True if not wine_ver: response = thread_safe_call(fsync_display_version_warning) if not fsync_supported: thread_safe_call(fsync_display_support_warning) response = False return widget, option, response def dxvk_vulkan_callback(widget, option, config): response = True if not is_vulkan_supported(): if not thread_safe_call(display_vulkan_error): response = False return widget, option, response self.runner_options = [ { "option": "version", "label": _("Wine version"), "type": "choice", "choices": get_wine_version_choices, "default": get_default_version(), "help": _( "The version of Wine used to launch the game.\n" "Using the last version is generally recommended, " "but some games work better on older versions." ), }, { "option": "custom_wine_path", "label": _("Custom Wine executable"), "type": "file", "advanced": True, "help": _("The Wine executable to be used if you have " 'selected "Custom" as the Wine version.'), }, { "option": "system_winetricks", "label": _("Use system winetricks"), "type": "bool", "default": False, "advanced": True, "help": _("Switch on to use /usr/bin/winetricks for winetricks."), }, { "option": "dxvk", "label": _("Enable DXVK"), "type": "extended_bool", "callback": dxvk_vulkan_callback, "callback_on": True, "default": True, "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", "label": _("DXVK version"), "advanced": True, "type": "choice_with_entry", "choices": DXVKManager().version_choices, "default": DXVKManager().version, }, { "option": "vkd3d", "label": _("Enable VKD3D"), "type": "extended_bool", "callback": dxvk_vulkan_callback, "callback_on": True, "default": True, "active": True, "help": _( "Use VKD3D to enable support for Direct3D 12 " "applications by translating their calls to Vulkan."), }, { "option": "vkd3d_version", "label": _("VKD3D version"), "advanced": True, "type": "choice_with_entry", "choices": VKD3DManager().version_choices, "default": VKD3DManager().version, }, { "option": "d3d_extras", "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", "label": _("D3D Extras version"), "advanced": True, "type": "choice_with_entry", "choices": D3DExtrasManager().version_choices, "default": D3DExtrasManager().version, }, { "option": "dxvk_nvapi", "label": _("Enable DXVK-NVAPI / DLSS"), "type": "bool", "default": True, "advanced": True, "help": _( "Enable emulation of Nvidia's NVAPI and add DLSS support, if available." ), }, { "option": "dxvk_nvapi_version", "label": _("DXVK NVAPI version"), "advanced": True, "type": "choice_with_entry", "choices": DXVKNVAPIManager().version_choices, "default": DXVKNVAPIManager().version, }, { "option": "esync", "label": _("Enable Esync"), "type": "extended_bool", "callback": esync_limit_callback, "callback_on": True, "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": "extended_bool", "callback": fsync_support_callback, "callback_on": True, "active": True, "help": _( "Enable futex-based synchronization (fsync). " "This will increase performance in applications " "that take advantage of multi-core processors. " "Requires a custom kernel with the fsync patchset." ), }, { "option": "fsr", "label": _("Enable AMD FidelityFX Super Resolution (FSR)"), "type": "bool", "default": False, "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": "Desktop", "label": _("Windowed (virtual desktop)"), "type": "bool", "default": False, "help": _( "Run the whole Windows desktop in a window.\n" "Otherwise, run it fullscreen.\n" "This corresponds to Wine's Virtual Desktop option." ), }, { "option": "WineDesktop", "label": _("Virtual desktop resolution"), "type": "choice_with_entry", "choices": DISPLAY_MANAGER.get_resolutions, "help": _("The size of the virtual desktop in pixels."), }, { "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", "label": _("Create a sandbox for Wine folders"), "default": True, "advanced": True, "help": _( "Do not use $HOME for desktop integration folders.\n" "By default, it use the directories in the confined " "Windows environment." ), }, { "option": "sandbox_dir", "type": "directory_chooser", "label": _("Sandbox directory"), "help": _("Custom directory for desktop integration folders."), "advanced": True, }, ] @property def context_menu_entries(self): """Return the contexual menu entries for wine""" menu_entries = [("wineexec", _("Run EXE inside Wine prefix"), self.run_wineexec)] if "Proton" not in self.get_version(): menu_entries.append(("winecfg", _("Wine configuration"), self.run_winecfg)) menu_entries += [ ("wineshell", _("Open Bash terminal"), self.run_wine_terminal), ("wineconsole", _("Open Wine console"), self.run_wineconsole), ("wine-regedit", _("Wine registry"), self.run_regedit), ("winekill", _("Kill all Wine processes"), self.run_winekill), ("winetricks", _("Winetricks"), self.run_winetricks), ("winecpl", _("Wine Control Panel"), self.run_winecpl), ] return menu_entries @property def prefix_path(self): """Return the absolute path of the Wine prefix""" _prefix_path = self.game_config.get("prefix") \ or os.environ.get("WINEPREFIX") \ or find_prefix(self.game_exe) if not _prefix_path: logger.warning( "Wine prefix not provided, defaulting to ~/.wine." " This is probably not the intended behavior." ) _prefix_path = "~/.wine" return os.path.expanduser(_prefix_path) @property def game_exe(self): """Return the game's executable's path.""" exe = self.game_config.get("exe") if not exe: logger.warning("The game doesn't have an executable") return if exe and os.path.isabs(exe): return system.fix_path_case(exe) if not self.game_path: return exe = system.fix_path_case(os.path.join(self.game_path, exe)) if system.path_exists(exe): return exe @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 option if self.game_exe: return os.path.dirname(self.game_exe) return super(wine, self).working_dir @property def wine_arch(self): """Return the wine architecture. Get it from the config or detect it from the prefix""" arch = self.game_config.get("arch") or "auto" if arch not in ("win32", "win64"): arch = detect_arch(self.prefix_path, self.get_executable()) return arch def get_version(self, use_default=True): """Return the Wine version to use. use_default can be set to false to force the installation of a specific wine version""" runner_version = self.runner_config.get("version") if runner_version: return runner_version if use_default: return get_default_version() def get_path_for_version(self, version): """Return the absolute path of a wine executable for a given version""" # logger.debug("Getting path for Wine %s", version) if version in WINE_PATHS.keys(): return system.find_executable(WINE_PATHS[version]) if "Proton" in version: for proton_path in get_proton_paths(): if os.path.isfile(os.path.join(proton_path, version, "dist/bin/wine")): return os.path.join(proton_path, version, "dist/bin/wine") if version.startswith("PlayOnLinux"): version, arch = version.split()[1].rsplit("-", 1) return os.path.join(POL_PATH, "wine", "linux-" + arch, version, "bin/wine") if version == "custom": return self.runner_config.get("custom_wine_path", "") return os.path.join(WINE_DIR, version, "bin/wine") def get_executable(self, version=None, fallback=True): """Return the path to the Wine executable. A specific version can be specified if needed. """ if version is None: version = self.get_version() if not version: return wine_path = self.get_path_for_version(version) if system.path_exists(wine_path): return wine_path if fallback: # Fallback to default version default_version = get_default_version() wine_path = self.get_path_for_version(default_version) if wine_path: # Update the version in the config if version == self.runner_config.get("version"): self.runner_config["version"] = default_version # TODO: runner_config is a dict so we have to instanciate a # LutrisConfig object to save it. # XXX: The version key could be either in the game specific # config or the runner specific config. We need to know # which one to get the correct LutrisConfig object. return wine_path def is_installed(self, version=None, fallback=True, min_version=None): """Check if Wine is installed. If no version is passed, checks if any version of wine is available """ if version: return system.path_exists(self.get_executable(version, fallback)) wine_versions = get_wine_versions() if min_version: min_version_list, _, _ = parse_version(min_version) for wine_version in wine_versions: version_list, _, _ = parse_version(wine_version) if version_list > min_version_list: return True logger.warning("Wine %s or higher not found", min_version) return bool(wine_versions) @classmethod def msi_exec( cls, msi_file, quiet=False, prefix=None, wine_path=None, working_dir=None, blocking=False, ): msi_args = "/i %s" % msi_file if quiet: msi_args += " /q" return wineexec( "msiexec", args=msi_args, prefix=prefix, wine_path=wine_path, working_dir=working_dir, blocking=blocking, ) def _run_executable(self, executable): """Runs a Windows executable using this game's configuration""" wineexec( executable, wine_path=self.get_executable(), prefix=self.prefix_path, working_dir=self.prefix_path, config=self, env=self.get_env(os_env=True), ) 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._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), ) 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") open_wine_terminal( terminal=terminal, wine_path=self.get_executable(), prefix=self.prefix_path, env=self.get_env(os_env=True) ) def run_winetricks(self, *args): """Run winetricks in the current context""" self.prelaunch() winetricks( "", prefix=self.prefix_path, wine_path=self.get_executable(), config=self, env=self.get_env(os_env=True) ) def run_winecpl(self, *args): """Execute Wine control panel.""" self.prelaunch() self._run_executable("control") def run_winekill(self, *args): """Runs wineserver -k.""" winekill( self.prefix_path, arch=self.wine_arch, wine_path=self.get_executable(), env=self.get_env(), initial_pids=self.get_pids(), ) return True def set_regedit_keys(self): """Reset regedit keys according to config.""" prefix_manager = WinePrefixManager(self.prefix_path) # Those options are directly changed with the prefix manager and skip # any calls to regedit. managed_keys = { "ShowCrashDialog": prefix_manager.set_crash_dialogs, "Desktop": prefix_manager.set_virtual_desktop, "WineDesktop": prefix_manager.set_desktop_size, } for key, path in self.reg_keys.items(): value = self.runner_config.get(key) or "auto" if not value or value == "auto" and key not in managed_keys.keys(): prefix_manager.clear_registry_subkeys(path, key) elif key in self.runner_config: if key in managed_keys.keys(): # Do not pass fallback 'auto' value to managed keys if value == "auto": value = None managed_keys[key](value) continue # Convert numeric strings to integers so they are saved as dword if value.isdigit(): value = int(value) prefix_manager.set_registry_key(path, key, value) def setup_dlls(self, manager_class, enable, version): """Enable or disable DLLs""" dll_manager = manager_class( self.prefix_path, arch=self.wine_arch, version=version, ) # manual version only sets the dlls to native if dll_manager.version.lower() != "manual": if enable: dll_manager.enable() else: dll_manager.disable() if enable: for dll in dll_manager.managed_dlls: # We have to make sure that the dll exists before setting it to native if dll_manager.dll_exists(dll): self.dll_overrides[dll] = "n" def prelaunch(self): if not system.path_exists(os.path.join(self.prefix_path, "user.reg")): create_prefix(self.prefix_path, arch=self.wine_arch) prefix_manager = WinePrefixManager(self.prefix_path) if self.runner_config.get("autoconf_joypad", False): prefix_manager.configure_joypads() self.sandbox(prefix_manager) self.set_regedit_keys() self.setup_dlls( DXVKManager, bool(self.runner_config.get("dxvk")), self.runner_config.get("dxvk_version") ) self.setup_dlls( VKD3DManager, bool(self.runner_config.get("vkd3d")), self.runner_config.get("vkd3d_version") ) self.setup_dlls( DXVKNVAPIManager, bool(self.runner_config.get("dxvk_nvapi")), self.runner_config.get("dxvk_nvapi_version") ) self.setup_dlls( D3DExtrasManager, bool(self.runner_config.get("d3d_extras")), self.runner_config.get("d3d_extras_version") ) return True 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): """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(wine, self).get_env(False) if os_env: env.update(os.environ.copy()) show_debug = self.runner_config.get("show_debug", "-all") if show_debug != "inherit": env["WINEDEBUG"] = show_debug env["WINEARCH"] = self.wine_arch env["WINE"] = self.get_executable() if is_gstreamer_build(self.get_executable()): path_64 = os.path.join(WINE_DIR, self.get_version(), "lib64/gstreamer-1.0/") path_32 = os.path.join(WINE_DIR, self.get_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" overrides = self.get_dll_overrides() if overrides: self.dll_overrides.update(overrides) env["WINEDLLOVERRIDES"] = get_overrides_env(self.dll_overrides) return env def get_runtime_env(self): """Return runtime environment variables with path to wine for Lutris builds""" wine_path = self.get_executable() wine_root = None if WINE_DIR: wine_root = os.path.dirname(os.path.dirname(wine_path)) for proton_path in get_proton_paths(): if proton_path in wine_path: wine_root = os.path.dirname(os.path.dirname(wine_path)) return runtime.get_env( version="Ubuntu-18.04", prefer_system_libs=self.system_config.get("prefer_system_libs", True), wine_path=wine_root, ) def get_pids(self, wine_path=None): """Return a list of pids of processes using the current wine exe.""" if wine_path: exe = wine_path else: exe = self.get_executable() if not exe.startswith("/"): exe = system.find_executable(exe) pids = system.get_pids_using_file(exe) if self.wine_arch == "win64" and os.path.basename(exe) == "wine": pids = pids | system.get_pids_using_file(exe + "64") # Add wineserver PIDs to the mix (at least one occurence of fuser not # picking the games's PID from wine/wine64 but from wineserver for some # unknown reason. pids = pids | system.get_pids_using_file(os.path.join(os.path.dirname(exe), "wineserver")) return pids def sandbox(self, wine_prefix): if self.runner_config.get("sandbox", True): wine_prefix.desktop_integration(desktop_dir=self.runner_config.get("sandbox_dir")) else: wine_prefix.desktop_integration(restore=True) 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 is_vulkan_supported(): if not display_vulkan_error(True): return {"error": "VULKAN_NOT_FOUND"} if not system.path_exists(game_exe): return {"error": "FILE_NOT_FOUND", "file": game_exe} if launch_info["env"].get("WINEESYNC") == "1": limit_set = is_esync_limit_set() wine_ver = is_version_esync(self.get_executable()) if not limit_set and not wine_ver: esync_display_version_warning(True) esync_display_limit_warning() return {"error": "ESYNC_LIMIT_NOT_SET"} if not is_esync_limit_set(): esync_display_limit_warning() return {"error": "ESYNC_LIMIT_NOT_SET"} if not wine_ver: if not esync_display_version_warning(True): return {"error": "NON_ESYNC_WINE_VERSION"} if launch_info["env"].get("WINEFSYNC") == "1": fsync_supported = is_fsync_supported() wine_ver = is_version_fsync(self.get_executable()) if not fsync_supported and not wine_ver: fsync_display_version_warning(True) fsync_display_support_warning() return {"error": "FSYNC_NOT_SUPPORTED"} if not fsync_supported: fsync_display_support_warning() return {"error": "FSYNC_NOT_SUPPORTED"} if not wine_ver: if not fsync_display_version_warning(True): return {"error": "NON_FSYNC_WINE_VERSION"} command = [self.get_executable()] game_exe, args, _working_dir = get_real_executable(game_exe, self.working_dir) command.append(game_exe) if args: command = command + args if arguments: for arg in split_arguments(arguments): command.append(arg) launch_info["command"] = command return launch_info @staticmethod def parse_wine_path(path, prefix_path=None): """Take a Windows path, return the corresponding Linux path.""" if not prefix_path: prefix_path = os.path.expanduser("~/.wine") path = path.replace("\\\\", "/").replace("\\", "/") if path[1] == ":": # absolute path drive = os.path.join(prefix_path, "dosdevices", path[:2].lower()) if os.path.islink(drive): # Try to resolve the path drive = os.readlink(drive) return os.path.join(drive, path[3:]) if path[0] == "/": # drive-relative path. C is as good a guess as any.. return os.path.join(prefix_path, "drive_c", path[1:]) # Relative path return path lutris-0.5.9.1/lutris/runners/winesteam.py000066400000000000000000000435621413267435700206200ustar00rootroot00000000000000"""Steam for Windows runner""" # Standard Library import os import time from gettext import gettext as _ # Lutris Modules from lutris import settings from lutris.command import MonitoredCommand from lutris.runners import wine from lutris.runners.commands.wine import ( # noqa: F401 pylint: disable=unused-import create_prefix, delete_registry_key, install_cab_component, set_regedit, set_regedit_file, winecfg, wineexec, winekill, winetricks ) from lutris.util import system from lutris.util.log import logger from lutris.util.steam.appmanifest import get_path_from_appmanifest from lutris.util.steam.config import read_config from lutris.util.strings import split_arguments from lutris.util.wine.registry import WineRegistry from lutris.util.wine.wine import WINE_DEFAULT_ARCH STEAM_INSTALLER_URL = ("https://lutris.nyc3.cdn.digitaloceanspaces.com/runners/winesteam/SteamSetup.exe") def is_running(): """Return whether Steam is running""" return bool(system.get_pid("Steam.exe$")) def kill(): """Force kills Steam""" system.kill_pid(system.get_pid("Steam.exe$")) # pylint: disable=C0103 class winesteam(wine.wine): description = _("Runs Steam for Windows games") multiple_versions = False human_name = _("Wine Steam") platforms = [_("Windows")] runnable_alone = True default_arch = WINE_DEFAULT_ARCH game_options = [ { "option": "appid", "type": "string", "label": _("Application ID"), "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"), }, { "option": "prefix", "type": "directory_chooser", "label": _("Prefix"), "help": _( 'The prefix (also named "bottle") 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.\n" "32-bit is recommended unless running " "64-bit only games." ), }, { "option": "nolaunch", "type": "bool", "default": False, "label": _("Do not launch game, only open Steam"), "help": _( "Opens Steam with the current settings without running the game, " "useful if a game has several launch options." ), }, { "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)"), }, ] def __init__(self, config=None): super(winesteam, self).__init__(config) self.own_game_remove_method = _("Remove game data (through Wine Steam)") self.no_game_remove_warning = True winesteam_options = [ { "option": "steam_path", "type": "directory_chooser", "label": _("Custom Steam location"), "help": _( "Choose a folder containing Steam.exe\n" "By default, Lutris will look for a Windows Steam " "installation into ~/.wine or will install it in " "its own custom Wine prefix." ), }, { "option": "quit_steam_on_exit", "label": _("Stop Steam after game exits"), "type": "bool", "default": True, "help": _("Shut down Steam after the game has quit."), }, { "option": "args", "type": "string", "label": _("Arguments"), "advanced": True, "help": _("Extra command line arguments used when " "launching Steam"), }, { "option": "default_win32_prefix", "type": "directory_chooser", "label": _("Default Wine prefix (32-bit)"), "default": os.path.join(settings.RUNNER_DIR, "winesteam/prefix"), "help": _("Default prefix location for Steam (32-bit)"), "advanced": True, }, { "option": "default_win64_prefix", "type": "directory_chooser", "label": _("Default Wine prefix (64-bit)"), "default": os.path.join(settings.RUNNER_DIR, "winesteam/prefix64"), "help": _("Default prefix location for Steam (64-bit)"), "advanced": True, }, ] for option in reversed(winesteam_options): self.runner_options.insert(0, option) def __repr__(self): return _("Winesteam runner (%s)") % self.config @property def appid(self): """Steam AppID used to uniquely identify games""" return self.game_config.get("appid") or "" @property def prefix_path(self): _prefix = self.game_config.get("prefix") or self.get_or_create_default_prefix(arch=self.game_config.get("arch")) return os.path.expanduser(_prefix) @property def game_path(self): if not self.appid: return None return self.get_game_path_from_appid(self.appid) @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 os.path.expanduser("~/") @property def launch_args(self): """Provide launch arguments for Steam""" steam_path = self.get_steam_path() if not steam_path: raise RuntimeError("Can't find a Steam executable") return [ self.get_executable(), steam_path, "-no-cef-sandbox", "-console", ] + split_arguments(self.runner_config.get("args") or "") @staticmethod def get_open_command(registry): """Return Steam's Open command, useful for locating steam when it has been installed but not yet launched""" value = registry.query("Software/Classes/steam/Shell/Open/Command", "default") if not value: return None parts = value.split('"') return parts[1].strip("\\") def get_steam_config(self): """Return the "Steam" part of Steam's config.vfd as a dict""" steam_data_dir = self.steam_data_dir if not steam_data_dir: return None return read_config(steam_data_dir) @property def steam_data_dir(self): """Return dir where Steam files lie""" steam_path = self.get_steam_path() if steam_path: steam_dir = os.path.dirname(steam_path) if os.path.isdir(steam_dir): return steam_dir def get_steam_path(self): """Return Steam exe's path""" custom_path = self.runner_config.get("steam_path") or "" if custom_path: custom_path = os.path.abspath(os.path.expanduser(os.path.join(custom_path, "Steam.exe"))) if system.path_exists(custom_path): return custom_path candidates = [ self.get_default_prefix(arch="win64"), self.get_default_prefix(arch="win32"), os.path.expanduser("~/.wine"), ] for prefix in candidates: # Try the default install path for default_path in [ "drive_c/Program Files (x86)/Steam/Steam.exe", "drive_c/Program Files/Steam/Steam.exe", ]: steam_path = os.path.join(prefix, default_path) if system.path_exists(steam_path): return steam_path # Try from the registry key user_reg = os.path.join(prefix, "user.reg") if not system.path_exists(user_reg): continue registry = WineRegistry(user_reg) steam_path = registry.query("Software/Valve/Steam", "SteamExe") if not steam_path: steam_path = self.get_open_command(registry) if not steam_path: continue return system.fix_path_case(registry.get_unix_path(steam_path)) return "" def install(self, version=None, downloader=None, callback=None): installer_path = os.path.join(settings.TMP_PATH, "SteamSetup.exe") def on_steam_downloaded(*_args): prefix = self.get_or_create_default_prefix() # Install CJK fonts in the Steam prefix before Steam winetricks("cjkfonts", prefix=prefix, wine_path=self.get_executable()) wineexec( installer_path, args="/S", prefix=prefix, wine_path=self.get_executable(), ) if callback: callback() downloader(STEAM_INSTALLER_URL, installer_path, on_steam_downloaded) def is_installed(self, version=None, fallback=True, min_version=None): """Checks if wine is installed and if the steam executable is on the drive""" if not super().is_installed(version=version, fallback=fallback, min_version=min_version): return False if not system.path_exists(self.get_default_prefix(arch=self.default_arch)): return False return system.path_exists(self.get_steam_path()) def get_appid_list(self): """Return the list of appids of all user's games""" steam_config = self.get_steam_config() if steam_config: apps = steam_config["apps"] return apps.keys() def get_game_path_from_appid(self, appid): """Return the game directory""" for apps_path in self.get_steamapps_dirs(): logger.debug("Checking for game %s in %s", appid, apps_path) game_path = get_path_from_appmanifest(apps_path, appid) if game_path: logger.debug("Game found in %s", game_path) return game_path logger.warning("Data path for SteamApp %s not found.", appid) def get_steamapps_dirs(self): """Return a list of the Steam library main + custom folders.""" dirs = [] # Main steamapps dir steam_data_dir = self.steam_data_dir if steam_data_dir: main_dir = os.path.join(steam_data_dir, "steamapps") main_dir = system.fix_path_case(main_dir) if main_dir and os.path.isdir(main_dir): dirs.append(os.path.abspath(main_dir)) # Custom dirs steam_config = self.get_steam_config() if steam_config: i = 1 while "BaseInstallFolder_%s" % i in steam_config: path = steam_config["BaseInstallFolder_%s" % i] + "/steamapps" linux_path = self.parse_wine_path(path, self.prefix_path) linux_path = system.fix_path_case(linux_path) if linux_path and os.path.isdir(linux_path): dirs.append(os.path.abspath(linux_path)) i += 1 return dirs def get_default_steamapps_path(self): """Return the default path used for storing Steam games""" steamapps_paths = self.get_steamapps_dirs() if steamapps_paths: return steamapps_paths[0] def create_default_prefix(self, prefix_dir, arch=None): """Create the default prefix for Steam Not sure Steam will keep on working on 32bit prefixes for long. Args: prefix_path (str): Destination of the default prefix arch (str): Optional architecture for the prefix, defaults to win64 """ logger.debug("Creating default winesteam prefix") arch = arch or self.default_arch if not system.path_exists(os.path.dirname(prefix_dir)): os.makedirs(os.path.dirname(prefix_dir)) create_prefix(prefix_dir, arch=arch, wine_path=self.get_executable()) def get_default_prefix(self, arch): """Return the default prefix' path.""" return self.runner_config["default_%s_prefix" % arch] def get_or_create_default_prefix(self, arch=None): """Return the default prefix' path. Create it if it doesn't exist""" if not arch or arch == "auto": arch = self.default_arch prefix = self.get_default_prefix(arch=arch) if not system.path_exists(prefix): self.create_default_prefix(prefix, arch=arch) return prefix def install_game(self, appid, generate_acf=False): """Install a game with Steam""" if not appid: raise ValueError("Missing appid in winesteam.install_game") system.execute(self.launch_args + ["steam://install/%s" % appid], env=self.get_env()) def validate_game(self, appid): """Validate game files with Steam""" if not appid: raise ValueError("Missing appid in winesteam.validate_game") system.execute(self.launch_args + ["steam://validate/%s" % appid], env=self.get_env()) def force_shutdown(self): """Forces a Steam shutdown, double checking its exit status and raising an error if it cannot be killed""" def has_steam_shutdown(times=10): for __ in range(1, times + 1): time.sleep(1) if not is_running(): return True # Stop existing winesteam to prevent Wine prefix/version problems if is_running(): logger.info("Waiting for Steam to shutdown...") self.shutdown() if not has_steam_shutdown(): logger.info("Forcing Steam shutdown") kill() if not has_steam_shutdown(5): logger.error("Failed to shut down Wine Steam :(") def prelaunch(self): super().prelaunch() try: self.force_shutdown() except RuntimeError: return False return True def get_run_data(self): return {"command": self.launch_args, "env": self.get_env(os_env=False)} def get_command(self): """Return the command used to launch a Steam game""" game_args = self.game_config.get("args") or "" game_binary = self.game_config.get("steamless_binary") if self.game_config.get("run_without_steam") and game_binary: # Start without steam if not system.path_exists(game_binary): raise FileNotFoundError(2, "Game binary not found", game_binary) command = [self.get_executable(), game_binary] for arg in split_arguments(game_args): command.append(arg) else: # Start through steam command = self.launch_args if self.game_config.get("nolaunch"): command.append("steam://open/games/details") elif not game_args: command.append("steam://rungameid/%s" % self.appid) else: command.append("-applaunch") command.append(self.appid) for arg in split_arguments(game_args): command.append(arg) return command def play(self): """Run a game""" try: return { "env": self.get_env(os_env=False), "command": self.get_command() } except FileNotFoundError as ex: return {"error": "FILE_NOT_FOUND", "file": ex.filename} def shutdown(self): """Orders Steam to shutdown""" logger.info("Shutting down Steam") shutdown_command = MonitoredCommand( (self.launch_args + ["-shutdown"]), runner=self, env=self.get_env(os_env=False), ) shutdown_command.start() def on_game_stop(self): """TODO: Call this once it is possible to monitor Steam games""" if bool(self.runner_config.get("quit_steam_on_exit")): logger.debug("Game configured to stop Steam on exit") self.shutdown() return True return False def remove_game_data(self, appid=None, **kwargs): """Uninstall a game from Steam""" if not self.is_installed(): logger.warning("Trying to remove a winesteam game but it's not installed.") return False self.force_shutdown() uninstall_command = MonitoredCommand( (self.launch_args + ["steam://uninstall/%s" % (appid or self.appid)]), runner=self, env=self.get_env(os_env=False), ) uninstall_command.start() lutris-0.5.9.1/lutris/runners/yuzu.py000066400000000000000000000055371413267435700176400ustar00rootroot00000000000000# Standard Library import filecmp import os from gettext import gettext as _ from shutil import copyfile # Lutris Modules 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" 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."), } ] @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 path: return path[:-len("nand")] def play(self): """Run the game.""" arguments = [self.get_executable()] rom = self.game_config.get("main_file") or "" if not system.path_exists(rom): return {"error": "FILE_NOT_FOUND", "file": rom} arguments.append(rom) 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) return True lutris-0.5.9.1/lutris/runners/zdoom.py000066400000000000000000000142601413267435700177450ustar00rootroot00000000000000import os from gettext import gettext as _ from lutris.runners.runner import Runner from lutris.util import display, system 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/zdoom" game_options = [ { "option": "main_file", "type": "file", "label": _("WAD file"), "help": _("The game data, commonly called a WAD file."), }, { "option": "args", "type": "string", "label": _("Arguments"), "help": _("Command line arguments used when launching the game."), }, { "option": "files", "type": "multiple", "label": _("PWAD files"), "help": _("Used to load one or more PWAD files which generally contain " "user-created levels."), }, { "option": "warp", "type": "string", "label": _("Warp to map"), "help": _("Starts the game on the given map."), }, { "option": "savedir", "type": "directory_chooser", "label": _("Save path"), "help": _("User-specified path where save files should be located."), }, ] runner_options = [ { "option": "2", "label": _("Pixel Doubling"), "type": "bool", "default": False }, { "option": "4", "label": _("Pixel Quadrupling"), "type": "bool", "default": False }, { "option": "nostartup", "label": _("Disable Startup Screens"), "type": "bool", "default": False, }, { "option": "skill", "label": _("Skill"), "type": "choice", "default": "", "choices": { (_("None"), ""), (_("I'm Too Young To Die (1)"), "1"), (_("Hey, Not Too Rough (2)"), "2"), (_("Hurt Me Plenty (3)"), "3"), (_("Ultra-Violence (4)"), "4"), (_("Nightmare! (5)"), "5"), }, }, { "option": "config", "label": _("Config file"), "type": "file", "help": _( "Used to load a user-created configuration file. If specified, " "the file must contain the wad directory list or launch will fail." ), }, ] def get_executable(self): executable = super(zdoom, self).get_executable() executable_dir = os.path.dirname(executable) if not system.path_exists(executable_dir): return executable if not system.path_exists(executable): gzdoom_executable = os.path.join(executable_dir, "gzdoom") if system.path_exists(gzdoom_executable): return gzdoom_executable return executable def prelaunch(self): if not LINUX_SYSTEM.get_soundfonts(): logger.warning("FluidSynth is not installed, you might not have any music") return True @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_executable()] 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.9.1/lutris/runtime.py000066400000000000000000000270211413267435700166030ustar00rootroot00000000000000"""Runtime handling module""" import concurrent.futures import os import time from gi.repository import GLib from lutris import settings from lutris.util import http, jobs, system from lutris.util.downloader import Downloader from lutris.util.extract import extract_archive from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger RUNTIME_DISABLED = os.environ.get("LUTRIS_RUNTIME", "").lower() in ("0", "off") DEFAULT_RUNTIME = "Ubuntu-18.04" class Runtime: """Class for manipulating runtime folders""" def __init__(self, name, updater): self.name = name self.updater = updater @property def local_runtime_path(self): """Return the local path for the runtime folder""" if not self.name: return None return os.path.join(settings.RUNTIME_DIR, self.name) def get_updated_at(self): """Return the modification date of the runtime folder""" if not system.path_exists(self.local_runtime_path): return None return time.gmtime(os.path.getmtime(self.local_runtime_path)) def set_updated_at(self): """Set the creation and modification time to now""" if not system.path_exists(self.local_runtime_path): logger.error("No local runtime path in %s", self.local_runtime_path) return os.utime(self.local_runtime_path) def should_update(self, remote_updated_at): """Determine if the current runtime should be updated""" local_updated_at = self.get_updated_at() if not local_updated_at: logger.warning("Runtime %s is not available locally", self.name) return True if local_updated_at and local_updated_at >= remote_updated_at: return False logger.debug( "Runtime %s locally updated on %s, remote created on %s)", self.name, time.strftime("%c", local_updated_at), time.strftime("%c", remote_updated_at), ) return True def should_update_component(self, filename, remote_modified_at): """Should an individual component be updated?""" file_path = os.path.join(settings.RUNTIME_DIR, self.name, filename) if not system.path_exists(file_path): return True locally_modified_at = time.gmtime(os.path.getmtime(file_path)) if locally_modified_at >= remote_modified_at: return False return True def download(self, remote_runtime_info): """Downloads a runtime locally""" url = remote_runtime_info["url"] if not url: return self.download_components() remote_updated_at = remote_runtime_info["created_at"] remote_updated_at = time.strptime(remote_updated_at[:remote_updated_at.find(".")], "%Y-%m-%dT%H:%M:%S") if not self.should_update(remote_updated_at): return None archive_path = os.path.join(settings.RUNTIME_DIR, os.path.basename(url)) downloader = Downloader(url, archive_path, overwrite=True) downloader.start() GLib.timeout_add(100, self.check_download_progress, downloader) return downloader def download_component(self, component): """Download an individual file from a runtime item""" file_path = os.path.join(settings.RUNTIME_DIR, self.name, component["filename"]) try: http.download_file(component["url"], file_path) except http.HTTPError as ex: logger.error("Failed to download runtime component %s: %s", component, ex) return return file_path def get_runtime_components(self): """Fetch runtime components from the API""" request = http.Request(settings.RUNTIME_URL + "/" + self.name) try: response = request.get() except http.HTTPError as ex: logger.error("Failed to get components: %s", ex) return [] if not response.json: return [] return response.json.get("components", []) def download_components(self): """Download a runtime item by individual components.""" components = self.get_runtime_components() downloads = [] for component in components: modified_at = time.strptime( component["modified_at"][:component["modified_at"].find(".")], "%Y-%m-%dT%H:%M:%S" ) if not self.should_update_component(component["filename"], modified_at): continue downloads.append(component) with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: future_downloads = { executor.submit(self.download_component, component): component["filename"] for component in downloads } for future in concurrent.futures.as_completed(future_downloads): filename = future_downloads[future] if not filename: logger.warning("Failed to get %s", future) def check_download_progress(self, downloader): """Call download.check_progress(), return True if download finished.""" if not downloader or downloader.state in [ downloader.CANCELLED, downloader.ERROR, ]: logger.debug("Runtime update interrupted") return False downloader.check_progress() if downloader.state == downloader.COMPLETED: self.on_downloaded(downloader.dest) return False return True def on_downloaded(self, path): """Actions taken once a runtime is downloaded Arguments: path (str): local path to the runtime archive """ stats = os.stat(path) if not stats.st_size: logger.error("Download failed: file %s is empty, Deleting file.", path) os.unlink(path) self.updater.notify_finish(self) return False directory, _filename = os.path.split(path) # Delete the existing runtime path initial_path = os.path.join(directory, self.name) system.remove_folder(initial_path) # Extract the runtime archive jobs.AsyncCall(extract_archive, self.on_extracted, path, settings.RUNTIME_DIR, merge_single=False) return False def on_extracted(self, result, error): """Callback method when a runtime has extracted""" if error: logger.error("Runtime update failed") logger.error(error) self.updater.notify_finish(self) return False archive_path, _destination_path = result logger.debug("Deleting runtime archive %s", archive_path) os.unlink(archive_path) self.set_updated_at() self.updater.notify_finish(self) return False class RuntimeUpdater: """Class handling the runtime updates""" current_updates = 0 status_updater = None def is_updating(self): """Return True if the update process is running""" return self.current_updates > 0 def update(self): """Launch the update process""" if RUNTIME_DISABLED: logger.warning("Runtime disabled, not updating it.") return 0 for remote_runtime in self._iter_remote_runtimes(): runtime = Runtime(remote_runtime["name"], self) downloader = runtime.download(remote_runtime) if downloader: self.current_updates += 1 return self.current_updates @staticmethod def _iter_remote_runtimes(): request = http.Request(settings.RUNTIME_URL + "?enabled=1") try: response = request.get() except http.HTTPError as ex: logger.error("Failed to get runtimes: %s", ex) return runtimes = response.json or [] for runtime in runtimes: # Skip 32bit runtimes on 64 bit systems except the main runtime if ( runtime["architecture"] == "i386" and LINUX_SYSTEM.is_64_bit and not runtime["name"].startswith(("Ubuntu", "lib32")) ): logger.debug( "Skipping runtime %s for %s", runtime["name"], runtime["architecture"], ) continue # Skip 64bit runtimes on 32 bit systems if runtime["architecture"] == "x86_64" and not LINUX_SYSTEM.is_64_bit: logger.debug( "Skipping runtime %s for %s", runtime["name"], runtime["architecture"], ) continue yield runtime def notify_finish(self, runtime): """A runtime has finished downloading""" logger.debug("Runtime %s is now updated and available", runtime.name) self.current_updates -= 1 if self.current_updates == 0: logger.info("Runtime is fully updated.") def get_env(version=None, prefer_system_libs=False, wine_path=None): """Return a dict containing LD_LIBRARY_PATH env var Params: version (str): Version of the runtime to use, such as "Ubuntu-18.04" or "legacy" prefer_system_libs (bool): Whether to prioritize system libs over runtime libs wine_path (str): 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. Returns: dict """ return { key: value for key, value in { "LD_LIBRARY_PATH": ":".join(get_paths(version=version, prefer_system_libs=prefer_system_libs, wine_path=wine_path)), }.items() if value } def get_winelib_paths(wine_path): """Return wine libraries path for a Lutris wine build""" paths = [] # Prioritize libwine.so.1 for lutris builds for winelib_path in ("lib", "lib64"): winelib_fullpath = os.path.join(wine_path or "", winelib_path) if system.path_exists(winelib_fullpath): paths.append(winelib_fullpath) return paths def get_runtime_paths(version=None, prefer_system_libs=True, wine_path=None): """Return Lutris runtime paths""" version = version or DEFAULT_RUNTIME lutris_runtime_path = "%s-i686" % version runtime_paths = [ lutris_runtime_path, "steam/i386/lib/i386-linux-gnu", "steam/i386/lib", "steam/i386/usr/lib/i386-linux-gnu", "steam/i386/usr/lib", ] if LINUX_SYSTEM.is_64_bit: lutris_runtime_path = "%s-x86_64" % version runtime_paths += [ lutris_runtime_path, "steam/amd64/lib/x86_64-linux-gnu", "steam/amd64/lib", "steam/amd64/usr/lib/x86_64-linux-gnu", "steam/amd64/usr/lib", ] paths = [] if prefer_system_libs: if wine_path: paths += get_winelib_paths(wine_path) paths += list(LINUX_SYSTEM.iter_lib_folders()) # Then resolve absolute paths for the runtime paths += [os.path.join(settings.RUNTIME_DIR, path) for path in runtime_paths] return paths def get_paths(version=None, prefer_system_libs=True, wine_path=None): """Return a list of paths containing the runtime libraries.""" if not RUNTIME_DISABLED: paths = get_runtime_paths(version=version, prefer_system_libs=prefer_system_libs, wine_path=wine_path) else: paths = [] # Put existing LD_LIBRARY_PATH at the end if os.environ.get("LD_LIBRARY_PATH"): paths.append(os.environ["LD_LIBRARY_PATH"]) return paths lutris-0.5.9.1/lutris/scanners/000077500000000000000000000000001413267435700163605ustar00rootroot00000000000000lutris-0.5.9.1/lutris/scanners/__init__.py000066400000000000000000000000001413267435700204570ustar00rootroot00000000000000lutris-0.5.9.1/lutris/scanners/lutris.py000066400000000000000000000011431413267435700202530ustar00rootroot00000000000000import os import re from lutris.util.log import logger def scan_directory(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 if not re.match(r"^[a-z0-9-]*$", folder): logger.info("Skipping non matching folder %s", folder) continue game_folders.append(folder) for game_folder in game_folders: print(game_folder) print("%d games to check" % len(game_folders)) lutris-0.5.9.1/lutris/scanners/retroarch.py000066400000000000000000000043761413267435700207350ustar00rootroot00000000000000import os from lutris.config import write_game_config from lutris.database.games import add_game, get_games from lutris.game import Game from lutris.util.log import logger from lutris.util.retroarch.core_config import RECOMMENDED_CORES from lutris.util.strings import slugify ROM_FLAGS = [ "USA", "Europe", "World", "Japan", "Japan, USA", "USA, Europe", "Proto", "SGB Enhanced", "Rev A", "V1.1", "F", "U", "E", "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_extentions = {os.path.splitext(filename)[1] for filename in files} core_matches = {} for core in RECOMMENDED_CORES: for ext in RECOMMENDED_CORES[core].get("extensions", []): if ext in folder_extentions: core_matches[ext] = core added_games = [] for filename in files: name, ext = os.path.splitext(filename) if ext not in core_matches: continue logger.info("Importing '%s'", name) slug = slugify(name) core = core_matches[ext] config = { "game": { "core": core_matches[ext], "main_file": os.path.join(dirname, filename) } } installer_slug = "%s-libretro-%s" % (slug, core) existing_game = get_games(filters={"installer_slug": installer_slug}) if existing_game: game = Game(existing_game[0]["id"]) game.remove() configpath = write_game_config(slug, config) game_id = add_game( name=name, runner="libretro", slug=slug, directory=dirname, installed=1, installer_slug=installer_slug, configpath=configpath ) added_games.append(game_id) return added_games lutris-0.5.9.1/lutris/services/000077500000000000000000000000001413267435700163675ustar00rootroot00000000000000lutris-0.5.9.1/lutris/services/__init__.py000066400000000000000000000037731413267435700205120ustar00rootroot00000000000000"""Service package""" import os from lutris import settings from lutris.services.battlenet import BattleNetService from lutris.services.bethesda import BethesdaService from lutris.services.dolphin import DolphinService from lutris.services.egs import EpicGamesStoreService 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.steam import SteamService from lutris.services.steamwindows import SteamWindowsService from lutris.services.ubisoft import UbisoftConnectService from lutris.services.xdg import XDGService from lutris.util import system from lutris.util.dolphin.cache_reader import DOLPHIN_GAME_CACHE_FILE from lutris.util.linux import LINUX_SYSTEM DEFAULT_SERVICES = ["lutris", "gog", "humblebundle", "steam"] def get_services(): """Return a mapping of available services""" _services = { "lutris": LutrisService, "xdg": XDGService, "gog": GOGService, "humblebundle": HumbleBundleService, "egs": EpicGamesStoreService, } if LINUX_SYSTEM.has_steam: _services["steam"] = SteamService _services["steamwindows"] = SteamWindowsService if system.path_exists(DOLPHIN_GAME_CACHE_FILE): _services["dolphin"] = DolphinService return _services SERVICES = get_services() # Those services are not yet ready to be used WIP_SERVICES = { "battlenet": BattleNetService, "bethesda": BethesdaService, "itchio": ItchIoService, "mame": MAMEService, "origin": OriginService, "ubisoft": UbisoftConnectService, } 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.9.1/lutris/services/base.py000066400000000000000000000250041413267435700176540ustar00rootroot00000000000000"""Generic service utilities""" import os import shutil from gi.repository import Gio, GObject from lutris import api, settings from lutris.config import write_game_config from lutris.database import sql from lutris.database.games import add_game, get_games from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.gui.dialogs.webconnect_dialog import WebConnectDialog from lutris.gui.views.media_loader import download_icons from lutris.installer import fetch_script from lutris.services.service_media import ServiceMedia from lutris.util import system from lutris.util.cookies import WebkitCookieJar from lutris.util.log import logger PGA_DB = settings.PGA_DB class AuthTokenExpired(Exception): """Exception raised when a token is no longer valid""" class LutrisBanner(ServiceMedia): service = 'lutris' size = (184, 69) dest_path = settings.BANNER_PATH file_pattern = "%s.jpg" api_field = 'banner_url' url_pattern = "https://lutris.net/games/banner/%s.jpg" def get_media_urls(self): return { game["slug"]: self.url_pattern % game["slug"] for game in get_games() } class LutrisIcon(LutrisBanner): size = (32, 32) dest_path = settings.ICON_PATH file_pattern = "lutris_%s.png" api_field = 'icon_url' url_pattern = "https://lutris.net/games/icon/%s.png" class BaseService(GObject.Object): """Base class for local services""" id = NotImplemented _matcher = None has_extras = False name = NotImplemented 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 medias = {} extra_medias = {} default_format = "icon" __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 reload(self): """Refresh the service's games""" self.emit("service-games-load") self.wipe_game_cache() self.load() self.load_icons() self.add_installed_games() self.emit("service-games-loaded") 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) # Download icons for icon_type in all_medias: service_media = all_medias[icon_type]() media_urls = service_media.get_media_urls() download_icons(media_urls, service_media) # Process icons for icon_type in all_medias: service_media = all_medias[icon_type]() service_media.render() if self.id != "lutris": for service_media_class in (LutrisIcon, LutrisBanner): service_media = service_media_class() media_urls = service_media.get_media_urls() download_icons(media_urls, service_media) def wipe_game_cache(self): logger.debug("Deleting games from service-games for %s", self.id) sql.db_delete(PGA_DB, "service_games", "service", self.id) def generate_installer(self, db_game): """Used to generate an installer from the data returned from the services""" return {} def match_game(self, service_game, api_game): """Match a service game to a lutris game referenced by its slug""" if not service_game: return sql.db_update( PGA_DB, "service_games", {"lutris_slug": api_game["slug"]}, conditions={"appid": service_game["appid"], "service": self.id} ) unmatched_lutris_games = get_games( searches={"installer_slug": self.matcher}, filters={"slug": api_game["slug"]}, excludes={"service": self.id} ) for game in unmatched_lutris_games: logger.debug("Updating unmatched game %s", game) sql.db_update( PGA_DB, "games", {"service": self.id, "service_id": service_game["appid"]}, conditions={"id": game["id"]} ) def match_games(self): """Matching of service games to lutris games""" service_games = { str(game["appid"]): game for game in ServiceGameCollection.get_for_service(self.id) } lutris_games = api.get_api_games(list(service_games.keys()), service=self.id) for lutris_game in lutris_games: for provider_game in lutris_game["provider_games"]: if provider_game["service"] != self.id: continue self.match_game(service_games.get(provider_game["slug"]), lutris_game) unmatched_service_games = get_games(searches={"installer_slug": self.matcher}, excludes={"service": self.id}) for lutris_game in api.get_api_games(game_slugs=[g["slug"] for g in unmatched_service_games]): for provider_game in lutris_game["provider_games"]: if provider_game["service"] != self.id: continue self.match_game(service_games.get(provider_game["slug"]), lutris_game) def match_existing_game(self, db_games, appid): """Checks if a game is already installed and populates the service info""" for _game in db_games: logger.debug("Matching %s with existing install: %s", appid, _game) game = Game(_game["id"]) game.appid = appid game.service = self.id game.save() service_game = ServiceGameCollection.get_game(self.id, appid) sql.db_update(PGA_DB, "service_games", {"lutris_slug": game.slug}, {"id": service_game["id"]}) return game def get_installers_from_api(self, appid): """Query the lutris API for an appid and get existing installers for the service""" lutris_games = api.get_api_games([appid], service=self.id) service_installers = [] if lutris_games: lutris_game = lutris_games[0] installers = fetch_script(lutris_game["slug"]) for installer in installers: if self.matcher in installer["version"].lower(): service_installers.append(installer) return service_installers def install(self, db_game): """Install a service game""" appid = db_game["appid"] logger.debug("Installing %s from service %s", appid, self.id) if self.local: return self.simple_install(db_game) service_installers = self.get_installers_from_api(appid) # Check if the game is not already installed for service_installer in service_installers: existing_game = self.match_existing_game( get_games(filters={"installer_slug": service_installer["slug"], "installed": "1"}), appid ) if existing_game: return if not service_installers: installer = self.generate_installer(db_game) if installer: service_installers.append(installer) if not service_installers: logger.error("No installer found for %s", db_game) return application = Gio.Application.get_default() application.show_installer_window(service_installers, service=self, appid=appid) def simple_install(self, db_game): """A simplified version of the install method, used when a game doesn't need any setup""" installer = self.generate_installer(db_game) configpath = write_game_config(db_game["slug"], installer["script"]) game_id = add_game( name=installer["name"], runner=installer["runner"], slug=installer["game_slug"], directory=self.get_game_directory(installer), installed=1, installer_slug=installer["slug"], configpath=configpath, service=self.id, service_id=db_game["appid"], ) return game_id def add_installed_games(self): """Services can implement this method to scan for locally installed games and add them to lutris. """ def get_game_directory(self, _installer): """Specific services should implement this""" return "" class OnlineService(BaseService): """Base class for online gaming services""" online = True cookies_path = NotImplemented cache_path = NotImplemented requires_login_page = False @property def credential_files(self): """Return a list of all files used for authentication""" return [self.cookies_path] def login(self, parent=None): logger.debug("Connecting to %s", self.name) dialog = WebConnectDialog(self, parent) dialog.set_modal(True) dialog.show() 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.9.1/lutris/services/battlenet.py000066400000000000000000000017371413267435700207330ustar00rootroot00000000000000"""Battle.net service. Not ready yet. """ from gettext import gettext as _ from lutris.services.base import OnlineService class BattleNetService(OnlineService): """Service class for Battle.net""" id = "battlenet" name = _("Battle.net") icon = "battlenet" medias = {} region = "na" @property def oauth_url(self): """Return the URL used for OAuth sign in""" if self.region == "cn": return "https://www.battlenet.com.cn/oauth" return "https://%s.battle.net/oauth" % self.region @property def api_url(self): """Main API endpoint""" if self.region == "cn": return "https://gateway.battlenet.com.cn" return "https://%s.api.blizzard.com" % self.region @property def login_url(self): """Battle.net login URL""" if self.region == "cn": return "https://www.battlenet.com.cn/login/zh" return "https://%s.battle.net/login/en" % self.region lutris-0.5.9.1/lutris/services/bethesda.py000066400000000000000000000004141413267435700205170ustar00rootroot00000000000000"""Bethesda service. Not ready yet. """ from gettext import gettext as _ from lutris.services.base import OnlineService class BethesdaService(OnlineService): """Service class for Battle.net""" id = "bethesda" name = _("Bethesda") icon = "bethesda" lutris-0.5.9.1/lutris/services/dolphin.py000066400000000000000000000063331413267435700204030ustar00rootroot00000000000000import json import os from gettext import gettext as _ from PIL import Image 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.dolphin.cache_reader import DOLPHIN_GAME_CACHE_FILE, DolphinCacheReader from lutris.util.strings import slugify class DolphinBanner(ServiceMedia): service = "dolphin" source = "local" size = (96, 32) file_pattern = "%s.png" dest_path = os.path.join(settings.CACHE_DIR, "dolphin/banners/small") class DolphinService(BaseService): id = "dolphin" icon = "dolphin" name = _("Dolphin") local = True medias = { "icon": DolphinBanner } def load(self): if not system.path_exists(DOLPHIN_GAME_CACHE_FILE): return cache_reader = DolphinCacheReader() dolphin_games = [DolphinGame.new_from_cache(game) for game in cache_reader.get_games()] for game in dolphin_games: game.save() return dolphin_games def generate_installer(self, db_game): details = json.loads(db_game["details"]) return { "name": db_game["name"], "version": "Dolphin", "slug": db_game["slug"], "game_slug": slugify(db_game["name"]), "runner": "dolphin", "script": { "game": { "main_file": details["path"], "platform": details["platform"] }, } } def get_game_directory(self, installer): """Pull install location from installer""" return os.path.dirname(installer["script"]["game"]["main_file"]) 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""" service_game = cls() service_game.name = cache_entry["internal_name"] service_game.appid = str(cache_entry["game_id"]) service_game.slug = slugify(cache_entry["internal_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_absolute_path(self.appid) if os.path.exists(banner_path): return banner_path (width, height), data = cache_entry["volume_banner"] if data: img = Image.frombytes("RGB", (width, height), data, "raw", ("BGRX")) # 96x32 is a bit small, maybe 2x scale? # img.resize((width * 2, height * 2)) img.save(banner_path) return banner_path return "" lutris-0.5.9.1/lutris/services/egs.py000066400000000000000000000360761413267435700175330ustar00rootroot00000000000000"""Epic Games Store service""" import json import os from gettext import gettext as _ import requests from gi.repository import Gio from lutris import settings from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_game, get_game_by_field from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.gui.widgets.utils import Image, paste_overlay, thumbnail_image from lutris.installer import get_installers from lutris.services.base import AuthTokenExpired, OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import system from lutris.util.egs.egs_launcher import EGSLauncher from lutris.util.log import logger from lutris.util.strings import slugify EGS_GAME_ART_PATH = os.path.expanduser("~/.cache/lutris/egs/game_box") EGS_GAME_BOX_PATH = os.path.expanduser("~/.cache/lutris/egs/game_box_tall") EGS_LOGO_PATH = os.path.expanduser("~/.cache/lutris/egs/game_logo") EGS_BANNERS_PATH = os.path.expanduser("~/.cache/lutris/egs/banners") EGS_BOX_ART_PATH = os.path.expanduser("~/.cache/lutris/egs/boxart") BANNER_SIZE = (316, 178) BOX_ART_SIZE = (200, 267) class DieselGameMedia(ServiceMedia): service = "egs" remote_size = (200, 267) file_pattern = "%s.jpg" min_logo_x = 300 min_logo_y = 150 def _render_filename(self, filename): game_box_path = os.path.join(self.dest_path, filename) logo_path = os.path.join(EGS_LOGO_PATH, filename.replace(".jpg", ".png")) has_logo = os.path.exists(logo_path) thumb_image = Image.open(game_box_path) thumb_image = thumb_image.convert("RGBA") thumb_image = thumbnail_image(thumb_image, self.remote_size) if has_logo: logo_image = Image.open(logo_path) logo_image = logo_image.convert("RGBA") logo_width, logo_height = logo_image.size if logo_width > self.min_logo_x: logo_image = logo_image.resize((self.min_logo_x, int( logo_height * (self.min_logo_x / logo_width))), resample=Image.BICUBIC) elif logo_height > self.min_logo_y: logo_image = logo_image.resize( (int(logo_width * (self.min_logo_y / logo_height)), self.min_logo_y), resample=Image.BICUBIC) thumb_image = paste_overlay(thumb_image, logo_image) thumb_path = os.path.join(self.dest_path, filename) thumb_image = thumb_image.convert("RGB") thumb_image.save(thumb_path) def get_media_url(self, detail): for image in detail.get("keyImages", []): if image["type"] == self.api_field: return image["url"] + "?w=%s&resize=1&h=%s" % ( self.remote_size[0], self.remote_size[1] ) class DieselGameBoxTall(DieselGameMedia): """EGS tall game box""" size = (200, 267) remote_size = size min_logo_x = 100 min_logo_y = 100 dest_path = os.path.join(settings.CACHE_DIR, "egs/game_box_tall") api_field = "DieselGameBoxTall" def render(self): for filename in os.listdir(self.dest_path): self._render_filename(filename) class DieselGameBoxSmall(DieselGameBoxTall): size = (100, 133) remote_size = (200, 267) class DieselGameBox(DieselGameBoxTall): """EGS game box""" size = (316, 178) remote_size = size min_logo_x = 300 min_logo_y = 150 dest_path = os.path.join(settings.CACHE_DIR, "egs/game_box") api_field = "DieselGameBox" class DieselGameBannerSmall(DieselGameBox): size = (158, 89) remote_size = (316, 178) class DieselGameBoxLogo(DieselGameMedia): """EGS game box""" size = (200, 100) remote_size = size file_pattern = "%s.png" 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") 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://www.epicgames.com/id/api/redirect" redirect_uri = "https://www.epicgames.com/id/api/redirect" launcher_url = "https://launcher-public-service-prod06.ol.epicgames.com" oauth_url = 'https://account-public-service-prod03.ol.epicgames.com' catalog_url = 'https://catalog-public-service-prod06.ol.epicgames.com' is_loading = False 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) 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["sid"] _session = requests.session() _session.headers.update({ 'X-Epic-Event-Action': 'login', 'X-Epic-Event-Category': 'login', 'X-Epic-Strategy-Flags': '', 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': self.user_agent }) _session.get('https://www.epicgames.com/id/api/set-sid', params={'sid': session_id}) _session.get('https://www.epicgames.com/id/api/csrf') response = _session.post( 'https://www.epicgames.com/id/api/exchange/generate', headers={'X-XSRF-TOKEN': _session.cookies['XSRF-TOKEN']} ) if response.status_code != 200: logger.error("Failed to connec to EGS (Status %s): %s", response.status_code, response.json()) return self.start_session(response.json()['code']) 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): if exchange_code: grant_type = 'exchange_code' token = exchange_code else: grant_type = 'refresh_token' token = self.session_data["refresh_token"] response = self.session.post( 'https://account-public-service-prod03.ol.epicgames.com/account/api/oauth/token', data={ 'grant_type': grant_type, grant_type: token, 'token_type': 'eg1' }, 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") 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/launcher/api/public/assets/Windows' % self.launcher_url, params={'label': 'Live'} ) response.raise_for_status() assets = response.json() games = [] for asset in assets: if asset["namespace"] == "ue": continue game_details = self.get_game_details(asset) games.append(game_details) return games def load(self): """Load the list of games""" if self.is_loading: logger.warning("EGS games are already loading") return self.is_loading = True try: library = self.get_library() except Exception as ex: # pylint=disable:broad-except self.is_loading = False logger.warning("EGS Token expired") raise AuthTokenExpired from ex egs_games = [] for game in library: egs_game = EGSGame.new_from_api(game) egs_game.save() egs_games.append(egs_game) self.is_loading = False return egs_games def install_from_egs(self, egs_game, manifest): """Create a new Lutris game based on an existing EGS install""" app_name = manifest["AppName"] logger.debug("Installing EGS game %s", app_name) service_game = ServiceGameCollection.get_game("egs", app_name) if not service_game: logger.error("Aborting install, %s is not present in the game library.", app_name) return lutris_game_id = slugify(service_game["name"]) + "-" + self.id existing_game = get_game_by_field(lutris_game_id, "installer_slug") if existing_game: return game_config = LutrisConfig(game_config_id=egs_game["configpath"]).game_level game_config["game"]["args"] = get_launch_arguments(app_name) configpath = write_game_config(lutris_game_id, game_config) game_id = add_game( name=service_game["name"], runner=egs_game["runner"], slug=slugify(service_game["name"]), directory=egs_game["directory"], installed=1, installer_slug=lutris_game_id, configpath=configpath, service=self.id, service_id=app_name, ) return game_id def add_installed_games(self): """Scan an existing EGS install for games""" egs_game = get_game_by_field("epic-games-store", "slug") if not egs_game: logger.error("EGS is not installed in Lutris") return egs_prefix = egs_game["directory"].split("drive_c")[0] logger.info("EGS detected in %s", egs_prefix) if not system.path_exists(os.path.join(egs_prefix, "drive_c")): logger.error("Invalid install of EGS at %s", egs_prefix) return egs_launcher = EGSLauncher(egs_prefix) for manifest in egs_launcher.iter_manifests(): self.install_from_egs(egs_game, manifest) logger.debug("All EGS games imported") def generate_installer(self, db_game, egs_db_game): egs_game = Game(egs_db_game["id"]) egs_exe = egs_game.config.game_config["exe"] if not os.path.isabs(egs_exe): egs_exe = os.path.join(egs_game.config.game_config["prefix"], egs_exe) return { "name": db_game["name"], "version": self.name, "slug": slugify(db_game["name"]) + "-" + self.id, "game_slug": slugify(db_game["name"]), "runner": self.runner, "appid": db_game["appid"], "script": { "requires": self.client_installer, "game": { "args": get_launch_arguments(db_game["appid"]), }, "installer": [ {"task": { "name": "wineexec", "executable": egs_exe, "args": get_launch_arguments(db_game["appid"], "install"), "prefix": egs_game.config.game_config["prefix"], "description": ( "The Epic Game Store will now open. Please launch " "the installation of %s then close the EGS client " "once the game has been downloaded." % db_game["name"] ) }} ] } } def install(self, db_game): egs_game = get_game_by_field(self.client_installer, "slug") application = Gio.Application.get_default() if not egs_game or not egs_game["installed"]: logger.warning("EGS (%s) not installed", self.client_installer) installers = get_installers( game_slug=self.client_installer, ) application.show_installer_window(installers) else: application.show_installer_window( [self.generate_installer(db_game, egs_game)], service=self, appid=db_game["appid"] ) def get_launch_arguments(app_name, action="launch"): return ( "-opengl" " -SkipBuildPatchPrereq" " -com.epicgames.launcher://apps/%s?action=%s" ) % (app_name, action) lutris-0.5.9.1/lutris/services/gog.py000066400000000000000000000436451413267435700175310ustar00rootroot00000000000000"""Module for handling the GOG service""" import json import os import time from collections import defaultdict from gettext import gettext as _ from urllib.parse import parse_qsl, urlencode, urlparse from lxml import etree import lutris.util.i18n as i18n from lutris import settings from lutris.exceptions import AuthenticationError, UnavailableGame 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 system from lutris.util.http import HTTPError, Request, UnauthorizedAccess from lutris.util.log import logger from lutris.util.strings import slugify class GogSmallBanner(ServiceMedia): """Small size game logo""" service = "gog" size = (100, 60) dest_path = os.path.join(settings.CACHE_DIR, "gog/banners/small") file_pattern = "%s.jpg" 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") is_loading = False def __init__(self): super().__init__() self.selected_extras = None gog_locales = { "en": "en-US", "de": "de-DE", "fr": "fr-FR", "pl": "pl-PL", "ru": "ru-RU", "zh": "zh-Hans", } self.locale = gog_locales.get(i18n.get_lang(), "en-US") @property def login_url(self): """Return authentication URL""" params = { "client_id": self.client_id, "layout": "client2", "redirect_uri": self.redirect_uri, "response_type": "code", } return "https://auth.gog.com/auth?" + urlencode(params) @property def credential_files(self): return [self.cookies_path, self.token_path] def is_connected(self): """Return whether the user is authenticated and if the service is available""" if not self.is_authenticated(): return False try: user_data = self.get_user_data() except UnauthorizedAccess: logger.warning("GOG token is invalid") return False return user_data and "username" in user_data def load(self): """Load the user game library from the GOG API""" if self.is_loading: logger.warning("GOG games are already loading") return if not self.is_connected(): logger.error("User not connected to GOG") return self.is_loading = True games = [GOGGame.new_from_gog_game(game) for game in self.get_library()] for game in games: game.save() self.match_games() self.is_loading = False 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: logger.error("Failed to get token, check your GOG credentials.") logger.warning("Clearing existing credentials") self.logout() return token = request.json with open(self.token_path, "w") 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) 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() return request.json def make_api_request(self, url): """Send a token authenticated request to GOG""" try: token = self.load_token() except AuthenticationError: return if self.get_token_age() > 2600: self.request_token(refresh_token=token["refresh_token"]) token = self.load_token() if not token: logger.warning( "Request to %s cancelled because the GOG token could not be acquired", url, ) return headers = {"Authorization": "Bearer " + token["access_token"]} request = Request(url, headers=headers, cookies=self.load_cookies()) try: request.get() except HTTPError: logger.error( "Failed to request %s, check your GOG credentials and internet connectivity", 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") 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") 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_details(self, product_id): """Return game information for a given game""" if not product_id: raise ValueError("Missing product ID") logger.info("Getting game details for %s", product_id) url = "{}/products/{}?expand=downloads&locale={}".format(self.api_url, product_id, self.locale) return self.make_api_request(url) def get_download_info(self, downlink): """Return file download information""" logger.info("Getting download info for %s", downlink) try: response = self.make_api_request(downlink) except HTTPError as ex: logger.error("HTTP error: %s", ex) raise UnavailableGame if not response: raise UnavailableGame for field in ("checksum", "downlink"): field_url = response[field] parsed = urlparse(field_url) query = dict(parse_qsl(parsed.query)) response[field + "_filename"] = os.path.basename(query.get("path") or parsed.path) return response def get_downloads(self, gogid): """Return all available downloads for a GOG ID""" 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""" downloads = self.get_downloads(gogid) return [ { "name": download.get("name", ""), "type": download.get("type", ""), "total_size": download.get("total_size", 0), "id": str(download["id"]), } for download in downloads.get("bonus_content") or [] ] 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["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: if runner == "linux": filter_os = "windows" else: filter_os = "linux" gog_installers = [installer for installer in gog_installers if installer["os"] != filter_os] language = self.determine_language_installer(gog_installers, language) gog_installers = [installer for installer in gog_installers if installer["language"] == language] return gog_installers def determine_language_installer(self, gog_installers, default_language): """Return locale language string if available in gog_installers""" language = i18n.get_lang() gog_installers = [installer for installer in gog_installers if installer["language"] == language] if not gog_installers: language = default_language return language def query_download_links(self, download): """Convert files from the GOG API to a format compatible with lutris installers""" download_links = [] for game_file in download.get("files", []): downlink = game_file.get("downlink") if not downlink: logger.error("No download information for %s", game_file) continue download_info = self.get_download_info(downlink) for field in ('checksum', 'downlink'): download_links.append({ "name": download.get("name", ""), "os": download.get("os", ""), "type": download.get("type", ""), "total_size": download.get("total_size", 0), "id": str(game_file["id"]), "url": download_info[field], "filename": download_info[field + "_filename"] }) return download_links def get_extra_files(self, downloads, installer): extra_files = [] for extra in downloads["bonus_content"]: if str(extra["id"]) not in self.selected_extras: continue links = self.query_download_links(extra) for link in links: if link["filename"].endswith(".xml"): # GOG gives a link for checksum XML files for bonus content # but downloading them results in a 404 error. continue extra_files.append( InstallerFile(installer.game_slug, str(extra["id"]), { "url": link["url"], "filename": link["filename"], }) ) return extra_files def get_installer_files(self, installer, installer_file_id): try: downloads = self.get_downloads(installer.service_appid) 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] links = self.query_download_links(_installer) except HTTPError: raise UnavailableGame("Couldn't load the download links for this game") if not links: raise UnavailableGame("Could not fing GOG game") _installer_files = defaultdict(dict) # keyed by filename for link in links: filename = link["filename"] 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") })) if not file_id_provided: raise UnavailableGame("Unable to determine correct file to launch installer") if self.selected_extras: for extra_file in self.get_extra_files(downloads, installer): files.append(extra_file) return files def read_file_checksum(self, file_path): """Return the MD5 checksum for a GOG file Requires a GOG XML file as input This has yet to be used. """ if not file_path.endswith(".xml"): raise ValueError("Pass a XML file to return the checksum") with open(file_path) as checksum_file: checksum_content = checksum_file.read() root_elem = etree.fromstring(checksum_content) return (root_elem.attrib["name"], root_elem.attrib["md5"]) def generate_installer(self, db_game): details = json.loads(db_game["details"]) platforms = [platform.lower() for platform, is_supported in details["worksOn"].items() if is_supported] system_config = {} if "linux" in platforms: runner = "linux" game_config = {"exe": AUTO_ELF_EXE} script = [ {"extract": {"file": "goginstaller", "format": "zip", "dst": "$CACHE"}}, {"merge": {"src": "$CACHE/data/noarch", "dst": "$GAMEDIR"}}, ] else: runner = "wine" game_config = {"exe": AUTO_WIN32_EXE} script = [ {"autosetup_gog_game": "goginstaller"}, ] return { "name": db_game["name"], "version": "GOG", "slug": details["slug"], "game_slug": slugify(db_game["name"]), "runner": runner, "gogid": db_game["appid"], "script": { "game": game_config, "system": system_config, "files": [ {"goginstaller": "N/A:Select the installer from GOG"} ], "installer": script } } lutris-0.5.9.1/lutris/services/humblebundle.py000066400000000000000000000336421413267435700214170ustar00rootroot00000000000000"""Manage Humble Bundle libraries""" import concurrent.futures import json import os from gettext import gettext as _ from lutris import settings from lutris.exceptions import UnavailableGame from lutris.installer import AUTO_ELF_EXE, AUTO_WIN32_EXE from lutris.installer.installer_file import InstallerFile from lutris.services.base import OnlineService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util import linux from lutris.util.http import HTTPError, Request from lutris.util.log import logger from lutris.util.strings import slugify class HumbleBundleIcon(ServiceMedia): """HumbleBundle icon""" service = "humblebundle" size = (70, 70) dest_path = os.path.join(settings.CACHE_DIR, "humblebundle/icons") file_pattern = "%s.png" 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") is_loading = False 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""" if self.is_loading: logger.warning("Humble bundle games are already loading") return self.is_loading = True try: library = self.get_library() except ValueError: logger.error("Failed to get Humble Bundle library. Try logging out and back-in.") return 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() self.is_loading = False return humble_games def make_api_request(self, url): """Make an authenticated request to the Humble API""" request = Request(url, cookies=self.load_cookies()) try: request.get() except HTTPError: logger.error( "Failed to request %s, check your Humble Bundle credentials and internet connectivity", 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) as cache_file: return json.load(cache_file) response = self.make_api_request(self.api_url + "api/v1/order/%s?all_tpkds=true" % gamekey) if not os.path.exists(self.cache_path): os.makedirs(self.cache_path) with open(cache_filename, "w") 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): """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 UnavailableGame if not link: raise UnavailableGame("No game found on Humble Bundle") filename = link.split("?")[0].split("/")[-1] return [ InstallerFile(installer.game_slug, installer_file_id, { "url": link, "filename": filename }) ] @staticmethod def get_filename_for_platform(downloads, platform): download = [d for d in downloads if d["platform"] == platform][0] url = pick_download_url_from_download_info(download) if not url: return return url.split("?")[0].split("/")[-1] @staticmethod def platform_has_downloads(downloads, platform): for download in downloads: if download["platform"] != platform: continue if len(download["download_struct"]) > 0: return True def generate_installer(self, db_game): details = json.loads(db_game["details"]) platforms = [download["platform"] for download in details["downloads"]] system_config = {} if "linux" in platforms and self.platform_has_downloads(details["downloads"], "linux"): runner = "linux" game_config = {"exe": AUTO_ELF_EXE} filename = self.get_filename_for_platform(details["downloads"], "linux") if filename.lower().endswith(".sh"): script = [ {"extract": {"file": "humblegame", "format": "zip", "dst": "$CACHE"}}, {"merge": {"src": "$CACHE/data/noarch", "dst": "$GAMEDIR", "optional": True}}, {"move": {"src": "$CACHE/data/noarch", "dst": "$CACHE/noarch", "optional": True}}, {"merge": {"src": "$CACHE/data/x86_64", "dst": "$GAMEDIR", "optional": True}}, {"move": {"src": "$CACHE/data/x86_64", "dst": "$CACHE/x86_64", "optional": True}}, {"merge": {"src": "$CACHE/data/x86", "dst": "$GAMEDIR", "optional": True}}, {"move": {"src": "$CACHE/data/x86", "dst": "$CACHE/x86", "optional": True}}, {"merge": {"src": "$CACHE/data/", "dst": "$GAMEDIR", "optional": True}}, ] elif filename.endswith("-bin") or filename.endswith("mojo.run"): script = [ {"extract": {"file": "humblegame", "format": "zip", "dst": "$CACHE"}}, {"merge": {"src": "$CACHE/data/", "dst": "$GAMEDIR"}}, ] elif filename.endswith(".air"): script = [ {"move": {"src": "humblegame", "dst": "$GAMEDIR"}}, ] else: script = [{"extract": {"file": "humblegame"}}] system_config = {"gamemode": 'false'} # Unity games crash with gamemode elif "windows" in platforms: runner = "wine" game_config = {"exe": AUTO_WIN32_EXE, "prefix": "$GAMEDIR"} filename = self.get_filename_for_platform(details["downloads"], "windows") if filename.lower().endswith(".zip"): script = [ {"task": {"name": "create_prefix", "prefix": "$GAMEDIR"}}, {"extract": {"file": "humblegame", "dst": "$GAMEDIR/drive_c/%s" % db_game["slug"]}} ] else: script = [ {"task": {"name": "wineexec", "executable": "humblegame"}} ] else: logger.warning("Unsupported platforms: %s", platforms) return {} return { "name": db_game["name"], "version": "Humble Bundle", "slug": details["machine_name"], "game_slug": slugify(db_game["name"]), "runner": runner, "humbleid": db_game["appid"], "script": { "game": game_config, "system": system_config, "files": [ {"humblegame": "N/A:Select the installer from Humble Bundle"} ], "installer": script } } def pick_download_url_from_download_info(download_info): """From a list of downloads in Humble Bundle, pick the most appropriate one for the installer. This needs a way to be explicitely filtered. """ if not download_info["download_struct"]: logger.warning("No downloads found") return def humble_sort(download): name = download["name"] if "rpm" in name: return -99 # Not supported as an extractor bonus = 1 if "deb" not in name: bonus = 2 if linux.LINUX_SYSTEM.is_64_bit: if "386" in name or "32" in name: return -1 else: if "64" in name: return -10 return 1 * bonus sorted_downloads = sorted(download_info["download_struct"], key=humble_sort, reverse=True) logger.debug("Humble bundle installers:") for download in sorted_downloads: logger.debug(download) return sorted_downloads[0]["url"]["web"] def get_humble_download_link(humbleid, runner): """Return a download link for a given humbleid and runner""" service = HumbleBundleService() platform = runner if runner != "wine" else "windows" downloads = service.get_downloads(humbleid, platform) if not downloads: logger.error("Game %s for %s not found in the Humble Bundle library", humbleid, platform) return logger.info("Found %s download for %s", len(downloads), humbleid) download = downloads[0] logger.info("Reloading order %s", download["product"]["human_name"]) os.remove(service.order_path(download["gamekey"])) order = service.get_order(download["gamekey"]) download_info = service.find_download_in_order(order, humbleid, platform) if download_info: return pick_download_url_from_download_info(download_info["download"]) logger.warning("Couldn't retrieve any downloads for %s", humbleid) lutris-0.5.9.1/lutris/services/itchio.py000066400000000000000000000004231413267435700202170ustar00rootroot00000000000000"""Itch.io service. Not ready yet. """ from gettext import gettext as _ from lutris.services.base import OnlineService class ItchIoService(OnlineService): """Service class for Itch.io""" id = "itchio" name = _("Itch.io (Not implemented)") icon = "itchio" lutris-0.5.9.1/lutris/services/lutris.py000066400000000000000000000111731413267435700202660ustar00rootroot00000000000000import json import os import urllib.parse from gettext import gettext as _ from gi.repository import Gio from lutris import settings from lutris.api import read_api_key from lutris.database.games import get_games from lutris.database.services import ServiceGameCollection from lutris.gui import dialogs from lutris.gui.views.media_loader import download_icons from lutris.installer import fetch_script from lutris.services.base import LutrisBanner, LutrisIcon, OnlineService from lutris.services.service_game import ServiceGame from lutris.util import http from lutris.util.log import logger class LutrisGame(ServiceGame): """Service game created from the Lutris API""" service = "lutris" @classmethod def new_from_api(cls, api_payload): """Create an instance of LutrisGame from the API response""" service_game = LutrisGame() service_game.appid = api_payload['slug'] service_game.slug = api_payload['slug'] service_game.name = api_payload['name'] service_game.details = json.dumps(api_payload) return service_game class LutrisService(OnlineService): """Service for Lutris games""" id = "lutris" name = _("Lutris") icon = "lutris" online = True medias = { "icon": LutrisIcon, "banner": LutrisBanner, } 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") is_loading = False @property def credential_files(self): """Return a list of all files used for authentication""" return [self.token_path] def match_games(self): """Matching lutris games is much simpler... No API call needed.""" service_games = { str(game["appid"]): game for game in ServiceGameCollection.get_for_service(self.id) } for lutris_game in get_games(): self.match_game(service_games.get(lutris_game["slug"]), lutris_game) def is_connected(self): """Is the service connected?""" return self.is_authenticated() def login(self, parent=None): """Connect to Lutris""" login_dialog = dialogs.ClientLoginDialog(parent=parent) login_dialog.connect("connected", self.on_connect_success) def on_connect_success(self, _widget, _username): """Handles connection success""" self.emit("service-login") def get_library(self): """Return the remote library as a list of dicts.""" credentials = read_api_key() if not credentials: return [] url = settings.SITE_URL + "/api/games/library/%s" % urllib.parse.quote(credentials["username"]) request = http.Request(url, headers={"Authorization": "Token " + credentials["token"]}) try: response = request.get() except http.HTTPError as ex: logger.error("Unable to load library: %s", ex) return [] response_data = response.json if response_data: return response_data["games"] return [] def load(self): if self.is_loading: logger.warning("Lutris games are already loading") return self.is_loading = True lutris_games = self.get_library() 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() self.is_loading = False logger.debug("Lutris games loaded") return lutris_games def install(self, db_game): if isinstance(db_game, dict): slug = db_game["slug"] else: slug = db_game installers = fetch_script(slug) if not installers: logger.warning("No installer for %s", slug) return application = Gio.Application.get_default() application.show_installer_window(installers) def download_lutris_media(slug): """Downloads the banner and icon for a given lutris game""" url = settings.SITE_URL + "/api/games/%s" % slug request = http.Request(url) try: response = request.get() except http.HTTPError as ex: logger.debug("Unable to load %s: %s", slug, ex) return response_data = response.json icon_url = response_data.get("icon_url") if icon_url: download_icons({slug: icon_url}, LutrisIcon()) banner_url = response_data.get("banner_url") if banner_url: download_icons({slug: banner_url}, LutrisBanner()) lutris-0.5.9.1/lutris/services/mame.py000066400000000000000000000003521413267435700176600ustar00rootroot00000000000000"""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.9.1/lutris/services/origin.py000066400000000000000000000077351413267435700202440ustar00rootroot00000000000000"""EA Origin service. Not ready yet. """ import json import os import random from gettext import gettext as _ from xml.etree import ElementTree import requests from lutris import settings from lutris.services.base import OnlineService from lutris.services.service_media import ServiceMedia from lutris.util.log import logger class OriginGameBox(ServiceMedia): service = "origin" file_pattern = "%s.jpg" size = (256, 284) dest_path = os.path.join(settings.CACHE_DIR, "origin/game_box") api_field = "boxart" class OriginService(OnlineService): """Service class for EA Origin""" id = "origin" name = _("Origin (WIP)") icon = "origin" online = True medias = { "game_box": OriginGameBox, } default_format = "game_box" 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 def __init__(self): super().__init__() self.session = requests.session() @property def api_url(self): return "https://api%s.origin.com" % random.randint(1, 4) def login_callback(self, url): token = self.get_access_token() if not token: raise RuntimeError("Failed to get access token") with open(self.token_path, "w") as token_file: token_file.write(json.dumps(token, indent=2)) self.emit("service-login") 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() if "error" in token_data: raise RuntimeError( "%s (Error code: %s)" % (token_data["error"], token_data["error_number"]) ) return token_data def get_identity(self): """Request the user info""" response = self.session.get("https://gateway.ea.com/proxy/identity/pids/me", cookies=self.load_cookies()) identity_data = response.json() user_id = identity_data["pid"]["pidId"] persona_id_response = self.session.get( "{}/atom/users?userIds={}".format(self.api_url, user_id) ) 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.debug(games) def get_library(self, user_id): """Request the user's library""" url = "%s/ecommerce2/consolidatedentitlements/%s?machine_hash=1" % ( self.api_url, user_id ) 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, access_token): """Return headers needed to authenticate HTTP requests""" return { "Authorization": "Bearer %s" % access_token, "AuthToken": access_token, "X-AuthToken": access_token } lutris-0.5.9.1/lutris/services/scummvm.py000066400000000000000000000016561413267435700204400ustar00rootroot00000000000000"""Legacy ScummVM 'service', has to be ported to the current architecture""" import os import re from configparser import ConfigParser from gettext import gettext as _ from lutris.util import system from lutris.util.log import logger NAME = _("ScummVM") ICON = "scummvm" ONLINE = False SCUMMVM_CONFIG_FILE = os.path.join(os.path.expanduser("~/.config/scummvm"), "scummvm.ini") def get_scummvm_games(): """Return the available ScummVM games""" if not system.path_exists(SCUMMVM_CONFIG_FILE): logger.info("No ScummVM config found") return [] config = ConfigParser() config.read(SCUMMVM_CONFIG_FILE) config_sections = config.sections() for section in config_sections: if section == "scummvm": continue scummvm_id = section name = re.split(r" \(.*\)$", config[section]["description"])[0] path = config[section]["path"] yield (scummvm_id, name, path) lutris-0.5.9.1/lutris/services/service_game.py000066400000000000000000000027731413267435700214030ustar00rootroot00000000000000"""Service game module""" from lutris import settings from lutris.database import sql from lutris.database.services import ServiceGameCollection from lutris.services.service_media import ServiceMedia PGA_DB = settings.PGA_DB class ServiceGame: """Representation of a game from a 3rd party service""" service = NotImplemented installer_slug = NotImplemented medias = (ServiceMedia, ) def __init__(self): self.appid = None # External ID of the game on the 3rd party service self.game_id = None # Internal Lutris ID self.runner = None # Name of the runner self.name = None # Name self.slug = None # Game slug self.lutris_slug = None # Slug used by the lutris website self.logo = None # Game logo self.icon = None # Game icon self.details = None # Additional details for the game def save(self): """Save this game to database""" game_data = { "service": self.service, "appid": self.appid, "name": self.name, "slug": self.slug, "lutris_slug": self.lutris_slug, "icon": self.icon, "logo": self.logo, "details": str(self.details), } existing_game = ServiceGameCollection.get_game(self.service, self.appid) if existing_game: sql.db_update(PGA_DB, "service_games", game_data, {"id": existing_game["id"]}) else: sql.db_insert(PGA_DB, "service_games", game_data) lutris-0.5.9.1/lutris/services/service_media.py000066400000000000000000000061621413267435700215450ustar00rootroot00000000000000import json import os import random import time from lutris import settings from lutris.database.services import ServiceGameCollection from lutris.util import system from lutris.util.http import HTTPError, download_file from lutris.util.log import logger PGA_DB = settings.PGA_DB class ServiceMedia: """Information about the service's media format""" service = NotImplemented size = NotImplemented source = "remote" # set to local if the files don't need to be downloaded visible = True # This media should be displayed as an option in the UI small_size = None dest_path = None file_pattern = NotImplemented api_field = NotImplemented url_pattern = "%s" def __init__(self): if self.dest_path and not system.path_exists(self.dest_path): os.makedirs(self.dest_path) def get_filename(self, slug): return self.file_pattern % slug def get_absolute_path(self, slug): """Return the abolute path of a local media""" return os.path.join(self.dest_path, self.get_filename(slug)) def exists(self, slug): """Whether the icon for the specified slug exists locally""" return system.path_exists(self.get_absolute_path(slug)) def get_url(self, service_game): return self.url_pattern % service_game[self.api_field] 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 os.unlink(cache_path) try: return download_file(url, cache_path, raise_errors=True) except HTTPError as ex: if ex.code == 404: open(cache_path, "a").close() else: logger.error(ex.code) return None return cache_path def render(self): """Used if the media requires extra processing""" lutris-0.5.9.1/lutris/services/steam.py000066400000000000000000000167331413267435700200640ustar00rootroot00000000000000"""Steam service""" import json import os from collections import defaultdict from gettext import gettext as _ from gi.repository import Gio from lutris import settings from lutris.config import LutrisConfig, write_game_config from lutris.database.games import add_game, get_game_by_field, get_games from lutris.database.services import ServiceGameCollection from lutris.game import Game from lutris.installer.installer_file import InstallerFile from lutris.services.base import BaseService from lutris.services.service_game import ServiceGame from lutris.services.service_media import ServiceMedia from lutris.util.log import logger from lutris.util.steam.appmanifest import AppManifest, get_appmanifests from lutris.util.steam.config import get_steam_library, get_steamapps_paths, get_user_steam_id from lutris.util.strings import slugify class SteamBanner(ServiceMedia): service = "steam" size = (184, 69) dest_path = os.path.join(settings.CACHE_DIR, "steam/banners") file_pattern = "%s.jpg" api_field = "appid" url_pattern = "http://cdn.akamai.steamstatic.com/steam/apps/%s/capsule_184x69.jpg" class SteamCover(ServiceMedia): service = "steam" size = (200, 300) dest_path = os.path.join(settings.CACHE_DIR, "steam/covers") file_pattern = "%s.jpg" api_field = "appid" url_pattern = "http://cdn.steamstatic.com/steam/apps/%s/library_600x900.jpg" class SteamBannerLarge(ServiceMedia): service = "steam" size = (460, 215) dest_path = os.path.join(settings.CACHE_DIR, "steam/header") file_pattern = "%s.jpg" api_field = "appid" url_pattern = "https://cdn.cloudflare.steamstatic.com/steam/apps/%s/header.jpg" class SteamGame(ServiceGame): """ServiceGame for Steam games""" service = "steam" installer_slug = "steam" runner = "steam" @classmethod def new_from_steam_game(cls, steam_game, game_id=None): """Return a Steam game instance from an AppManifest""" game = cls() game.appid = steam_game["appid"] game.game_id = steam_game["appid"] game.name = steam_game["name"] game.slug = slugify(steam_game["name"]) game.runner = cls.runner game.details = json.dumps(steam_game) return game class SteamService(BaseService): id = "steam" name = _("Steam") icon = "steam-client" medias = { "banner": SteamBanner, "banner_large": SteamBannerLarge, "cover": SteamCover, } default_format = "banner" is_loading = False 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""" if self.is_loading: logger.warning("Steam games are already loading") return self.is_loading = True steamid = get_user_steam_id() 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() self.is_loading = False return steam_games def get_installer_files(self, installer, installer_file_id): steam_uri = "$STEAM:%s:." appid = str(installer.script["game"]["appid"]) return [ InstallerFile(installer.game_slug, "steam_game", { "url": steam_uri % appid, "filename": appid }) ] def install_from_steam(self, manifest): """Create a new Lutris game based on an existing Steam install""" if not manifest.is_installed(): return appid = manifest.steamid if appid in self.excluded_appids: return service_game = ServiceGameCollection.get_game(self.id, appid) if not service_game: return lutris_game_id = "%s-%s" % (self.id, appid) existing_game = get_game_by_field(lutris_game_id, "installer_slug") if existing_game: return game_config = LutrisConfig().game_level game_config["game"]["appid"] = appid configpath = write_game_config(lutris_game_id, game_config) game_id = add_game( name=service_game["name"], runner="steam", slug=slugify(service_game["name"]), installed=1, installer_slug=lutris_game_id, configpath=configpath, platform="Linux", service=self.id, service_id=appid, ) return game_id @property def steamapps_paths(self): return get_steamapps_paths() def add_installed_games(self): """Syncs installed Steam games with Lutris""" installed_appids = [] for steamapps_path in self.steamapps_paths: for appmanifest_file in get_appmanifests(steamapps_path): app_manifest_path = os.path.join(steamapps_path, appmanifest_file) app_manifest = AppManifest(app_manifest_path) installed_appids.append(app_manifest.steamid) self.install_from_steam(app_manifest) db_games = get_games(filters={"runner": "steam"}) for db_game in db_games: steam_game = Game(db_game["id"]) appid = steam_game.config.game_level["game"]["appid"] if appid not in installed_appids: steam_game.remove(no_signal=True) 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: steam_game.remove(no_signal=True) steam_game.delete() def generate_installer(self, db_game): """Generate a basic Steam installer""" return { "name": db_game["name"], "version": self.name, "slug": slugify(db_game["name"]) + "-" + self.id, "game_slug": slugify(db_game["name"]), "runner": self.runner, "appid": db_game["appid"], "script": { "game": {"appid": db_game["appid"]} } } def install(self, db_game): appid = db_game["appid"] db_games = get_games(filters={"service_id": appid, "installed": "1", "service": self.id}) existing_game = self.match_existing_game(db_games, appid) if existing_game: logger.debug("Found steam game: %s", existing_game) game = Game(existing_game.id) game.save() return service_installers = self.get_installers_from_api(appid) if not service_installers: service_installers = [self.generate_installer(db_game)] application = Gio.Application.get_default() application.show_installer_window(service_installers, service=self, appid=appid) lutris-0.5.9.1/lutris/services/steamwindows.py000066400000000000000000000060551413267435700214730ustar00rootroot00000000000000import os from gettext import gettext as _ from gi.repository import Gio from lutris.database.games import get_game_by_field, get_games from lutris.game import Game from lutris.installer import get_installers from lutris.services.steam import SteamGame, SteamService from lutris.util import system from lutris.util.log import logger from lutris.util.strings import slugify STEAM_INSTALLER = "steam-wine" # Lutris installer used to setup the Steam client class SteamWindowsGame(SteamGame): service = "steamwindows" installer_slug = "steamwindows" runner = "wine" class SteamWindowsService(SteamService): id = "steamwindows" name = _("Steam for Windows") runner = "wine" game_class = SteamWindowsGame client_installer = "steam-wine" def generate_installer(self, db_game, steam_game): """Generate a basic Steam installer""" return { "name": db_game["name"], "version": self.name, "slug": slugify(db_game["name"]) + "-" + self.id, "game_slug": slugify(db_game["name"]), "runner": self.runner, "appid": db_game["appid"], "script": { "requires": self.client_installer, "game": { "exe": steam_game.config.game_config["exe"], "args": "-no-cef-sandbox -applaunch %s" % db_game["appid"], "prefix": steam_game.config.game_config["prefix"], } } } def get_steam(self): db_entry = get_game_by_field(self.client_installer, "installer_slug") if db_entry: return Game(db_entry["id"]) def install(self, db_game): steam_game = self.get_steam() if not steam_game: installers = get_installers( game_slug=self.client_installer, ) else: installers = [self.generate_installer(db_game, steam_game)] appid = db_game["appid"] db_games = get_games(filters={"service_id": appid, "installed": "1", "service": self.id}) existing_game = self.match_existing_game(db_games, appid) if existing_game: logger.debug("Found steam game: %s", existing_game) game = Game(existing_game.id) game.save() return application = Gio.Application.get_default() application.show_installer_window( installers, service=self, appid=appid ) @property def steamapps_paths(self): """Return steamapps paths""" steam_game = self.get_steam() if not steam_game: return [] dirs = [] steam_path = steam_game.config.game_config["exe"] steam_data_dir = os.path.dirname(steam_path) if steam_data_dir: main_dir = os.path.join(steam_data_dir, "steamapps") main_dir = system.fix_path_case(main_dir) if main_dir and os.path.isdir(main_dir): dirs.append(os.path.abspath(main_dir)) return dirs lutris-0.5.9.1/lutris/services/tosec.py000066400000000000000000000003601413267435700200550ustar00rootroot00000000000000"""TOSEC service Not ready yet""" from gettext import gettext as _ from lutris.services.base import BaseService class TOSECService(BaseService): """Service class for TOSEC""" id = "tosec" name = _("TOSEC") icon = "tosec" lutris-0.5.9.1/lutris/services/ubisoft.py000066400000000000000000000004651413267435700204210ustar00rootroot00000000000000"""Ubisoft Connect service. Not ready yet. """ from gettext import gettext as _ from lutris.services.base import OnlineService class UbisoftConnectService(OnlineService): """Service class for Ubisoft Connect""" id = "ubisoft" name = _("Ubisoft Connect (Not implemented)") icon = "ubisoft" lutris-0.5.9.1/lutris/services/xdg.py000066400000000000000000000123551413267435700175310ustar00rootroot00000000000000"""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.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) class XDGService(BaseService): id = "xdg" name = _("Local") icon = "linux" online = False local = True medias = { "icon": XDGMedia } ignored_games = ("lutris", ) ignored_executables = ("lutris", "steam") ignored_categories = ("Emulator", "Development", "Utility") @classmethod def iter_xdg_games(cls): """Iterates through XDG games only""" for app in Gio.AppInfo.get_all(): if cls._is_importable(app): yield app @property def lutris_games(self): """Iterates through Lutris games imported from XDG""" for game in get_games_where(runner=XDGGame.runner, installer_slug=XDGGame.installer_slug, installed=1): yield game @classmethod def _is_importable(cls, app): """Returns whether a XDG game is importable to Lutris""" appid = get_appid(app) executable = app.get_executable() or "" if any( [ app.get_nodisplay() or app.get_is_hidden(), # App is hidden not executable, # Check app has an executable appid.startswith("net.lutris"), # Skip lutris created shortcuts appid.lower() in map(str.lower, cls.ignored_games), # game blacklisted executable.lower() in cls.ignored_executables, # exe blacklisted ] ): return False # must be in Game category categories = app.get_categories() or "" categories = list(filter(None, categories.lower().split(";"))) if "game" not in categories: return False # contains a blacklisted category if bool([category for category in categories if category in map(str.lower, cls.ignored_categories)]): return False return True def match_games(self): """XDG games aren't on the lutris website""" return def load(self): """Return the list of games stored in the XDG menu.""" xdg_games = [XDGGame.new_from_xdg_app(app) for app in self.iter_xdg_games()] for game in xdg_games: game.save() return xdg_games def generate_installer(self, db_game): details = json.loads(db_game["details"]) return { "name": db_game["name"], "version": "XDG", "slug": db_game["slug"], "game_slug": slugify(db_game["name"]), "runner": "linux", "script": { "game": { "exe": details["exe"], "args": details["args"], }, "system": {"disable_runtime": True} } } def get_game_directory(self, installer): """Pull install location from installer""" return os.path.dirname(installer["script"]["game"]["exe"]) class XDGGame(ServiceGame): """XDG game (Linux game with a desktop launcher)""" service = "xdg" runner = "linux" installer_slug = "desktopapp" @staticmethod def get_app_icon(xdg_app): """Return the name of the icon for an XDG app if one if set""" icon = xdg_app.get_icon() if not icon: return "" return icon.to_string() @classmethod def new_from_xdg_app(cls, xdg_app): """Create a service game from a XDG entry""" service_game = cls() service_game.name = xdg_app.get_display_name() service_game.icon = cls.get_app_icon(xdg_app) service_game.appid = get_appid(xdg_app) service_game.slug = cls.get_slug(xdg_app) exe, args = cls.get_command_args(xdg_app) service_game.details = json.dumps({ "exe": exe, "args": args, }) return service_game @staticmethod def get_command_args(app): """Return a tuple with absolute command path and an argument string""" command = shlex.split(app.get_commandline()) # remove %U etc. and change %% to % in arguments args = list(map(lambda arg: re.sub("%[^%]", "", arg).replace("%%", "%"), command[1:])) exe = command[0] if not exe.startswith("/"): exe = system.find_executable(exe) return exe, subprocess.list2cmdline(args) @staticmethod def get_slug(xdg_app): """Get the slug from the game name""" return slugify(xdg_app.get_display_name()) or slugify(get_appid(xdg_app)) lutris-0.5.9.1/lutris/settings.py000066400000000000000000000033721413267435700167630ustar00rootroot00000000000000"""Internal settings.""" import os import sys from gettext import gettext as _ from gi.repository import GLib from lutris import __version__ from lutris.util.settings import SettingsIO PROJECT = "Lutris" VERSION = __version__ COPYRIGHT = _("(c) 2010-2021 Lutris Team") AUTHORS = [_("The Lutris team")] # Paths CONFIG_DIR = os.path.join(GLib.get_user_config_dir(), "lutris") CONFIG_FILE = os.path.join(CONFIG_DIR, "lutris.conf") DATA_DIR = os.path.join(GLib.get_user_data_dir(), "lutris") RUNNER_DIR = os.path.join(DATA_DIR, "runners") RUNTIME_DIR = os.path.join(DATA_DIR, "runtime") CACHE_DIR = os.path.join(GLib.get_user_cache_dir(), "lutris") GAME_CONFIG_DIR = os.path.join(CONFIG_DIR, "games") TMP_PATH = os.path.join(CACHE_DIR, "tmp") BANNER_PATH = os.path.join(DATA_DIR, "banners") COVERART_PATH = os.path.join(DATA_DIR, "coverart") ICON_PATH = os.path.join(GLib.get_user_data_dir(), "icons", "hicolor", "128x128", "apps") sio = SettingsIO(CONFIG_FILE) if "nosetests" in sys.argv[0] or "pytest" in sys.argv[0]: PGA_DB = "/tmp/pga.db" else: PGA_DB = sio.read_setting("pga_path") or os.path.join(DATA_DIR, "pga.db") SITE_URL = sio.read_setting("website") or "https://lutris.net" DRIVER_HOWTO_URL = "https://github.com/lutris/docs/blob/master/InstallingDrivers.md" INSTALLER_URL = SITE_URL + "/api/installers/%s" # XXX change this, should query on the installer, not the game. INSTALLER_REVISION_URL = SITE_URL + "/api/installers/games/%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" DISCORD_CLIENT_ID = sio.read_setting("discord_client_id") or "618290412402114570" read_setting = sio.read_setting write_setting = sio.write_setting lutris-0.5.9.1/lutris/startup.py000066400000000000000000000157421413267435700166310ustar00rootroot00000000000000"""Check to run at program start""" import os import sqlite3 import time from gettext import gettext as _ 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.gui.dialogs import DontShowAgainDialog from lutris.runners.json import load_json_runners from lutris.runtime import RuntimeUpdater from lutris.services import DEFAULT_SERVICES from lutris.util.graphics import drivers, vkquery from lutris.util.linux import LINUX_SYSTEM from lutris.util.log import logger from lutris.util.system import create_folder from lutris.util.wine.d3d_extras import D3DExtrasManager from lutris.util.wine.dxvk import DXVKManager from lutris.util.wine.dxvk_nvapi import DXVKNVAPIManager from lutris.util.wine.vkd3d import VKD3DManager def init_dirs(): """Creates Lutris directories""" directories = [ settings.CONFIG_DIR, os.path.join(settings.CONFIG_DIR, "runners"), os.path.join(settings.CONFIG_DIR, "games"), settings.DATA_DIR, os.path.join(settings.DATA_DIR, "covers"), settings.ICON_PATH, os.path.join(settings.DATA_DIR, "banners"), os.path.join(settings.DATA_DIR, "coverart"), os.path.join(settings.DATA_DIR, "runners"), os.path.join(settings.DATA_DIR, "lib"), settings.RUNTIME_DIR, settings.CACHE_DIR, os.path.join(settings.CACHE_DIR, "installer"), os.path.join(settings.CACHE_DIR, "tmp"), ] for directory in directories: create_folder(directory) def check_driver(): """Report on the currently running driver""" driver_info = {} if drivers.is_nvidia(): driver_info = drivers.get_nvidia_driver_info() # pylint: disable=logging-format-interpolation logger.info("Using {vendor} drivers {version} for {arch}".format(**driver_info["nvrm"])) gpus = drivers.get_nvidia_gpu_ids() for gpu_id in gpus: gpu_info = drivers.get_nvidia_gpu_info(gpu_id) logger.info("GPU: %s", gpu_info.get("Model")) elif LINUX_SYSTEM.glxinfo: # pylint: disable=no-member if hasattr(LINUX_SYSTEM.glxinfo, "GLX_MESA_query_renderer"): logger.info( "Running %s Mesa driver %s on %s", LINUX_SYSTEM.glxinfo.opengl_vendor, LINUX_SYSTEM.glxinfo.GLX_MESA_query_renderer.version, LINUX_SYSTEM.glxinfo.GLX_MESA_query_renderer.device, ) else: logger.warning("glxinfo is not available on your system, unable to detect driver version") for card in drivers.get_gpus(): # pylint: disable=logging-format-interpolation try: logger.info("GPU: {PCI_ID} {PCI_SUBSYS_ID} ({DRIVER} drivers)".format(**drivers.get_gpu_info(card))) except KeyError: logger.error("Unable to get GPU information from '%s'", card) if drivers.is_outdated(): setting = "hide-outdated-nvidia-driver-warning" if settings.read_setting(setting) != "True": DontShowAgainDialog( setting, _("Your NVIDIA driver is outdated."), secondary_message=_( "You are currently running driver %s which does not " "fully support all features for Vulkan and DXVK games.\n" "Please upgrade your driver as described in our " "installation guide" ) % ( driver_info["nvrm"]["version"], settings.DRIVER_HOWTO_URL, ) ) 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 missing_vulkan_libs = [] for req in components: for index, arch in enumerate(LINUX_SYSTEM.runtime_architectures): for lib in missing_libs[req][index]: if req == "VULKAN": missing_vulkan_libs.append(arch) logger.error("%s %s missing (needed by %s)", arch, lib, req.lower()) if missing_vulkan_libs: setting = "dismiss-missing-vulkan-library-warning" if settings.read_setting(setting) != "True": DontShowAgainDialog( setting, _("Missing vulkan libraries"), secondary_message=_( "Lutris was unable to detect Vulkan support for " "the %s architecture.\n" "This will prevent many games and programs from working.\n" "To install it, please use the following guide: " "Installing Graphics Drivers" ) % ( _(" and ").join(missing_vulkan_libs), settings.DRIVER_HOWTO_URL, ) ) def check_vulkan(): """Reports if Vulkan is enabled on the system""" if not vkquery.is_vulkan_supported(): logger.warning("Vulkan is not available or your system isn't Vulkan capable") 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(save_config=False) def run_all_checks(): """Run all startup checks""" check_driver() check_libs() check_vulkan() fill_missing_platforms() def init_lutris(): """Run full initialization of Lutris""" logger.info("Starting Lutris %s", settings.VERSION) runners.inject_runners(load_json_runners()) # Load runner names and platforms runners.RUNNER_NAMES = runners.get_runner_names() runners.RUNNER_PLATFORMS = runners.get_platforms() init_dirs() try: syncdb() except sqlite3.DatabaseError: raise RuntimeError( "Failed to open database file in %s. Try renaming this file and relaunch Lutris" % settings.PGA_DB ) for service in DEFAULT_SERVICES: if not settings.read_setting(service, section="services"): settings.write_setting(service, True, section="services") def update_runtime(): """Update runtime components""" runtime_updater = RuntimeUpdater() components_to_update = runtime_updater.update() if components_to_update: while runtime_updater.current_updates: time.sleep(0.3) for dll_manager_class in (DXVKManager, DXVKNVAPIManager, VKD3DManager, D3DExtrasManager): dll_manager = dll_manager_class() dll_manager.upgrade() logger.info("Startup complete") lutris-0.5.9.1/lutris/sysoptions.py000066400000000000000000000425241413267435700173570ustar00rootroot00000000000000"""Options list for system config.""" import glob import os from collections import OrderedDict, defaultdict from gettext import gettext as _ from lutris import runners from lutris.util import linux, system from lutris.util.display import DISPLAY_MANAGER, SCREEN_SAVER_INHIBITOR, USE_DRI_PRIME VULKAN_DATA_DIRS = [ "/usr/local/etc/vulkan", # standard site-local location "/usr/local/share/vulkan", # standard site-local location "/etc/vulkan", # standard location "/usr/share/vulkan", # standard location "/usr/lib/x86_64-linux-gnu/GL/vulkan", # Flatpak GL extension "/usr/lib/i386-linux-gnu/GL/vulkan", # Flatpak GL32 extension "/opt/amdgpu-pro/etc/vulkan" # AMD GPU Pro - TkG ] 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_output_choices(): """Return list of outputs for drop-downs""" displays = DISPLAY_MANAGER.get_display_names() output_choices = list(zip(displays, displays)) output_choices.insert(0, (_("Off"), "off")) output_choices.insert(1, (_("Primary"), "primary")) return output_choices def get_output_list(): """Return a list of output with their index. This is used to indicate to SDL 1.2 which monitor to use. """ choices = [(_("Off"), "off")] displays = DISPLAY_MANAGER.get_display_names() for index, output in enumerate(displays): # Display name can't be used because they might not be in the right order # Using DISPLAYS to get the number of connected monitors choices.append((output, str(index))) return choices def get_optirun_choices(): """Return menu choices (label, value) for Optimus""" choices = [(_("Off"), "off")] if system.find_executable("primusrun"): choices.append(("primusrun", "primusrun")) if system.find_executable("optirun"): choices.append(("optirun/virtualgl", "optirun")) if system.find_executable("pvkrun"): choices.append(("primus vk", "pvkrun")) return choices def get_vk_icd_choices(): """Return available Vulkan ICD loaders""" choices = [(_("Auto"), "")] icd_files = defaultdict(list) # Add loaders for data_dir in VULKAN_DATA_DIRS: path = os.path.join(data_dir, "icd.d", "*.json") for loader in glob.glob(path): icd_key = os.path.basename(loader).split(".")[0] icd_files[icd_key].append(os.path.join(path, loader)) for icd_key in icd_files: files = ":".join(icd_files[icd_key]) choices.append((icd_key.capitalize().replace("_icd", " ICD"), files)) return choices system_options = [ # pylint: disable=invalid-name { "option": "game_path", "type": "directory_chooser", "label": _("Default installation folder"), "default": os.path.expanduser("~/Games"), "scope": ["runner", "system"], "help": _("The default folder where you install your games.") }, { "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."), }, { "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."), }, { "option": "reset_desktop", "type": "bool", "label": _("Restore resolution on game exit"), "default": False, "help": _("Some games don't restore your screen resolution when \n" "closed or when they crash. This is when this option comes \n" "into play to save your bacon."), }, { "option": "gamescope", "type": "bool", "label": _("Enable gamescope"), "default": False, "advanced": True, "condition": bool(system.find_executable("gamescope")), "help": _("Use gamescope to draw the game window isolated from your desktop.\n" "Use Ctrl+Super+F to toggle fullscreen"), }, { "option": "gamescope_output_res", "type": "string", "label": _("Gamescope output resolution"), "default": False, "advanced": True, "condition": bool(system.find_executable("gamescope")), "help": _("Resolution of the window on your desktop"), }, { "option": "gamescope_game_res", "type": "string", "label": _("Gamescope game resolution"), "default": False, "advanced": True, "condition": bool(system.find_executable("gamescope")), "help": _("Resolution of the screen visible to the game"), }, { "option": "single_cpu", "type": "bool", "label": _("Restrict to single core"), "advanced": True, "default": False, "help": _("Restrict the game to a single CPU core."), }, { "option": "restore_gamma", "type": "bool", "default": False, "label": _("Restore gamma on game exit"), "advanced": True, "help": _("Some games don't correctly restores gamma on exit, making " "your display too bright. Select this option to correct it."), }, { "option": "disable_compositor", "label": _("Disable desktop effects"), "type": "bool", "default": False, "advanced": True, "help": _("Disable desktop effects while game is running, " "reducing stuttering and increasing performance"), }, { "option": "disable_screen_saver", "label": _("Disable screen saver"), "type": "bool", "default": SCREEN_SAVER_INHIBITOR is not None, "advanced": False, "condition": SCREEN_SAVER_INHIBITOR is not None, "help": _("Disable the screen saver while a game is running. " "Requires the screen saver's functionality " "to be exposed over DBus."), }, { "option": "reset_pulse", "type": "bool", "label": _("Reset PulseAudio"), "default": False, "advanced": True, "condition": system.find_executable("pulseaudio"), "help": _("Restart PulseAudio before launching the game."), }, { "option": "pulse_latency", "type": "bool", "label": _("Reduce PulseAudio latency"), "default": False, "advanced": True, "condition": system.find_executable("pulseaudio") or system.find_executable("pipewire-pulse"), "help": _("Set the environment variable PULSE_LATENCY_MSEC=60 " "to improve audio quality on some games"), }, { "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"), }, { "option": "optimus", "type": "choice", "default": "off", "choices": get_optirun_choices, "label": _("Optimus launcher (NVIDIA Optimus laptops)"), "advanced": True, "help": _("If you have installed the primus or bumblebee packages, " "select what launcher will run the game with the command, " "activating your NVIDIA graphic chip for high 3D " "performance. primusrun normally has better performance, but" "optirun/virtualgl works better for more games." "Primus VK provide vulkan support under bumblebee."), }, { "option": "vk_icd", "type": "choice", "default": "", "choices": get_vk_icd_choices, "label": _("Vulkan ICD loader"), "advanced": True, "help": _("The ICD loader is a library that is placed between a Vulkan " "application and any number of Vulkan drivers, in order to support " "multiple drivers and the instance-level functionality that works " "across these drivers.") }, { "option": "mangohud", "type": "choice", "label": _("FPS counter (MangoHud)"), "choices": ( (_("Disabled"), ""), (_("Enabled (Vulkan)"), "vk64"), (_("Enabled (OpenGL)"), "gl64"), (_("Enabled (OpenGL, 32bit)"), "gl32") ), "default": "", "advanced": False, "condition": bool(system.find_executable("mangohud")), "help": _("Display the game's FPS + other information. Requires MangoHud to be installed."), }, { "option": "fps_limit", "type": "string", "size": "small", "label": _("FPS limit"), "advanced": True, "condition": bool(system.find_executable("strangle")), "help": _("Limit the game's FPS to desired number"), }, { "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"), }, { "option": "prime", "type": "bool", "default": False, "condition": True, "label": _("Enable NVIDIA Prime Render Offload"), "help": _("If you have the latest NVIDIA driver and the properly patched xorg-server (see " "https://download.nvidia.com/XFree86/Linux-x86_64/435.17/README/primerenderoffload.html" "), you can launch a game on your NVIDIA GPU by toggling this switch. This will apply " "__NV_PRIME_RENDER_OFFLOAD=1 and " "__GLX_VENDOR_LIBRARY_NAME=nvidia environment variables.") }, { "option": "dri_prime", "type": "bool", "default": USE_DRI_PRIME, "condition": USE_DRI_PRIME, "label": _("Use discrete graphics"), "advanced": True, "help": _("If you have open source graphic drivers (Mesa), selecting this " "option will run the game with the 'DRI_PRIME=1' environment variable, " "activating your discrete graphic chip for high 3D " "performance."), }, { "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"), }, { "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."), }, { "option": "resolution", "type": "choice", "label": _("Switch resolution to"), "choices": get_resolution_choices, "default": "off", "help": _("Switch to this screen resolution while the game is running."), }, { "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."), }, { "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."), }, { "option": "env", "type": "mapping", "label": _("Environment variables"), "help": _("Environment variables loaded at run time"), }, { "option": "antimicro_config", "type": "file", "label": _("AntiMicroX Profile"), "advanced": True, "help": _("Path to an AntiMicroX profile file"), }, { "option": "prefix_command", "type": "string", "label": _("Command prefix"), "advanced": True, "help": _("Command line instructions to add in front of the game's " "execution command."), }, { "option": "manual_command", "type": "file", "label": _("Manual script"), "advanced": True, "help": _("Script to execute from the game's contextual menu"), }, { "option": "prelaunch_command", "type": "file", "label": _("Pre-launch script"), "advanced": True, "help": _("Script to execute before the game starts"), }, { "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"), }, { "option": "postexit_command", "type": "file", "label": _("Post-exit script"), "advanced": True, "help": _("Script to execute when the game exits"), }, { "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."), }, { "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."), }, { "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)"), }, { "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."), }, { "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"), }, { "option": "xephyr_resolution", "type": "string", "label": _("Xephyr resolution"), "advanced": True, "help": _("Screen resolution of the Xephyr server"), }, { "option": "xephyr_fullscreen", "type": "bool", "label": _("Xephyr Fullscreen"), "default": True, "advanced": True, "help": _("Open Xephyr in fullscreen (at the desktop resolution)"), }, ] def with_runner_overrides(runner_slug): """Return system options updated with overrides from given runner.""" options = system_options try: runner = runners.import_runner(runner_slug) except runners.InvalidRunner: return options if not getattr(runner, "system_options_override"): runner = runner() if runner.system_options_override: opts_dict = OrderedDict((opt["option"], opt) for opt in options) for option in runner.system_options_override: key = option["option"] if opts_dict.get(key): opts_dict[key] = opts_dict[key].copy() opts_dict[key].update(option) else: opts_dict[key] = option options = list(opts_dict.values()) return options lutris-0.5.9.1/lutris/util/000077500000000000000000000000001413267435700155215ustar00rootroot00000000000000lutris-0.5.9.1/lutris/util/__init__.py000066400000000000000000000007001413267435700176270ustar00rootroot00000000000000""" Misc common functions """ def selective_merge(base_obj, delta_obj): """ used by write_json """ if not isinstance(base_obj, dict): return delta_obj common_keys = set(base_obj).intersection(delta_obj) new_keys = set(delta_obj).difference(common_keys) for k in common_keys: base_obj[k] = selective_merge(base_obj[k], delta_obj[k]) for k in new_keys: base_obj[k] = delta_obj[k] return base_obj lutris-0.5.9.1/lutris/util/audio.py000066400000000000000000000007621413267435700172010ustar00rootroot00000000000000"""Whatever it is we want to do with audio module""" # Standard Library import time # Lutris Modules from lutris.util import system from lutris.util.log import logger def reset_pulse(): """Reset pulseaudio.""" if not system.find_executable("pulseaudio"): logger.warning("PulseAudio not installed. Nothing to do.") return system.execute(["pulseaudio", "--kill"]) time.sleep(1) system.execute(["pulseaudio", "--start"]) logger.debug("PulseAudio restarted") lutris-0.5.9.1/lutris/util/cookies.py000066400000000000000000000053531413267435700175350ustar00rootroot00000000000000# Standard Library import time from http.cookiejar import Cookie, MozillaCookieJar, _warn_unhandled_exception 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: _warn_unhandled_exception() raise OSError("invalid Netscape format cookies file %r: %r" % (filename, line)) lutris-0.5.9.1/lutris/util/datapath.py000066400000000000000000000020471413267435700176640ustar00rootroot00000000000000"""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.9.1/lutris/util/display.py000066400000000000000000000275741413267435700175570ustar00rootroot00000000000000"""Module to deal with various aspects of displays""" # isort:skip_file import enum import os import subprocess import gi gi.require_version("GnomeDesktop", "3.0") try: from dbus.exceptions import DBusException DBUS_AVAILABLE = True except ImportError: DBUS_AVAILABLE = False from gi.repository import Gdk, GLib, GnomeDesktop, Gio from lutris.util import system from lutris.util.graphics.displayconfig import MutterDisplayManager from lutris.util.graphics.xrandr import LegacyDisplayManager, change_resolution, get_outputs from lutris.util.log import logger class NoScreenDetected(Exception): """Raise this when unable to detect screens""" def restore_gamma(): """Restores gamma to a normal level.""" xgamma_path = system.find_executable("xgamma") try: subprocess.Popen([xgamma_path, "-gamma", "1.0"]) except (FileNotFoundError, TypeError): logger.warning("xgamma is not available on your system") except PermissionError: logger.warning("you do not have permission to call xgamma") def _get_graphics_adapters(): """Return the list of graphics cards available on a system Returns: list: list of tuples containing PCI ID and description of the display controller """ lspci_path = system.find_executable("lspci") dev_subclasses = ["VGA", "XGA", "3D controller", "Display controller"] if not lspci_path: logger.warning("lspci is not available. List of graphics cards not available") return [] return [ (pci_id, device_desc.split(": ")[1]) for pci_id, device_desc in [ line.split(maxsplit=1) for line in system.execute(lspci_path, timeout=3).split("\n") if any(subclass in line for subclass in dev_subclasses) ] ] class DisplayManager: """Get display and resolution using GnomeDesktop""" def __init__(self): screen = Gdk.Screen.get_default() if not screen: raise NoScreenDetected self.rr_screen = GnomeDesktop.RRScreen.new(screen) self.rr_config = GnomeDesktop.RRConfig.new_current(self.rr_screen) self.rr_config.load_current() def get_display_names(self): """Return names of connected displays""" return [output_info.get_display_name() for output_info in self.rr_config.get_outputs()] def get_resolutions(self): """Return available resolutions""" resolutions = ["%sx%s" % (mode.get_width(), mode.get_height()) for mode in self.rr_screen.list_modes()] 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 "", "" 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 instanciate MutterDisplayConfig. Please report with exception: %s", ex) else: logger.error("DBus is not available, lutris was not properly installed.") try: return DisplayManager() except (GLib.Error, NoScreenDetected): return LegacyDisplayManager() DISPLAY_MANAGER = get_display_manager() USE_DRI_PRIME = len(_get_graphics_adapters()) > 1 class DesktopEnvironment(enum.Enum): """Enum of desktop environments.""" PLASMA = 0 MATE = 1 XFCE = 2 DEEPIN = 3 UNKNOWN = 999 def get_desktop_environment(): """Converts the value of the DESKTOP_SESSION environment variable to one of the constants in the DesktopEnvironment class. Returns None if DESKTOP_SESSION is empty or unset. """ desktop_session = os.environ.get("DESKTOP_SESSION", "").lower() if not desktop_session: return None if desktop_session.endswith("plasma"): return DesktopEnvironment.PLASMA if desktop_session.endswith("mate"): return DesktopEnvironment.MATE if desktop_session.endswith("xfce"): return DesktopEnvironment.XFCE if desktop_session.endswith("deepin"): return DesktopEnvironment.DEEPIN 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( command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, close_fds=True ).communicate()[0] except FileNotFoundError: logger.error("Unable to run command, %s not found", command[0]) def is_compositing_enabled(): """Checks whether compositing is currently disabled or enabled. Returns True for enabled, False for disabled, and None if unknown. """ desktop_environment = get_desktop_environment() if desktop_environment is DesktopEnvironment.PLASMA: return _get_command_output( "qdbus", "org.kde.KWin", "/Compositor", "org.kde.kwin.Compositing.active" ) == b"true\n" if desktop_environment is DesktopEnvironment.MATE: return _get_command_output("gsettings", "get org.mate.Marco.general", "compositing-manager") == b"true\n" if desktop_environment is DesktopEnvironment.XFCE: return _get_command_output( "xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing" ) == b"true\n" if desktop_environment is DesktopEnvironment.DEEPIN: return _get_command_output( "dbus-send", "--session", "--dest=com.deepin.WMSwitcher", "--type=method_call", "--print-reply=literal", "/com/deepin/WMSwitcher", "com.deepin.WMSwitcher.CurrentWM" ) == b"deepin wm\n" return None # One element is appended to this for every invocation of disable_compositing: # True if compositing has been disabled, False if not. enable_compositing # removes the last element, and only re-enables compositing if that element # was True. _COMPOSITING_DISABLED_STACK = [] def _get_compositor_commands(): """Returns the commands to enable/disable compositing on the current desktop environment as a 2-tuple. """ start_compositor = None stop_compositor = None desktop_environment = get_desktop_environment() if desktop_environment is DesktopEnvironment.PLASMA: stop_compositor = ("qdbus", "org.kde.KWin", "/Compositor", "org.kde.kwin.Compositing.suspend") start_compositor = ("qdbus", "org.kde.KWin", "/Compositor", "org.kde.kwin.Compositing.resume") elif desktop_environment is DesktopEnvironment.MATE: stop_compositor = ("gsettings", "set org.mate.Marco.general", "compositing-manager", "false") start_compositor = ("gsettings", "set org.mate.Marco.general", "compositing-manager", "true") elif desktop_environment is DesktopEnvironment.XFCE: stop_compositor = ("xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing", "--set=false") start_compositor = ("xfconf-query", "--channel=xfwm4", "--property=/general/use_compositing", "--set=true") elif desktop_environment is DesktopEnvironment.DEEPIN: start_compositor = ( "dbus-send", "--session", "--dest=com.deepin.WMSwitcher", "--type=method_call", "/com/deepin/WMSwitcher", "com.deepin.WMSwitcher.RequestSwitchWM", ) stop_compositor = start_compositor return start_compositor, stop_compositor def _run_command(*command): """Random _run_command lost in the middle of the project, are you lost little _run_command? """ try: return subprocess.Popen(command, stdin=subprocess.DEVNULL, close_fds=True) except FileNotFoundError: logger.error("Oh no") def disable_compositing(): """Disable compositing if not already disabled.""" compositing_enabled = is_compositing_enabled() if compositing_enabled is None: compositing_enabled = True if any(_COMPOSITING_DISABLED_STACK): compositing_enabled = False _COMPOSITING_DISABLED_STACK.append(compositing_enabled) if not compositing_enabled: return _, stop_compositor = _get_compositor_commands() if stop_compositor: _run_command(*stop_compositor) def enable_compositing(): """Re-enable compositing if the corresponding call to disable_compositing disabled it.""" compositing_disabled = _COMPOSITING_DISABLED_STACK.pop() if not compositing_disabled: return start_compositor, _ = _get_compositor_commands() if start_compositor: _run_command(*start_compositor) class DBusScreenSaverInhibitor: """Inhibit and uninhibit the screen saver using DBus. Requires the Inhibit() and UnInhibit() methods to be exposed over DBus.""" def __init__(self, name, path, interface, bus_type=Gio.BusType.SESSION): 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.""" try: return self.proxy.Inhibit("(ss)", "Lutris", "Running game: %s" % game_name) except Exception: return None def uninhibit(self, cookie): """Uninhibit the screen saver. Takes a cookie as returned by inhibit. If cookie is None, no action is taken.""" if cookie is not None: self.proxy.UnInhibit("(u)", cookie) def _get_screen_saver_inhibitor(): """Return the appropriate screen saver inhibitor instance. Returns None if the required interface isn't available.""" desktop_environment = get_desktop_environment() # Candidates are triples (name, path, interface) candidates = [("org.freedesktop.ScreenSaver", "/org/freedesktop/ScreenSaver", "org.freedesktop.ScreenSaver")] if desktop_environment is DesktopEnvironment.MATE: candidates.append(("org.mate.ScreenSaver", "/", "org.mate.ScreenSaver")) elif desktop_environment is DesktopEnvironment.XFCE: candidates.append(("org.xfce.ScreenSaver", "/", "org.xfce.ScreenSaver")) for (name, path, interface) in candidates: try: return DBusScreenSaverInhibitor(name, path, interface) except GLib.Error as err: logger.warning("Failed to create DBusScreenSaverInhibitor for name %s, path %s, " "interface %s: %s", name, path, interface, str(err)) return None SCREEN_SAVER_INHIBITOR = _get_screen_saver_inhibitor() lutris-0.5.9.1/lutris/util/dolphin.py000066400000000000000000000024141413267435700175310ustar00rootroot00000000000000# 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+") 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.9.1/lutris/util/dolphin/000077500000000000000000000000001413267435700171565ustar00rootroot00000000000000lutris-0.5.9.1/lutris/util/dolphin/__init__.py000066400000000000000000000000001413267435700212550ustar00rootroot00000000000000lutris-0.5.9.1/lutris/util/dolphin/cache_reader.py000066400000000000000000000107041413267435700221170ustar00rootroot00000000000000"""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") CACHE_REVISION = 20 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_is_accurate': 1, 'is_datel_disc': 1, 'is_nkit': 1, '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() if get_word_len(self.cache_content[:4]) != CACHE_REVISION: raise Exception('Incompatible Dolphin 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.9.1/lutris/util/downloader.py000066400000000000000000000152161413267435700202360ustar00rootroot00000000000000import os 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, dest, overwrite=False, referer=None, callback=None): self.url = url self.dest = dest self.overwrite = overwrite self.referer = referer self.stop_request = None self.thread = None self.callback = callback # Read these after a check_progress() 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 self.last_speeds = [] self.speed_check_time = 0 self.time_left_check_time = 0 self.file_pointer = None def __str__(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") self.thread = jobs.AsyncCall(self.async_download, self.download_cb) 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 self.last_speeds = [] self.speed_check_time = 0 self.time_left_check_time = 0 self.file_pointer = None def check_progress(self): """Append last downloaded chunk to dest file and store stats. :return: progress (between 0.0 and 1.0)""" if self.state not in [self.CANCELLED, self.ERROR]: self.get_stats() return self.progress_fraction 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 download_cb(self, _result, error): if error: logger.error("Download failed: %s", error) self.state = self.ERROR self.error = error if self.file_pointer: self.file_pointer.close() self.file_pointer = None return 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 if self.callback: self.callback() def async_download(self, stop_request=None): 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) 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) for chunk in response.iter_content(chunk_size=1024): if not self.file_pointer: break if chunk: self.downloaded_size += len(chunk) self.file_pointer.write(chunk) 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): """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.9.1/lutris/util/egs/000077500000000000000000000000001413267435700162775ustar00rootroot00000000000000lutris-0.5.9.1/lutris/util/egs/__init__.py000066400000000000000000000000001413267435700203760ustar00rootroot00000000000000lutris-0.5.9.1/lutris/util/egs/egs_launcher.py000066400000000000000000000017071413267435700213150ustar00rootroot00000000000000"""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)) as manifest_file: manifest_content = json.loads(manifest_file.read()) if manifest_content["MainGameAppName"] != manifest_content["AppName"]: continue yield manifest_content lutris-0.5.9.1/lutris/util/extract.py000066400000000000000000000230641413267435700175520ustar00rootroot00000000000000import gzip import os import shutil import subprocess import tarfile import uuid import zlib from lutris import settings from lutris.util import system from lutris.util.log import logger class ExtractFailure(Exception): """Exception raised when and archive fails to extract""" def random_id(): """Return a random ID""" return str(uuid.uuid4())[:8] def is_7zip_supported(path, extractor): supported_extractors = ( "7z", "xz", "bzip2", "gzip", "tar", "zip", "ar", "arj", "cab", "chm", "cpio", "cramfs", "dmg", "ext", "fat", "gpt", "hfs", "ihex", "iso", "lzh", "lzma", "mbr", "msi", "nsis", "ntfs", "qcow2", "rar", "rpm", "squashfs", "udf", "uefi", "vdi", "vhd", "vmdk", "wim", "xar", "z", "auto", ) if extractor: return extractor.lower() in supported_extractors _base, ext = os.path.splitext(path) if ext: ext = ext.lstrip(".").lower() return ext in supported_extractors def guess_extractor(path): """Guess what extractor should be used from a file name""" if path.endswith((".tar.gz", ".tgz")): extractor = "tgz" elif path.endswith((".tar.xz", ".txz")): extractor = "txz" elif path.endswith(".tar"): extractor = "tar" elif path.endswith((".tar.bz2", ".tbz")): extractor = "bz2" elif path.endswith(".gz"): extractor = "gzip" elif path.endswith(".exe"): extractor = "exe" elif path.endswith(".deb"): extractor = "deb" else: extractor = None return extractor def get_archive_opener(extractor, path): """Return the archive opener and optional mode for an extractor""" mode = None if extractor == "tgz": opener, mode = tarfile.open, "r:gz" elif extractor == "txz": opener, mode = tarfile.open, "r:xz" elif extractor == "tar": opener, mode = tarfile.open, "r:" elif extractor == "bz2": opener, mode = tarfile.open, "r:bz2" elif extractor == "gzip": opener = "gz" elif extractor == "gog": opener = "innoextract" elif extractor == "exe": opener = "exe" elif extractor == "deb": opener = "deb" else: opener = "7zip" return opener, mode def extract_archive(path, to_directory=".", merge_single=True, extractor=None): path = os.path.abspath(path) logger.debug("Extracting %s to %s", path, to_directory) if extractor is None: extractor = guess_extractor(path) opener, mode = get_archive_opener(extractor, path) temp_path = temp_dir = os.path.join(to_directory, ".extract-%s" % random_id()) try: _do_extract(path, temp_path, opener, mode, extractor) except (OSError, zlib.error, tarfile.ReadError, EOFError) as ex: logger.error("Extraction failed: %s", ex) raise ExtractFailure(str(ex)) if merge_single: extracted = os.listdir(temp_path) if len(extracted) == 1: temp_path = os.path.join(temp_path, extracted[0]) if os.path.isfile(temp_path): destination_path = os.path.join(to_directory, extracted[0]) if os.path.isfile(destination_path): logger.warning("Overwrite existing file %s", destination_path) os.remove(destination_path) if os.path.isdir(destination_path): os.rename(destination_path, destination_path + random_id()) shutil.move(temp_path, to_directory) os.removedirs(temp_dir) else: for archive_file in os.listdir(temp_path): source_path = os.path.join(temp_path, archive_file) destination_path = os.path.join(to_directory, archive_file) # logger.debug("Moving extracted files from %s to %s", source_path, destination_path) if system.path_exists(destination_path): logger.warning("Overwrite existing path %s", destination_path) if os.path.isfile(destination_path): os.remove(destination_path) shutil.move(source_path, destination_path) elif os.path.isdir(destination_path): try: system.merge_folders(source_path, destination_path) except OSError as ex: logger.error( "Failed to merge to destination %s: %s", destination_path, ex, ) raise ExtractFailure(str(ex)) else: shutil.move(source_path, destination_path) system.remove_folder(temp_dir) logger.debug("Finished extracting %s to %s", path, to_directory) return path, to_directory def _do_extract(archive, dest, opener, mode=None, extractor=None): if opener == "gz": decompress_gz(archive, dest) elif opener == "7zip": extract_7zip(archive, dest, archive_type=extractor) elif opener == "exe": extract_exe(archive, dest) elif opener == "innoextract": extract_gog(archive, dest) elif opener == "deb": extract_deb(archive, dest) else: handler = opener(archive, mode) handler.extractall(dest) handler.close() def extract_exe(path, dest): if check_inno_exe(path): decompress_gog(path, dest) else: # use 7za to check if exe is an archive _7zip_path = os.path.join(settings.RUNTIME_DIR, "p7zip/7za") if not system.path_exists(_7zip_path): _7zip_path = system.find_executable("7za") if not system.path_exists(_7zip_path): raise OSError("7zip is not found in the lutris runtime or on the system") command = [_7zip_path, "t", path] return_code = subprocess.call(command) if return_code == 0: extract_7zip(path, dest) else: raise RuntimeError("specified exe is not an archive or GOG setup file") def extract_deb(archive, dest): """Extract the contents of a deb file to a destination folder""" extract_7zip(archive, dest, archive_type="ar") debian_folder = os.path.join(dest, "debian") os.makedirs(debian_folder) shutil.move(os.path.join(dest, "control.tar.gz"), debian_folder) data_file = os.path.join(dest, "data.tar.gz") extractor = "r:gz" if not os.path.exists(data_file): data_file = os.path.join(dest, "data.tar.xz") extractor = "r:xz" handler = tarfile.open(data_file, extractor) handler.extractall(dest) handler.close() os.remove(data_file) def extract_gog(path, dest): if check_inno_exe(path): decompress_gog(path, dest) else: raise RuntimeError("specified exe is not a GOG setup file") def get_innoextract_path(): """Return the path where innoextract is installed""" inno_dirs = [path for path in os.listdir(settings.RUNTIME_DIR) if path.startswith("innoextract")] if inno_dirs: inno_path = os.path.join(settings.RUNTIME_DIR, inno_dirs[0], "innoextract") else: inno_path = system.find_executable("innoextract") if inno_path: logger.warning("innoextract not available in the runtime folder, using some random version") if system.path_exists(inno_path): return inno_path def check_inno_exe(path): """Check if a path in a compatible innosetup archive""" _innoextract_path = get_innoextract_path() if not _innoextract_path: logger.warning("Innoextract not found, can't determine type of archive %s", path) return False command = [_innoextract_path, "-i", path] return_code = subprocess.call(command) return return_code == 0 def get_innoextract_list(file_path): """Return the list of files contained in a GOG archive""" output = system.read_process_output([get_innoextract_path(), "-lmq", file_path]) return [line[3:] for line in output.split("\n") if line] def decompress_gog(file_path, destination_path): innoextract_path = get_innoextract_path() if not innoextract_path: raise OSError("innoextract is not found in the lutris runtime or on the system") system.create_folder(destination_path) # innoextract cannot do mkdir -p return_code = subprocess.call([innoextract_path, "-m", "-g", "-d", destination_path, "-e", file_path]) if return_code != 0: raise RuntimeError("innoextract failed to extract GOG setup file") def decompress_gz(file_path, dest_path): """Decompress a gzip file.""" if dest_path: dest_filename = os.path.join(dest_path, os.path.basename(file_path[:-3])) else: dest_filename = file_path[:-3] if not os.path.exists(os.path.dirname(dest_filename)): os.makedirs(os.path.dirname(dest_filename)) with open(dest_filename, "wb") as dest_file: gzipped_file = gzip.open(file_path, "rb") dest_file.write(gzipped_file.read()) gzipped_file.close() return dest_path def extract_7zip(path, dest, archive_type=None): _7zip_path = os.path.join(settings.RUNTIME_DIR, "p7zip/7z") if not system.path_exists(_7zip_path): _7zip_path = system.find_executable("7z") if not system.path_exists(_7zip_path): raise OSError("7zip is not found in the lutris runtime or on the system") command = [_7zip_path, "x", path, "-o{}".format(dest), "-aoa"] if archive_type and archive_type != "auto": command.append("-t{}".format(archive_type)) subprocess.call(command) lutris-0.5.9.1/lutris/util/fileio.py000066400000000000000000000047311413267435700173470ustar00rootroot00000000000000# 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