Dotfiles
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

553 lines
20 KiB

# 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 "<steamutil.AppInfo #%7d '%s' (%s)>" % (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 "<steamutil.App #%7d '%s' @ \"%s\">" % (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 "<steamutil.LibraryFolder @ \"%s\">" % 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 "<steamutil.UserAppConfig appid=%d for account %s>" % (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 "<steamutil.LoginUser %d %s '%s'>" % (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 "<steamutil.Steam @ \"%s\">" % 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