heroic: Implement Heroic source
This commit is contained in:
@@ -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", ("<Control>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()
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
188
cartridges/sources/heroic.py
Normal file
188
cartridges/sources/heroic.py
Normal 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,
|
||||
)
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user