sources: Add basic Steam source

This commit is contained in:
kramo
2025-12-03 01:23:56 +01:00
parent fceaac05c6
commit da102515a8
5 changed files with 167 additions and 3 deletions

View File

@@ -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", ("<Control>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)

View File

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

View File

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

136
cartridges/sources.py Normal file
View File

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

View File

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