diff --git a/data/hu.kramo.Cartridges.gschema.xml b/data/hu.kramo.Cartridges.gschema.xml
index f4a9368..1230c42 100644
--- a/data/hu.kramo.Cartridges.gschema.xml
+++ b/data/hu.kramo.Cartridges.gschema.xml
@@ -16,6 +16,12 @@
"~/.steam/"
+
+ ~/.var/app/com.valvesoftware.Steam/data/Steam/
+
+
+ "C:\Program Files (x86)\Steam"
+
true
diff --git a/src/importer/importer.py b/src/importer/importer.py
index 42aac19..2f1e5ae 100644
--- a/src/importer/importer.py
+++ b/src/importer/importer.py
@@ -116,6 +116,8 @@ class Importer:
exc_info=exception,
)
continue
+ if game is None:
+ continue
# TODO register in store instead of dict
diff --git a/src/importer/source.py b/src/importer/source.py
index a2fca0c..663b92e 100644
--- a/src/importer/source.py
+++ b/src/importer/source.py
@@ -1,35 +1,38 @@
from abc import abstractmethod
from collections.abc import Iterable, Iterator, Sized
+from src.game import Game
+from src.window import CartridgesWindow
+
class SourceIterator(Iterator, Sized):
"""Data producer for a source of games"""
- source = None
+ source: "Source" = None
- def __init__(self, source) -> None:
+ def __init__(self, source: "Source") -> None:
super().__init__()
self.source = source
- def __iter__(self):
+ def __iter__(self) -> "SourceIterator":
return self
@abstractmethod
- def __len__(self):
+ def __len__(self) -> int:
"""Get a rough estimate of the number of games produced by the source"""
@abstractmethod
- def __next__(self):
+ def __next__(self) -> "Game" | None:
"""Get the next generated game from the source.
Raises StopIteration when exhausted.
- May raise any other exception signifying an error on this specific game."""
+ May raise any other exception signifying an error on this specific game.
+ May return None when a game has been skipped without an error."""
class Source(Iterable):
"""Source of games. E.g an installed app with a config file that lists game directories"""
- win = None
-
+ win: "CartridgesWindow" = None
name: str
variant: str
@@ -38,7 +41,7 @@ class Source(Iterable):
self.win = win
@property
- def full_name(self):
+ def full_name(self) -> str:
"""The source's full name"""
full_name_ = self.name
if self.variant is not None:
@@ -46,7 +49,7 @@ class Source(Iterable):
return full_name_
@property
- def id(self): # pylint: disable=invalid-name
+ def id(self) -> str: # pylint: disable=invalid-name
"""The source's identifier"""
id_ = self.name.lower()
if self.variant is not None:
@@ -54,7 +57,7 @@ class Source(Iterable):
return id_
@property
- def game_id_format(self):
+ def game_id_format(self) -> str:
"""The string format used to construct game IDs"""
format_ = self.name.lower()
if self.variant is not None:
@@ -64,14 +67,14 @@ class Source(Iterable):
@property
@abstractmethod
- def executable_format(self):
+ def executable_format(self) -> str:
"""The executable format used to construct game executables"""
@property
@abstractmethod
- def is_installed(self):
+ def is_installed(self) -> bool:
"""Whether the source is detected as installed"""
@abstractmethod
- def __iter__(self):
+ def __iter__(self) -> SourceIterator:
"""Get the source's iterator, to use in for loops"""
diff --git a/src/importer/sources/steam_source.py b/src/importer/sources/steam_source.py
new file mode 100644
index 0000000..d548009
--- /dev/null
+++ b/src/importer/sources/steam_source.py
@@ -0,0 +1,180 @@
+import re
+import logging
+from time import time
+from pathlib import Path
+
+import requests
+from requests import HTTPError, JSONDecodeError
+
+from src.game import Game
+from src.importer.source import Source, SourceIterator
+from src.utils.decorators import (
+ replaced_by_path,
+ replaced_by_schema_key,
+ replaced_by_env_path,
+)
+from src.utils.save_cover import resize_cover, save_cover
+
+
+class SteamAPIError(Exception):
+ pass
+
+
+class SteamSourceIterator(SourceIterator):
+ source: "SteamSource"
+
+ manifests = None
+ manifests_iterator = None
+
+ installed_state_mask = 4
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ self.manifests = set()
+
+ # Get dirs that contain steam app manifests
+ manifests_dirs = set()
+ libraryfolders_path = self.source.location / "steamapps" / "libraryfolders.vdf"
+ with open(libraryfolders_path, "r") as file:
+ for line in file.readlines():
+ line = line.strip()
+ prefix = '"path"'
+ if not line.startswith(prefix):
+ continue
+ library_folder = Path(line[len(prefix) :].strip()[1:-1])
+ manifests_dir = library_folder / "steamapps"
+ if not (manifests_dir).is_dir():
+ continue
+ manifests_dirs.add(manifests_dir)
+
+ # Get app manifests
+ for manifests_dir in manifests_dirs:
+ for child in manifests_dir.iterdir():
+ if child.is_file() and "appmanifest" in child.name:
+ self.manifests.add(child)
+
+ self.manifests_iterator = iter(self.manifests)
+
+ def __len__(self):
+ return len(self.manifests)
+
+ def __next__(self):
+ # Get metadata from manifest
+ # Ignore manifests that don't have a value for all keys
+ manifest = next(self.manifests_iterator)
+ manifest_data = {"name": None, "appid": None, "StateFlags": "0"}
+ try:
+ with open(manifest) as file:
+ contents = file.read()
+ for key in manifest_data:
+ regex = f'"{key}"\s+"(.*)"\n'
+ if (match := re.search(regex, contents)) is None:
+ return None
+ manifest_data[key] = match.group(1)
+ except OSError:
+ return None
+
+ # Skip non installed games
+ if not int(manifest_data["StateFlags"]) & self.installed_state_mask:
+ return None
+
+ # Build basic game
+ appid = manifest_data["appid"]
+ values = {
+ "added": int(time()),
+ "name": manifest_data["name"],
+ "hidden": False,
+ "source": self.source.id,
+ "game_id": self.source.game_id_format.format(game_id=appid),
+ "executable": self.source.executable_format.format(game_id=appid),
+ "blacklisted": False,
+ "developer": None,
+ }
+ game = Game(self.source.win, values, allow_side_effects=False)
+
+ # Add official cover image
+ cover_path = (
+ self.source.location
+ / "appcache"
+ / "librarycache"
+ / f"{appid}_library_600x900.jpg"
+ )
+ if cover_path.is_file():
+ save_cover(self.win, game.game_id, resize_cover(self.win, cover_path))
+
+ # Make Steam API call
+ try:
+ with requests.get(
+ "https://store.steampowered.com/api/appdetails?appids=%s"
+ % manifest_data["appid"],
+ timeout=5,
+ ) as response:
+ response.raise_for_status()
+ steam_api_data = response.json()[appid]
+ except (HTTPError, JSONDecodeError) as error:
+ logging.warning(
+ "Error while querying Steam API for %s (%s)",
+ manifest_data["name"],
+ manifest_data["appid"],
+ exc_info=error,
+ )
+ return game
+
+ # Fill out new values
+ if not steam_api_data["success"] or steam_api_data["data"]["type"] != "game":
+ values["blacklisted"] = True
+ else:
+ values["developer"] = ", ".join(steam_api_data["data"]["developers"])
+ game.update_values(values)
+ return game
+
+
+class SteamSource(Source):
+ name = "Steam"
+ executable_format = "xdg-open steam://rungameid/{game_id}"
+ location = None
+
+ @property
+ def is_installed(self):
+ # pylint: disable=pointless-statement
+ try:
+ self.location
+ except FileNotFoundError:
+ return False
+ return True
+
+ def __iter__(self):
+ return SteamSourceIterator(source=self)
+
+
+class SteamNativeSource(SteamSource):
+ variant = "native"
+
+ @property
+ @replaced_by_schema_key("steam-location")
+ @replaced_by_env_path("XDG_DATA_HOME", "Steam/")
+ @replaced_by_path("~/.steam/")
+ @replaced_by_path("~/.local/share/Steam/")
+ def location(self):
+ raise FileNotFoundError()
+
+
+class SteamFlatpakSource(SteamSource):
+ variant = "flatpak"
+
+ @property
+ @replaced_by_schema_key("steam-flatpak-location")
+ @replaced_by_path("~/.var/app/com.valvesoftware.Steam/data/Steam/")
+ def location(self):
+ raise FileNotFoundError()
+
+
+class SteamWindowsSource(SteamSource):
+ variant = "windows"
+
+ @property
+ @replaced_by_schema_key("steam-windows-location")
+ @replaced_by_env_path("programfiles(x86)", "Steam")
+ def location(self):
+ raise FileNotFoundError()
diff --git a/src/main.py b/src/main.py
index 37ffe9a..9d3a3b8 100644
--- a/src/main.py
+++ b/src/main.py
@@ -36,6 +36,11 @@ from src.importer.sources.lutris_source import (
LutrisFlatpakSource,
LutrisNativeSource,
)
+from src.importer.sources.steam_source import (
+ SteamNativeSource,
+ SteamFlatpakSource,
+ SteamWindowsSource,
+)
from src.preferences import PreferencesWindow
from src.window import CartridgesWindow
@@ -156,6 +161,10 @@ class CartridgesApplication(Adw.Application):
if self.win.schema.get_boolean("lutris"):
importer.add_source(LutrisNativeSource(self.win))
importer.add_source(LutrisFlatpakSource(self.win))
+ if self.win.schema.get_boolean("steam"):
+ importer.add_source(SteamNativeSource(self.win))
+ importer.add_source(SteamFlatpakSource(self.win))
+ importer.add_source(SteamWindowsSource(self.win))
importer.run()
def on_remove_game_action(self, *_args):
diff --git a/src/utils/decorators.py b/src/utils/decorators.py
index 0ec1ea7..456c450 100644
--- a/src/utils/decorators.py
+++ b/src/utils/decorators.py
@@ -14,7 +14,7 @@ class MyClass():
"""
from pathlib import Path
-from os import PathLike
+from os import PathLike, environ
from functools import wraps
@@ -52,3 +52,21 @@ def replaced_by_schema_key(key: str): # Decorator builder
return wrapper
return decorator
+
+
+def replaced_by_env_path(env_var_name: str, suffix: PathLike | None = None):
+ """Replace the method's returned path with a path whose root is the env variable"""
+
+ def decorator(original_function): # Built decorator (closure)
+ @wraps(original_function)
+ def wrapper(*args, **kwargs): # func's override
+ try:
+ env_var = environ[env_var_name]
+ except KeyError:
+ return original_function(*args, **kwargs)
+ override = Path(env_var) / suffix
+ return replaced_by_path(override)(original_function)(*args, **kwargs)
+
+ return wrapper
+
+ return decorator