diff --git a/Makefile.in b/Makefile.in index 67dfa9a0..0da026b1 100644 --- a/Makefile.in +++ b/Makefile.in @@ -110,6 +110,7 @@ umu-launcher-install: umu-launcher-dist-install umu-launcher-bin-install $(OBJDIR)/.build-umu-vendored: | $(OBJDIR) $(info :: Building vendored dependencies ) python3 -m pip install urllib3 -t $(OBJDIR) + python3 -m pip install vdf -t $(OBJDIR) python3 -m pip install pyzstd --config-settings="--build-option=--dynamic-link-zstd" -t $(OBJDIR) .PHONY: umu-vendored @@ -119,6 +120,7 @@ umu-vendored-install: umu-vendored $(info :: Installing subprojects ) install -d $(DESTDIR)$(PYTHONDIR)/umu/_vendor cp -r $(OBJDIR)/urllib3 $(DESTDIR)$(PYTHONDIR)/umu/_vendor + cp -r $(OBJDIR)/vdf $(DESTDIR)$(PYTHONDIR)/umu/_vendor cp -r $(OBJDIR)/pyzstd $(DESTDIR)$(PYTHONDIR)/umu/_vendor $(OBJDIR): diff --git a/pyproject.toml b/pyproject.toml index bd39488c..d095cda1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ classifiers = [ urls = { repository = "https://github.com/Open-Wine-Components/umu-launcher" } # Note: urllib3 is a vendored dependency. When using our Makefile, it will be # installed automatically. -dependencies = ["python-xlib>=0.33", "urllib3>=2.0.0,<3.0.0"] +dependencies = ["python-xlib>=0.33", "urllib3>=2.0.0,<3.0.0", "vdf>=3.4"] [project.optional-dependencies] # Recommended @@ -156,7 +156,7 @@ exclude = [ "subprojects", ] # Same as Black. -line-length = 88 +line-length = 79 indent-width = 4 target-version = "py310" diff --git a/requirements.in b/requirements.in index 32fe1291..c9449f0e 100644 --- a/requirements.in +++ b/requirements.in @@ -1,5 +1,6 @@ python-xlib>=0.33 urllib3>=2.0.0,<3.0.0 +vdf>=3.4 xxhash>=3.2.0 pyzstd>=0.16.2 cbor2>=5.4.6 diff --git a/umu/umu_consts.py b/umu/umu_consts.py index 667281a1..b95e8b38 100644 --- a/umu/umu_consts.py +++ b/umu/umu_consts.py @@ -1,4 +1,5 @@ import os +from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -94,3 +95,32 @@ class GamescopeAtom(Enum): # Constant defined in prctl.h # See prctl(2) for more details PR_SET_CHILD_SUBREAPER = 36 + + +@dataclass +class UmuRuntime: + """Holds information about a runtime.""" + + name: str + version: str + path: Path | None = None + + def __post_init__(self) -> None: # noqa: D105 + if self.version == "native": + return + if self.path is None: + self.path = UMU_LOCAL.joinpath(self.name) + # Temporary override for backwards compatibility + if self.version == "steamrt3": + self.path = UMU_LOCAL + + +RUNTIME_VERSIONS = { + "host": UmuRuntime("host", "native" ), + "1070560": UmuRuntime("scout", "steamrt1"), + "1391110": UmuRuntime("soldier", "steamrt2"), + "1628350": UmuRuntime("sniper", "steamrt3"), + # "" : UmuRuntime("medic", "steamrt4"), +} + +RUNTIME_NAMES = {RUNTIME_VERSIONS[key].name: key for key in RUNTIME_VERSIONS} diff --git a/umu/umu_proton.py b/umu/umu_proton.py index 11e6b71a..40cb9822 100644 --- a/umu/umu_proton.py +++ b/umu/umu_proton.py @@ -17,7 +17,13 @@ from urllib3.response import BaseHTTPResponse from umu.umu_bspatch import Content, ContentContainer, CustomPatcher -from umu.umu_consts import STEAM_COMPAT, UMU_CACHE, UMU_COMPAT, UMU_LOCAL, HTTPMethod +from umu.umu_consts import ( + STEAM_COMPAT, + UMU_CACHE, + UMU_COMPAT, + UMU_LOCAL, + HTTPMethod, +) from umu.umu_log import log from umu.umu_util import ( extract_tarfile, diff --git a/umu/umu_run.py b/umu/umu_run.py index 6c1eb9ff..abebce0a 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -39,6 +39,8 @@ from umu.umu_consts import ( PR_SET_CHILD_SUBREAPER, PROTON_VERBS, + RUNTIME_NAMES, + RUNTIME_VERSIONS, STEAM_COMPAT, STEAM_WINDOW_ID, UMU_LOCAL, @@ -49,6 +51,7 @@ from umu.umu_proton import get_umu_proton from umu.umu_runtime import setup_umu from umu.umu_util import ( + CompatibilityTool, get_libc, get_library_paths, has_umu_setup, @@ -129,7 +132,7 @@ def check_env( env["WINEPREFIX"] = os.environ.get("WINEPREFIX", "") # Skip Proton if running a native Linux executable - if os.environ.get("UMU_NO_PROTON") == "1": + if os.environ.get("UMU_NO_TOOL") == "1": return env path: Path = STEAM_COMPAT.joinpath(os.environ.get("PROTONPATH", "")) @@ -148,6 +151,11 @@ def check_env( os.environ["PROTONPATH"] = "" get_umu_proton(env, session_pools) + if (key := os.environ.get("PROTONPATH")) in RUNTIME_NAMES: + os.environ["PROTONPATH"] = str( + RUNTIME_VERSIONS[RUNTIME_NAMES[key]].path + ) + env["PROTONPATH"] = os.environ["PROTONPATH"] # If download fails/doesn't exist in the system, raise an error @@ -252,7 +260,7 @@ def set_env( # Runtime env["UMU_NO_RUNTIME"] = os.environ.get("UMU_NO_RUNTIME") or "" env["UMU_RUNTIME_UPDATE"] = os.environ.get("UMU_RUNTIME_UPDATE") or "" - env["UMU_NO_PROTON"] = os.environ.get("UMU_NO_PROTON") or "" + env["UMU_NO_TOOL"] = os.environ.get("UMU_NO_TOOL") or "" # Proton logging (to stdout) # Check for PROTON_LOG because it redirects output to log file @@ -298,16 +306,13 @@ def enable_steam_game_drive(env: dict[str, str]) -> dict[str, str]: def build_command( env: dict[str, str], local: Path, - opts: list[str] = [], + opts: list[str] | None = None, ) -> tuple[Path | str, ...]: """Build the command to be executed.""" shim: Path = local.joinpath("umu-shim") - proton: Path = Path(env["PROTONPATH"], "proton") entry_point: Path = local.joinpath("umu") - - if env.get("UMU_NO_PROTON") != "1" and not proton.is_file(): - err: str = "The following file was not found in PROTONPATH: proton" - raise FileNotFoundError(err) + if opts is None: + opts = [] # Exit if the entry point is missing # The _v2-entry-point script and container framework tools are included in @@ -319,41 +324,47 @@ def build_command( ) raise FileNotFoundError(err) + if env.get("UMU_NO_TOOL") == "1": + runtime = CompatibilityTool(str(RUNTIME_VERSIONS[RUNTIME_NAMES["sniper"]].path), shim) + # Will run the game within the Steam Runtime w/o Proton + # Ideally, for reliability, executables should be compiled within + # the Steam Runtime + log.debug( + "Compatibility tool disabled. Executing linux-native executable %s", env["EXE"] + ) + return ( + *runtime.command(env["PROTON_VERB"]), + env["EXE"], + *opts, + ) + + # Setup compatibility tool + # If the user explicitly requested to run without the runtime, + # force runtime to None + compat_tool = CompatibilityTool(env["PROTONPATH"], shim) + if env["UMU_NO_RUNTIME"] == "1": + compat_tool.runtime = None + log.info("Using compatibility tool %s", compat_tool.display_name) + # Will run the game outside the Steam Runtime w/ Proton + if not compat_tool.runtime_enabled: + log.warning("Runtime Platform disabled") + # Winetricks if env.get("EXE", "").endswith("winetricks") and opts: + if compat_tool.layer != "proton": + err: str = "Winetricks is available only on Proton and Proton-derived tools" + raise ValueError(err) # The position of arguments matter for winetricks # Usage: ./winetricks [options] [command|verb|path-to-verb] ... return ( - entry_point, - "--verb", - env["PROTON_VERB"], - "--", - proton, - env["PROTON_VERB"], + *compat_tool.command(env["PROTON_VERB"]), env["EXE"], "-q", *opts, ) - # Will run the game within the Steam Runtime w/o Proton - # Ideally, for reliability, executables should be compiled within - # the Steam Runtime - if env.get("UMU_NO_PROTON") == "1": - return (entry_point, "--verb", env["PROTON_VERB"], "--", env["EXE"], *opts) - - # Will run the game outside the Steam Runtime w/ Proton - if env.get("UMU_NO_RUNTIME") == "1": - log.warning("Runtime Platform disabled") - return proton, env["PROTON_VERB"], env["EXE"], *opts - return ( - entry_point, - "--verb", - env["PROTON_VERB"], - "--", - shim, - proton, - env["PROTON_VERB"], + *compat_tool.command(env["PROTON_VERB"]), env["EXE"], *opts, ) @@ -735,7 +746,7 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: "UMU_ZENITY": "", "UMU_NO_RUNTIME": "", "UMU_RUNTIME_UPDATE": "", - "UMU_NO_PROTON": "", + "UMU_NO_TOOL": "", } opts: list[str] = [] prereq: bool = False diff --git a/umu/umu_test.py b/umu/umu_test.py index bc82d378..6733b285 100644 --- a/umu/umu_test.py +++ b/umu/umu_test.py @@ -20,6 +20,7 @@ ) from unittest.mock import MagicMock, Mock, patch +import vdf from Xlib.display import Display from Xlib.error import DisplayConnectionError from Xlib.protocol.rq import Event @@ -76,6 +77,7 @@ def setUp(self): # Proton verb # Used when testing build_command self.test_verb = "waitforexitandrun" + self.test_verb_as_arg = "--verb=waitforexitandrun" # Test directory self.test_file = "./tmp.WMYQiPb9A" # Executable @@ -131,6 +133,18 @@ def setUp(self): Path(self.test_user_share, "run").touch() Path(self.test_user_share, "run-in-sniper").touch() Path(self.test_user_share, "umu").touch() + with Path(self.test_user_share, "toolmanifest.vdf").open( + "w" + ) as toolmanifest: + vdf.dump( + { + "manifest": { + "commandline": "/_v2-entry-point --verb=%verb% --", + "compatmanager_layer_name": "container-runtime", + } + }, + toolmanifest, + ) # Mock pressure vessel Path(self.test_user_share, "pressure-vessel", "bin").mkdir(parents=True) @@ -139,6 +153,34 @@ def setUp(self): # Mock the proton file in the dir self.test_proton_dir.joinpath("proton").touch(exist_ok=True) + with Path(self.test_proton_dir, "toolmanifest.vdf").open( + "w" + ) as toolmanifest: + vdf.dump( + { + "manifest": { + "commandline": "/proton %verb%", + "require_tool_appid": "1628350", + "compatmanager_layer_name": "proton", + } + }, + toolmanifest, + ) + with Path(self.test_proton_dir, "compatibilitytool.vdf").open( + "w" + ) as compatibilitytool: + vdf.dump( + { + "compatibilitytools": { + "compat_tools": { + "Proton": { + "display_name": "Proton", + } + } + } + }, + compatibilitytool, + ) # Mock the release downloaded in the cache: # tmp.5HYdpddgvs/umu-Proton-5HYdpddgvs.tar.gz @@ -1867,7 +1909,7 @@ def test_game_drive_empty(self): def test_build_command_linux_exe(self): """Test build_command when running a Linux executable. - UMU_NO_PROTON=1 disables Proton, running the executable directly in the + UMU_NO_TOOL=1 skips using a tool, running the executable directly in the Steam Linux Runtime. """ result_args = None @@ -1884,7 +1926,7 @@ def test_build_command_linux_exe(self): os.environ["PROTONPATH"] = self.test_file os.environ["GAMEID"] = self.test_file os.environ["STORE"] = self.test_file - os.environ["UMU_NO_PROTON"] = "1" + os.environ["UMU_NO_TOOL"] = "1" # Args result_args = __main__.parse_args() # Config @@ -1926,6 +1968,10 @@ def test_build_command_linux_exe(self): Path(self.test_user_share, "umu"), Path(self.test_local_share, "umu"), ) + copy( + Path(self.test_user_share, "toolmanifest.vdf"), + Path(self.test_local_share, "toolmanifest.vdf"), + ) # Build test_command = umu_run.build_command(self.env, self.test_local_share) @@ -1934,18 +1980,17 @@ def test_build_command_linux_exe(self): ) self.assertEqual( len(test_command), - 5, - f"Expected 5 element, received {len(test_command)}", + 4, + f"Expected 4 element, received {len(test_command)}", ) - entry_point, opt, verb, sep, exe = [*test_command] + entry_point, verb, sep, exe = [*test_command] self.assertEqual( - entry_point, - self.test_local_share / "umu", + Path(entry_point), + Path(self.test_local_share / "umu"), "Expected an entry point", ) - self.assertEqual(opt, "--verb", "Expected --verb") - self.assertEqual(verb, "waitforexitandrun", "Expected PROTON_VERB") + self.assertEqual(verb, self.test_verb_as_arg, "Expected PROTON_VERB") self.assertEqual(sep, "--", "Expected --") self.assertEqual(exe, self.env["EXE"], "Expected the EXE") @@ -1963,12 +2008,16 @@ def test_build_command_nopv(self): # Mock the proton file Path(self.test_file, "proton").touch() + # Mock the shim file + shim_path = Path(self.test_local_share, "umu-shim") + shim_path.touch() + with ( patch("sys.argv", ["", self.test_exe]), ThreadPoolExecutor() as thread_pool, ): os.environ["WINEPREFIX"] = self.test_file - os.environ["PROTONPATH"] = self.test_file + os.environ["PROTONPATH"] = self.test_proton_dir.as_posix() os.environ["GAMEID"] = self.test_file os.environ["STORE"] = self.test_file os.environ["UMU_NO_RUNTIME"] = "1" @@ -2011,6 +2060,10 @@ def test_build_command_nopv(self): Path(self.test_user_share, "umu"), Path(self.test_local_share, "umu"), ) + copy( + Path(self.test_user_share, "toolmanifest.vdf"), + Path(self.test_local_share, "toolmanifest.vdf"), + ) os.environ |= self.env @@ -2021,13 +2074,13 @@ def test_build_command_nopv(self): ) self.assertEqual( len(test_command), - 3, - f"Expected 3 elements, received {len(test_command)}", + 4, + f"Expected 4 elements, received {len(test_command)}", ) - proton, verb, exe, *_ = [*test_command] - self.assertIsInstance(proton, os.PathLike, "Expected proton to be PathLike") + _, proton, verb, exe, *_ = [*test_command] + self.assertIsInstance(Path(proton), os.PathLike, "Expected proton to be PathLike") self.assertEqual( - proton, + Path(proton), Path(self.env["PROTONPATH"], "proton"), "Expected PROTONPATH", ) @@ -2094,7 +2147,7 @@ def test_build_command(self): ThreadPoolExecutor() as thread_pool, ): os.environ["WINEPREFIX"] = self.test_file - os.environ["PROTONPATH"] = self.test_file + os.environ["PROTONPATH"] = self.test_proton_dir.as_posix() os.environ["GAMEID"] = self.test_file os.environ["STORE"] = self.test_file # Args @@ -2139,6 +2192,10 @@ def test_build_command(self): Path(self.test_user_share, "umu"), Path(self.test_local_share, "umu"), ) + copy( + Path(self.test_user_share, "toolmanifest.vdf"), + Path(self.test_local_share, "toolmanifest.vdf"), + ) # Build test_command = umu_run.build_command(self.env, self.test_local_share) @@ -2147,23 +2204,24 @@ def test_build_command(self): ) self.assertEqual( len(test_command), - 8, - f"Expected 8 elements, received {len(test_command)}", + 7, + f"Expected 7 elements, received {len(test_command)}", ) - entry_point, opt1, verb, opt2, shim, proton, verb2, exe = [*test_command] + entry_point, verb, sep, shim, proton, verb2, exe = [*test_command] # The entry point dest could change. Just check if there's a value self.assertTrue(entry_point, "Expected an entry point") self.assertIsInstance( - entry_point, os.PathLike, "Expected entry point to be PathLike" - ) - self.assertEqual(opt1, "--verb", "Expected --verb") - self.assertEqual(verb, self.test_verb, "Expected a verb") - self.assertEqual(opt2, "--", "Expected --") - self.assertIsInstance(shim, os.PathLike, "Expected shim to be PathLike") - self.assertEqual(shim, shim_path, "Expected the shim file") - self.assertIsInstance(proton, os.PathLike, "Expected proton to be PathLike") + Path(entry_point), + os.PathLike, + "Expected entry point to be PathLike", + ) + self.assertEqual(verb, self.test_verb_as_arg, "Expected a verb") + self.assertEqual(sep, "--", "Expected --") + self.assertIsInstance(Path(shim), os.PathLike, "Expected shim to be PathLike") + self.assertEqual(Path(shim), shim_path, "Expected the shim file") + self.assertIsInstance(Path(proton), os.PathLike, "Expected proton to be PathLike") self.assertEqual( - proton, + Path(proton), Path(self.env["PROTONPATH"], "proton"), "Expected the proton file", ) diff --git a/umu/umu_test_plugins.py b/umu/umu_test_plugins.py index 6ffc211c..bd5c69e9 100644 --- a/umu/umu_test_plugins.py +++ b/umu/umu_test_plugins.py @@ -9,6 +9,7 @@ from shutil import copy, copytree, rmtree from unittest.mock import MagicMock, patch +import vdf from tomllib import TOMLDecodeError sys.path.append(str(Path(__file__).parent.parent)) @@ -47,6 +48,7 @@ def setUp(self): # Proton verb # Used when testing build_command self.test_verb = "waitforexitandrun" + self.test_verb_as_arg = "--verb=waitforexitandrun" # Test directory self.test_file = "./tmp.AKN6tnueyO" # Executable @@ -89,6 +91,18 @@ def setUp(self): Path(self.test_user_share, "run").touch() Path(self.test_user_share, "run-in-sniper").touch() Path(self.test_user_share, "umu").touch() + with Path(self.test_user_share, "toolmanifest.vdf").open( + "w" + ) as toolmanifest: + vdf.dump( + { + "manifest": { + "commandline": "/_v2-entry-point --verb=%verb% --", + "compatmanager_layer_name": "container-runtime", + } + }, + toolmanifest, + ) # Mock pressure vessel Path(self.test_user_share, "pressure-vessel").mkdir() @@ -101,6 +115,34 @@ def setUp(self): # Mock the proton file in the dir self.test_proton_dir.joinpath("proton").touch(exist_ok=True) + with Path(self.test_proton_dir, "toolmanifest.vdf").open( + "w" + ) as toolmanifest: + vdf.dump( + { + "manifest": { + "commandline": "/proton %verb%", + "require_tool_appid": "1628350", + "compatmanager_layer_name": "proton", + } + }, + toolmanifest, + ) + with Path(self.test_proton_dir, "compatibilitytool.vdf").open( + "w" + ) as compatibilitytool: + vdf.dump( + { + "compatibilitytools": { + "compat_tools": { + "Proton": { + "display_name": "Proton", + } + } + } + }, + compatibilitytool, + ) Path(self.test_file).mkdir(exist_ok=True) Path(self.test_exe).touch() @@ -295,12 +337,16 @@ def test_build_command_proton(self): Path(self.test_user_share, "umu"), Path(self.test_local_share, "umu"), ) + copy( + Path(self.test_user_share, "toolmanifest.vdf"), + Path(self.test_local_share, "toolmanifest.vdf"), + ) for key, val in self.env.items(): os.environ[key] = val # Build - with self.assertRaisesRegex(FileNotFoundError, "proton"): + with self.assertRaisesRegex(FileNotFoundError, "proton|toolmanifest.vdf|compatibilitytool.vdf"): umu_run.build_command(self.env, self.test_local_share, test_command) def test_build_command_toml(self): @@ -313,7 +359,7 @@ def test_build_command_toml(self): toml_str = f""" [umu] prefix = "{self.test_file}" - proton = "{self.test_file}" + proton = "{self.test_proton_dir}" game_id = "{self.test_file}" launch_args = ["{self.test_file}", "{self.test_file}"] exe = "{self.test_exe}" @@ -376,6 +422,10 @@ def test_build_command_toml(self): Path(self.test_user_share, "umu"), Path(self.test_local_share, "umu"), ) + copy( + Path(self.test_user_share, "toolmanifest.vdf"), + Path(self.test_local_share, "toolmanifest.vdf"), + ) for key, val in self.env.items(): os.environ[key] = val @@ -384,20 +434,21 @@ def test_build_command_toml(self): test_command = umu_run.build_command(self.env, self.test_local_share) # Verify contents of the command - entry_point, opt1, verb, opt2, shim, proton, verb2, exe = [*test_command] + entry_point, verb, sep, shim, proton, verb2, exe = [*test_command] # The entry point dest could change. Just check if there's a value self.assertTrue(entry_point, "Expected an entry point") self.assertIsInstance( - entry_point, os.PathLike, "Expected entry point to be PathLike" + Path(entry_point), + os.PathLike, + "Expected entry point to be PathLike", ) - self.assertEqual(opt1, "--verb", "Expected --verb") - self.assertEqual(verb, self.test_verb, "Expected a verb") - self.assertEqual(opt2, "--", "Expected --") - self.assertIsInstance(shim, os.PathLike, "Expected shim to be PathLike") - self.assertEqual(shim, shim_path, "Expected the shim file") - self.assertIsInstance(proton, os.PathLike, "Expected proton to be PathLike") + self.assertEqual(verb, self.test_verb_as_arg, "Expected a verb") + self.assertEqual(sep, "--", "Expected --") + self.assertIsInstance(Path(shim), os.PathLike, "Expected shim to be PathLike") + self.assertEqual(Path(shim), shim_path, "Expected the shim file") + self.assertIsInstance(Path(proton), os.PathLike, "Expected proton to be PathLike") self.assertEqual( - proton, + Path(proton), Path(self.env["PROTONPATH"], "proton"), "Expected the proton file", ) diff --git a/umu/umu_util.py b/umu/umu_util.py index 36cba71f..301d3e53 100644 --- a/umu/umu_util.py +++ b/umu/umu_util.py @@ -1,4 +1,5 @@ import os +import shlex import sys from collections.abc import Generator from contextlib import contextmanager @@ -15,10 +16,11 @@ from tarfile import open as taropen from typing import Any +import vdf from urllib3.response import BaseHTTPResponse from Xlib import display -from umu.umu_consts import UMU_LOCAL +from umu.umu_consts import RUNTIME_VERSIONS, UMU_LOCAL, UmuRuntime from umu.umu_log import log @@ -342,3 +344,103 @@ def file_digest(fileobj, digest, /, *, _bufsize=2**18): # noqa: ANN001 digestobj.update(view[:size]) return digestobj + + +class SteamBase: + """Base class describing runtime and compat tool common features.""" + + def __init__(self, path: str) -> None: # noqa: D107 + self.tool_path = path + with Path(path).joinpath("toolmanifest.vdf").open(encoding="utf-8") as f: + self.tool_manifest = vdf.load(f)["manifest"] + + @property + def required_tool_appid(self) -> str | None: # noqa: D102 + return str(ret) if (ret := self.tool_manifest.get("require_tool_appid")) else None + + @property + def required_runtime(self) -> UmuRuntime: + """Map the required tool's appid to a runtime known by umu.""" + if self.required_tool_appid is None: + return RUNTIME_VERSIONS["host"] + return RUNTIME_VERSIONS[self.required_tool_appid] + + @property + def layer(self) -> str | None: # noqa: D102 + return str(ret) if (ret := self.tool_manifest.get("compatmanager_layer_name")) else None + + def command(self, verb: str) -> list[str]: + """Return the tool specific entry point.""" + tool_path = os.path.normpath(self.tool_path) + cmd = "".join([shlex.quote(tool_path), self.tool_manifest["commandline"]]) + # Temporary override for backwards compatibility + if self.tool_path == str(UMU_LOCAL): + cmd = cmd.replace("_v2-entry-point", "umu") + cmd = cmd.replace("%verb%", verb) + return shlex.split(cmd) + + def as_str(self, verb: str): # noqa: D102 + return " ".join(map(shlex.quote, self.command(verb))) + + +class SteamRuntime(SteamBase): + """A Steam Linux Runtime (soldier, sniper, medic etc).""" + + def __init__(self, path: str) -> None: # noqa: D107 + super().__init__(path) + self.runtime = ( + SteamRuntime(str(self.required_runtime.path)) + if self.required_tool_appid is not None + else None + ) + _path = Path(path) + if _path.joinpath("compatibilitytool.vdf").exists(): + with _path.joinpath("compatibilitytool.vdf").open(encoding="utf-8") as f: + # There can be multiple tools definitions in `compatibilitytools.vdf` + # Take the first one and hope it is the one with the correct display_name + compat_tools = tuple(vdf.load(f)["compatibilitytools"]["compat_tools"].values()) + self.compatibility_tool = compat_tools[0] + else: + self.compatibility_tool = {"display_name": _path.name} + + @property + def display_name(self) -> str | None: # noqa: D102 + return str(ret) if (ret := self.compatibility_tool.get("display_name")) else None + + @property + def runtime_enabled(self) -> bool: + """Report if the compatibility tool has a configured runtime.""" + return self.runtime is not None + + def command(self, verb: str) -> list[str]: + """Return the fully qualified command for the runtime. + + If the runtime uses another runtime, its entry point is prepended to the local command. + """ + log.info("Running '%s' using runtime '%s'", self.display_name, self.required_runtime.name) + cmd = self.runtime.command(verb) if self.runtime is not None else [] + cmd.extend(super().command(verb)) + return cmd + + +class CompatibilityTool(SteamRuntime): + """A compatibility tool (Proton, luxtorpeda, etc).""" + + def __init__(self, path: str, shim: Path) -> None: # noqa: D107 + super().__init__(path) + self.shim = shim + + def command(self, verb: str) -> list[str]: + """Return the fully qualified command for the tool. + + If the tool uses a runtime, its entry point is prepended to the tool's command. + """ + log.info("Running '%s' using runtime '%s'", self.display_name, self.required_runtime.name) + cmd = self.runtime.command(verb) if self.runtime is not None else [] + target = super(SteamRuntime, self).command(verb) + if self.layer in {"container-runtime", "scout-in-container"}: + cmd.extend([*target, self.shim.as_posix()]) + else: + cmd.extend([self.shim.as_posix(), *target]) + return cmd +