Gather installer files before installation, present them to the user

This commit is contained in:
Mathieu Comandon 2020-01-27 19:37:19 -08:00
parent 759061b4e2
commit 26e476cea3
14 changed files with 823 additions and 792 deletions

View file

@ -34,7 +34,7 @@ from gi.repository import Gio, GLib, Gtk
from lutris import pga
from lutris.game import Game
from lutris import settings
from lutris.gui.dialogs import ErrorDialog, InstallOrPlayDialog, GtkBuilderDialog
from lutris.gui.dialogs import ErrorDialog, InstallOrPlayDialog
from lutris.gui.dialogs.issue import IssueReportWindow
from lutris.gui.installerwindow import InstallerWindow
from lutris.gui.widgets.status_icon import LutrisStatusIcon
@ -318,9 +318,8 @@ class Application(Gtk.Application):
elif action == "install":
# Installers can use game or installer slugs
self.run_in_background = True
db_game = pga.get_game_by_field(
game_slug, "slug"
) or pga.get_game_by_field(game_slug, "installer_slug")
db_game = pga.get_game_by_field(game_slug, "slug") \
or pga.get_game_by_field(game_slug, "installer_slug")
else:
# Dazed and confused, try anything that might works
db_game = (
@ -329,6 +328,10 @@ class Application(Gtk.Application):
or pga.get_game_by_field(game_slug, "installer_slug")
)
# If reinstall flag is passed, force the action to install
if options.contains("reinstall"):
action = "install"
# Graphical commands
self.activate()
self.set_tray_icon()

View file

@ -3,7 +3,6 @@
import os
from lutris import api, pga, runtime, settings
from lutris.gui.widgets.utils import open_uri
from lutris.gui.widgets.log_text_view import LogTextView
from lutris.util import datapath
from lutris.util.log import logger

View file

