diff --git a/src/importer/sources/heroic_source.py b/src/importer/sources/heroic_source.py new file mode 100644 index 0000000..1dc1d8f --- /dev/null +++ b/src/importer/sources/heroic_source.py @@ -0,0 +1,176 @@ +import json +from abc import abstractmethod +from hashlib import sha256 +from json import JSONDecodeError +from pathlib import Path +from time import time +from typing import Generator, Optional, TypedDict + +from src import shared +from src.game import Game +from src.importer.sources.source import Source, SourceIterator +from src.utils.decorators import ( + replaced_by_env_path, + replaced_by_path, + replaced_by_schema_key, +) +from src.utils.save_cover import resize_cover, save_cover + + +class HeroicLibraryEntry(TypedDict): + app_name: str + installed: Optional[bool] + runner: str + title: str + developer: str + art_square: str + + +class HeroicSubSource(TypedDict): + service: str + path: tuple[str] + + +class HeroicSourceIterator(SourceIterator): + source: "HeroicSource" + generator: Generator = None + sub_sources: dict[str, HeroicSubSource] = { + "sideload": { + "service": "sideload", + "path": ("sideload_apps", "library.json"), + }, + "legendary": { + "service": "epic", + "path": ("store_cache", "legendary_library.json"), + }, + "gog": { + "service": "gog", + "path": ("store_cache", "gog_library.json"), + }, + } + + def game_from_library_entry(self, entry: HeroicLibraryEntry) -> Optional[Game]: + """Helper method used to build a Game from a Heroic library entry""" + + # Skip games that are not installed + if not entry["installed"]: + return None + + # Build game + app_name = entry["app_name"] + runner = entry["runner"] + service = self.sub_sources[runner]["service"] + values = { + "version": shared.SPEC_VERSION, + "hidden": False, + "source": self.source.id, + "added": int(time()), + "name": entry["title"], + "developer": entry["developer"], + "game_id": self.source.game_id_format.format( + service=service, game_id=app_name + ), + "executable": self.source.executable_format.format(app_name=app_name), + } + + # Save image from the heroic cache + # Filenames are derived from the URL that heroic used to get the file + uri: str = entry["art_square"] + if service == "epic": + uri += "?h=400&resize=1&w=300" + digest = sha256(uri.encode()).hexdigest() + image_path = self.source.location / "images-cache" / digest + if image_path.is_file(): + save_cover(values["game_id"], resize_cover(image_path)) + + return Game(values, allow_side_effects=False) + + def sub_sources_generator(self): + """Generator method producing games from all the Heroic sub-sources""" + for key, sub_source in self.sub_sources.items(): + # Skip disabled sub-sources + if not shared.schema.get_boolean("heroic-import-" + key): + continue + # Load games from JSON + try: + file = self.source.location.joinpath(*sub_source["path"]) + library = json.load(file.open())["library"] + except (JSONDecodeError, OSError, KeyError): + # Invalid library.json file, skip it + continue + for entry in library: + try: + game = self.game_from_library_entry(entry) + except KeyError: + # Skip invalid games + continue + yield game + + def __init__(self, source: "HeroicSource") -> None: + self.source = source + self.generator = self.sub_sources_generator() + + def __next__(self) -> Optional[Game]: + try: + game = next(self.generator) + except StopIteration: + raise + return game + + +class HeroicSource(Source): + """Generic heroic games launcher source""" + + name = "Heroic" + executable_format = "xdg-open heroic://launch/{app_name}" + + @property + @abstractmethod + def location(self) -> Path: + pass + + @property + def game_id_format(self) -> str: + """The string format used to construct game IDs""" + return self.name.lower() + "_{service}_{game_id}" + + @property + def is_installed(self): + # pylint: disable=pointless-statement + try: + self.location + except FileNotFoundError: + return False + return True + + def __iter__(self): + return HeroicSourceIterator(source=self) + + +class HeroicNativeSource(HeroicSource): + variant = "native" + + @replaced_by_schema_key("heroic-location") + @replaced_by_env_path("XDG_CONFIG_HOME", "heroic/") + @replaced_by_path("~/.config/heroic/") + def location(self) -> Path: + raise FileNotFoundError() + + +class HeroicFlatpakSource(HeroicSource): + variant = "flatpak" + + @replaced_by_schema_key("heroic-flatpak-location") + @replaced_by_path("~/.var/app/com.heroicgameslauncher.hgl/config/heroic/") + def location(self) -> Path: + raise FileNotFoundError() + + +class HeroicWindowsSource(HeroicSource): + variant = "windows" + executable_format = "start heroic://launch/{app_name}" + + @replaced_by_schema_key("heroic-windows-location") + @replaced_by_env_path("appdata", "heroic/") + def location(self) -> Path: + raise FileNotFoundError() diff --git a/src/main.py b/src/main.py index fc90bf4..8646622 100644 --- a/src/main.py +++ b/src/main.py @@ -34,6 +34,11 @@ from src import shared from src.details_window import DetailsWindow from src.game import Game from src.importer.importer import Importer +from src.importer.sources.heroic_source import ( + HeroicFlatpakSource, + HeroicNativeSource, + HeroicWindowsSource, +) from src.importer.sources.lutris_source import LutrisFlatpakSource, LutrisNativeSource from src.importer.sources.steam_source import ( SteamFlatpakSource, @@ -193,6 +198,10 @@ class CartridgesApplication(Adw.Application): importer.add_source(SteamNativeSource()) importer.add_source(SteamFlatpakSource()) importer.add_source(SteamWindowsSource()) + if shared.schema.get_boolean("heroic"): + importer.add_source(HeroicNativeSource()) + importer.add_source(HeroicFlatpakSource()) + importer.add_source(HeroicWindowsSource()) importer.run() def on_remove_game_action(self, *_args):