From da102515a85d0c2598688bba9170ea77919f0d6c Mon Sep 17 00:00:00 2001 From: kramo Date: Wed, 3 Dec 2025 01:23:56 +0100 Subject: [PATCH] sources: Add basic Steam source --- cartridges/application.py | 21 ++++++ cartridges/games.py | 4 +- cartridges/meson.build | 8 ++- cartridges/sources.py | 136 ++++++++++++++++++++++++++++++++++++++ po/POTFILES.in | 1 + 5 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 cartridges/sources.py diff --git a/cartridges/application.py b/cartridges/application.py index a7cf4fe..4cb9ecf 100644 --- a/cartridges/application.py +++ b/cartridges/application.py @@ -1,11 +1,17 @@ # SPDX-License-Identifier: GPL-3.0-or-later # SPDX-FileCopyrightText: Copyright 2025 Zoey Ahmed +# SPDX-FileCopyrightText: Copyright 2025 kramo +from collections.abc import Generator, Iterable from gettext import gettext as _ from typing import override from gi.repository import Adw +from cartridges import games +from cartridges.games import Game +from cartridges.sources import Source, SteamSource + from .config import APP_ID, PREFIX from .ui.window import Window @@ -22,6 +28,21 @@ class Application(Adw.Application): )) self.set_accels_for_action("app.quit", ("q",)) + saved = tuple(games.load()) + new = self.import_games(SteamSource(), skip_ids={g.game_id for g in saved}) + games.model.splice(0, 0, (*saved, *new)) + + @staticmethod + def import_games(*sources: Source, skip_ids: Iterable[str]) -> Generator[Game]: + """Import games from `sources`, skipping ones in `skip_ids`.""" + for source in sources: + try: + new = source.get_games(skip_ids=skip_ids) + except FileNotFoundError: + continue + + yield from new + @override def do_startup(self): Adw.Application.do_startup(self) diff --git a/cartridges/games.py b/cartridges/games.py index 7ce61e6..bdd6f27 100644 --- a/cartridges/games.py +++ b/cartridges/games.py @@ -157,7 +157,8 @@ def _increment_manually_added_id() -> int: raise ValueError -def _load() -> Generator[Game]: +def load() -> Generator[Game]: + """Load previously saved games from disk.""" for path in _GAMES_DIR.glob("*.json"): try: with path.open(encoding="utf-8") as f: @@ -184,4 +185,3 @@ def _load() -> Generator[Game]: model = Gio.ListStore.new(Game) -model.splice(0, 0, tuple(_load())) diff --git a/cartridges/meson.build b/cartridges/meson.build index 331e689..4a3f1e1 100644 --- a/cartridges/meson.build +++ b/cartridges/meson.build @@ -1,5 +1,11 @@ python.install_sources( - files('__init__.py', '__main__.py', 'application.py', 'games.py'), + files( + '__init__.py', + '__main__.py', + 'application.py', + 'games.py', + 'sources.py', + ), subdir: 'cartridges', ) diff --git a/cartridges/sources.py b/cartridges/sources.py new file mode 100644 index 0000000..743f745 --- /dev/null +++ b/cartridges/sources.py @@ -0,0 +1,136 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright 2023 Geoffrey Coulaud +# SPDX-FileCopyrightText: Copyright 2022-2025 kramo + +import itertools +import os +import re +import sys +import time +from collections.abc import Generator, Iterable +from pathlib import Path +from typing import Protocol + +from gi.repository import Gdk, GLib + +from cartridges.games import Game + +DATA = Path(GLib.get_user_data_dir()) +FLATPAK = Path.home() / ".var" / "app" +PROGRAM_FILES_X86 = Path(os.getenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)")) +APPLICATION_SUPPORT = Path.home() / "Library" / "Application Support" + +OPEN = ( + "open" + if sys.platform.startswith("darwin") + else "start" + if sys.platform.startswith("win32") + else "xdg-open" +) + + +class Source(Protocol): + """A source of games to import.""" + + ID: str + + def get_games(self, *, skip_ids: Iterable[str]) -> Generator[Game]: + """Installed games, except those in `skip_ids`.""" + ... + + +class SteamSource: + """A source for the Valve's Steam.""" + + ID: str = "steam" + + _INSTALLED_MASK = 4 + _CAPSULE_NAMES = "library_600x900.jpg", "library_capsule.jpg" + _DATA_PATHS = ( + Path.home() / ".steam" / "steam", + DATA / "Steam", + FLATPAK / "com.valvesoftware.Steam" / "data" / "Steam", + PROGRAM_FILES_X86 / "Steam", + APPLICATION_SUPPORT / "Steam", + ) + + @property + def _data(self) -> Path: + for path in self._DATA_PATHS: + if path.is_dir(): + return path + + raise FileNotFoundError + + @property + def _library_folders(self) -> Generator[Path]: + return ( + steamapps + for folder in re.findall( + r'"path"\s+"(.*)"\n', + (self._data / "steamapps" / "libraryfolders.vdf").read_text("utf-8"), + re.IGNORECASE, + ) + if (steamapps := Path(folder) / "steamapps").is_dir() + ) + + @property + def _manifests(self) -> Generator[Path]: + return ( + manifest + for folder in self._library_folders + for manifest in folder.glob("appmanifest_*.acf") + if manifest.is_file() + ) + + def get_games(self, *, skip_ids: Iterable[str]) -> Generator[Game]: + """Installed Steam games.""" + added = int(time.time()) + librarycache = self._data / "appcache" / "librarycache" + appids = {i.rsplit("_", 1)[-1] for i in skip_ids if i.startswith(f"{self.ID}_")} + for manifest in self._manifests: + contents = manifest.read_text("utf-8") + try: + name, appid, stateflags = ( + self._parse(contents, key) + for key in ("name", "appid", "stateflags") + ) + stateflags = int(stateflags) + except ValueError: + continue + + duplicate = appid in appids + installed = stateflags & self._INSTALLED_MASK + + if duplicate or not installed: + continue + + game = Game( + added=added, + executable=f"{OPEN} steam://rungameid/{appid}", + game_id=f"{self.ID}_{appid}", + source=self.ID, + name=name, + ) + + for path in itertools.chain.from_iterable( + (librarycache / appid).rglob(filename) + for filename in self._CAPSULE_NAMES + ): + try: + game.cover = Gdk.Texture.new_from_filename(str(path)) + except GLib.Error: + continue + else: + break + + yield game + appids.add(appid) + + @staticmethod + def _parse(manifest: str, key: str) -> str: + match = re.search(rf'"{key}"\s+"(.*)"\n', manifest, re.IGNORECASE) + if match and isinstance(group := match.group(1), str): + return group + + raise ValueError diff --git a/po/POTFILES.in b/po/POTFILES.in index e7d9ef2..1dff0d1 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,5 +1,6 @@ cartridges/application.py cartridges/games.py +cartridges/sources.py cartridges/ui/cover.blp cartridges/ui/cover.py cartridges/ui/game-details.blp