@ -13,9 +13,8 @@ from lutris.gui.config.add_game import AddGameDialog
from lutris.gui.dialogs import (
NoInstallerDialog, DirectoryDialog, InstallerSourceDialog, QuestionDialog
)
from lutris.gui.widgets.download_progress import DownloadProgressBox
from lutris.gui.widgets.common import FileChooserEntry, InstallerLabel
from lutris.gui.widgets.installer import InstallerPicker
from lutris.gui.widgets.installer import InstallerPicker, InstallerFilesBox
from lutris.gui.widgets.log_text_view import LogTextView
from lutris.gui.widgets.window import BaseApplicationWindow
@ -23,7 +22,7 @@ from lutris.util import jobs
from lutris.util import system
from lutris.util import xdgshortcuts
from lutris.util.log import logger
from lutris.util.strings import add_url_tags, escape_gtk_label
from lutris.util.strings import add_url_tags, gtk_safe
class InstallerWindow(BaseApplicationWindow):
@ -38,13 +37,10 @@ class InstallerWindow(BaseApplicationWindow):
):
super().__init__(application=application)
self.download_progress = None
self.install_in_progress = False
self.interpreter = None
self.selected_directory = None # Latest directory chosen by user
self.parent = parent
self.game_slug = game_slug
self.installer_file = installer_file
self.revision = revision
self.log_buffer = None
@ -62,8 +58,6 @@ class InstallerWindow(BaseApplicationWindow):
self.widget_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.vbox.pack_start(self.widget_box, True, True, 0)
self.location_entry = None
self.vbox.add(Gtk.HSeparator())
self.action_buttons = Gtk.Box(spacing=6)
@ -83,8 +77,8 @@ class InstallerWindow(BaseApplicationWindow):
self.continue_handler = None
# check if installer is local or online
if system.path_exists(self.installer_file):
self.on_scripts_obtained(interpreter.read_script(self.installer_file))
if system.path_exists(installer_file):
self.on_scripts_obtained(interpreter.read_script(installer_file))
else:
self.title_label.set_markup("Waiting for response from %s" % (settings.SITE_URL))
self.add_spinner()
@ -110,6 +104,7 @@ class InstallerWindow(BaseApplicationWindow):
return button
def on_scripts_obtained(self, scripts, _error=None):
"""Continue the install process when the scripts are available"""
if not scripts:
self.destroy()
self.run_no_installer_dialog()
@ -173,25 +168,41 @@ class InstallerWindow(BaseApplicationWindow):
"""Stage where we choose an install script."""
self.validate_scripts()
base_script = self.scripts[0]
self.title_label.set_markup("<b>Install %s</b>" % escape_gtk_label(base_script["name"]))
self.title_label.set_markup("<b>Install %s</b>" % gtk_safe(base_script["name"]))
installer_picker = InstallerPicker(self.scripts)
installer_picker.connect("installer-selected", self.on_installer_selected)
scrolledwindow = Gtk.ScrolledWindow(
hexpand=True, vexpand=True, child=installer_picker
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)
scrolledwindow.show()
def prepare_install(self, script_slug):
def get_script_from_slug(self, script_slug):
"""Return a installer script from its slug, raise an error if one isn't found"""
install_script = None
for script in self.scripts:
if script["slug"] == script_slug:
install_script = script
if not install_script:
raise ValueError("Could not find script %s" % script_slug)
return install_script
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(install_script, self)
self.interpreter = interpreter.ScriptInterpreter(
self.get_script_from_slug(installer_slug),
self
)
except MissingGameDependency as ex:
dlg = QuestionDialog(
{
@ -207,21 +218,19 @@ class InstallerWindow(BaseApplicationWindow):
)
self.destroy()
return
self.title_label.set_markup(
u"<b>Installing {}</b>".format(
escape_gtk_label(self.interpreter.game_name)
gtk_safe(self.interpreter.installer.game_name)
)
)
self.select_install_folder()
def select_install_folder(self):
"""Stage where we select the install directory."""
if self.interpreter.creates_game_folder:
if self.interpreter.installer.creates_game_folder:
self.set_message("Select installation directory")
default_path = self.interpreter.get_default_target()
self.set_path_chooser(self.on_target_changed, "folder", default_path)
self.set_install_destination(default_path)
else:
self.set_message("Click install to continue")
if self.continue_handler:
@ -231,11 +240,7 @@ class InstallerWindow(BaseApplicationWindow):
self.install_button.grab_focus()
self.install_button.show()
def on_installer_selected(self, widget, installer_slug):
self.clean_widgets()
self.prepare_install(installer_slug)
def on_target_changed(self, text_entry, _data):
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())
@ -243,96 +248,23 @@ class InstallerWindow(BaseApplicationWindow):
"""Let the interpreter take charge of the next stages."""
button.hide()
self.source_button.hide()
self.interpreter.check_runner_install()
self.interpreter.connect("runners-installed", self.on_runners_ready)
self.interpreter.launch_install()
def ask_user_for_file(self, message):
self.clean_widgets()
self.set_message(message)
path = self.selected_directory or os.path.expanduser("~")
self.set_path_chooser(
self.continue_guard,
"file",
default_path=path
)
def continue_guard(self, _, action):
"""This is weird and needs to be explained."""
path = os.path.expanduser(self.location_entry.get_text())
if (
action == Gtk.FileChooserAction.OPEN and os.path.isfile(path)
) or (
action == Gtk.FileChooserAction.SELECT_FOLDER and os.path.isdir(path)
):
self.continue_button.set_sensitive(True)
self.continue_button.connect("clicked", self.on_file_selected)
self.continue_button.grab_focus()
else:
self.continue_button.set_sensitive(False)
def set_path_chooser(self, callback_on_changed, action=None, default_path=None):
"""Display a file/folder chooser."""
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)
if action == "file":
title = "Select file"
action = Gtk.FileChooserAction.OPEN
enable_warnings = False
elif action == "folder":
title = "Select folder"
action = Gtk.FileChooserAction.SELECT_FOLDER
enable_warnings = True
else:
raise ValueError("Invalid action %s" % action)
if self.location_entry:
self.location_entry.destroy()
self.location_entry = FileChooserEntry(
title,
action,
location_entry = FileChooserEntry(
"Select folder",
Gtk.FileChooserAction.SELECT_FOLDER,
path=default_path,
warn_if_non_empty=enable_warnings,
warn_if_ntfs=enable_warnings
warn_if_non_empty=True,
warn_if_ntfs=True
)
self.location_entry.entry.connect("changed", callback_on_changed, action)
self.widget_box.pack_start(self.location_entry, False, False, 0)
def on_file_selected(self, _widget):
file_path = os.path.expanduser(self.location_entry.get_text())
if os.path.isfile(file_path):
self.selected_directory = os.path.dirname(file_path)
else:
logger.warning("%s is not a file", file_path)
return
self.interpreter.file_selected(file_path)
def start_download(
self, file_uri, dest_file, callback=None, data=None, referer=None
):
self.clean_widgets()
logger.debug("Downloading %s to %s", file_uri, dest_file)
self.download_progress = DownloadProgressBox(
{"url": file_uri, "dest": dest_file, "referer": referer}, cancelable=True
)
self.download_progress.cancel_button.hide()
self.download_progress.connect("complete", self.on_download_complete, callback, data)
self.widget_box.pack_start(self.download_progress, False, False, 10)
self.download_progress.show()
self.download_progress.start()
self.interpreter.abort_current_task = self.download_progress.cancel
def on_download_complete(self, _widget, _data, callback=None, callback_data=None):
"""Action called on a completed download."""
if callback:
try:
callback_data = callback_data or {}
callback(**callback_data)
except Exception as ex: # pylint: disable:broad-except
raise ScriptingError(str(ex))
self.interpreter.abort_current_task = None
self.interpreter.iter_game_files()
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."""
@ -369,7 +301,7 @@ class InstallerWindow(BaseApplicationWindow):
requires = callback_data["requires"]
callback(widget, requires, folder)
def on_eject_clicked(self, widget, data=None):
def on_eject_clicked(self, _widget, data=None):
self.interpreter.eject_wine_disc()
def input_menu(self, alias, options, preselect, has_entry, callback):
@ -404,15 +336,51 @@ class InstallerWindow(BaseApplicationWindow):
"""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):
"""The runners are ready, proceed with file selection"""
self.clean_widgets()
self.interpreter.installer.prepare_game_files()
installer_files_box = InstallerFilesBox(self.interpreter.installer.files, self)
installer_files_box.connect("files-available", self.on_files_available)
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_handler = self.continue_button.connect(
"clicked", self.on_files_confirmed, installer_files_box
)
def on_files_confirmed(self, _button, file_box):
"""Call this when the user confirms the install files
This will start the downloads.
"""
self.continue_button.set_sensitive(False)
self.continue_button.disconnect(self.continue_handler)
file_box.start_all()
def on_files_available(self, widget):
"""All files are available, continue the install"""
logger.info("All files are available, continuing install")
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
self.desktop_shortcut_box = Gtk.CheckButton("Create desktop shortcut")
self.menu_shortcut_box = Gtk.CheckButton("Create application menu " "shortcut")
self.desktop_shortcut_box = Gtk.CheckButton("Create desktop shortcut", visible=True)
self.menu_shortcut_box = Gtk.CheckButton("Create application menu " "shortcut", visible=True)
self.widget_box.pack_start(self.desktop_shortcut_box, False, False, 5)
self.widget_box.pack_start(self.menu_shortcut_box, False, False, 5)
self.widget_box.show_all()
self.widget_box.show()
if settings.read_setting("create_desktop_shortcut") == "True":
self.desktop_shortcut_box.set_active(True)
@ -432,7 +400,8 @@ class InstallerWindow(BaseApplicationWindow):
self.set_urgency_hint(True) # Blink in taskbar
self.connect("focus-in-event", self.on_window_focus)
def on_window_focus(self, widget, *args):
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):
@ -444,7 +413,7 @@ class InstallerWindow(BaseApplicationWindow):
"""Launch a game after it's been installed."""
widget.set_sensitive(False)
self.on_destroy(widget)
self.application.launch(Game(self.interpreter.game_id))
self.application.launch(Game(self.interpreter.installer.game_id))
def on_destroy(self, _widget, _data=None):
"""destroy event handler"""
@ -459,9 +428,9 @@ class InstallerWindow(BaseApplicationWindow):
def create_shortcuts(self, *args):
"""Create desktop and global menu shortcuts."""
game_slug = self.interpreter.game_slug
game_id = self.interpreter.game_id
game_name = self.interpreter.game_name
game_slug = self.interpreter.installer.game_slug
game_id = self.interpreter.installer.game_id
game_name = self.interpreter.installer.game_name
create_desktop_shortcut = self.desktop_shortcut_box.get_active()
create_menu_shortcut = self.menu_shortcut_box.get_active()
@ -473,7 +442,7 @@ class InstallerWindow(BaseApplicationWindow):
settings.write_setting("create_desktop_shortcut", create_desktop_shortcut)
settings.write_setting("create_menu_shortcut", create_menu_shortcut)
def cancel_installation(self, widget=None):
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:
@ -496,7 +465,9 @@ class InstallerWindow(BaseApplicationWindow):
def on_source_clicked(self, _button):
InstallerSourceDialog(
self.interpreter.script_pretty, self.interpreter.game_name, self
self.interpreter.installer.script_pretty,
self.interpreter.installer.game_name,
self
)
def clean_widgets(self):

View file

