From ea0e5b5c478a59cfe44006f6a25843a9f7edf96d Mon Sep 17 00:00:00 2001 From: kramo Date: Mon, 22 Dec 2025 16:29:29 +0100 Subject: [PATCH] heroic: Implement Heroic source --- cartridges/application.py | 4 +- cartridges/sources/__init__.py | 12 ++ cartridges/sources/heroic.py | 188 +++++++++++++++++++++++ flatpak/page.kramo.Cartridges.Devel.json | 1 + po/POTFILES.in | 1 + 5 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 cartridges/sources/heroic.py diff --git a/cartridges/application.py b/cartridges/application.py index b896c69..869e24f 100644 --- a/cartridges/application.py +++ b/cartridges/application.py @@ -10,7 +10,7 @@ from gi.repository import Adw from cartridges import collections, games from cartridges.games import Game -from cartridges.sources import Source, steam +from cartridges.sources import Source, heroic, steam from .config import APP_ID, PREFIX from .ui.window import Window @@ -29,7 +29,7 @@ class Application(Adw.Application): self.set_accels_for_action("app.quit", ("q",)) saved = tuple(games.load()) - new = self.import_games(steam, skip_ids={g.game_id for g in saved}) + new = self.import_games(steam, heroic, skip_ids={g.game_id for g in saved}) games.model.splice(0, 0, (*saved, *new)) collections.load() diff --git a/cartridges/sources/__init__.py b/cartridges/sources/__init__.py index 19b0257..3f272a9 100644 --- a/cartridges/sources/__init__.py +++ b/cartridges/sources/__init__.py @@ -12,8 +12,20 @@ from gi.repository import GLib from cartridges.games import Game DATA = Path(GLib.get_user_data_dir()) +CONFIG = Path(GLib.get_user_config_dir()) +CACHE = Path(GLib.get_user_cache_dir()) + FLATPAK = Path.home() / ".var" / "app" +HOST_DATA = Path(os.getenv("HOST_XDG_DATA_HOME", Path.home() / ".local" / "share")) +HOST_CONFIG = Path(os.getenv("HOST_XDG_CONFIG_HOME", Path.home() / ".config")) +HOST_CACHE = Path(os.getenv("HOST_XDG_CACHE_HOME", Path.home() / ".cache")) + PROGRAM_FILES_X86 = Path(os.getenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) +APPDATA = Path(os.getenv("APPDATA", r"C:\Users\Default\AppData\Roaming")) +LOCAL_APPDATA = Path( + os.getenv("CSIDL_LOCAL_APPDATA", r"C:\Users\Default\AppData\Local") +) + APPLICATION_SUPPORT = Path.home() / "Library" / "Application Support" OPEN = ( diff --git a/cartridges/sources/heroic.py b/cartridges/sources/heroic.py new file mode 100644 index 0000000..e658a04 --- /dev/null +++ b/cartridges/sources/heroic.py @@ -0,0 +1,188 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright 2023 Geoffrey Coulaud +# SPDX-FileCopyrightText: Copyright 2022-2025 kramo + +import json +import time +from abc import ABC, abstractmethod +from collections.abc import Generator, Iterable +from contextlib import suppress +from gettext import gettext as _ +from hashlib import sha256 +from json import JSONDecodeError +from pathlib import Path +from typing import Any, override + +from gi.repository import Gdk, GLib + +from cartridges.games import Game + +from . import APPDATA, APPLICATION_SUPPORT, CONFIG, FLATPAK, HOST_CONFIG, OPEN + +ID, NAME = "heroic", _("Heroic") + +_CONFIG_PATHS = ( + CONFIG / "heroic", + HOST_CONFIG / "heroic", + FLATPAK / "com.heroicgameslauncher.hgl" / "config" / "heroic", + APPDATA / "heroic", + APPLICATION_SUPPORT / "heroic", +) + + +class _Source(ABC): + ID: str + COVER_URI_PARAMS = "" + LIBRARY_KEY = "library" + + @classmethod + @abstractmethod + def library_path(cls) -> Path: ... + + @classmethod + def installed_app_names(cls) -> set[str] | None: + return None + + +class _SideloadSource(_Source): + ID = "sideload" + LIBRARY_KEY = "games" + + @classmethod + def library_path(cls) -> Path: + return Path("sideload_apps", "library.json") + + +class _StoreSource(_Source): + _INSTALLED_PATH: Path + + @classmethod + def library_path(cls) -> Path: + return Path("store_cache", f"{cls.ID}_library.json") + + @override + @classmethod + def installed_app_names(cls) -> set[str]: + try: + with (_config_dir() / cls._INSTALLED_PATH).open() as fp: + data = json.load(fp) + except (FileNotFoundError, JSONDecodeError): + return set() + + return set(cls._installed(data)) + + @staticmethod + def _installed(data: Any) -> Generator[str]: # noqa: ANN401 + with suppress(AttributeError): + yield from data.keys() + + +class _LegendarySource(_StoreSource): + ID = "legendary" + COVER_URI_PARAMS = "?h=400&resize=1&w=300" + _INSTALLED_PATH = Path("legendaryConfig", "legendary", "installed.json") + + +class _GOGSource(_StoreSource): + ID = "gog" + LIBRARY_KEY = "games" + _INSTALLED_PATH = Path("gog_store", "installed.json") + + @override + @staticmethod + def _installed(data: Any) -> Generator[str]: + with suppress(TypeError, KeyError): + for entry in data["installed"]: + with suppress(TypeError, KeyError): + yield entry["appName"] + + +class _NileSource(_StoreSource): + ID = "nile" + LIBRARY_PATH = Path("store_cache", "nile_library.json") + _INSTALLED_PATH = Path("nile_config", "nile", "installed.json") + + @override + @staticmethod + def _installed(data: Any) -> Generator[str]: + with suppress(TypeError): + for entry in data: + with suppress(TypeError, KeyError): + yield entry["id"] + + +def get_games(*, skip_ids: Iterable[str]) -> Generator[Game]: + """Installed Heroic games.""" + added = int(time.time()) + for source in _LegendarySource, _GOGSource, _NileSource, _SideloadSource: + yield from _games_from(source, added, skip_ids) + + +def _config_dir() -> Path: + for path in _CONFIG_PATHS: + if path.is_dir(): + return path + + raise FileNotFoundError + + +def _hidden_app_names() -> Generator[str]: + try: + with (_config_dir() / "store" / "config.json").open() as fp: + config = json.load(fp) + except (FileNotFoundError, JSONDecodeError): + return + + with suppress(TypeError, KeyError): + for game in config["games"]["hidden"]: + with suppress(TypeError, KeyError): + yield game["appName"] + + +def _games_from( + source: type[_Source], added: int, skip_ids: Iterable[str] +) -> Generator[Game]: + try: + with (_config_dir() / source.library_path()).open() as fp: + library = json.load(fp) + except (FileNotFoundError, JSONDecodeError): + return + + if not isinstance(library := library.get(source.LIBRARY_KEY), Iterable): + return + + source_id = f"{ID}_{source.ID}" + images_cache = _config_dir() / "images-cache" + + installed = source.installed_app_names() + hidden = set(_hidden_app_names()) + + for entry in library: + with suppress(TypeError, KeyError): + app_name = entry["app_name"] + + if (installed is not None) and (app_name not in installed): + continue + + game_id = f"{source_id}_{app_name}" + if game_id in skip_ids: + continue + + cover_uri = f"{entry.get('art_square', '')}{source.COVER_URI_PARAMS}" + cover_path = images_cache / sha256(cover_uri.encode()).hexdigest() + + try: + cover = Gdk.Texture.new_from_filename(str(cover_path)) + except GLib.Error: + cover = None + + yield Game( + added=added, + executable=f"{OPEN} heroic://launch/{entry['runner']}/{app_name}", + game_id=game_id, + source=source_id, + hidden=app_name in hidden, + name=entry["title"], + developer=entry.get("developer"), + cover=cover, + ) diff --git a/flatpak/page.kramo.Cartridges.Devel.json b/flatpak/page.kramo.Cartridges.Devel.json index 9df337f..679cd3b 100644 --- a/flatpak/page.kramo.Cartridges.Devel.json +++ b/flatpak/page.kramo.Cartridges.Devel.json @@ -12,6 +12,7 @@ "--socket=wayland", "--talk-name=org.freedesktop.Flatpak", "--filesystem=host:ro", + "--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro", "--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro" ], "cleanup": [ diff --git a/po/POTFILES.in b/po/POTFILES.in index b584dda..b609298 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -3,6 +3,7 @@ cartridges/collections.py cartridges/gamepads.py cartridges/games.py cartridges/sources/__init__.py +cartridges/sources/heroic.py cartridges/sources/steam.py cartridges/ui/collections.py cartridges/ui/cover.blp