Merge pull request #124 from kra-mo/flatpak-source

Flatpak source
This commit is contained in:
kramo
2023-06-30 23:50:58 +02:00
committed by GitHub
15 changed files with 299 additions and 50 deletions

View File

@@ -132,6 +132,15 @@ template $PreferencesWindow : Adw.PreferencesWindow {
valign: center;
}
}
Adw.ActionRow {
title: _("Import Flatpak Games");
activatable-widget: lutris_import_flatpak_switch;
Switch lutris_import_flatpak_switch {
valign: center;
}
}
}
Adw.ExpanderRow heroic_expander_row {
@@ -216,6 +225,29 @@ template $PreferencesWindow : Adw.PreferencesWindow {
}
}
}
Adw.ExpanderRow flatpak_expander_row {
title: _("Flatpak");
show-enable-switch: true;
Adw.ActionRow flatpak_data_action_row {
title: _("Install Location");
Button flatpak_config_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
}
Adw.ActionRow flatpak_import_launchers_row {
title: _("Import Game Launchers");
activatable-widget: flatpak_import_launchers_switch;
Switch flatpak_import_launchers_switch {
valign: center;
}
}
}
}
}

View File

@@ -28,6 +28,9 @@
<key name="lutris-import-steam" type="b">
<default>false</default>
</key>
<key name="lutris-import-flatpak" type="b">
<default>false</default>
</key>
<key name="heroic" type="b">
<default>true</default>
</key>
@@ -61,6 +64,15 @@
<key name="legendary-location" type="s">
<default>"~/.config/legendary/"</default>
</key>
<key name="flatpak" type="b">
<default>true</default>
</key>
<key name="flatpak-location" type="s">
<default>"/var/lib/flatpak/exports"</default>
</key>
<key name="flatpak-import-launchers" type="b">
<default>false</default>
</key>
<key name="sgdb-key" type="s">
<default>""</default>
</key>

View File

@@ -16,7 +16,8 @@
"--filesystem=~/.var/app/net.lutris.Lutris/:ro",
"--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro",
"--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/:ro",
"--filesystem=~/.var/app/io.itch.itch/config/itch/:ro"
"--filesystem=~/.var/app/io.itch.itch/config/itch/:ro",
"--filesystem=/var/lib/flatpak:ro"
],
"cleanup" : [
"/include",
@@ -96,6 +97,20 @@
"sha256": "bf548479d336726d7a0eceb6e767e179fbde37833ae42794602631a070d630f1"
}
]
},
{
"name": "python3-pyxdg",
"buildsystem": "simple",
"build-commands": [
"pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"pyxdg\" --no-build-isolation"
],
"sources": [
{
"type": "file",
"url": "https://files.pythonhosted.org/packages/e5/8d/cf41b66a8110670e3ad03dab9b759704eeed07fa96e90fdc0357b2ba70e2/pyxdg-0.28-py2.py3-none-any.whl",
"sha256": "bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab"
}
]
}
]
},

View File

