mirror of
https://github.com/lutris/lutris
synced 2024-10-06 15:59:39 +00:00
Merge pull request #3803 from AlexanderRavenheart/flatpak_runner
[WIP] Add Flathub Service and Flatpak Runner
This commit is contained in:
commit
a896d5f894
|
@ -58,7 +58,7 @@ def get_games_where(**conditions):
|
|||
if not hasattr(value, "__iter__"):
|
||||
raise ValueError("Value should be an iterable (%s given)" % value)
|
||||
if len(value) > 999:
|
||||
raise ValueError("SQLite limnited to a maximum of 999 parameters.")
|
||||
raise ValueError("SQLite limited to a maximum of 999 parameters.")
|
||||
if value:
|
||||
condition_fields.append("{} in ({})".format(field, ", ".join("?" * len(value)) or ""))
|
||||
condition_values = list(chain(condition_values, value))
|
||||
|
|
|
@ -13,7 +13,7 @@ class ServiceGameCollection:
|
|||
|
||||
@classmethod
|
||||
def get_game(cls, service, appid):
|
||||
"""Return a single game refered by its appid"""
|
||||
"""Return a single game referred by its appid"""
|
||||
if not service:
|
||||
raise ValueError("No service provided")
|
||||
if not appid:
|
||||
|
|
|
@ -236,7 +236,7 @@ class Game(GObject.Object):
|
|||
self.config.remove()
|
||||
xdgshortcuts.remove_launcher(self.slug, self.id, desktop=True, menu=True)
|
||||
if delete_files and self.runner:
|
||||
self.runner.remove_game_data(game_path=self.directory)
|
||||
self.runner.remove_game_data(app_id=self.appid, game_path=self.directory)
|
||||
self.is_installed = False
|
||||
self.runner = None
|
||||
if no_signal:
|
||||
|
|
|
@ -300,7 +300,7 @@ class Application(Gtk.Application):
|
|||
return str(kwargs)
|
||||
|
||||
def show_window(self, window_class, **kwargs):
|
||||
"""Instanciate a window keeping 1 instance max
|
||||
"""Instantiate a window keeping 1 instance max
|
||||
|
||||
Params:
|
||||
window_class (Gtk.Window): class to create the instance from
|
||||
|
@ -375,7 +375,7 @@ class Application(Gtk.Application):
|
|||
# Switch back the log output to stderr (the default in Python)
|
||||
# to avoid messing with any output from command line options.
|
||||
|
||||
# Use when targetting Python 3.7 minimum
|
||||
# Use when targeting Python 3.7 minimum
|
||||
# console_handler.setStream(sys.stderr)
|
||||
|
||||
# Until then...
|
||||
|
|
|
@ -6,6 +6,7 @@ __all__ = [
|
|||
"linux",
|
||||
"steam",
|
||||
"web",
|
||||
"flatpak",
|
||||
# Microsoft based
|
||||
"wine",
|
||||
"dosbox",
|
||||
|
|
143
lutris/runners/flatpak.py
Normal file
143
lutris/runners/flatpak.py
Normal file
|
@ -0,0 +1,143 @@
|
|||
import os
|
||||
import shutil
|
||||
from gettext import gettext as _
|
||||
from pathlib import Path
|
||||
|
||||
from lutris.command import MonitoredCommand
|
||||
from lutris.runners.runner import Runner
|
||||
from lutris.util.strings import split_arguments
|
||||
|
||||
|
||||
class flatpak(Runner):
|
||||
"""
|
||||
Runner for Flatpak applications.
|
||||
"""
|
||||
|
||||
description = _("Runs Flatpak applications")
|
||||
platforms = [_("Linux")]
|
||||
entry_point_option = "application"
|
||||
human_name = _("Flatpak")
|
||||
runnable_alone = False
|
||||
system_options_override = [{"option": "disable_runtime", "default": True}]
|
||||
# runner_executable = "flatpak"
|
||||
install_locations = {
|
||||
"system": "var/lib/flatpak/app/",
|
||||
"user": f"{Path.home()}/.local/share/flatpak/app/"
|
||||
}
|
||||
|
||||
game_options = [
|
||||
{
|
||||
"option": "appid",
|
||||
"type": "string",
|
||||
"label": _("Application ID"),
|
||||
"help": _("The application's unique three-part identifier (tld.domain.app).")
|
||||
},
|
||||
{
|
||||
"option": "arch",
|
||||
"type": "string",
|
||||
"label": _("Architecture"),
|
||||
"help": _("The architecture to run. "
|
||||
"See flatpak --supported-arches for architectures supported by the host."),
|
||||
"advanced": True
|
||||
},
|
||||
{
|
||||
"option": "branch",
|
||||
"type": "string",
|
||||
"label": _("Branch"),
|
||||
"help": _("The branch to use."),
|
||||
"advanced": True
|
||||
},
|
||||
{
|
||||
"option": "install_type",
|
||||
"type": "string",
|
||||
"label": _("Install type"),
|
||||
"help": _("Can be system or user."),
|
||||
"advanced": True
|
||||
},
|
||||
{
|
||||
"option": "args",
|
||||
"type": "string",
|
||||
"label": _("Args"),
|
||||
"help": _("Arguments to be passed to the application.")
|
||||
},
|
||||
{
|
||||
"option": "command",
|
||||
"type": "string",
|
||||
"label": _("Command"),
|
||||
"help": _("The command to run instead of the one listed in the application metadata."),
|
||||
"advanced": True
|
||||
},
|
||||
{
|
||||
"option": "working_dir",
|
||||
"type": "directory_chooser",
|
||||
"label": _("Working directory"),
|
||||
"help": _("The directory to run the command in. Note that this must be a directory inside the sandbox."),
|
||||
"advanced": True
|
||||
},
|
||||
|
||||
{
|
||||
"option": "env_vars",
|
||||
"type": "string",
|
||||
"label": _("Environment variables"),
|
||||
"help": _("Set an environment variable in the application. "
|
||||
"This overrides to the Context section from the application metadata."),
|
||||
"advanced": True
|
||||
}
|
||||
]
|
||||
|
||||
def is_installed(self):
|
||||
return shutil.which("flatpak")
|
||||
|
||||
def get_executable(self):
|
||||
return shutil.which("flatpak")
|
||||
|
||||
def can_uninstall(self):
|
||||
return False
|
||||
|
||||
def uninstall(self):
|
||||
pass
|
||||
|
||||
def game_path(self):
|
||||
install_type, application, arch, branch = (self.game_data[key] for key in
|
||||
("install_type", "application", "arch", "branch"))
|
||||
return os.path.join(self.install_locations[install_type], application, arch, branch)
|
||||
|
||||
def remove_game_data(self, app_id=None, game_path=None, **kwargs):
|
||||
if not self.is_installed():
|
||||
return False
|
||||
command = MonitoredCommand(
|
||||
[self.get_executable(), f"uninstall --app --noninteractive {app_id}"],
|
||||
runner=self,
|
||||
env=self.get_env(),
|
||||
title=f"Uninstalling Flatpak App: {app_id}"
|
||||
)
|
||||
command.start()
|
||||
|
||||
def play(self):
|
||||
arch = self.game_config.get("arch", "")
|
||||
branch = self.game_config.get("branch", "")
|
||||
args = self.game_config.get("args", "")
|
||||
app_id = self.game_config.get("application", "")
|
||||
|
||||
if not app_id:
|
||||
return {"error": "CUSTOM", "text": "No application specified."}
|
||||
|
||||
if app_id.count(".") < 2:
|
||||
return {"error": "CUSTOM", "text": "Application ID is not specified in correct format."
|
||||
"Must be something like: tld.domain.app"}
|
||||
|
||||
if any(x in app_id for x in ("--", "/")):
|
||||
return {"error": "CUSTOM", "text": "Application ID field must not contain options or arguments."}
|
||||
|
||||
command = [self.get_executable(), "run"]
|
||||
if arch:
|
||||
command.append(f"--arch={arch}")
|
||||
if branch:
|
||||
command.append(f"--branch={branch}")
|
||||
command.append(app_id)
|
||||
if args:
|
||||
command.extend(split_arguments(args))
|
||||
launch_info = {
|
||||
"command": command
|
||||
}
|
||||
return launch_info
|
|
@ -444,7 +444,8 @@ class Runner: # pylint: disable=too-many-public-methods
|
|||
if callback:
|
||||
callback()
|
||||
|
||||
def remove_game_data(self, app_id=None, game_path=None):
|
||||
@staticmethod
|
||||
def remove_game_data(app_id=None, game_path=None):
|
||||
system.remove_folder(game_path)
|
||||
|
||||
def can_uninstall(self):
|
||||
|
|
|
@ -297,11 +297,11 @@ class steam(Runner):
|
|||
"env": self.get_env(),
|
||||
}
|
||||
|
||||
def remove_game_data(self, appid=None, **kwargs):
|
||||
def remove_game_data(self, app_id=None, **kwargs):
|
||||
if not self.is_installed():
|
||||
return False
|
||||
command = MonitoredCommand(
|
||||
[self.get_executable(), "steam://uninstall/%s" % (appid or self.appid)],
|
||||
[self.get_executable(), "steam://uninstall/%s" % (app_id or self.appid)],
|
||||
runner=self,
|
||||
env=self.get_env(),
|
||||
)
|
||||
|
|
|
@ -5,6 +5,7 @@ from lutris import settings
|
|||
from lutris.services.battlenet import BattleNetService
|
||||
from lutris.services.dolphin import DolphinService
|
||||
from lutris.services.egs import EpicGamesStoreService
|
||||
from lutris.services.flathub import FlathubService
|
||||
from lutris.services.gog import GOGService
|
||||
from lutris.services.humblebundle import HumbleBundleService
|
||||
from lutris.services.itchio import ItchIoService
|
||||
|
@ -49,7 +50,8 @@ SERVICES = get_services()
|
|||
WIP_SERVICES = {
|
||||
"battlenet": BattleNetService,
|
||||
"itchio": ItchIoService,
|
||||
"mame": MAMEService,
|
||||
"mame": MAMEService,
|
||||
"flathub": FlathubService
|
||||
}
|
||||
|
||||
if os.environ.get("LUTRIS_ENABLE_ALL_SERVICES"):
|
||||
|
|
201
lutris/services/flathub.py
Normal file
201
lutris/services/flathub.py
Normal file
|
@ -0,0 +1,201 @@
|
|||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from gettext import gettext as _
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from gi.repository import Gio
|
||||
|
||||
from lutris import settings
|
||||
from lutris.services.base import BaseService
|
||||
from lutris.services.service_game import ServiceGame
|
||||
from lutris.services.service_media import ServiceMedia
|
||||
from lutris.util import system
|
||||
from lutris.util.log import logger
|
||||
from lutris.util.strings import slugify
|
||||
|
||||
|
||||
class FlathubBanner(ServiceMedia):
|
||||
"""Standard size of a Flathub banner"""
|
||||
service = "flathub"
|
||||
size = (128, 128)
|
||||
dest_path = os.path.join(settings.CACHE_DIR, "flathub/banners")
|
||||
file_pattern = "%s.png"
|
||||
url_field = 'iconDesktopUrl'
|
||||
|
||||
def get_media_url(self, details):
|
||||
return details.get(self.url_field)
|
||||
|
||||
|
||||
class FlathubGame(ServiceGame):
|
||||
"""Representation of a Flathub game"""
|
||||
service = "flathub"
|
||||
|
||||
@classmethod
|
||||
def new_from_flathub_game(cls, flathub_game):
|
||||
"""Return a Flathub game instance from the API info"""
|
||||
service_game = FlathubGame()
|
||||
service_game.appid = flathub_game["flatpakAppId"]
|
||||
service_game.slug = slugify(flathub_game["name"])
|
||||
service_game.game_slug = slugify(flathub_game["name"])
|
||||
service_game.name = flathub_game["name"]
|
||||
service_game.summary = flathub_game["summary"]
|
||||
service_game.version = flathub_game["currentReleaseVersion"]
|
||||
service_game.runner = "flatpak"
|
||||
service_game.details = json.dumps(flathub_game)
|
||||
return service_game
|
||||
|
||||
|
||||
class FlathubService(BaseService):
|
||||
"""Service class for Flathub"""
|
||||
|
||||
id = "flathub"
|
||||
name = _("Flathub")
|
||||
icon = "flathub"
|
||||
medias = {
|
||||
"banner": FlathubBanner
|
||||
}
|
||||
default_format = "banner"
|
||||
api_url = "https://flathub.org/api/v1/apps/category/Game"
|
||||
cache_path = os.path.join(settings.CACHE_DIR, "flathub-library.json")
|
||||
|
||||
is_loading = False
|
||||
branch = "stable"
|
||||
arch = "x86_64"
|
||||
install_type = "system" # can be either system (default) or user
|
||||
install_locations = {
|
||||
"system": "var/lib/flatpak/app/",
|
||||
"user": f"{Path.home()}/.local/share/flatpak/app/"
|
||||
}
|
||||
runner = "flatpak"
|
||||
game_class = FlathubGame
|
||||
|
||||
def wipe_game_cache(self):
|
||||
"""Wipe the game cache, allowing it to be reloaded"""
|
||||
if system.path_exists(self.cache_path):
|
||||
logger.debug("Deleting %s cache %s", self.id, self.cache_path)
|
||||
os.remove(self.cache_path)
|
||||
super().wipe_game_cache()
|
||||
|
||||
def load(self):
|
||||
"""Load the available games from Flathub"""
|
||||
if self.is_loading:
|
||||
logger.warning("Flathub games are already loading")
|
||||
return
|
||||
self.is_loading = True
|
||||
response = requests.get(self.api_url)
|
||||
entries = response.json()
|
||||
# seen = set()
|
||||
flathub_games = []
|
||||
for game in entries:
|
||||
# if game["flatpakAppId"] in seen:
|
||||
# continue
|
||||
flathub_games.append(FlathubGame.new_from_flathub_game(game))
|
||||
# seen.add(game["flatpakAppId"])
|
||||
for game in flathub_games:
|
||||
game.save()
|
||||
self.is_loading = False
|
||||
return flathub_games
|
||||
|
||||
def install(self, db_game):
|
||||
"""Install a Flathub game"""
|
||||
app_id = db_game["appid"]
|
||||
logger.debug("Installing %s from service %s", app_id, self.id)
|
||||
# Check if Flathub repo is active on the system
|
||||
if not self.is_flathub_remote_active():
|
||||
logger.error("Flathub is not configured on the system. Visit https://flatpak.org/setup/ for instructions.")
|
||||
return
|
||||
# Check if game is already installed
|
||||
if app_id in self.get_installed_apps():
|
||||
logger.debug("%s is already installed.", app_id)
|
||||
return
|
||||
# Install the game
|
||||
service_installers = self.get_installers_from_api(app_id)
|
||||
if not service_installers:
|
||||
service_installers = [self.generate_installer(db_game)]
|
||||
application = Gio.Application.get_default()
|
||||
application.show_installer_window(service_installers, service=self, appid=app_id)
|
||||
|
||||
@staticmethod
|
||||
def get_installed_apps():
|
||||
"""Get list of installed Flathub apps"""
|
||||
try:
|
||||
process = subprocess.run(["flatpak", "list", "--app", "--columns=application"],
|
||||
capture_output=True, check=True, encoding="utf-8", text=True, timeout=5.0)
|
||||
return process.stdout.splitlines() or []
|
||||
except (TimeoutError, subprocess.CalledProcessError) as err:
|
||||
logger.exception("Error occurred while getting installed flatpak apps: %s", err)
|
||||
return []
|
||||
|
||||
def is_flathub_remote_active(self):
|
||||
"""Check if Flathub is configured and enabled as a flatpak repository"""
|
||||
remotes = self.get_active_remotes()
|
||||
for remote in remotes:
|
||||
if 'flathub' in remote.values():
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_active_remotes():
|
||||
"""Get a list of dictionaries containing name, title and url"""
|
||||
try:
|
||||
process = subprocess.run(["flatpak", "remotes", "--columns=name,title,url"],
|
||||
capture_output=True, check=True, encoding="utf-8", text=True, timeout=5.0)
|
||||
entries = []
|
||||
for line in process.stdout.splitlines():
|
||||
cols = line.split("\t")
|
||||
entries.append({
|
||||
"name": cols[0].lower(),
|
||||
"title": cols[1].lower(),
|
||||
"url": cols[2]
|
||||
})
|
||||
return entries
|
||||
except (TimeoutError, subprocess.CalledProcessError) as err:
|
||||
logger.exception("Error occurred while getting installed flatpak apps: %s", err)
|
||||
return []
|
||||
|
||||
def generate_installer(self, db_game):
|
||||
# TODO: Add options for user to select arch, branch and install_type
|
||||
return {
|
||||
"appid": db_game["appid"],
|
||||
"game_slug": slugify(db_game["name"]),
|
||||
"slug": slugify(db_game["name"]) + "-" + self.id,
|
||||
"name": db_game["name"],
|
||||
"version": "Flathub",
|
||||
"runner": self.runner,
|
||||
"script": {
|
||||
"game": {
|
||||
"appid": db_game["appid"],
|
||||
"arch": self.arch,
|
||||
"branch": self.branch,
|
||||
"install_type": self.install_type
|
||||
},
|
||||
"system": {
|
||||
"disable_runtime": True
|
||||
},
|
||||
"require-binaries": "flatpak",
|
||||
"installer": [
|
||||
{
|
||||
"execute":
|
||||
{
|
||||
"file": "flatpak",
|
||||
"args": f"install --app --noninteractive flathub "
|
||||
f"app/{db_game['appid']}/{self.arch}/{self.branch}",
|
||||
"disable_runtime": True
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
def get_game_directory(self, _installer):
|
||||
install_type, application, arch, branch = (_installer["script"]["game"][key] for key in
|
||||
("install_type", "application", "arch", "branch"))
|
||||
return os.path.join(self.install_locations[install_type], application, arch, branch)
|
||||
|
||||
# def add_installed_games(self):
|
||||
# process = subprocess.run(["flatpak", "list", "--app", "--columns=application,arch,branch,installation,name"],
|
||||
# capture_output=True, check=True, encoding="utf-8", text=True)
|
||||
# for line in process.stdout.splitlines():
|
||||
# cols = line.split("\t")
|
Loading…
Reference in a new issue