@ -1,7 +1,11 @@
"""Widgets for the installer window"""
import os
from gi.repository import Gtk, GObject, Pango
from lutris.util.strings import escape_gtk_label
from lutris.util.strings import gtk_safe
from lutris.util.log import logger
from lutris.gui.widgets.download_progress import DownloadProgressBox
from lutris.gui.widgets.utils import get_icon
from lutris.installer.steam_installer import SteamInstaller
class InstallerLabel(Gtk.Label):
@ -11,7 +15,7 @@ class InstallerLabel(Gtk.Label):
self.set_line_wrap(True)
self.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
self.set_alignment(0, 0.5)
self.set_markup(escape_gtk_label(text))
self.set_markup(text)
class InstallerScriptBox(Gtk.VBox):
@ -41,13 +45,13 @@ class InstallerScriptBox(Gtk.VBox):
"""Return the central information box"""
info_box = Gtk.VBox(spacing=6)
title_box = Gtk.HBox(spacing=6)
title_box.add(InstallerLabel("<b>%s</b>" % self.script["version"]))
title_box.add(InstallerLabel("<b>%s</b>" % 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("%s" % self.script["description"]))
info_box.add(InstallerLabel(gtk_safe(self.script["description"])))
return info_box
def get_revealer(self, revealed):
@ -79,7 +83,7 @@ class InstallerScriptBox(Gtk.VBox):
notes = self.script["notes"].strip()
if not notes:
return Gtk.Alignment()
notes_label = InstallerLabel(notes)
notes_label = InstallerLabel(gtk_safe(notes))
notes_label.set_margin_top(12)
notes_label.set_margin_bottom(12)
notes_label.set_margin_right(12)
@ -112,10 +116,155 @@ class InstallerPicker(Gtk.ListBox):
self.connect('row-selected', self.on_activate)
self.show_all()
def on_activate(self, widget, row):
@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()
class InstallerFileBox(Gtk.VBox):
"""Container for an installer file downloader / selector"""
__gsignals__ = {
"file-available": (GObject.SIGNAL_RUN_FIRST, None, (str, ))
}
def __init__(self, installer_file):
super().__init__()
self.installer_file = installer_file
self.start_func = None
self.abort_func = None
self.set_margin_left(12)
self.set_margin_right(12)
box = Gtk.Box(
spacing=12,
margin_top=12,
margin_bottom=12,
)
file_provider_widget = self.get_file_provider_widget()
box.add(file_provider_widget)
self.add(box)
def get_file_provider_widget(self):
"""Return the widget used to track progress of file"""
provider = self.get_provider()
if provider == "download":
download_progress = DownloadProgressBox({
"url": self.installer_file.url,
"dest": self.installer_file.dest_file,
"referer": self.installer_file.referer
}, cancelable=True)
download_progress.cancel_button.hide()
download_progress.connect("complete", self.on_download_complete)
download_progress.show()
if (
not self.installer_file.uses_pga_cache()
and os.path.exists(self.installer_file.dest_file)
):
os.remove(self.installer_file.dest_file)
self.start_func = download_progress.start
self.abort_func = download_progress.cancel
return download_progress
if provider == "pga":
pga_label = Gtk.Label()
pga_label.set_markup("URL: <b>%s</b>\nCached in: <b>%s</b>" % (
gtk_safe(self.installer_file.url),
gtk_safe(self.installer_file.dest_file)
))
return pga_label
if provider == "user":
user_label = Gtk.Label()
user_label.set_markup(self.get_user_message())
return user_label
if provider == "steam":
steam_installer = SteamInstaller(self.installer_file.url,
self.installer_file.id)
steam_installer.connect("game-installed", self.on_download_complete)
self.start_func = steam_installer.install_steam_game
self.stop_func = steam_installer.stop_func
return Gtk.Label(self.installer_file.url)
def get_provider(self):
"""Return file provider used"""
if self.installer_file.url.startswith(("$WINESTEAM", "$STEAM")):
return "steam"
if self.installer_file.url.startswith("N/A"):
return "user"
if self.installer_file.is_cached:
return "pga"
return "download"
def get_user_message(self):
"""Return the message prompting to provide a file"""
if self.installer_file.url.startswith("N/A"):
# Ask the user where the file is located
parts = self.installer_file.url.split(":", 1)
if len(parts) == 2:
return parts[1]
return "Please select file '%s'" % self.installer_file.id
def start(self):
"""Starts the download of the file"""
provider = self.get_provider()
self.installer_file.prepare()
if provider == "pga":
logger.info("File is cached!")
self.emit("file-available", self.installer_file.id)
return
if self.start_func:
logger.info("Start func: %s", self.start_func)
self.start_func()
def on_download_cancelled(self):
"""Handle cancellation of installers"""
def on_download_complete(self, widget, _data=None):
"""Action called on a completed download."""
if isinstance(widget, SteamInstaller):
self.installer_file.dest_file = widget.get_steam_data_path()
self.emit("file-available", self.installer_file.id)
class InstallerFilesBox(Gtk.ListBox):
"""List box presenting all files needed for an installer"""
__gsignals__ = {
"files-available": (GObject.SIGNAL_RUN_LAST, None, ())
}
def __init__(self, installer_files, parent):
super().__init__()
self.parent = parent
self.installer_files = installer_files
self.available_files = set()
self.installer_files_boxes = {}
for installer_file in installer_files:
installer_file_box = InstallerFileBox(installer_file)
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)
self.show_all()
def start_all(self):
"""Start all downloads"""
for file_id in self.installer_files_boxes:
self.installer_files_boxes[file_id].start()
def on_file_available(self, _widget, file_id):
"""A new file is available"""
self.available_files.add(file_id)
if len(self.available_files) == len(self.installer_files):
logger.info("All files ready")
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
}

View file

@ -32,10 +32,13 @@ class CommandsMixin:
def _get_runner_version(self):
"""Return the version of the runner used for the installer"""
if self.runner in ("wine", "winesteam") and self.script.get(self.runner):
return self.script[self.runner].get("version")
if self.runner == "libretro":
return self.script["game"]["core"]
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
@ -220,7 +223,7 @@ class CommandsMixin:
"containing the following file or folder:\n"
"<i>%s</i>" % requires
)
if self.runner == "wine":
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
@ -363,7 +366,7 @@ class CommandsMixin:
# than the one for this installer
runner_name, task_name = task_name.split(".")
else:
runner_name = self.runner
runner_name = self.installer.runner
return runner_name, task_name
def task(self, data):
@ -384,10 +387,10 @@ class CommandsMixin:
if wine_version:
data["wine_path"] = get_wine_version_exe(wine_version)
data["prefix"] = data.get("prefix") \
or self.script.get("game", {}).get("prefix") \
or self.installer.script.get("game", {}).get("prefix") \
or "$GAMEDIR"
data["arch"] = data.get("arch") \
or self.script.get("game", {}).get("arch") \
or self.installer.script.get("game", {}).get("arch") \
or WINE_DEFAULT_ARCH
if task_name == "wineexec" and self.script_env:
data["env"] = self.script_env

View file

@ -1,6 +1,8 @@
"""Installer specific exceptions"""
import sys
from lutris.util.log import logger
from lutris.util.strings import gtk_safe
from lutris.gui.dialogs import ErrorDialog
@ -33,14 +35,15 @@ class MissingGameDependency(Exception):
super().__init__()
_excepthook = sys.excepthook
_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<b>" + str(value.faulty_data) + "</b>"
message += "\n<b>%s</b>" % gtk_safe(value.faulty_data)
ErrorDialog(message)
else:
_excepthook(error_type, value, traceback)

View file

