Merge pull request #3803 from AlexanderRavenheart/flatpak_runner

[WIP] Add Flathub Service and Flatpak Runner
This commit is contained in:
Mathieu Comandon 2022-08-10 18:01:31 -07:00 committed by GitHub
commit a896d5f894
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 357 additions and 9 deletions

View file

@ -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))

View file

@ -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:

View file

@ -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:

View file

@ -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...

View file

@ -6,6 +6,7 @@ __all__ = [
"linux",
"steam",
"web",
"flatpak",
# Microsoft based
"wine",
"dosbox",

143
lutris/runners/flatpak.py Normal file
View 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

View file

@ -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):

View file

@ -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(),
)

View file

@ -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
View 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")