# Discover Steam install and games # (c) 2020 Taeyeon Mori CC-BY-SA import datetime import fnmatch import os import re import sys from pathlib import Path from typing import List, Iterable, Dict, Literal, Mapping, Tuple, Optional, Union, Any, cast, overload from vdfparser import VdfParser, DeepDict, AppInfoFile, LowerCaseNormalizingDict, dd_getpath from propex import SettableCachedProperty, DictPathProperty, DictPathRoProperty, cached_property _vdf = VdfParser() _vdf_ci = VdfParser(factory=LowerCaseNormalizingDict) class MalformedManifestError(Exception): @property def filename(self): return self.args[1] class AppInfo: steam: 'Steam' appid: int def __init__(self, steam, appid, *, appinfo_data=None): self.steam = steam self.appid = appid if appinfo_data is not None: self.__dict__["appinfo"] = appinfo_data def __repr__(self): return "" % (self.appid, self.name, self.install_dir) installed = False # AppInfo @cached_property def appinfo(self): # FIXME: properly close AppInfoFile but also deal with always-open appinfo return self.steam.appinfo[self.appid] @property def launch_configs(self): return self.appinfo["appinfo"]["config"]["launch"].values() name = DictPathRoProperty[Optional[str]]("appinfo", ("appinfo", "common", "name"), default=None) oslist = DictPathRoProperty[List[str]] ("appinfo", ("appinfo", "common", "oslist"), type=lambda s: s.split(",")) install_dir = DictPathRoProperty[Optional[str]]("appinfo", ("appinfo", "config", "installdir"), default=None) languages = DictPathRoProperty[Any] ("appinfo", ("appinfo", "common", "supported_languages")) gameid = DictPathRoProperty[int] ("appinfo", ("appinfo", "common", "gameid"), type=int) # Misc. def get_userdata_path(self, user_id: Union[int, 'LoginUser']) -> Path: return self.steam.get_userdata_path(user_id) / str(self.appid) @property def is_native(self): """ Whether the app has a version native to the current platform """ return sys.platform in self.oslist @cached_property def compat_tool(self) -> dict: """ The compatibility tool selected for this app. Note: this will still return a default if no tool is used """ mapping = self.steam.compat_tool_mapping appid = str(self.appid) # User override if appid in mapping and mapping[appid]["name"]: tool = dict(mapping[appid]) tool["source"] = "user" return tool # Steam play manifest manifest = self.steam.steamplay_manifest["extended"]["app_mappings"] if appid in manifest: tool = dict(manifest[appid]) tool["name"] = tool["tool"] tool["source"] = "valve" return tool # User default tool = dict(mapping["0"]) tool["source"] = "default" return tool class App(AppInfo): steam: 'Steam' library_folder: 'LibraryFolder' steamapps_path: Path manifest_path: Path manifest: DeepDict def __init__(self, libfolder, manifest_path: Path, *, manifest_data=None): self.library_folder = libfolder self.steamapps_path = libfolder.steamapps_path self.manifest_path = manifest_path if manifest_data is None: with open(manifest_path, encoding="utf-8") as f: self.manifest = _vdf.parse(f) else: self.manifest = manifest_data if "AppState" not in self.manifest: raise MalformedManifestError("App manifest doesn't have AppState key", self.manifest_path) super().__init__(libfolder.steam, int(dd_getpath(self.manifest, ("AppState", "appid"), t=str))) installed = True def __repr__(self): return "" % (self.appid, self.name, self.install_path) # Basic info name = DictPathRoProperty[Optional[str]]("manifest", ("AppState", "name"), None) language = DictPathRoProperty[Optional[str]]("manifest", ("AppState", "UserConfig", "language"), None) install_dir = DictPathRoProperty[Optional[str]]("manifest", ("AppState", "installdir"), None) @cached_property def install_path(self) -> Path: return self.steamapps_path / "common" / self.install_dir # Workshop # TODO @cached_property def workshop_path(self) -> Path: return self.steamapps_path / "workshop" / "content" / str(self.appid) # Steam Play info @property def platform_override(self) -> Tuple[Optional[str], Optional[str]]: uc = dd_getpath(self.manifest, ("AppState", "UserConfig"), None, t=dict) if uc: return uc.get("platform_override_source", None), uc.get("platform_override_dest", None) return None, None @property def is_steam_play(self) -> Union[str, bool]: """ Whether app needs a compatibility tool to run """ if (po := self.platform_override[0]) is not None: return po return not self.is_native @property def is_proton_app(self) -> Optional[bool]: """ Whether app needs (specifically) Proton to run """ # XXX: Should this try to figure out if selected compat tool is actually proton? return self.platform_override[0] == "windows" or not self.is_native and "windows" in self.oslist @cached_property def compat_path(self) -> Path: return self.steamapps_path / "compatdata" / str(self.appid) @cached_property def compat_prefix(self) -> Path: return self.compat_path / "pfx" @cached_property def compat_drive(self) -> Path: return self.compat_prefix / "drive_c" # Install size declared_install_size = DictPathRoProperty[int]("manifest", ("AppState", "SizeOnDisk"), 0, type=int) def compute_install_size(self) -> int: def sum_size(p: Path): acc = 0 for x in p.iterdir(): if x.is_dir(): acc += sum_size(x) else: acc += x.stat().st_size return acc return sum_size(self.install_path) class LibraryFolder: steam: 'Steam' path: Path def __init__(self, steam: 'Steam', path: Path): self.steam = steam self.path = path def __repr__(self): return "" % self.path # Paths @cached_property def steamapps_path(self) -> Path: steamapps = self.path / "steamapps" if not steamapps.exists(): # Emulate case-insensitivity cased = self.path / "SteamApps" if cased.exists(): steamapps = cased else: # try to find other variation found = [d for d in self.path.iterdir() if d.is_dir() and d.name.lower() == "steamapps"] if len(found) > 1: raise Exception("More than one steamapps folder in library folder", self.path) elif found: return found[0] # if none exists, return non-existant default name return steamapps @property def common_path(self) -> Path: return self.steamapps_path / "common" @property def appmanifests(self) -> Iterable[Path]: return self.steamapps_path.glob("appmanifest_*.acf") # pylint:disable=no-member @property def apps(self) -> Iterable[App]: for mf in self.appmanifests: try: yield App(self, mf) except MalformedManifestError as e: print("Warning: Malformed app manifest:", e.filename) def get_app(self, appid: int) -> Optional[App]: manifest = self.steamapps_path / ("appmanifest_%d.acf" % appid) if manifest.exists(): return App(self, manifest) return None def find_apps_re(self, regexp: str) -> Iterable[App]: reg = re.compile(r'"name"\s+".*%s.*"' % regexp, re.IGNORECASE) for manifest in self.appmanifests: with open(manifest, encoding="utf-8") as f: content = f.read() if reg.search(content): yield App(self, manifest, manifest_data=_vdf.parse_string(content)) def find_apps(self, pattern: str) -> Iterable[App]: return self.find_apps_re(fnmatch.translate(pattern).rstrip("\\Z")) class UserAppConfig: user: 'LoginUser' appid: int def __init__(self, user, appid): self.user = user self.appid = appid def __repr__(self): return "" % (self.appid, self.user.account_name) @property def _data(self): return dd_getpath(self.user.localconfig, ("UserLocalConfigStore", "Software", "Valve", "Steam", ("Apps", "apps"), str(self.appid)), {}, t=dict) @property def last_played(self) -> datetime.datetime: return datetime.datetime.fromtimestamp(int(self._data.get("LastPlayed", 0))) @property def playtime(self) -> datetime.time: return datetime.time(minute=int(self._data.get("Playtime", 0))) @property def playtime_two_weeks(self) -> datetime.time: return datetime.time(minute=int(self._data.get("Playtime2wks", 0))) launch_options = DictPathProperty[Optional[DeepDict]]("_data", ("LaunchOptions",), None) class LoginUser: steam: 'Steam' id: int info: Dict[str, str] def __init__(self, steam, id: int, info: Dict): self.steam = steam self.id = id self.info = info def __repr__(self): return "" % (self.id , self.account_name, self.username) @property def account_id(self): """ 32-bit account ID """ return self.id & 0xffffffff account_name = DictPathRoProperty[str]("info", ("AccountName",)) username = DictPathRoProperty[str]("info", ("PersonaName",)) @cached_property def userdata_path(self) -> Path: return self.steam.get_userdata_path(self) @property def localconfig_vdf(self) -> Path: return self.userdata_path / "config" / "localconfig.vdf" @cached_property def localconfig(self) -> DeepDict: with open(self.localconfig_vdf, encoding="utf-8") as f: return _vdf.parse(f) # Game config def get_app_config(self, app: Union[int, App]) -> Optional[UserAppConfig]: if isinstance(app, App): app = app.appid return UserAppConfig(self, app) class Steam: root: Path def __init__(self, install_path=None): self.root = install_path if install_path is not None else self.find_install_path() if self.root is None: raise Exception("Could not find Steam") def __repr__(self): return "" % self.root @staticmethod def find_install_path() -> Optional[Path]: # Linux if sys.platform.startswith("linux"): # Try ~/.steam first dotsteam = Path("~/.steam").expanduser() if dotsteam.exists(): steamroot = (dotsteam / "root").resolve() if steamroot.exists(): return steamroot # Try ~/.local/share/Steam, classic ~/Steam data_dir = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() for path in data_dir, Path("~").expanduser(): for name in "Steam", "SteamBeta": steamroot = path / name if (steamroot / "steamapps" / "libraryfolders.vdf").exists(): return steamroot # Try Flatpak appdir = Path("~/.var/app/com.valvesoftware.Steam").expanduser() if appdir.exists(): return (appdir / ".steam" / "root").resolve() elif sys.platform.startswith("win"): try: import winreg key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "SOFTWARE\\Valve\\Steam") path, t = winreg.QueryValueEx(key, "steampath") if (t == winreg.REG_SZ): return Path(path) except WindowsError: pass # try PROGRAMFILES pfiles = (os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"), os.environ.get("ProgramFiles", "C:\\Program Files")) for path in pfiles: if path.exists(): path /= "Steam" if path.exists(): return path return None @property def is_flatpak(self) -> bool: return self.root.is_relative_to(Path("~/.var/app/com.valvesoftware.Steam").expanduser()) # Various paths @property def libraryfolders_vdf(self) -> Path: """ The libraryfolders.vdf file listing all configured library locations """ return self.root / "steamapps" / "libraryfolders.vdf" @property def config_vdf(self) -> Path: return self.root / "config" / "config.vdf" @property def loginusers_vdf(self) -> Path: return self.root / "config" / "loginusers.vdf" # Users @cached_property def most_recent_user(self) -> Optional[LoginUser]: try: # Apparently, Steam doesn't care about case in the config/*.vdf keys with open(self.loginusers_vdf, encoding="utf-8") as f: data = _vdf_ci.parse(f) for id, info in cast(Mapping[str, Dict], data["users"]).items(): if info["mostrecent"] == "1": return LoginUser(self, int(id), info) except KeyError: pass return None def get_userdata_path(self, user_id: Union[int, LoginUser]) -> Path: if isinstance(user_id, LoginUser): user_id = user_id.account_id return self.root / "userdata" / str(user_id) # Config @cached_property def config(self) -> DeepDict: with open(self.config_vdf, encoding="utf-8") as f: return _vdf.parse(f) config_install_store = DictPathProperty[Dict]("config", ("InstallConfigStore",)) config_software_steam = DictPathProperty[Dict]("config", ("InstallConfigStore", "Software", "Valve", "Steam")) compat_tool_mapping = DictPathProperty[Dict]("config", ("InstallConfigStore", "Software", "Valve", "Steam", "CompatToolMapping")) # AppInfo cache @cached_property def appinfo_vdf(self): return self.root / "appcache" / "appinfo.vdf" @cached_property def appinfo(self) -> AppInfoFile: return AppInfoFile.open(self.appinfo_vdf) @SettableCachedProperty def steamplay_manifest(self) -> DeepDict: with self.appinfo as info: return info[891390]["appinfo"] @cached_property def compat_tools(self) -> Dict[str, Dict]: tools = {} # Find official proton installs valve = self.steamplay_manifest["extended"]["compat_tools"] for name, t in valve.items(): app = self.get_app(t["appid"]) if app: tool = dict(t) tool["install_path"] = app.install_path tools[name] = tool # Find custom compat tools compattools_d = self.root / "compatibilitytools.d" if compattools_d.exists(): manifests = [] for p in compattools_d.iterdir(): if p.suffix == ".vdf": manifests.append(p) elif p.is_dir(): c = p / "compatibilitytool.vdf" if c.exists(): manifests.append(c) for mfst_path in manifests: with open(mfst_path, encoding="utf-8") as f: mfst = _vdf.parse(f) for name, t in dd_getpath(mfst, ("compatibilitytools", "compat_tools"), t=dict).items(): # TODO warn duplicate name t["install_path"] = mfst_path.parent / t["install_path"] tools[name] = t return tools # Game/App Library @cached_property def library_folder_paths(self) -> List[Path]: with open(self.libraryfolders_vdf, encoding="utf-8") as f: data = _vdf_ci.parse(f) def gen(): for k, v in dd_getpath(data, ("LibraryFolders",), t=dict).items(): if k.isdigit(): if isinstance(v, str): yield Path(v) elif 'path' in v: yield Path(v['path']) else: raise ValueError("Unknown format of libraryfolders.vdf") return list(gen()) @cached_property def library_folders(self) -> List[LibraryFolder]: return [LibraryFolder(self, self.root)] + [LibraryFolder(self, p) for p in self.library_folder_paths] @property def apps(self) -> Iterable[App]: for lf in self.library_folders: yield from lf.apps @overload def get_app(self, id: int, installed: Literal[True]=True) -> Optional[App]: ... @overload def get_app(self, id: int, installed: Literal[False]) -> Optional[AppInfo]: ... def get_app(self, id: int, installed=True) -> Optional[AppInfo]: for lf in self.library_folders: app = lf.get_app(id) if app is not None: return app if not installed: for appinfo in self.appinfo: if appinfo.id == id: return AppInfo(self, id, appinfo_data=appinfo) return None @overload def find_apps_re(self, regexp: str, installed: Literal[True]) -> Iterable[App]: ... @overload def find_apps_re(self, regexp: str, installed: Literal[False]) -> Iterable[AppInfo]: ... def find_apps_re(self, regexp: str, installed=True) -> Iterable[AppInfo]: """ Find all apps by regular expression """ if not installed: # Search whole appinfo cache reg = re.compile(regexp, re.IGNORECASE) broken_ids = set() try: for appinfo in self.appinfo: # Skip broken entries try: name = appinfo["appinfo"]["common"]["name"] except KeyError: broken_ids.add(appinfo.id) continue if reg.search(name): for lf in self.library_folders: app = lf.get_app(appinfo.id) if app: yield app break else: yield AppInfo(self, appinfo.id, appinfo_data=appinfo) except Exception: import traceback traceback.print_exc() print("[SteamUtil] Warning: could not read non-installed apps from Steam appinfo cache. Searching locally") else: return finally: if broken_ids: print("[SteamUtil] Warning: found broken entries in appinfo cache:", ",".join(map(str, broken_ids))) # Search local manifests directly reg = re.compile(r'"name"\s+".*%s.*"' % regexp, re.IGNORECASE) for lf in self.library_folders: for manifest in lf.appmanifests: with open(manifest, encoding="utf-8") as f: content = f.read() if reg.search(content): yield App(lf, manifest, manifest_data=_vdf.parse_string(content)) def find_apps(self, pattern: str, installed=True) -> Iterable[App]: return self.find_apps_re(fnmatch.translate(pattern).rstrip("\\Z"), installed=installed) def find_app(self, pattern: str, installed=True) -> Optional[App]: for app in self.find_apps(pattern, installed=installed): return app return None