@@ -41,13 +41,13 @@ class BottlesSourceIterator(SourceIterator):
data = self.source.data_location["library.yml"].read_text("utf-8")
library: dict = yaml.safe_load(data)
added_time = int(time())
for entry in library.values():
# Build game
values = {
"version": shared.SPEC_VERSION,
"source": self.source.id,
"added": int(time()),
"added": added_time,
"name": entry["name"],
"game_id": self.source.game_id_format.format(game_id=entry["id"]),
"executable": self.source.executable_format.format(

View File

@@ -0,0 +1,134 @@
# flatpak_source.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from pathlib import Path
from time import time
import subprocess
from xdg import IconTheme
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.source import Source, SourceIterationResult, SourceIterator
class FlatpakSourceIterator(SourceIterator):
source: "FlatpakSource"
def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games"""
added_time = int(time())
IconTheme.icondirs.append(self.source.data_location["icons"])
try:
process = subprocess.run(
("flatpak-spawn", "--host", "flatpak", "list", "--columns=application"),
capture_output=True,
encoding="utf-8",
check=True,
)
flatpak_ids = process.stdout.split("\n")
to_remove = (
{"hu.kramo.Cartridges"}
if shared.schema.get_boolean("flatpak-import-launchers")
else {
"hu.kramo.Cartridges",
"com.valvesoftware.Steam",
"net.lutris.Lutris",
"com.heroicgameslauncher.hgl",
"com.usebottles.Bottles",
"io.itch.itch",
}
)
for item in to_remove:
if item in flatpak_ids:
flatpak_ids.remove(item)
except subprocess.CalledProcessError:
return
for entry in (self.source.data_location["applications"]).iterdir():
flatpak_id = entry.stem
if flatpak_id not in flatpak_ids:
continue
with entry.open("r", encoding="utf-8") as open_file:
string = open_file.read()
desktop_values = {"Name": None, "Icon": None, "Categories": None}
for key in desktop_values:
if regex := re.findall(f"{key}=(.*)\n", string):
desktop_values[key] = regex[0]
if not desktop_values["Name"]:
continue
if not desktop_values["Categories"]:
continue
if not "Game" in desktop_values["Categories"].split(";"):
continue
values = {
"source": self.source.id,
"added": added_time,
"name": desktop_values["Name"],
"game_id": self.source.game_id_format.format(game_id=flatpak_id),
"executable": self.source.executable_format.format(
flatpak_id=flatpak_id
),
}
game = Game(values, allow_side_effects=False)
additional_data = {}
if icon_name := desktop_values["Icon"]:
if icon_path := IconTheme.getIconPath(icon_name, 512):
additional_data = {"local_icon_path": Path(icon_path)}
else:
pass
# Produce game
yield (game, additional_data)
class FlatpakSource(Source):
"""Generic Flatpak source"""
name = "Flatpak"
iterator_class = FlatpakSourceIterator
executable_format = "flatpak run {flatpak_id}"
available_on = set(("linux",))
data_location = Location(
schema_key="flatpak-location",
candidates=(
"/var/lib/flatpak/exports/",
shared.data_dir / "flatpak" / "exports",
),
paths={
"applications": (True, "share/applications"),
"icons": (True, "share/icons"),
},
)

View File

@@ -68,7 +68,7 @@ class HeroicSourceIterator(SourceIterator):
}
def game_from_library_entry(
self, entry: HeroicLibraryEntry
self, entry: HeroicLibraryEntry, added_time: int
) -> SourceIterationResult:
"""Helper method used to build a Game from a Heroic library entry"""
@@ -81,9 +81,8 @@ class HeroicSourceIterator(SourceIterator):
runner = entry["runner"]
service = self.sub_sources[runner]["service"]
values = {
"version": shared.SPEC_VERSION,
"source": self.source.id,
"added": int(time()),
"added": added_time,
"name": entry["title"],
"developer": entry["developer"],
"game_id": self.source.game_id_format.format(
@@ -119,9 +118,12 @@ class HeroicSourceIterator(SourceIterator):
# Invalid library.json file, skip it
logging.warning("Couldn't open Heroic file: %s", str(file))
continue
added_time = int(time())
for entry in library:
try:
result = self.game_from_library_entry(entry)
result = self.game_from_library_entry(entry, added_time)
except KeyError:
# Skip invalid games
logging.warning("Invalid Heroic game skipped in %s", str(file))
@@ -130,7 +132,7 @@ class HeroicSourceIterator(SourceIterator):
class HeroicSource(URLExecutableSource):
"""Generic heroic games launcher source"""
"""Generic Heroic Games Launcher source"""
name = "Heroic"
iterator_class = HeroicSourceIterator
@@ -141,9 +143,8 @@ class HeroicSource(URLExecutableSource):
schema_key="heroic-location",
candidates=(
"~/.var/app/com.heroicgameslauncher.hgl/config/heroic/",
shared.config_dir / "heroic/",
"~/.config/heroic/",
shared.appdata_dir / "heroic/",
shared.config_dir / "heroic",
shared.appdata_dir / "heroic",
),
paths={
"config.json": (False, "config.json"),

View File

@@ -59,11 +59,12 @@ class ItchSourceIterator(SourceIterator):
connection = connect(db_path)
cursor = connection.execute(db_request)
added_time = int(time())
# Create games from the db results
for row in cursor:
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"added": added_time,
"source": self.source.id,
"name": row[1],
"game_id": self.source.game_id_format.format(game_id=row[0]),
@@ -87,9 +88,8 @@ class ItchSource(URLExecutableSource):
schema_key="itch-location",
candidates=(
"~/.var/app/io.itch.itch/config/itch/",
shared.config_dir / "itch/",
"~/.config/itch/",
shared.appdata_dir / "itch/",
shared.config_dir / "itch",
shared.appdata_dir / "itch",
),
paths={"butler.db": (False, "db/butler.db")},
)

View File

@@ -32,7 +32,9 @@ from src.importer.sources.source import Source, SourceIterationResult, SourceIte
class LegendarySourceIterator(SourceIterator):
source: "LegendarySource"
def game_from_library_entry(self, entry: dict) -> SourceIterationResult:
def game_from_library_entry(
self, entry: dict, added_time: int
) -> SourceIterationResult:
# Skip non-games
if entry["is_dlc"]:
return None
@@ -40,8 +42,7 @@ class LegendarySourceIterator(SourceIterator):
# Build game
app_name = entry["app_name"]
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"added": added_time,
"source": self.source.id,
"name": entry["title"],
"game_id": self.source.game_id_format.format(game_id=app_name),
@@ -72,10 +73,13 @@ class LegendarySourceIterator(SourceIterator):
except (JSONDecodeError, OSError):
logging.warning("Couldn't open Legendary file: %s", str(file))
return
added_time = int(time())
# Generate games from library
for entry in library.values():
try:
result = self.game_from_library_entry(entry)
result = self.game_from_library_entry(entry, added_time)
except KeyError as error:
# Skip invalid games
logging.warning(
@@ -93,10 +97,7 @@ class LegendarySource(Source):
iterator_class = LegendarySourceIterator
data_location: Location = Location(
schema_key="legendary-location",
candidates=(
shared.config_dir / "legendary/",
"~/.config/legendary",
),
candidates=(shared.config_dir / "legendary",),
paths={
"installed.json": (False, "installed.json"),
"metadata": (True, "metadata"),

View File

@@ -48,19 +48,24 @@ class LutrisSourceIterator(SourceIterator):
AND configPath IS NOT NULL
AND installed
AND (runner IS NOT "steam" OR :import_steam)
AND (runner IS NOT "flatpak" OR :import_flatpak)
;
"""
params = {"import_steam": shared.schema.get_boolean("lutris-import-steam")}
params = {
"import_steam": shared.schema.get_boolean("lutris-import-steam"),
"import_flatpak": shared.schema.get_boolean("lutris-import-flatpak"),
}
db_path = copy_db(self.source.data_location["pga.db"])
connection = connect(db_path)
cursor = connection.execute(request, params)
added_time = int(time())
# Create games from the DB results
for row in cursor:
# Create game
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"added": added_time,
"hidden": row[4],
"name": row[1],
"source": f"{self.source.id}_{row[3]}",
@@ -83,7 +88,7 @@ class LutrisSourceIterator(SourceIterator):
class LutrisSource(URLExecutableSource):
"""Generic lutris source"""
"""Generic Lutris source"""
name = "Lutris"
iterator_class = LutrisSourceIterator
@@ -96,8 +101,7 @@ class LutrisSource(URLExecutableSource):
schema_key="lutris-location",
candidates=(
"~/.var/app/net.lutris.Lutris/data/lutris/",
shared.data_dir / "lutris/",
"~/.local/share/lutris/",
shared.data_dir / "lutris",
),
paths={
"pga.db": (False, "pga.db"),
@@ -108,8 +112,7 @@ class LutrisSource(URLExecutableSource):
schema_key="lutris-cache-location",
candidates=(
"~/.var/app/net.lutris.Lutris/cache/lutris/",
shared.cache_dir / "lutris/",
"~/.cache/lutris",
shared.cache_dir / "lutris",
),
paths={
"coverart": (True, "coverart"),

View File

@@ -87,7 +87,7 @@ class Source(Iterable):
@property
def game_id_format(self) -> str:
"""The string format used to construct game IDs"""
return self.name.lower() + "_{game_id}"
return self.id + "_{game_id}"
@property
def is_available(self):

View File

@@ -66,6 +66,9 @@ class SteamSourceIterator(SourceIterator):
"""Generator method producing games"""
appid_cache = set()
manifests = self.get_manifests()
added_time = int(time())
for manifest in manifests:
# Get metadata from manifest
steam = SteamFileHelper()
@@ -87,8 +90,7 @@ class SteamSourceIterator(SourceIterator):
# Build game from local data
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"added": added_time,
"name": local_data["name"],
"source": self.source.id,
"game_id": self.source.game_id_format.format(game_id=appid),
@@ -117,8 +119,8 @@ class SteamSource(URLExecutableSource):
schema_key="steam-location",
candidates=(
"~/.var/app/com.valvesoftware.Steam/data/Steam/",
shared.data_dir / "Steam/",
"~/.steam/",
shared.data_dir / "Steam",
"~/.steam",
shared.programfiles32_dir / "Steam",
),
paths={

View File

@@ -34,6 +34,7 @@ from src.details_window import DetailsWindow
from src.game import Game
from src.importer.importer import Importer
from src.importer.sources.bottles_source import BottlesSource
from src.importer.sources.flatpak_source import FlatpakSource
from src.importer.sources.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource
@@ -222,6 +223,9 @@ class CartridgesApplication(Adw.Application):
if shared.schema.get_boolean("bottles"):
importer.add_source(BottlesSource())
if shared.schema.get_boolean("flatpak"):
importer.add_source(FlatpakSource())
if shared.schema.get_boolean("itch"):
importer.add_source(ItchSource())

View File

@@ -26,6 +26,7 @@ from gi.repository import Adw, Gio, GLib, Gtk
from src import shared
from src.importer.sources.bottles_source import BottlesSource
from src.importer.sources.flatpak_source import FlatpakSource
from src.importer.sources.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource
@@ -59,6 +60,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
lutris_cache_action_row = Gtk.Template.Child()
lutris_cache_file_chooser_button = Gtk.Template.Child()
lutris_import_steam_switch = Gtk.Template.Child()
lutris_import_flatpak_switch = Gtk.Template.Child()
heroic_expander_row = Gtk.Template.Child()
heroic_config_action_row = Gtk.Template.Child()
@@ -79,6 +81,11 @@ class PreferencesWindow(Adw.PreferencesWindow):
legendary_config_action_row = Gtk.Template.Child()
legendary_config_file_chooser_button = Gtk.Template.Child()
flatpak_expander_row = Gtk.Template.Child()
flatpak_data_action_row = Gtk.Template.Child()
flatpak_config_file_chooser_button = Gtk.Template.Child()
flatpak_import_launchers_switch = Gtk.Template.Child()
sgdb_key_group = Gtk.Template.Child()
sgdb_key_entry_row = Gtk.Template.Child()
sgdb_switch = Gtk.Template.Child()
@@ -124,6 +131,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
# Sources settings
for source_class in (
BottlesSource,
FlatpakSource,
HeroicSource,
ItchSource,
LegendarySource,
@@ -168,9 +176,11 @@ class PreferencesWindow(Adw.PreferencesWindow):
"cover-launches-game",
"high-quality-images",
"lutris-import-steam",
"lutris-import-flatpak",
"heroic-import-epic",
"heroic-import-gog",
"heroic-import-sideload",
"flatpak-import-launchers",
"sgdb",
"sgdb-prefer",
"sgdb-animated",

View File

@@ -1,6 +1,7 @@
# local_cover_manager.py
#
# Copyright 2023 Geoffrey Coulaud
# Copyright 2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -17,12 +18,13 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from gi.repository import GdkPixbuf
from src import shared
from src.game import Game
from src.store.managers.manager import Manager
from src.store.managers.steam_api_manager import SteamAPIManager
from src.utils.save_cover import save_cover, resize_cover
from src.utils.save_cover import resize_cover, save_cover
class LocalCoverManager(Manager):
@@ -31,12 +33,39 @@ class LocalCoverManager(Manager):
run_after = (SteamAPIManager,)
def manager_logic(self, game: Game, additional_data: dict) -> None:
# Ensure that the cover path is in the additional data
try:
image_path: Path = additional_data["local_image_path"]
except KeyError:
return
if image_path := additional_data.get("local_image_path"):
if not image_path.is_file():
return
# Save the image
save_cover(game.game_id, resize_cover(image_path))
elif icon_path := additional_data.get("local_icon_path"):
cover_width, cover_height = shared.image_size
dest_width = cover_width * 0.7
dest_height = cover_width * 0.7
dest_x = cover_width * 0.15
dest_y = (cover_height - dest_height) / 2
image = GdkPixbuf.Pixbuf.new_from_file(str(icon_path)).scale_simple(
dest_width, dest_height, GdkPixbuf.InterpType.BILINEAR
)
cover = image.scale_simple(
1, 2, GdkPixbuf.InterpType.BILINEAR
).scale_simple(cover_width, cover_height, GdkPixbuf.InterpType.BILINEAR)
image.composite(
cover,
dest_x,
dest_y,
dest_width,
dest_height,
dest_x,
dest_y,
1,
1,
GdkPixbuf.InterpType.BILINEAR,
255,
)
save_cover(game.game_id, resize_cover(pixbuf=cover))

View File

@@ -21,7 +21,7 @@
from pathlib import Path
from shutil import copyfile
from gi.repository import Gio
from gi.repository import Gdk, Gio, GLib
from PIL import Image, ImageSequence, UnidentifiedImageError
from src import shared
@@ -63,6 +63,12 @@ def resize_cover(cover_path=None, pixbuf=None):
else "webp",
)
except UnidentifiedImageError:
try:
Gdk.Texture.new_from_filename(str(cover_path)).save_to_tiff(
tmp_path := Gio.File.new_tmp("XXXXXX.tiff")[0].get_path()
)
return resize_cover(tmp_path)
except GLib.GError:
return None
return tmp_path