@ -0,0 +1,331 @@
"""Lutris installer class"""
import os
import json
import yaml
from lutris import pga
from lutris import settings
from lutris.game import Game
from lutris.installer.installer_file import InstallerFile
from lutris.installer.errors import ScriptingError
from lutris.util.log import logger
from lutris.util.http import HTTPError
from lutris.util import system
from lutris.services import UnavailableGame
from lutris.services.gog import get_gog_download_links, MultipleInstallerError
from lutris.services.humblebundle import get_humble_download_link
from lutris.config import LutrisConfig, make_game_config_id
class LutrisInstaller: # pylint: disable=too-many-instance-attributes
"""Represents a Lutris installer"""
def __init__(self, installer, interpreter):
self.interpreter = interpreter
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 = self.script.get("custom-name") or installer["name"]
self.game_slug = installer["game_slug"]
self.steamid = installer.get("steamid")
game_config = self.script.get("game", {})
self.gogid = game_config.get("gogid") or installer.get("gogid")
self.humbleid = game_config.get("humbleid") or installer.get("humblestoreid")
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()
@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 = pga.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 swap_steam_install(self):
"""Add steam installation to commands if it's a Steam game"""
# XXX Steam is no longer an install command, it's a file
# if self.runner in ("steam", "winesteam"):
# self.steam_data["appid"] = self.script["game"]["appid"]
# if "arch" in self.script["game"]:
# self.steam_data["arch"] = self.script["game"]["arch"]
# commands = self.script.get("installer", [])
# self.steam_data["platform"] = (
# "windows" if self.runner == "winesteam" else "linux"
# )
# commands.insert(0, "install_steam_game")
# self.script["installer"] = commands
def swap_gog_game_files(self):
"""Replace user provided file with downloads from GOG"""
if not self.gogid:
raise UnavailableGame("The installer has no GOG ID!")
try:
links = get_gog_download_links(self.gogid, self.runner)
except HTTPError:
raise UnavailableGame("Couldn't load the download links for this game")
except MultipleInstallerError:
raise UnavailableGame("Don't know how to deal with multiple installers yet.")
if not links:
raise UnavailableGame("Could not fing GOG game")
installer_file_id = self.pop_user_provided_file()
if not installer_file_id:
raise UnavailableGame("Installer has no user provided file")
file_id_provided = False # Only assign installer_file_id once
for index, link in enumerate(links):
filename = link.split("?")[0].split("/")[-1]
if filename.lower().endswith((".exe", ".sh")) and not file_id_provided:
file_id = installer_file_id
file_id_provided = True
else:
file_id = "gog_file_%s" % index
self.files.append(
InstallerFile(self.game_slug, file_id, {
"url": link,
"filename": filename,
})
)
def pop_user_provided_file(self):
"""Return and remove the first user provided file, which is used for game stores
"""
installer_file_id = None
for index, file in enumerate(self.files):
if file.url.startswith("N/A"):
logger.debug("File %s detected as user provided, removing from files", file.id)
self.files.pop(index)
installer_file_id = file.id
break
return installer_file_id
def prepare_game_files(self):
"""Gathers necessary files before iterating through them."""
# If this is a GOG installer, download required files.
version = self.version.lower()
if version.startswith("gog"):
try:
self.swap_gog_game_files()
except UnavailableGame as ex:
logger.error("Unable to get the game from GOG: %s", ex)
if version.startswith("humble"):
try:
self.swap_humble_game_files()
except UnavailableGame as ex:
logger.error("Unable to get the game from GOG: %s", ex)
if self.runner in ("steam", "winesteam"):
steam_uri = "$WINESTEAM:%s:." if self.runner == "winesteam" else "$STEAM:%s:."
appid = str(self.script["game"]["appid"])
self.files.append(
InstallerFile(self.game_slug, "steam_game", {
"url": steam_uri % appid,
"filename": appid
})
)
def swap_humble_game_files(self):
"""Replace the user provided file with download links from Humble Bundle"""
if not self.humbleid:
raise UnavailableGame(
"This installer has no Humble Bundle ID ('humbleid' in the game section)"
)
installer_file_id = self.pop_user_provided_file()
if not installer_file_id:
raise UnavailableGame("Installer has no user provided file")
try:
link = get_humble_download_link(self.humbleid, self.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]
self.files.append(
InstallerFile(self.game_slug, installer_file_id, {
"url": link,
"filename": filename
})
)
def _substitute_config(self, script_config):
"""Substitute values such as $GAMEDIR in a config dict."""
config = {}
for key in script_config:
if not isinstance(key, str):
raise ScriptingError("Game config key must be a string", key)
value = script_config[key]
if str(value).lower() == 'true':
value = True
if str(value).lower() == 'false':
value = False
if 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 write_config(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 = make_game_config_id(self.slug)
config_filename = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % configpath)
if self.requires:
# Load the base game config
required_game = pga.get_game_by_field(self.requires, field="installer_slug")
base_config = LutrisConfig(
runner_slug=self.runner, game_config_id=required_game["configpath"]
)
config = base_config.game_level
else:
config = {"game": {}}
self.game_id = pga.add_or_update(
name=self.game_name,
runner=self.runner,
slug=self.game_slug,
directory=self.interpreter.target_path,
installed=1,
installer_slug=self.slug,
parent_slug=self.requires,
year=self.year,
steamid=self.steamid,
configpath=configpath,
id=self.game_id,
)
game = Game(self.game_id)
game.save()
logger.debug("Saved game entry %s (%d)", self.game_slug, self.game_id)
# 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])
if not self.interpreter.game_files:
raise RuntimeError("You haven't populated game files, fuckface")
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"])
yaml_config = yaml.safe_dump(config, default_flow_style=False)
with open(config_filename, "w") as config_file:
config_file.write(yaml_config)
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
"""
launcher, launcher_value = self.get_game_launcher()
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
def get_game_launcher(self):
"""Return the key and value of the launcher"""
launcher_value = None
# exe64 can be provided to specify an executable for 64bit systems
exe = "exe64" if "exe64" in self.script and system.LINUX_SYSTEM.is_64_bit else "exe"
for launcher in (exe, "iso", "rom", "disk", "main_file"):
if launcher not in self.script:
continue
launcher_value = self.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

View file

@ -2,10 +2,10 @@
import os
from lutris import pga
from lutris import settings
from lutris.installer.errors import ScriptingError, FileNotAvailable
from lutris.installer.errors import ScriptingError
from lutris.util.log import logger
from lutris.util import system
from lutris.cache import get_cache_path
from lutris import cache
class InstallerFile:
@ -13,7 +13,6 @@ class InstallerFile:
def __init__(self, game_slug, file_id, file_meta):
self.game_slug = game_slug
self.id = file_id # pylint: disable=invalid-name
self.dest_file = None
if isinstance(file_meta, dict):
for field in ("url", "filename"):
if field not in file_meta:
@ -29,6 +28,7 @@ class InstallerFile:
self.filename = os.path.basename(file_meta)
self.referer = None
self.checksum = None
self.dest_file = os.path.join(self.cache_path, self.filename)
if self.url.startswith(("$STEAM", "$WINESTEAM")):
self.filename = self.url
@ -56,7 +56,7 @@ class InstallerFile:
Returns:
bool
"""
cache_path = get_cache_path()
cache_path = cache.get_cache_path()
if not cache_path:
return False
if system.path_exists(cache_path):
@ -74,7 +74,7 @@ class InstallerFile:
@property
def cache_path(self):
"""Return the directory used as a cache for the duration of the installation"""
_cache_path = get_cache_path()
_cache_path = cache.get_cache_path()
if not _cache_path:
_cache_path = os.path.join(settings.CACHE_DIR, "installer")
if "cdn.gog.com" in self.url or "cdn-hw.gog.com" in self.url:
@ -83,22 +83,16 @@ class InstallerFile:
folder = self.id
return os.path.join(_cache_path, self.game_slug, folder)
def get_download_info(self):
"""Retrieve the file locally"""
if self.url.startswith(("$WINESTEAM", "$STEAM", "N/A")):
raise FileNotAvailable()
# Check for file availability in PGA
pga_uri = pga.check_for_file(self.game_slug, self.id)
if pga_uri:
self.url = pga_uri
def prepare(self):
"""Prepare the file for download"""
if not system.path_exists(self.cache_path):
os.makedirs(self.cache_path)
dest_file = os.path.join(self.cache_path, self.filename)
logger.debug("Downloading [%s]: %s to %s", self.id, self.url, dest_file)
if not self.uses_pga_cache() and os.path.exists(dest_file):
os.remove(dest_file)
self.dest_file = dest_file
return self.dest_file
def pga_uri(self):
"""Return the URI of the file stored in the PGA
This isn't used yet, it looks in the PGA sources
"""
return pga.check_for_file(self.game_slug, self.id)
def check_hash(self):
"""Checks the checksum of `file` and compare it to `value`
@ -118,18 +112,7 @@ class InstallerFile:
if system.get_file_checksum(self.dest_file, hash_type) != expected_hash:
raise ScriptingError(hash_type.capitalize() + " checksum mismatch ", self.checksum)
def download(self, downloader):
"""Download a file with a given downloader"""
if self.uses_pga_cache() and system.path_exists(self.dest_file):
logger.info("File %s already cached", self)
return False
if not system.path_exists(self.cache_path):
os.makedirs(self.cache_path)
downloader(
self.url,
self.dest_file,
callback=self.check_hash,
referer=self.referer
)
return True
@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)

View file

@ -1,38 +1,28 @@
# pylint: disable=E1101, E0611
"""Install a game by following its install script."""
import os
import time
import json
import yaml
from gi.repository import GLib
from gi.repository import GLib, GObject
from lutris import pga
from lutris import settings
from lutris.game import Game
from lutris.gui.dialogs import WineNotInstalledWarning
from lutris.util import system
from lutris.util.display import DISPLAY_MANAGER
from lutris.util.strings import unpack_dependencies
from lutris.util.jobs import AsyncCall
from lutris.util.log import logger
from lutris.util.steam.log import get_app_state_log
from lutris.util.http import Request, HTTPError
from lutris.util.http import Request
from lutris.util.wine.wine import get_wine_version_exe, get_system_wine_version
from lutris.config import LutrisConfig, make_game_config_id
from lutris.config import LutrisConfig
from lutris.installer.errors import ScriptingError, FileNotAvailable, MissingGameDependency
from lutris.installer.errors import ScriptingError, MissingGameDependency
from lutris.installer.commands import CommandsMixin
from lutris.installer.installer_file import InstallerFile
from lutris.services import UnavailableGame
from lutris.services.gog import get_gog_download_links, MultipleInstallerError
from lutris.services.humblebundle import get_humble_download_link
from lutris.installer.installer import LutrisInstaller
from lutris.runners import (
wine,
winesteam,
steam,
import_runner,
InvalidRunner,
@ -72,127 +62,50 @@ def read_script(filename):
return scripts
def _get_game_launcher(script):
"""Return the key and value of the launcher"""
launcher_value = None
class ScriptInterpreter(GObject.Object, CommandsMixin):
"""Control the execution of an installer"""
# exe64 can be provided to specify an executable for 64bit systems
exe = "exe64" if "exe64" in script and system.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
class ScriptInterpreter(CommandsMixin):
"""Convert raw installer script data into actions.
Really fucked up class that tries to do way more than it should.
"""
__gsignals__ = {
"runners-installed": (GObject.SIGNAL_RUN_FIRST, None, ()),
}
def __init__(self, installer, parent):
self.error = None
self.errors = []
super().__init__()
self.target_path = None
self.parent = parent
self.game_dir_created = False # Whether a game folder was created during the install
self.game_files = {}
self.game_disc = None
self.game_files = {}
self.cancelled = False
self.abort_current_task = None
self.user_inputs = []
self.steam_data = {}
self.script = installer.get("script")
if not self.script:
raise ScriptingError("This installer doesn't have a 'script' section")
self.script_pretty = json.dumps(self.script, indent=4)
self.install_start_time = None # Time of the start of the install
self.steam_poll = None # Reference to the Steam poller that checks if games are downloaded
self.current_command = None # Current installer command when iterating through them
self.current_file_id = None # Current file when downloading / gathering files
self.current_command = 0 # Current installer command when iterating through them
self.runners_to_install = []
self.prev_states = [] # Previous states for the Steam installer
self.version = installer["version"]
self.slug = installer["slug"]
self.year = installer.get("year")
self.runner = installer["runner"]
self.game_name = self.script.get("custom-name") or installer["name"]
self.game_slug = installer["game_slug"]
self.steamid = installer.get("steamid")
game_config = self.script.get("game", {})
self.gogid = game_config.get("gogid") or installer.get("gogid")
self.humbleid = game_config.get("humbleid") or installer.get("humblestoreid")
if not self.is_valid():
self.installer = LutrisInstaller(installer, self)
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(self.errors)), self.script
"Invalid script: \n{}".format("\n".join(script_errors)), self.installer.script
)
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.current_resolution = DISPLAY_MANAGER.get_current_resolution()
self._check_binary_dependencies()
self._check_dependency()
if self.creates_game_folder:
if self.installer.creates_game_folder:
self.target_path = self.get_default_target()
# If the game is in the library and uninstalled, the first installation
# updates it
existing_game = pga.get_game_by_field(self.game_slug, "slug")
if existing_game and not existing_game["installed"]:
self.game_id = existing_game["id"]
else:
self.game_id = None
def get_default_target(self):
"""Return default installation dir"""
config = LutrisConfig(runner_slug=self.runner)
config = LutrisConfig(runner_slug=self.installer.runner)
games_dir = config.system_config.get("game_path", os.path.expanduser("~"))
return os.path.expanduser(os.path.join(games_dir, self.game_slug))
return os.path.expanduser(os.path.join(games_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.game_slug)
@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
return os.path.join(settings.CACHE_DIR, "installer/%s" % self.installer.game_slug)
@property
def script_env(self):
@ -202,41 +115,13 @@ class ScriptInterpreter(CommandsMixin):
"""
return {
key: self._substitute(value) for key, value in
self.script.get('system', {}).get('env', {}).items()
self.installer.script.get('system', {}).get('env', {}).items()
}
# --------------------------
# "Initial validation" stage
# --------------------------
def is_valid(self):
"""Return True if script is usable."""
if not isinstance(self.script, dict):
self.errors.append("Script must be a dictionary")
# Return early since the method assumes a dict
return False
# 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):
self.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"]:
self.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"):
self.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"):
self.errors.append("Scripts can't have both extends and requires")
return not bool(self.errors)
@staticmethod
def _get_installed_dependency(dependency):
"""Return whether a dependency is installed"""
@ -253,7 +138,7 @@ class ScriptInterpreter(CommandsMixin):
This reads a `require-binaries` entry in the script, parsed the same way as
the `requires` entry.
"""
binary_dependencies = unpack_dependencies(self.script.get("require-binaries"))
binary_dependencies = unpack_dependencies(self.installer.script.get("require-binaries"))
for dependency in binary_dependencies:
if isinstance(dependency, tuple):
installed_binaries = {
@ -278,10 +163,10 @@ class ScriptInterpreter(CommandsMixin):
The first game available listed in the dependencies is the one picked to base
the installed on.
"""
if self.extends:
dependencies = [self.extends]
if self.installer.extends:
dependencies = [self.installer.extends]
else:
dependencies = unpack_dependencies(self.requires)
dependencies = unpack_dependencies(self.installer.requires)
error_message = "You need to install {} before"
for index, dependency in enumerate(dependencies):
if isinstance(dependency, tuple):
@ -306,166 +191,43 @@ class ScriptInterpreter(CommandsMixin):
self.target_path = game["directory"]
self.requires = game["installer_slug"]
# ---------------------
# "Get the files" stage
# ---------------------
def pop_user_provided_file(self):
"""Return and remove the first user provided file, which is used for game stores"""
installer_file_id = None
for index, file in enumerate(self.files):
if file.url.startswith("N/A"):
logger.debug("File %s detected as user provided, removing from files", file.id)
self.files.pop(index)
installer_file_id = file.id
break
return installer_file_id
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 swap_gog_game_files(self):
"""Replace user provided file with downloads from GOG"""
if not self.gogid:
raise UnavailableGame("The installer has no GOG ID!")
try:
links = get_gog_download_links(self.gogid, self.runner)
except HTTPError:
raise UnavailableGame("Couldn't load the download links for this game")
except MultipleInstallerError:
raise UnavailableGame("Don't know how to deal with multiple installers yet.")
if not links:
raise UnavailableGame("Could not fing GOG game")
installer_file_id = self.pop_user_provided_file()
if not installer_file_id:
raise UnavailableGame("Installer has no user provided file")
file_id_provided = False # Only assign installer_file_id once
for index, link in enumerate(links):
filename = link.split("?")[0].split("/")[-1]
if filename.lower().endswith((".exe", ".sh")) and not file_id_provided:
file_id = installer_file_id
file_id_provided = True
else:
file_id = "gog_file_%s" % index
self.files.append(
InstallerFile(self.game_slug, file_id, {
"url": link,
"filename": filename,
})
)
def swap_humble_game_files(self):
"""Replace the user provided file with download links from Humble Bundle"""
if not self.humbleid:
raise UnavailableGame("This installer has no Humble Bundle ID ('humbleid' in the game section)")
installer_file_id = self.pop_user_provided_file()
if not installer_file_id:
raise UnavailableGame("Installer has no user provided file")
try:
link = get_humble_download_link(self.humbleid, self.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]
self.files.append(
InstallerFile(self.game_slug, installer_file_id, {
"url": link,
"filename": filename
})
)
def prepare_game_files(self):
"""Gathers necessary files before iterating through them."""
# If this is a GOG installer, download required files.
version = self.version.lower()
if version.startswith("gog"):
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:
self.swap_gog_game_files()
except UnavailableGame as ex:
logger.error("Unable to get the game from GOG: %s", ex)
if version.startswith("humble"):
try:
self.swap_humble_game_files()
except UnavailableGame as ex:
logger.error("Unable to get the game from GOG: %s", ex)
self.iter_game_files()
def iter_game_files(self):
"""Iterate through game files, downloading them or querying them from the user"""
if self.files:
if (
self.target_path
and not system.path_exists(self.target_path)
and self.creates_game_folder
):
try:
os.makedirs(self.target_path)
except PermissionError:
raise ScriptingError(
"Lutris does not have the necessary permissions to install to path:",
self.target_path,
)
logger.debug("Creating destination path %s", self.target_path)
os.makedirs(self.target_path)
self.game_dir_created = True
if len(self.game_files) < len(self.files):
logger.info(
"Downloading file %d of %d", len(self.game_files) + 1, len(self.files)
)
file_index = len(self.game_files)
try:
current_file = self.files[file_index]
except KeyError:
except PermissionError:
raise ScriptingError(
"Error getting file %d in %s" % file_index, self.files
"Lutris does not have the necessary permissions to install to path:",
self.target_path,
)
self._download_file(current_file)
else:
self.current_command = 0
self._prepare_commands()
def _download_file(self, installer_file):
"""Download a file referenced in the installer script.
Game files can be either a string, containing the location of the
file to fetch or a dict with the following keys:
- url : location of file, if not present, filename will be used
this should be the case for local files.
- filename : force destination filename when url is present or path
of local file.
"""
try:
self.game_files[installer_file.id] = installer_file.get_download_info()
except FileNotAvailable:
if installer_file.url.startswith(("$WINESTEAM", "$STEAM")):
# Download Steam data
self._download_steam_data(installer_file.url, installer_file.id)
return
if installer_file.url.startswith("N/A"):
# Ask the user where the file is located
parts = installer_file.url.split(":", 1)
if len(parts) == 2:
message = parts[1]
else:
message = "Please select file '%s'" % installer_file.id
self.current_file_id = installer_file.id
self.parent.ask_user_for_file(message)
return
is_downloading = installer_file.download(self.parent.start_download)
if not is_downloading:
self.iter_game_files()
def check_runner_install(self):
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.runner)
runner = self.get_runner_class(self.installer.runner)
if runner.depends_on is not None:
required_runners.append(runner.depends_on())
required_runners.append(runner())
for command in self.script.get("installer", []):
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(
@ -475,12 +237,11 @@ class ScriptInterpreter(CommandsMixin):
if runner_name not in runner_names:
required_runners.append(self.get_runner_class(runner_name)())
logger.debug("Required runners: %s", required_runners)
for runner in required_runners:
params = {}
if self.runner == "libretro":
params["core"] = self.script["game"]["core"]
if self.runner.startswith("wine"):
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
@ -495,29 +256,29 @@ class ScriptInterpreter(CommandsMixin):
# 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.runner not in self.script:
self.script[self.runner] = {}
params["version"] = self.script[self.runner]["version"] = "{}-{}".format(
default_wine["version"],
default_wine["architecture"]
)
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")
self.runners_to_install.append(runner)
runners_to_install.append(runner)
if self.runner.startswith("wine") and not get_system_wine_version():
if self.installer.runner.startswith("wine") and not get_system_wine_version():
WineNotInstalledWarning(parent=self.parent)
self.install_runners()
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.prepare_game_files()
self.emit("runners-installed")
def install_runner(self, runner):
"""Install runner required by the install script"""
@ -541,39 +302,12 @@ class ScriptInterpreter(CommandsMixin):
raise ScriptingError("Invalid runner provided %s" % runner_name)
return runner
def file_selected(self, file_path):
"""Continue install after a file has been selected by the user"""
file_id = self.current_file_id
if not file_path or not os.path.exists(file_path):
raise ScriptingError("Can't continue installation without file", file_id)
self.game_files[file_id] = file_path
self.iter_game_files()
# ---------------
# "Commands" stage
# ---------------
def _prepare_commands(self):
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)
# Add steam installation to commands if it's a Steam game
if self.runner in ("steam", "winesteam"):
self.steam_data["appid"] = self.script["game"]["appid"]
if "arch" in self.script["game"]:
self.steam_data["arch"] = self.script["game"]["arch"]
commands = self.script.get("installer", [])
self.steam_data["platform"] = (
"windows" if self.runner == "winesteam" else "linux"
)
commands.insert(0, "install_steam_game")
self.script["installer"] = commands
self._iter_commands()
def _iter_commands(self, result=None, exception=None):
@ -584,7 +318,7 @@ class ScriptInterpreter(CommandsMixin):
self.parent.add_spinner()
self.parent.continue_button.hide()
commands = self.script.get("installer", [])
commands = self.installer.script.get("installer", [])
if exception:
self.parent.on_install_error(repr(exception))
elif self.current_command < len(commands):
@ -625,22 +359,18 @@ class ScriptInterpreter(CommandsMixin):
raise ScriptingError('The command "%s" does not exist.' % command_name)
return getattr(self, command_name), command_params
# ----------------
# "Finalize" stage
# ----------------
def _finish_install(self):
game = self.script.get("game")
game = self.installer.script.get("game")
launcher_value = None
if game:
_launcher, launcher_value = _get_game_launcher(game)
_launcher, launcher_value = self.installer.get_game_launcher()
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._write_config()
if path and not os.path.isfile(path) and self.runner not in ("web", "browser"):
self.installer.write_config()
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
@ -651,125 +381,15 @@ class ScriptInterpreter(CommandsMixin):
self.parent.on_install_finished()
def _write_config(self):
"""Write the game configuration in the DB and config file.
This needs to be unfucked
"""
if self.extends:
logger.info(
"This is an extension to %s, not creating a new game entry",
self.extends,
)
return
configpath = make_game_config_id(self.slug)
config_filename = os.path.join(settings.CONFIG_DIR, "games/%s.yml" % configpath)
if self.requires:
# Load the base game config
required_game = pga.get_game_by_field(self.requires, field="installer_slug")
base_config = LutrisConfig(
runner_slug=self.runner, game_config_id=required_game["configpath"]
)
config = base_config.game_level
else:
config = {"game": {}}
self.game_id = pga.add_or_update(
name=self.game_name,
runner=self.runner,
slug=self.game_slug,
directory=self.target_path,
installed=1,
installer_slug=self.slug,
parent_slug=self.requires,
year=self.year,
steamid=self.steamid,
configpath=configpath,
id=self.game_id,
)
game = Game(self.game_id)
game.save()
logger.debug("Saved game entry %s (%d)", self.game_slug, self.game_id)
# 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])
# 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
launcher, launcher_value = _get_game_launcher(self.script)
if isinstance(launcher_value, list):
game_files = []
for game_file in launcher_value:
if game_file in self.game_files:
game_files.append(self.game_files[game_file])
else:
game_files.append(game_file)
config["game"][launcher] = game_files
elif launcher_value:
if launcher_value in self.game_files:
launcher_value = self.game_files[launcher_value]
elif self.target_path and os.path.exists(
os.path.join(self.target_path, launcher_value)
):
launcher_value = os.path.join(self.target_path, launcher_value)
config["game"][launcher] = launcher_value
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"])
yaml_config = yaml.safe_dump(config, default_flow_style=False)
with open(config_filename, "w") as config_file:
config_file.write(yaml_config)
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._substitute(i) for i in value]
elif isinstance(value, dict):
config[key] = {k: self._substitute(v) for (k, v) in value.items()}
elif isinstance(value, bool):
config[key] = value
else:
config[key] = self._substitute(value)
return config
# --------------------
# "After the end" stage
# --------------------
def cleanup(self):
"""Clean up install dir after a successful install"""
os.chdir(os.path.expanduser("~"))
system.remove_folder(self.cache_path)
# --------------
# Revert install
# --------------
def revert(self):
"""Revert installation in case of an error"""
logger.info("Cancelling installation of %s", self.game_name)
if self.runner.startswith("wine"):
logger.info("Cancelling installation of %s", self.installer.game_name)
if self.installer.runner.startswith("wine"):
self.task({"name": "winekill"})
self.cancelled = True
@ -780,10 +400,6 @@ class ScriptInterpreter(CommandsMixin):
if self.game_dir_created:
system.remove_folder(self.target_path)
# -------------
# Utility stuff
# -------------
def _substitute(self, template_string):
"""Replace path aliases with real paths."""
replacements = {
@ -794,7 +410,7 @@ class ScriptInterpreter(CommandsMixin):
"DISC": self.game_disc,
"USER": os.getenv("USER"),
"INPUT": self._get_last_user_input(),
"VERSION": self.version,
"VERSION": self.installer.version,
"RESOLUTION": "x".join(self.current_resolution),
"RESOLUTION_WIDTH": self.current_resolution[0],
"RESOLUTION_HEIGHT": self.current_resolution[1],
@ -805,7 +421,6 @@ class ScriptInterpreter(CommandsMixin):
alias = input_data["alias"]
if alias:
replacements[alias] = input_data["value"]
replacements.update(self.game_files)
return system.substitute(template_string, replacements)
@ -816,147 +431,3 @@ class ScriptInterpreter(CommandsMixin):
"""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)
# -----------
# Steam stuff
# -----------
def install_steam_game(self, runner_class=None, is_game_files=False):
"""Launch installation of a steam game.
runner_class: class of the steam runner to use
is_game_files: whether game data is added to game_files
"""
# Check if Steam is installed, save the method's arguments so it can
# be called again once Steam is installed.
self.steam_data["callback_args"] = (runner_class, is_game_files)
steam_runner = self._get_steam_runner(runner_class)
self.steam_data["is_game_files"] = is_game_files
appid = self.steam_data["appid"]
if not steam_runner.get_game_path_from_appid(appid):
logger.debug("Installing steam game %s", appid)
steam_runner.config = LutrisConfig(runner_slug=steam_runner.name)
if "arch" in self.steam_data:
steam_runner.config.game_config["arch"] = self.steam_data["arch"]
AsyncCall(steam_runner.install_game, self.on_steam_game_installed, appid, is_game_files)
self.install_start_time = time.localtime()
self.steam_poll = GLib.timeout_add(2000, self._monitor_steam_game_install)
self.abort_current_task = lambda: steam_runner.remove_game_data(appid=appid)
return "STOP"
if is_game_files:
self._append_steam_data_to_files(runner_class)
else:
self.target_path = self._get_steam_game_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 _get_steam_runner(self, runner_class=None):
if not runner_class:
if self.runner == "steam":
runner_class = steam.steam
elif self.runner == "winesteam":
runner_class = winesteam.winesteam
elif self.steam_data["is_game_files"]:
if self.steam_data["platform"] == "windows":
runner_class = winesteam.winesteam
else:
runner_class = steam.steam
return runner_class()
def _monitor_steam_game_install(self):
if self.cancelled:
return False
appid = self.steam_data["appid"]
steam_runner = self._get_steam_runner()
states = get_app_state_log(
steam_runner.steam_data_dir, appid, self.install_start_time
)
if states != self.prev_states:
logger.debug("Steam installation status:")
logger.debug(states)
self.prev_states = states
if states and states[-1].startswith("Fully Installed"):
logger.debug("Steam game has finished installing")
self._on_steam_game_installed()
return False
return True
def _on_steam_game_installed(self, *_args):
"""Fired whenever a Steam game has finished installing."""
self.abort_current_task = None
if self.steam_data["is_game_files"]:
if self.steam_data["platform"] == "windows":
runner_class = winesteam.winesteam
else:
runner_class = steam.steam
self._append_steam_data_to_files(runner_class)
else:
self.target_path = self._get_steam_game_path()
self._iter_commands()
def _get_steam_game_path(self, runner_class=None):
if not runner_class:
steam_runner = self._get_steam_runner()
else:
steam_runner = runner_class()
return steam_runner.get_game_path_from_appid(self.steam_data["appid"])
def _append_steam_data_to_files(self, runner_class):
data_path = self._get_steam_game_path(runner_class)
if not data_path or not os.path.exists(data_path):
raise ScriptingError("Unable to get Steam data for game")
self.game_files[self.steam_data["file_id"]] = os.path.abspath(
os.path.join(data_path, self.steam_data["steam_rel_path"])
)
self.iter_game_files()
def _download_steam_data(self, file_uri, file_id):
"""Download the game files from Steam to use them outside of Steam.
file_uri: Colon separated game info containing:
- $STEAM or $WINESTEAM depending on the version of Steam
Since Steam for Linux can download games for any
platform, using $WINESTEAM has little value except in
some cases where the game needs to be started by Steam
in order to get a CD key (ie. Doom 3 or UT2004)
- The Steam appid
- The relative path of files to retrieve
file_id: The lutris installer internal id for the game files
"""
try:
parts = file_uri.split(":", 2)
steam_rel_path = parts[2].strip()
except IndexError:
raise ScriptingError("Malformed steam path: %s" % file_uri)
if steam_rel_path == "/":
steam_rel_path = "."
self.steam_data = {
"appid": parts[1],
"steam_rel_path": steam_rel_path,
"file_id": file_id,
}
logger.debug("Getting Steam data for appid %s", self.steam_data["appid"])
self.parent.clean_widgets()
self.parent.add_spinner()
if parts[0] == "$WINESTEAM":
self.parent.set_status("Getting Wine Steam game data")
self.steam_data["platform"] = "windows"
self.install_steam_game(winesteam.winesteam, is_game_files=True)
else:
# Getting data from Linux Steam
self.parent.set_status("Getting Steam game data")
self.steam_data["platform"] = "linux"
self.install_steam_game(steam.steam, is_game_files=True)

View file

@ -0,0 +1,124 @@
"""Collection of installer files"""
import os
import time
from gi.repository import GLib, GObject
from lutris.installer.errors import ScriptingError
from lutris.util.steam.log import get_app_state_log
from lutris.util.jobs import AsyncCall
from lutris.util.log import logger
from lutris.config import LutrisConfig
from lutris.runners import (
winesteam,
steam
)
class SteamInstaller(GObject.Object):
"""Handles installation of Steam games"""
__gsignals__ = {
"game-installed": (GObject.SIGNAL_RUN_FIRST, None, (str, )),
}
def __init__(self, steam_uri, file_id):
"""
Params:
steam_uri: Colon separated game info containing:
- $STEAM or $WINESTEAM depending on the version of Steam
Since Steam for Linux can download games for any
platform, using $WINESTEAM has little value except in
some cases where the game needs to be started by Steam
in order to get a CD key (ie. Doom 3 or UT2004)
- 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.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:
runner_id, appid, path = self.steam_uri.split(":", 2)
except ValueError:
raise ScriptingError("Malformed steam path: %s" % self.steam_uri)
self.appid = appid
self.path = path
if runner_id == "$WINESTEAM":
self.platform = "windows"
else:
self.platform = "linux"
@property
def runner(self):
"""Return the runner instance used by this install"""
if not self._runner:
if self.platform == "windows":
self._runner = winesteam.winesteam()
self._runner = steam.steam()
return self._runner
@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
def on_steam_game_installed(self, _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))
self.emit("game-installed", self.appid)
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.emit("game-installed", self.appid)
else:
logger.debug("Installing steam game %s", self.appid)
self.runner.config = LutrisConfig(runner_slug=self.runner.name)
# FIXME Find a way to bring back arch support
# if "arch" in self.steam_data:
# steam_runner.config.game_config["arch"] = self.steam_data["arch"]
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):
raise ScriptingError("Unable to get Steam data for game")
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 != self.prev_states:
logger.debug("Steam installation status:")
logger.debug(states)
self.prev_states = states
if states and states[-1].startswith("Fully Installed"):
logger.debug("Steam game has finished installing")
return False
return True

