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