heroic: Implement Heroic source

This commit is contained in:
kramo
2025-12-22 16:29:29 +01:00
parent d1371baf2b
commit ea0e5b5c47
5 changed files with 204 additions and 2 deletions

View File

@@ -10,7 +10,7 @@ from gi.repository import Adw
from cartridges import collections, games from cartridges import collections, games
from cartridges.games import Game 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 .config import APP_ID, PREFIX
from .ui.window import Window from .ui.window import Window
@@ -29,7 +29,7 @@ class Application(Adw.Application):
self.set_accels_for_action("app.quit", ("<Control>q",)) self.set_accels_for_action("app.quit", ("<Control>q",))
saved = tuple(games.load()) 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)) games.model.splice(0, 0, (*saved, *new))
collections.load() collections.load()

View File

@@ -12,8 +12,20 @@ from gi.repository import GLib
from cartridges.games import Game from cartridges.games import Game
DATA = Path(GLib.get_user_data_dir()) 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" 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)")) 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" APPLICATION_SUPPORT = Path.home() / "Library" / "Application Support"
OPEN = ( OPEN = (

View File

@@ -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,
)

View File

@@ -12,6 +12,7 @@
"--socket=wayland", "--socket=wayland",
"--talk-name=org.freedesktop.Flatpak", "--talk-name=org.freedesktop.Flatpak",
"--filesystem=host:ro", "--filesystem=host:ro",
"--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro",
"--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro" "--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro"
], ],
"cleanup": [ "cleanup": [

View File

@@ -3,6 +3,7 @@ cartridges/collections.py
cartridges/gamepads.py cartridges/gamepads.py
cartridges/games.py cartridges/games.py
cartridges/sources/__init__.py cartridges/sources/__init__.py
cartridges/sources/heroic.py
cartridges/sources/steam.py cartridges/sources/steam.py
cartridges/ui/collections.py cartridges/ui/collections.py
cartridges/ui/cover.blp cartridges/ui/cover.blp