mirror of
https://github.com/lutris/lutris
synced 2024-09-15 22:09:55 +00:00
Gather installer files before installation, present them to the user
This commit is contained in:
parent
759061b4e2
commit
26e476cea3
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
331
lutris/installer/installer.py
Normal file
331
lutris/installer/installer.py
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
124
lutris/installer/steam_installer.py
Normal file
124
lutris/installer/steam_installer.py
Normal 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
|
|
@ -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)
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def escape_gtk_label(string):
|
||||
"""Used to escape some characters for display in Gtk labels"""
|
||||
return re.sub("&(?!amp;)", "&", string)
|
||||
|
||||
|
||||
def get_formatted_playtime(playtime):
|
||||
"""Return a human readable value of the play time"""
|
||||
if not playtime:
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in a new issue