View file

@ -355,8 +355,7 @@ def check_for_file(game, file_id):
game_dir = os.path.join(source, game)
if not system.path_exists(game_dir):
continue
game_files = os.listdir(game_dir)
for game_file in game_files:
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)

View file

@ -8,7 +8,7 @@ from lutris import runtime, settings
from lutris.config import LutrisConfig
from lutris.runners import import_runner
from lutris.command import MonitoredCommand
from lutris.util import datapath, system
from lutris.util import system
from lutris.util.strings import split_arguments
from lutris.util.log import logger
from lutris.util.wine.wine import (

View file

@ -103,14 +103,10 @@ def gtk_safe(string):
"""Return a string ready to used in Gtk widgets"""
if not string:
string = ""
string = str(string)
return string.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def escape_gtk_label(string):
"""Used to escape some characters for display in Gtk labels"""
return re.sub("&(?!amp;)", "&amp;", string)
def get_formatted_playtime(playtime):
"""Return a human readable value of the play time"""
if not playtime:

View file

@ -1,5 +1,6 @@
from unittest import TestCase
from lutris.installer.interpreter import ScriptInterpreter
from lutris.installer.installer import LutrisInstaller
from lutris.installer.errors import ScriptingError
TEST_INSTALLER = {
@ -20,9 +21,6 @@ class MockInterpreter(ScriptInterpreter):
"""A script interpreter mock."""
runner = 'linux'
def is_valid(self):
return True
class TestScriptInterpreter(TestCase):
def test_script_with_correct_values_is_valid(self):
@ -35,16 +33,17 @@ class TestScriptInterpreter(TestCase):
'version': 'doom-gzdoom'
}
interpreter = ScriptInterpreter(installer, None)
self.assertEqual(interpreter.game_name, 'Doom')
self.assertFalse(interpreter.errors)
self.assertTrue(interpreter.is_valid())
self.assertEqual(interpreter.installer.game_name, 'Doom')
self.assertFalse(interpreter.installer.get_errors())
def test_move_requires_src_and_dst(self):
script = {
'foo': 'bar',
'script': [],
'script': {},
'name': 'missing_runner',
'game_slug': 'missing-runner',
'slug': 'some-slug',
'runner': 'linux',
'version': 'bar-baz'
}
with self.assertRaises(ScriptingError):