diff --git a/Makefile.in b/Makefile.in index 9324abc2..43ae26ab 100644 --- a/Makefile.in +++ b/Makefile.in @@ -102,6 +102,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) .PHONY: umu-vendored umu-vendored: $(OBJDIR)/.build-umu-vendored @@ -110,6 +111,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 $(OBJDIR): @mkdir -p $(@) diff --git a/pyproject.toml b/pyproject.toml index 2fc0ca28..aaf06429 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", "filelock>=3.9.0", "urllib3>=2.0.0,<3.0.0"] +dependencies = ["python-xlib>=0.33", "filelock>=3.9.0", "urllib3>=2.0.0,<3.0.0", "vdf>=3.4"] [project.optional-dependencies] # Recommended diff --git a/requirements.in b/requirements.in index 49ce05ae..3e6b5a6a 100644 --- a/requirements.in +++ b/requirements.in @@ -1,3 +1,4 @@ python-xlib>=0.33 filelock>=3.15.4 urllib3>=2.0.0,<3.0.0 +vdf>=3.4 diff --git a/umu/umu_consts.py b/umu/umu_consts.py index 44d7c7eb..bc9cd59e 100644 --- a/umu/umu_consts.py +++ b/umu/umu_consts.py @@ -53,6 +53,13 @@ class GamescopeAtom(Enum): "getnativepath", } +RUNTIME_VERSIONS = { + # "1070560": ("scout", "steamrt1"), + "1391110": ("soldier", "steamrt2"), + "1628350": ("sniper", "steamrt3"), + # "": ("medic", "steamrt4"), +} + XDG_CACHE_HOME: Path = ( Path(os.environ["XDG_CACHE_HOME"]) if os.environ.get("XDG_CACHE_HOME") diff --git a/umu/umu_run.py b/umu/umu_run.py index 1bd76648..6cdcc1d8 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -50,6 +50,8 @@ from umu.umu_proton import get_umu_proton from umu.umu_runtime import setup_umu from umu.umu_util import ( + CompatibilityTool, + SteamRuntime, get_libc, get_library_paths, has_umu_setup, @@ -134,7 +136,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 # Proton Version @@ -266,7 +268,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 @@ -312,16 +314,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 @@ -333,48 +332,49 @@ def build_command( ) raise FileNotFoundError(err) - # Winetricks - if env.get("EXE", "").endswith("winetricks") and opts: - # 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"], - env["EXE"], - "-q", - *opts, - ) - + runtime = SteamRuntime(local.as_posix()) # 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": + if env.get("UMU_NO_TOOL") == "1": + log.debug( + "Compatibility tool disabled. Executing linux-native executable %s", env["EXE"] + ) return ( - entry_point, - "--verb", - env["PROTON_VERB"], - "--", + *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, + None if env["UMU_NO_RUNTIME"] == "1" else runtime + ) + log.info("Using compatibility tool %s", compat_tool.display_name) # Will run the game outside the Steam Runtime w/ Proton - if env.get("UMU_NO_RUNTIME") == "1": + if not compat_tool.runtime_enabled: log.warning("Runtime Platform disabled") - return proton, env["PROTON_VERB"], env["EXE"], *opts + + # 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 ( + *compat_tool.command(env["PROTON_VERB"]), + env["EXE"], + "-q", + *opts, + ) return ( - entry_point, - "--verb", - env["PROTON_VERB"], - "--", - shim, - proton, - env["PROTON_VERB"], + *compat_tool.command(env["PROTON_VERB"]), env["EXE"], *opts, ) @@ -771,7 +771,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 15efbfdb..4373a8cc 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( @@ -143,6 +157,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 @@ -1784,7 +1826,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 @@ -1801,7 +1843,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 @@ -1845,6 +1887,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) @@ -1853,18 +1899,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") @@ -1882,12 +1927,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" @@ -1932,6 +1981,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 @@ -1942,15 +1995,15 @@ 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] + _, proton, verb, exe, *_ = [*test_command] self.assertIsInstance( - proton, os.PathLike, "Expected proton to be PathLike" + Path(proton), os.PathLike, "Expected proton to be PathLike" ) self.assertEqual( - proton, + Path(proton), Path(self.env["PROTONPATH"], "proton"), "Expected PROTONPATH", ) @@ -2017,7 +2070,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 @@ -2064,6 +2117,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) @@ -2072,29 +2129,28 @@ 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" + 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.assertEqual(verb, self.test_verb_as_arg, "Expected a verb") + self.assertEqual(sep, "--", "Expected --") self.assertIsInstance( - shim, os.PathLike, "Expected shim to be PathLike" + Path(shim), os.PathLike, "Expected shim to be PathLike" ) - self.assertEqual(shim, shim_path, "Expected the shim file") + self.assertEqual(Path(shim), shim_path, "Expected the shim file") self.assertIsInstance( - proton, os.PathLike, "Expected proton to be PathLike" + 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 7b620cec..446f4086 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 @@ -91,6 +93,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() @@ -105,6 +119,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() @@ -305,12 +347,18 @@ 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 ) @@ -325,7 +373,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}" @@ -390,6 +438,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 @@ -398,26 +450,25 @@ 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.assertEqual(verb, self.test_verb_as_arg, "Expected a verb") + self.assertEqual(sep, "--", "Expected --") self.assertIsInstance( - shim, os.PathLike, "Expected shim to be PathLike" + Path(shim), os.PathLike, "Expected shim to be PathLike" ) - self.assertEqual(shim, shim_path, "Expected the shim file") + self.assertEqual(Path(shim), shim_path, "Expected the shim file") self.assertIsInstance( - proton, os.PathLike, "Expected proton to be PathLike" + 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 dceeb730..e2dafb05 100644 --- a/umu/umu_util.py +++ b/umu/umu_util.py @@ -1,4 +1,5 @@ import os +import shlex import sys from contextlib import contextmanager from ctypes.util import find_library @@ -12,10 +13,11 @@ from subprocess import PIPE, STDOUT, Popen, TimeoutExpired from tarfile import open as taropen +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 from umu.umu_log import log @@ -303,3 +305,79 @@ 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_tool_name(self) -> tuple: + """Map the required tool's appid to a tuple of commonly used names.""" + if self.required_tool_appid is None: + return None, None + 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"]]) + cmd = cmd.replace("_v2-entry-point", "umu") + cmd = cmd.replace("%verb%", str(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) + + +class CompatibilityTool(SteamBase): + """A compatibility tool (Proton, luxtorpeda, etc).""" + + def __init__(self, tool_path: str, shim: Path, runtime: SteamRuntime | None) -> None: # noqa: D107 + super().__init__(tool_path) + self.shim = shim + self.runtime = runtime if self.required_tool_appid is not None else None + with Path(tool_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] + + @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 tool . + + If the tool uses a runtime, its entry point is prepended to the tool's command. + """ + cmd = self.runtime.command(verb) if self.runtime is not None else [] + cmd.append(self.shim.as_posix()) + cmd.extend(super().command(verb)) + return cmd +