import platform as pf import os import shlex from dataclasses import dataclass from enum import StrEnum from hashlib import md5 from logging import getLogger from typing import Optional, Union, List, Dict, Set if pf.system() in {"Linux", "FreeBSD"}: # noinspection PyUnresolvedReferences import vdf # pylint: disable=E0401 logger = getLogger("Proton") steam_compat_client_install_paths = [os.path.expanduser("~/.local/share/Steam")] def find_steam() -> Optional[str]: # return the first valid path for path in steam_compat_client_install_paths: if os.path.isdir(path) and os.path.isfile(os.path.join(path, "steam.sh")): return path return None def find_libraries(steam_path: str) -> Set[str]: vdf_path = os.path.join(steam_path, "steamapps", "libraryfolders.vdf") with open(vdf_path, "r") as f: libraryfolders = vdf.load(f)["libraryfolders"] # libraries = [os.path.join(folder["path"], "steamapps") for key, folder in libraryfolders.items()] libraries = {os.path.join(folder["path"], "steamapps") for key, folder in libraryfolders.items()} return libraries # Notes: # Anything older than 'Proton 5.13' doesn't have the 'require_tool_appid' attribute. # Anything older than 'Proton 7.0' doesn't have the 'compatmanager_layer_name' attribute. # In addition to that, the 'Steam Linux Runtime 1.0 (scout)' runtime lists the # 'Steam Linux Runtime 2.0 (soldier)' runtime as a dependency and is probably what was # being used for any version before 5.13. # # As a result the following implementation will list versions from 7.0 onwards which honestly # is a good trade-off for the amount of complexity supporting everything would ensue. class SteamVerb(StrEnum): RUN = "run" WAIT_FOR_EXIT_AND_RUN = "waitforexitandrun" RUN_IN_PREFIX = "runinprefix" DESTROY_PREFIX = "destroyprefix" GET_COMPAT_PATH = "getcompatpath" GET_NATIVE_PATH = "getnativepath" DEFAULT = WAIT_FOR_EXIT_AND_RUN @dataclass class SteamBase: steam_path: str tool_path: str toolmanifest: Dict def __eq__(self, other): return self.tool_path == other.tool_path def __hash__(self): return hash(self.tool_path) @property def required_tool(self) -> Optional[str]: return self.toolmanifest.get("require_tool_appid", None) @property def layer(self) -> Optional[str]: return self.toolmanifest.get("compatmanager_layer_name", None) def command(self, verb: SteamVerb = SteamVerb.DEFAULT) -> List[str]: tool_path = os.path.normpath(self.tool_path) cmd = "".join([shlex.quote(tool_path), self.toolmanifest["commandline"]]) # NOTE: "waitforexitandrun" seems to be the verb used in by steam to execute stuff # `run` is used when setting up the environment, so use that if we are setting up the prefix. cmd = cmd.replace("%verb%", str(verb)) return shlex.split(cmd) def as_str(self, verb: SteamVerb = SteamVerb.DEFAULT): return " ".join(map(shlex.quote, self.command(verb))) @property def checksum(self) -> str: return md5(self.as_str().encode("utf-8")).hexdigest() @dataclass class SteamRuntime(SteamBase): steam_library: str appmanifest: Dict @property def name(self) -> str: return self.appmanifest["AppState"]["name"] @property def appid(self) -> str: return self.appmanifest["AppState"]["appid"] @dataclass class ProtonTool(SteamRuntime): runtime: SteamRuntime = None def __bool__(self) -> bool: if appid := self.required_tool: return self.runtime is not None and self.runtime.appid == appid return True def command(self, verb: SteamVerb = SteamVerb.DEFAULT) -> List[str]: cmd = self.runtime.command(verb) cmd.extend(super().command(verb)) return cmd @dataclass class CompatibilityTool(SteamBase): compatibilitytool: Dict runtime: SteamRuntime = None def __bool__(self) -> bool: if appid := self.required_tool: return self.runtime is not None and self.runtime.appid == appid return True @property def name(self) -> str: return self.compatibilitytool["display_name"] def command(self, verb: SteamVerb = SteamVerb.DEFAULT) -> List[str]: cmd = self.runtime.command(verb) if self.runtime is not None else [] cmd.extend(super().command(verb)) return cmd def find_appmanifests(library: str) -> List[dict]: appmanifests = [] for entry in os.scandir(library): if entry.is_file() and entry.name.endswith(".acf"): with open(os.path.join(library, entry.name), "r") as f: appmanifest = vdf.load(f) appmanifests.append(appmanifest) return appmanifests def find_protons(steam_path: str, library: str) -> List[ProtonTool]: protons = [] appmanifests = find_appmanifests(library) common = os.path.join(library, "common") for appmanifest in appmanifests: folder = appmanifest["AppState"]["installdir"] tool_path = os.path.join(common, folder) if os.path.isfile(vdf_file := os.path.join(tool_path, "toolmanifest.vdf")): with open(vdf_file, "r") as f: toolmanifest = vdf.load(f) if toolmanifest["manifest"]["compatmanager_layer_name"] == "proton": protons.append( ProtonTool( steam_path=steam_path, steam_library=library, appmanifest=appmanifest, tool_path=tool_path, toolmanifest=toolmanifest["manifest"], ) ) return protons def find_compatibility_tools(steam_path: str) -> List[CompatibilityTool]: compatibilitytools_paths = { "/usr/share/steam/compatibilitytools.d", os.path.expanduser(os.path.join(steam_path, "compatibilitytools.d")), os.path.expanduser("~/.steam/compatibilitytools.d"), os.path.expanduser("~/.steam/root/compatibilitytools.d"), } compatibilitytools_paths = { os.path.realpath(path) for path in compatibilitytools_paths if os.path.isdir(path) } tools = [] for path in compatibilitytools_paths: for entry in os.scandir(path): entry_path = os.path.join(path, entry.name) if entry.is_dir(): tool_vdf = os.path.join(entry_path, "compatibilitytool.vdf") elif entry.is_file() and entry.name.endswith(".vdf"): tool_vdf = entry_path else: continue if not os.path.isfile(tool_vdf): continue with open(tool_vdf, "r") as f: compatibilitytool = vdf.load(f) entry_tools = compatibilitytool["compatibilitytools"]["compat_tools"] for entry_tool in entry_tools.values(): if entry_tool["from_oslist"] != "windows" and entry_tool["to_oslist"] != "linux": continue install_path = entry_tool["install_path"] tool_path = os.path.abspath(os.path.join(os.path.dirname(tool_vdf), install_path)) manifest_vdf = os.path.join(tool_path, "toolmanifest.vdf") if not os.path.isfile(manifest_vdf): continue with open(manifest_vdf, "r") as f: manifest = vdf.load(f) tools.append( CompatibilityTool( steam_path=steam_path, tool_path=tool_path, toolmanifest=manifest["manifest"], compatibilitytool=entry_tool, ) ) return tools def find_runtimes(steam_path: str, library: str) -> Dict[str, SteamRuntime]: runtimes = {} appmanifests = find_appmanifests(library) common = os.path.join(library, "common") for appmanifest in appmanifests: folder = appmanifest["AppState"]["installdir"] tool_path = os.path.join(common, folder) if os.path.isfile(vdf_file := os.path.join(tool_path, "toolmanifest.vdf")): with open(vdf_file, "r") as f: toolmanifest = vdf.load(f) if toolmanifest["manifest"]["compatmanager_layer_name"] == "container-runtime": print(toolmanifest["manifest"]) runtimes.update( { appmanifest["AppState"]["appid"]: SteamRuntime( steam_path=steam_path, steam_library=library, appmanifest=appmanifest, tool_path=tool_path, toolmanifest=toolmanifest["manifest"], ) } ) return runtimes def find_runtime( tool: Union[ProtonTool, CompatibilityTool], runtimes: Dict[str, SteamRuntime] ) -> Optional[SteamRuntime]: required_tool = tool.required_tool if required_tool is None: return None return runtimes.get(required_tool, None) def get_ulwgl_environment( tool: Optional[ProtonTool] = None, compat_path: Optional[str] = None ) -> Dict: # If the tool is unset, return all affected env variable names # IMPORTANT: keep this in sync with the code below environ = {"WINEPREFIX": compat_path if compat_path else ""} if tool is None: environ["WINEPREFIX"] = "" environ["PROTONPATH"] = "" environ["GAMEID"] = "" environ["STORE"] = "" return environ environ["PROTONPATH"] = tool.tool_path environ["STORE"] = "egs" return environ def get_steam_environment( tool: Optional[Union[ProtonTool, CompatibilityTool]] = None, compat_path: Optional[str] = None ) -> Dict: # If the tool is unset, return all affected env variable names # IMPORTANT: keep this in sync with the code below environ = {"STEAM_COMPAT_DATA_PATH": compat_path if compat_path else ""} if tool is None: environ["STEAM_COMPAT_DATA_PATH"] = "" environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = "" environ["STEAM_COMPAT_LIBRARY_PATHS"] = "" environ["STEAM_COMPAT_MOUNTS"] = "" environ["STEAM_COMPAT_TOOL_PATHS"] = "" return environ environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = tool.steam_path if isinstance(tool, ProtonTool): environ["STEAM_COMPAT_LIBRARY_PATHS"] = tool.steam_library if tool.runtime is not None: compat_mounts = [tool.tool_path, tool.runtime.tool_path] environ["STEAM_COMPAT_MOUNTS"] = ":".join(compat_mounts) tool_paths = [tool.tool_path] if tool.runtime is not None: tool_paths.append(tool.runtime.tool_path) environ["STEAM_COMPAT_TOOL_PATHS"] = ":".join(tool_paths) return environ def find_tools() -> List[Union[ProtonTool, CompatibilityTool]]: steam_path = find_steam() if steam_path is None: logger.info("Steam could not be found") return [] logger.info("Found Steam in %s", steam_path) steam_libraries = find_libraries(steam_path) logger.debug("Searching for tools in libraries:") logger.debug("%s", steam_libraries) runtimes = {} for library in steam_libraries: runtimes.update(find_runtimes(steam_path, library)) tools = [] for library in steam_libraries: tools.extend(find_protons(steam_path, library)) tools.extend(find_compatibility_tools(steam_path)) for tool in tools: runtime = find_runtime(tool, runtimes) tool.runtime = runtime tools = list(filter(lambda t: bool(t), tools)) return tools if __name__ == "__main__": from pprint import pprint _tools = find_tools() pprint(_tools) for _tool in _tools: print(get_steam_environment(_tool)) print(_tool.name) print(_tool.command(SteamVerb.RUN)) print(" ".join(_tool.command(SteamVerb.RUN_IN_PREFIX)))