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 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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
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",
|
"--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": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user