diff --git a/data/gtk/preferences.blp b/data/gtk/preferences.blp index eacd33c..6fc3da1 100644 --- a/data/gtk/preferences.blp +++ b/data/gtk/preferences.blp @@ -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; + } + } + } } } diff --git a/data/hu.kramo.Cartridges.gschema.xml.in b/data/hu.kramo.Cartridges.gschema.xml.in index 4738807..038a465 100644 --- a/data/hu.kramo.Cartridges.gschema.xml.in +++ b/data/hu.kramo.Cartridges.gschema.xml.in @@ -28,6 +28,9 @@ false + + false + true @@ -61,6 +64,15 @@ "~/.config/legendary/" + + true + + + "/var/lib/flatpak/exports" + + + false + "" diff --git a/flatpak/hu.kramo.Cartridges.Devel.json b/flatpak/hu.kramo.Cartridges.Devel.json index 719deed..3c6d159 100644 --- a/flatpak/hu.kramo.Cartridges.Devel.json +++ b/flatpak/hu.kramo.Cartridges.Devel.json @@ -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" + } + ] } ] }, diff --git a/src/importer/sources/bottles_source.py b/src/importer/sources/bottles_source.py index 1136334..01e504b 100644 --- a/src/importer/sources/bottles_source.py +++ b/src/importer/sources/bottles_source.py @@ -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( diff --git a/src/importer/sources/flatpak_source.py b/src/importer/sources/flatpak_source.py new file mode 100644 index 0000000..0609dae --- /dev/null +++ b/src/importer/sources/flatpak_source.py @@ -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 . +# +# 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"), + }, + ) diff --git a/src/importer/sources/heroic_source.py b/src/importer/sources/heroic_source.py index 17d5da2..a5583ed 100644 --- a/src/importer/sources/heroic_source.py +++ b/src/importer/sources/heroic_source.py @@ -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"), diff --git a/src/importer/sources/itch_source.py b/src/importer/sources/itch_source.py index df861fd..4c6cd23 100644 --- a/src/importer/sources/itch_source.py +++ b/src/importer/sources/itch_source.py @@ -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")}, ) diff --git a/src/importer/sources/legendary_source.py b/src/importer/sources/legendary_source.py index 4806c24..65758b4 100644 --- a/src/importer/sources/legendary_source.py +++ b/src/importer/sources/legendary_source.py @@ -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"), diff --git a/src/importer/sources/lutris_source.py b/src/importer/sources/lutris_source.py index 1fa0aa0..f9a384e 100644 --- a/src/importer/sources/lutris_source.py +++ b/src/importer/sources/lutris_source.py @@ -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"), diff --git a/src/importer/sources/source.py b/src/importer/sources/source.py index 11f4eb1..cb9eb0c 100644 --- a/src/importer/sources/source.py +++ b/src/importer/sources/source.py @@ -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): diff --git a/src/importer/sources/steam_source.py b/src/importer/sources/steam_source.py index aed6328..5b7820c 100644 --- a/src/importer/sources/steam_source.py +++ b/src/importer/sources/steam_source.py @@ -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={ diff --git a/src/main.py b/src/main.py index e16bd35..9d5851e 100644 --- a/src/main.py +++ b/src/main.py @@ -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()) diff --git a/src/preferences.py b/src/preferences.py index dc25957..72ed609 100644 --- a/src/preferences.py +++ b/src/preferences.py @@ -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", diff --git a/src/store/managers/local_cover_manager.py b/src/store/managers/local_cover_manager.py index 9aa8703..8f621e6 100644 --- a/src/store/managers/local_cover_manager.py +++ b/src/store/managers/local_cover_manager.py @@ -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 not image_path.is_file(): - return - # Save the image - save_cover(game.game_id, resize_cover(image_path)) + if image_path := additional_data.get("local_image_path"): + if not image_path.is_file(): + return + 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)) diff --git a/src/utils/save_cover.py b/src/utils/save_cover.py index f405977..0bbcd4b 100644 --- a/src/utils/save_cover.py +++ b/src/utils/save_cover.py @@ -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,7 +63,13 @@ def resize_cover(cover_path=None, pixbuf=None): else "webp", ) except UnidentifiedImageError: - return None + 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