diff --git a/cartridges/application.py b/cartridges/application.py index 4e0543a..a5c4154 100644 --- a/cartridges/application.py +++ b/cartridges/application.py @@ -10,7 +10,7 @@ from gi.repository import Adw from cartridges import games from cartridges.games import Game -from cartridges.sources import Source, SteamSource +from cartridges.sources import Source, 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(SteamSource(), skip_ids={g.game_id for g in saved}) + new = self.import_games(steam, skip_ids={g.game_id for g in saved}) games.model.splice(0, 0, (*saved, *new)) @staticmethod diff --git a/cartridges/meson.build b/cartridges/meson.build index 4a3f1e1..aa2fd8c 100644 --- a/cartridges/meson.build +++ b/cartridges/meson.build @@ -1,14 +1,10 @@ python.install_sources( - files( - '__init__.py', - '__main__.py', - 'application.py', - 'games.py', - 'sources.py', - ), + files('__init__.py', '__main__.py', 'application.py', 'games.py'), subdir: 'cartridges', ) +install_subdir('sources', install_dir: python.get_install_dir() / 'cartridges') + configure_file( input: 'config.py.in', output: '@BASENAME@', diff --git a/cartridges/sources.py b/cartridges/sources.py deleted file mode 100644 index 743f745..0000000 --- a/cartridges/sources.py +++ /dev/null @@ -1,136 +0,0 @@ -# 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/cartridges/sources/__init__.py b/cartridges/sources/__init__.py new file mode 100644 index 0000000..8ef3a45 --- /dev/null +++ b/cartridges/sources/__init__.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright 2025 kramo + +import os +import sys +from collections.abc import Generator, Iterable +from pathlib import Path +from typing import Protocol + +from gi.repository import 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 + + @staticmethod + def get_games(*, skip_ids: Iterable[str]) -> Generator[Game]: + """Installed games, except those in `skip_ids`.""" + ... diff --git a/cartridges/sources/steam.py b/cartridges/sources/steam.py new file mode 100644 index 0000000..b647d66 --- /dev/null +++ b/cartridges/sources/steam.py @@ -0,0 +1,107 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# SPDX-FileCopyrightText: Copyright 2023 Geoffrey Coulaud +# SPDX-FileCopyrightText: Copyright 2022-2025 kramo + +import itertools +import re +import time +from collections.abc import Generator, Iterable +from pathlib import Path + +from gi.repository import Gdk, GLib + +from cartridges.games import Game + +from . import APPLICATION_SUPPORT, DATA, FLATPAK, OPEN, PROGRAM_FILES_X86 + +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", +) + + +def get_games(*, skip_ids: Iterable[str]) -> Generator[Game]: + """Installed Steam games.""" + added = int(time.time()) + librarycache = _data_dir() / "appcache" / "librarycache" + appids = {i.rsplit("_", 1)[-1] for i in skip_ids if i.startswith(f"{ID}_")} + for manifest in _manifests(): + contents = manifest.read_text("utf-8") + try: + name, appid, stateflags = ( + _parse(contents, key) for key in ("name", "appid", "stateflags") + ) + stateflags = int(stateflags) + except ValueError: + continue + + duplicate = appid in appids + installed = stateflags & _INSTALLED_MASK + + if duplicate or not installed: + continue + + game = Game( + added=added, + executable=f"{OPEN} steam://rungameid/{appid}", + game_id=f"{ID}_{appid}", + source=ID, + name=name, + ) + + for path in itertools.chain.from_iterable( + (librarycache / appid).rglob(filename) for filename in _CAPSULE_NAMES + ): + try: + game.cover = Gdk.Texture.new_from_filename(str(path)) + except GLib.Error: + continue + else: + break + + yield game + appids.add(appid) + + +def _data_dir() -> Path: + for path in _DATA_PATHS: + if path.is_dir(): + return path + + raise FileNotFoundError + + +def _library_folders() -> Generator[Path]: + return ( + steamapps + for folder in re.findall( + r'"path"\s+"(.*)"\n', + (_data_dir() / "steamapps" / "libraryfolders.vdf").read_text("utf-8"), + re.IGNORECASE, + ) + if (steamapps := Path(folder) / "steamapps").is_dir() + ) + + +def _manifests() -> Generator[Path]: + return ( + manifest + for folder in _library_folders() + for manifest in folder.glob("appmanifest_*.acf") + if manifest.is_file() + ) + + +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 b88289a..8f4600d 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,6 +1,7 @@ cartridges/application.py cartridges/games.py -cartridges/sources.py +cartridges/sources/__init__.py +cartridges/sources/steam.py cartridges/ui/cover.blp cartridges/ui/cover.py cartridges/ui/game-details.blp