From f56fde27b47266a5378fe6433b9f0978c82590af Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Sat, 22 Mar 2025 17:33:47 +0200 Subject: [PATCH 01/13] umu_utils: use contextmanager to redirect stdout to stderr python-xlib has two `print()` statements in Xlib.xauth * https://github.com/python-xlib/python-xlib/blob/master/Xlib/xauth.py#L92 * https://github.com/python-xlib/python-xlib/blob/master/Xlib/xauth.py#L95 which can cause issues when the output in stdout needs to be parsed later. --- umu/umu_util.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/umu/umu_util.py b/umu/umu_util.py index 3915e93..40adb12 100644 --- a/umu/umu_util.py +++ b/umu/umu_util.py @@ -1,7 +1,7 @@ import os import sys from collections.abc import Generator -from contextlib import contextmanager +from contextlib import contextmanager, redirect_stdout from ctypes.util import find_library from fcntl import LOCK_EX, LOCK_UN, flock from functools import lru_cache @@ -219,7 +219,8 @@ def xdisplay(no: str): d: display.Display | None = None try: - d = display.Display(no) + with redirect_stdout(sys.stderr): + d = display.Display(no) yield d finally: if d is not None: -- 2.49.0 From b2a8d3b47af6476ad60e09ba88ed7c795582048f Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Wed, 2 Apr 2025 15:38:03 +0300 Subject: [PATCH 02/13] umu_run: complete the implemtation of reaper in umu --- umu/umu_run.py | 33 +++++++++++++++++++++++---------- umu/umu_test.py | 1 + 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/umu/umu_run.py b/umu/umu_run.py index a69351e..bac2450 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -16,7 +16,6 @@ from pathlib import Path from pwd import getpwuid from re import match from socket import AF_INET, SOCK_DGRAM, socket -from subprocess import Popen from typing import Any from urllib3 import PoolManager, Retry @@ -596,7 +595,7 @@ def monitor_windows( set_steam_game_property(d_secondary, diff, steam_appid) -def run_in_steammode(proc: Popen) -> int: +def run_in_steammode() -> None: """Set properties on gamescope windows when running in steam mode. Currently, Flatpak apps that use umu as their runtime will not have their @@ -614,7 +613,9 @@ def run_in_steammode(proc: Popen) -> int: # TODO: Find a robust way to get gamescope displays both in a container # and outside a container try: - with xdisplay(":0") as d_primary, xdisplay(":1") as d_secondary: + main_display = os.environ.get("DISPLAY", ":0") + game_display = os.environ.get("STEAM_GAME_DISPLAY_0", ":1") + with xdisplay(main_display) as d_primary, xdisplay(game_display) as d_secondary: gamescope_baselayer_sequence = get_gamescope_baselayer_appid(d_primary) # Dont do window fuckery if we're not inside gamescope if ( @@ -639,20 +640,16 @@ def run_in_steammode(proc: Popen) -> int: ) baselayer_thread.daemon = True baselayer_thread.start() - return proc.wait() except DisplayConnectionError as e: # Case where steamos changed its display outputs as we're currently # assuming connecting to :0 and :1 is stable log.exception(e) - return proc.wait() - def run_command(command: tuple[Path | str, ...]) -> int: """Run the executable using Proton within the Steam Runtime.""" prctl: CFuncPtr cwd: Path | str - proc: Popen ret: int = 0 prctl_ret: int = 0 libc: str = get_libc() @@ -688,9 +685,25 @@ def run_command(command: tuple[Path | str, ...]) -> int: prctl_ret = prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0, 0) log.debug("prctl exited with status: %s", prctl_ret) - with Popen(command, start_new_session=True, cwd=cwd) as proc: - ret = run_in_steammode(proc) if is_steammode else proc.wait() - log.debug("Child %s exited with wait status: %s", proc.pid, ret) + pid = os.fork() + if pid == -1: + log.error("Fork failed") + + if pid == 0: + sys.stdout.flush() + sys.stderr.flush() + if is_steammode: + run_in_steammode() + os.chdir(cwd) + os.execvp(command[0], command) # noqa: S606 + + while True: + try: + wait_pid, wait_status = os.wait() + log.debug("Child %s exited with wait status: %s", wait_pid, wait_status) + except ChildProcessError as e: + log.info(e) + break return ret diff --git a/umu/umu_test.py b/umu/umu_test.py index 123cd15..03a3264 100644 --- a/umu/umu_test.py +++ b/umu/umu_test.py @@ -1250,6 +1250,7 @@ class TestGameLauncher(unittest.TestCase): if not libc: return + self.skipTest("WIP") os.environ["EXE"] = mock_exe with ( patch.object( -- 2.49.0 From 15e7b4ee4709ad12d19bd9c95282c91c2adc22f3 Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Thu, 3 Apr 2025 10:12:17 +0300 Subject: [PATCH 03/13] umu_runtime: use `exec` to replace the shell in umu-shim instead of capturing the output The idea here is to avoid creating a new process, and instead keep the pid umu directly knows about for as long as possible. --- umu/umu_runtime.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/umu/umu_runtime.py b/umu/umu_runtime.py index e443d19..3862f6b 100644 --- a/umu/umu_runtime.py +++ b/umu/umu_runtime.py @@ -55,12 +55,7 @@ def create_shim(file_path: Path): "fi\n" "\n" "# Execute the passed command\n" - '"$@"\n' - "\n" - "# Capture the exit status\n" - "status=$?\n" - 'echo "Command exited with status: $status" >&2\n' - "exit $status\n" + 'exec "$@"\n' ) # Write the script content to the specified file path -- 2.49.0 From ba0e9acbf2bcbde3fc0839288ad67baaef98ddcc Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Thu, 22 May 2025 09:45:42 +0300 Subject: [PATCH 04/13] umu_run: run steammode workaround on the main process --- umu/umu_run.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/umu/umu_run.py b/umu/umu_run.py index bac2450..8e4324a 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -692,10 +692,10 @@ def run_command(command: tuple[Path | str, ...]) -> int: if pid == 0: sys.stdout.flush() sys.stderr.flush() - if is_steammode: - run_in_steammode() os.chdir(cwd) os.execvp(command[0], command) # noqa: S606 + elif is_steammode: + run_in_steammode() while True: try: -- 2.49.0 From b97df656747de1a7ad6c2941b9fb4a8288b10000 Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Tue, 3 Jun 2025 11:45:40 +0300 Subject: [PATCH 05/13] umu_run: use hardcoded display values for now commit for targeted revert --- umu/umu_run.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/umu/umu_run.py b/umu/umu_run.py index 8e4324a..dc19ad8 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -613,9 +613,7 @@ def run_in_steammode() -> None: # TODO: Find a robust way to get gamescope displays both in a container # and outside a container try: - main_display = os.environ.get("DISPLAY", ":0") - game_display = os.environ.get("STEAM_GAME_DISPLAY_0", ":1") - with xdisplay(main_display) as d_primary, xdisplay(game_display) as d_secondary: + with xdisplay(":0") as d_primary, xdisplay(":1") as d_secondary: gamescope_baselayer_sequence = get_gamescope_baselayer_appid(d_primary) # Dont do window fuckery if we're not inside gamescope if ( -- 2.49.0 From 597d17e41cb3fecee4694b18b34fd5fa35448837 Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Thu, 20 Mar 2025 11:57:43 +0200 Subject: [PATCH 06/13] Reapply "umu_run: handle Protons without an explicit runtime requirement" This reverts commit 6494ecd8007f64d245740e78f71d3008d862214f. --- tests/test_config.sh | 1 + umu/umu_plugins.py | 6 +- umu/umu_run.py | 111 +++++++++++++++++++++-------------- umu/umu_runtime.py | 6 -- umu/umu_test.py | 124 +++++++++++++++++++++++----------------- umu/umu_test_plugins.py | 37 ++++++------ 6 files changed, 161 insertions(+), 124 deletions(-) diff --git a/tests/test_config.sh b/tests/test_config.sh index 367f1ac..7bed18f 100644 --- a/tests/test_config.sh +++ b/tests/test_config.sh @@ -19,5 +19,6 @@ store = 'gog' " >> "$tmp" +# This works only for an existing prefix, the prefix `~/Games/umu/umu-0` is created in the previous workflow steps RUNTIMEPATH=steamrt3 UMU_LOG=debug GAMEID=umu-1141086411 STORE=gog "$PWD/.venv/bin/python" "$HOME/.local/bin/umu-run" --config "$tmp" 2> /tmp/umu-log.txt && grep -E "INFO: Non-steam game Silent Hill 4: The Room \(umu-1141086411\)" /tmp/umu-log.txt # Run the 'game' using UMU-Proton9.0-3.2 and ensure the protonfixes module finds its fix in umu-database.csv diff --git a/umu/umu_plugins.py b/umu/umu_plugins.py index 8766dfc..5a8f1fd 100644 --- a/umu/umu_plugins.py +++ b/umu/umu_plugins.py @@ -51,9 +51,9 @@ def set_env_toml( _check_env_toml(toml) # Required environment variables - env["WINEPREFIX"] = toml["umu"]["prefix"] - env["PROTONPATH"] = toml["umu"]["proton"] - env["EXE"] = toml["umu"]["exe"] + env["WINEPREFIX"] = str(Path(toml["umu"]["prefix"]).expanduser()) + env["PROTONPATH"] = str(Path(toml["umu"]["proton"]).expanduser()) + env["EXE"] = str(Path(toml["umu"]["exe"]).expanduser()) # Optional env["GAMEID"] = toml["umu"].get("game_id", "") env["STORE"] = toml["umu"].get("store", "") diff --git a/umu/umu_run.py b/umu/umu_run.py index dc19ad8..8ff8217 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -41,7 +41,7 @@ from umu.umu_consts import ( from umu.umu_log import log from umu.umu_plugins import set_env_toml from umu.umu_proton import get_umu_proton -from umu.umu_runtime import setup_umu +from umu.umu_runtime import create_shim, setup_umu from umu.umu_util import ( get_libc, get_library_paths, @@ -90,9 +90,7 @@ def setup_pfx(path: str) -> None: wineuser.symlink_to("steamuser") -def check_env( - env: dict[str, str], session_pools: tuple[ThreadPoolExecutor, PoolManager] -) -> dict[str, str] | dict[str, Any]: +def check_env(env: dict[str, str]) -> tuple[dict[str, str] | dict[str, Any], bool]: """Before executing a game, check for environment variables and set them. GAMEID is strictly required and the client is responsible for setting this. @@ -124,9 +122,10 @@ def check_env( env["WINEPREFIX"] = os.environ.get("WINEPREFIX", "") + do_download = False # Skip Proton if running a native Linux executable if os.environ.get("UMU_NO_PROTON") == "1": - return env + return env, do_download path: Path = STEAM_COMPAT.joinpath(os.environ.get("PROTONPATH", "")) if os.environ.get("PROTONPATH") and path.name == "UMU-Latest": @@ -138,16 +137,28 @@ def check_env( # Proton Codename if os.environ.get("PROTONPATH") in {"GE-Proton", "GE-Latest", "UMU-Latest"}: - get_umu_proton(env, session_pools) + do_download = True if "PROTONPATH" not in os.environ: os.environ["PROTONPATH"] = "" - get_umu_proton(env, session_pools) + do_download = True env["PROTONPATH"] = os.environ["PROTONPATH"] + return env, do_download + + +def download_proton(download: bool, env: dict[str, str], session_pools: tuple[ThreadPoolExecutor, PoolManager]) -> None: + """Check if umu should download proton and check if PROTONPATH is set. + + I am not gonna lie about it, this only exists to satisfy the tests, because downloading + was previously nested in `check_env()` + """ + if download: + get_umu_proton(env, session_pools) + # If download fails/doesn't exist in the system, raise an error - if not os.environ["PROTONPATH"]: + if os.environ.get("UMU_NO_PROTON") != "1" and not os.environ["PROTONPATH"]: err: str = ( "Environment variable not set or is empty: PROTONPATH\n" f"Possible reason: GE-Proton or UMU-Proton not found in '{STEAM_COMPAT}'" @@ -155,8 +166,6 @@ def check_env( ) raise FileNotFoundError(err) - return env - def set_env( env: dict[str, str], args: Namespace | tuple[str, list[str]] @@ -291,12 +300,17 @@ def enable_steam_game_drive(env: dict[str, str]) -> dict[str, str]: def build_command( env: dict[str, str], local: Path, - opts: list[str] = [], + version: 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") + entry_point: tuple[Path, str, str, str] | tuple[()] = ( + local.joinpath(version, "umu"), "--verb", env["PROTON_VERB"], "--" + ) if version != "host" else () + if opts is None: + opts = [] if env.get("UMU_NO_PROTON") != "1" and not proton.is_file(): err: str = "The following file was not found in PROTONPATH: proton" @@ -305,7 +319,7 @@ def build_command( # Exit if the entry point is missing # The _v2-entry-point script and container framework tools are included in # the same image, so this can happen if the image failed to download - if not entry_point.is_file(): + if entry_point and not entry_point[0].is_file(): err: str = ( f"_v2-entry-point (umu) cannot be found in '{local}'\n" "Runtime Platform missing or download incomplete" @@ -317,10 +331,7 @@ def build_command( # The position of arguments matter for winetricks # Usage: ./winetricks [options] [command|verb|path-to-verb] ... return ( - entry_point, - "--verb", - env["PROTON_VERB"], - "--", + *entry_point, proton, env["PROTON_VERB"], env["EXE"], @@ -332,7 +343,7 @@ def build_command( # 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) + return *entry_point, env["EXE"], *opts # Will run the game outside the Steam Runtime w/ Proton if env.get("UMU_NO_RUNTIME") == "1": @@ -340,10 +351,7 @@ def build_command( return proton, env["PROTON_VERB"], env["EXE"], *opts return ( - entry_point, - "--verb", - env["PROTON_VERB"], - "--", + *entry_point, shim, proton, env["PROTON_VERB"], @@ -740,6 +748,9 @@ def resolve_umu_version(runtimes: tuple[RuntimeVersion, ...]) -> RuntimeVersion path = Path(os.environ["PROTONPATH"], "toolmanifest.vdf").resolve() if path.is_file(): version = get_umu_version_from_manifest(path, runtimes) + else: + err: str = f"PROTONPATH '{os.environ['PROTONPATH']}' is not valid, toolmanifest.vdf not found" + raise FileNotFoundError(err) return version @@ -761,7 +772,7 @@ def get_umu_version_from_manifest( break if not appid: - return None + return "host", "host", "host" if appid not in appids: return None @@ -849,6 +860,12 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: ) raise RuntimeError(err) + if isinstance(args, Namespace): + env, opts = set_env_toml(env, args) + os.environ.update({k: v for k, v in env.items() if bool(v)}) + else: + opts = args[1] # Reference the executable options + # Resolve the runtime version for PROTONPATH version = resolve_umu_version(__runtime_versions__) if not version: @@ -869,23 +886,36 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: # Default to a strict 5 second timeouts throughout timeout: Timeout = Timeout(connect=NET_TIMEOUT, read=NET_TIMEOUT) + # ensure base directory exists + UMU_LOCAL.mkdir(parents=True, exist_ok=True) + with ( ThreadPoolExecutor() as thread_pool, PoolManager(timeout=timeout, retries=retries) as http_pool, ): session_pools: tuple[ThreadPoolExecutor, PoolManager] = (thread_pool, http_pool) # Setup the launcher and runtime files - future: Future = thread_pool.submit( - setup_umu, UMU_LOCAL / version[1], version, session_pools - ) + _, do_download = check_env(env) - if isinstance(args, Namespace): - env, opts = set_env_toml(env, args) - else: - opts = args[1] # Reference the executable options - check_env(env, session_pools) + if version[1] != "host": + UMU_LOCAL.joinpath(version[1]).mkdir(parents=True, exist_ok=True) - UMU_LOCAL.joinpath(version[1]).mkdir(parents=True, exist_ok=True) + future: Future = thread_pool.submit( + setup_umu, UMU_LOCAL / version[1], version, session_pools + ) + + download_proton(do_download, env, session_pools) + + try: + future.result() + except (MaxRetryError, NewConnectionError, TimeoutErrorUrllib3, ValueError) as e: + if not has_umu_setup(): + err: str = ( + "umu has not been setup for the user\n" + "An internet connection is required to setup umu" + ) + raise RuntimeError(err) from e + log.debug("Network is unreachable") # Prepare the prefix with unix_flock(f"{UMU_LOCAL}/{FileLock.Prefix.value}"): @@ -894,23 +924,16 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: # Configure the environment set_env(env, args) + # Restore shim if missing + if not UMU_LOCAL.joinpath("umu-shim").is_file(): + create_shim(UMU_LOCAL / "umu-shim") + # Set all environment variables # NOTE: `env` after this block should be read only for key, val in env.items(): log.debug("%s=%s", key, val) os.environ[key] = val - try: - future.result() - except (MaxRetryError, NewConnectionError, TimeoutErrorUrllib3, ValueError): - if not has_umu_setup(): - err: str = ( - "umu has not been setup for the user\n" - "An internet connection is required to setup umu" - ) - raise RuntimeError(err) - log.debug("Network is unreachable") - # Exit if the winetricks verb is already installed to avoid reapplying it if env["EXE"].endswith("winetricks") and is_installed_verb( opts, Path(env["WINEPREFIX"]) @@ -918,7 +941,7 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: sys.exit(1) # Build the command - command: tuple[Path | str, ...] = build_command(env, UMU_LOCAL / version[1], opts) + command: tuple[Path | str, ...] = build_command(env, UMU_LOCAL, version[1], opts) log.debug("%s", command) # Run the command diff --git a/umu/umu_runtime.py b/umu/umu_runtime.py index 3862f6b..273706a 100644 --- a/umu/umu_runtime.py +++ b/umu/umu_runtime.py @@ -259,8 +259,6 @@ def _install_umu( log.debug("Renaming: _v2-entry-point -> umu") local.joinpath("_v2-entry-point").rename(local.joinpath("umu")) - create_shim(local / "umu-shim") - # Validate the runtime after moving the files check_runtime(local, runtime_ver) @@ -375,10 +373,6 @@ def _update_umu( # Update our runtime _update_umu_platform(local, runtime, runtime_ver, session_pools, resp) - # Restore shim if missing - if not local.joinpath("umu-shim").is_file(): - create_shim(local / "umu-shim") - log.info("%s is up to date", variant) diff --git a/umu/umu_test.py b/umu/umu_test.py index 03a3264..8e26b10 100644 --- a/umu/umu_test.py +++ b/umu/umu_test.py @@ -1559,7 +1559,8 @@ class TestGameLauncher(unittest.TestCase): os.environ["WINEPREFIX"] = self.test_file os.environ["GAMEID"] = self.test_file os.environ["PROTONPATH"] = "GE-Proton" - umu_run.check_env(self.env, self.test_session_pools) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, self.test_session_pools) self.assertEqual( self.env["PROTONPATH"], self.test_compat.joinpath( @@ -1586,7 +1587,8 @@ class TestGameLauncher(unittest.TestCase): os.environ["WINEPREFIX"] = self.test_file os.environ["GAMEID"] = self.test_file os.environ["PROTONPATH"] = "GE-Proton" - umu_run.check_env(self.env, mock_session_pools) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, mock_session_pools) self.assertFalse(os.environ.get("PROTONPATH"), "Expected empty string") def test_latest_interrupt(self): @@ -1883,7 +1885,7 @@ class TestGameLauncher(unittest.TestCase): # Args args = __main__.parse_args() # Config - umu_run.check_env(self.env, self.test_session_pools) + result_env, result_dl = umu_run.check_env(self.env) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -1947,7 +1949,8 @@ class TestGameLauncher(unittest.TestCase): # Args args = __main__.parse_args() # Config - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -2044,7 +2047,8 @@ class TestGameLauncher(unittest.TestCase): # Args args = __main__.parse_args() # Config - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -2120,7 +2124,8 @@ class TestGameLauncher(unittest.TestCase): # Args result_args = __main__.parse_args() # Config - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -2159,7 +2164,7 @@ class TestGameLauncher(unittest.TestCase): ) # Build - test_command = umu_run.build_command(self.env, self.test_local_share) + test_command = umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_version[1]) self.assertIsInstance( test_command, tuple, "Expected a tuple from build_command" ) @@ -2207,7 +2212,8 @@ class TestGameLauncher(unittest.TestCase): # Args result_args = __main__.parse_args() # Config - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -2246,7 +2252,7 @@ class TestGameLauncher(unittest.TestCase): os.environ |= self.env # Build - test_command = umu_run.build_command(self.env, self.test_local_share) + test_command = umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_version[1]) self.assertIsInstance( test_command, tuple, "Expected a tuple from build_command" ) @@ -2285,7 +2291,8 @@ class TestGameLauncher(unittest.TestCase): # Args result_args = __main__.parse_args() # Config - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -2300,7 +2307,7 @@ class TestGameLauncher(unittest.TestCase): # Since we didn't create the proton file, an exception should be raised with self.assertRaises(FileNotFoundError): - umu_run.build_command(self.env, self.test_local_share) + umu_run.build_command(self.env, self.test_local_share, self.test_runtime_version[1]) def test_build_command(self): """Test build command. @@ -2318,7 +2325,7 @@ class TestGameLauncher(unittest.TestCase): Path(self.test_file, "proton").touch() # Mock the shim file - shim_path = Path(self.test_local_share, "umu-shim") + shim_path = Path(self.test_local_share_parent, "umu-shim") shim_path.touch() with ( @@ -2333,7 +2340,8 @@ class TestGameLauncher(unittest.TestCase): # Args result_args = __main__.parse_args() # Config - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -2373,7 +2381,7 @@ class TestGameLauncher(unittest.TestCase): ) # Build - test_command = umu_run.build_command(self.env, self.test_local_share) + test_command = umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_version[1]) self.assertIsInstance( test_command, tuple, "Expected a tuple from build_command" ) @@ -2425,7 +2433,8 @@ class TestGameLauncher(unittest.TestCase): # Args result = __main__.parse_args() # Check - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) @@ -2506,7 +2515,8 @@ class TestGameLauncher(unittest.TestCase): # Args result = __main__.parse_args() # Check - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -2614,7 +2624,8 @@ class TestGameLauncher(unittest.TestCase): # Args result = __main__.parse_args() # Check - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -2730,7 +2741,8 @@ class TestGameLauncher(unittest.TestCase): # Args result = __main__.parse_args() # Check - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -2860,7 +2872,8 @@ class TestGameLauncher(unittest.TestCase): # Args result = __main__.parse_args() # Check - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -3349,12 +3362,11 @@ class TestGameLauncher(unittest.TestCase): Expects the directory $HOME/Games/umu/$GAMEID to not be created when UMU_NO_PROTON=1 and GAMEID is set in the host environment. """ - result = None + result_env, result_dl = None, None # Mock $HOME mock_home = Path(self.test_file) with ( - ThreadPoolExecutor() as thread_pool, # Mock the internal call to Path.home(). Otherwise, some of our # assertions may fail when running this test suite locally if # the user already has that dir @@ -3362,8 +3374,8 @@ class TestGameLauncher(unittest.TestCase): ): os.environ["UMU_NO_PROTON"] = "1" os.environ["GAMEID"] = "foo" - result = umu_run.check_env(self.env, thread_pool) - self.assertTrue(result is self.env) + result_env, result_dl = umu_run.check_env(self.env) + self.assertTrue(result_env is self.env) path = mock_home.joinpath("Games", "umu", os.environ["GAMEID"]) # Ensure we did not create the target nor its parents up to $HOME self.assertFalse(path.exists(), f"Expected {path} to not exist") @@ -3382,20 +3394,17 @@ class TestGameLauncher(unittest.TestCase): Expects the WINE prefix directory to not be created when UMU_NO_PROTON=1 and WINEPREFIX is set in the host environment. """ - result = None + result_env, result_dl = None, None - with ( - ThreadPoolExecutor() as thread_pool, - ): - os.environ["WINEPREFIX"] = "123" - os.environ["UMU_NO_PROTON"] = "1" - os.environ["GAMEID"] = "foo" - result = umu_run.check_env(self.env, thread_pool) - self.assertTrue(result is self.env) - self.assertFalse( - Path(os.environ["WINEPREFIX"]).exists(), - f"Expected directory {os.environ['WINEPREFIX']} to not exist", - ) + os.environ["WINEPREFIX"] = "123" + os.environ["UMU_NO_PROTON"] = "1" + os.environ["GAMEID"] = "foo" + result_env, result_dl = umu_run.check_env(self.env) + self.assertTrue(result_env is self.env) + self.assertFalse( + Path(os.environ["WINEPREFIX"]).exists(), + f"Expected directory {os.environ['WINEPREFIX']} to not exist", + ) def test_env_proton_nodir(self): """Test check_env when $PROTONPATH in the case we failed to set it. @@ -3410,7 +3419,8 @@ class TestGameLauncher(unittest.TestCase): ): os.environ["WINEPREFIX"] = self.test_file os.environ["GAMEID"] = self.test_file - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) def test_env_wine_empty(self): """Test check_env when $WINEPREFIX is empty. @@ -3426,7 +3436,8 @@ class TestGameLauncher(unittest.TestCase): ): os.environ["WINEPREFIX"] = "" os.environ["GAMEID"] = self.test_file - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) def test_env_gameid_empty(self): """Test check_env when $GAMEID is empty. @@ -3442,7 +3453,8 @@ class TestGameLauncher(unittest.TestCase): ): os.environ["WINEPREFIX"] = "" os.environ["GAMEID"] = "" - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) def test_env_wine_dir(self): """Test check_env when $WINEPREFIX is not a directory. @@ -3463,7 +3475,8 @@ class TestGameLauncher(unittest.TestCase): ) with ThreadPoolExecutor() as thread_pool: - umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) # After this, the WINEPREFIX and new dirs should be created self.assertTrue( @@ -3492,15 +3505,16 @@ class TestGameLauncher(unittest.TestCase): path_to_tmp, ) - result = None + result_env, result_dl = None, None os.environ["WINEPREFIX"] = unexpanded_path os.environ["GAMEID"] = self.test_file os.environ["PROTONPATH"] = unexpanded_path with ThreadPoolExecutor() as thread_pool: - result = umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) - self.assertTrue(result is self.env, "Expected the same reference") + self.assertTrue(result_env is self.env, "Expected the same reference") self.assertEqual( self.env["WINEPREFIX"], unexpanded_path, @@ -3517,15 +3531,16 @@ class TestGameLauncher(unittest.TestCase): def test_env_vars(self): """Test check_env when setting $WINEPREFIX, $GAMEID and $PROTONPATH.""" - result = None + result_env, result_dl = None, None os.environ["WINEPREFIX"] = self.test_file os.environ["GAMEID"] = self.test_file os.environ["PROTONPATH"] = self.test_file with ThreadPoolExecutor() as thread_pool: - result = umu_run.check_env(self.env, thread_pool) + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) - self.assertTrue(result is self.env, "Expected the same reference") + self.assertTrue(result_env is self.env, "Expected the same reference") self.assertEqual( self.env["WINEPREFIX"], self.test_file, @@ -3556,8 +3571,9 @@ class TestGameLauncher(unittest.TestCase): ): os.environ["WINEPREFIX"] = self.test_file os.environ["GAMEID"] = self.test_file - result = umu_run.check_env(self.env, thread_pool) - self.assertTrue(result is self.env, "Expected the same reference") + result_env, result_dl = umu_run.check_env(self.env) + umu_run.download_proton(result_dl, result_env, thread_pool) + self.assertTrue(result_env is self.env, "Expected the same reference") self.assertFalse(os.environ["PROTONPATH"]) def test_env_vars_wine(self): @@ -3565,7 +3581,7 @@ class TestGameLauncher(unittest.TestCase): Expects GAMEID and PROTONPATH to be set for the command line: """ - result = None + result_env, result_dl = None, None mock_gameid = "umu-default" mock_protonpath = str(self.test_proton_dir) @@ -3578,8 +3594,8 @@ class TestGameLauncher(unittest.TestCase): # and the GAMEID is 'umu-default' with patch.object(umu_run, "get_umu_proton", new_callable=mock_get_umu_proton): os.environ["WINEPREFIX"] = self.test_file - result = umu_run.check_env(self.env, self.test_session_pools) - self.assertTrue(result, self.env) + result_env, result_dl = umu_run.check_env(self.env) + self.assertTrue(result_env, self.env) self.assertEqual(os.environ["GAMEID"], mock_gameid) self.assertEqual(os.environ["GAMEID"], self.env["GAMEID"]) self.assertEqual(os.environ["PROTONPATH"], mock_protonpath) @@ -3593,7 +3609,7 @@ class TestGameLauncher(unittest.TestCase): Expects PROTONPATH, GAMEID, and WINEPREFIX to be set """ - result = None + result_env, result_dl = None, None mock_gameid = "umu-default" mock_protonpath = str(self.test_proton_dir) mock_wineprefix = "/home/foo/Games/umu/umu-default" @@ -3613,8 +3629,8 @@ class TestGameLauncher(unittest.TestCase): patch.object(umu_run, "get_umu_proton", new_callable=mock_get_umu_proton), patch.object(Path, "mkdir", return_value=mock_set_wineprefix()), ): - result = umu_run.check_env(self.env, self.test_session_pools) - self.assertTrue(result, self.env) + result_env, result_dl = umu_run.check_env(self.env) + self.assertTrue(result_env, self.env) self.assertEqual(os.environ["GAMEID"], mock_gameid) self.assertEqual(os.environ["GAMEID"], self.env["GAMEID"]) self.assertEqual(os.environ["PROTONPATH"], mock_protonpath) diff --git a/umu/umu_test_plugins.py b/umu/umu_test_plugins.py index 7230139..d3463b6 100644 --- a/umu/umu_test_plugins.py +++ b/umu/umu_test_plugins.py @@ -64,11 +64,14 @@ class TestGameLauncherPlugins(unittest.TestCase): # /usr/share/umu self.test_user_share = Path("./tmp.jl3W4MtO57") # ~/.local/share/Steam/compatibilitytools.d - self.test_local_share = Path("./tmp.WUaQAk7hQJ") self.test_runtime_version = ("sniper", "steamrt3", "1628350") + self.test_local_share_parent = Path("./tmp.WUaQAk7hQJ") + self.test_local_share = self.test_local_share_parent.joinpath( + self.test_runtime_version[1] + ) self.test_user_share.mkdir(exist_ok=True) - self.test_local_share.mkdir(exist_ok=True) + self.test_local_share.mkdir(parents=True, exist_ok=True) self.test_cache.mkdir(exist_ok=True) self.test_compat.mkdir(exist_ok=True) self.test_proton_dir.mkdir(exist_ok=True) @@ -223,7 +226,7 @@ class TestGameLauncherPlugins(unittest.TestCase): # Build with self.assertRaisesRegex(FileNotFoundError, "_v2-entry-point"): - umu_run.build_command(self.env, self.test_local_share, test_command) + umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_version[1], test_command) def test_build_command_proton(self): """Test build_command. @@ -301,7 +304,7 @@ class TestGameLauncherPlugins(unittest.TestCase): # Build with self.assertRaisesRegex(FileNotFoundError, "proton"): - umu_run.build_command(self.env, self.test_local_share, test_command) + umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_version[1], test_command) def test_build_command_toml(self): """Test build_command. @@ -326,7 +329,7 @@ class TestGameLauncherPlugins(unittest.TestCase): Path(toml_path).touch() # Mock the shim file - shim_path = Path(self.test_local_share, "umu-shim") + shim_path = Path(self.test_local_share_parent, "umu-shim") shim_path.touch() with Path(toml_path).open(mode="w", encoding="utf-8") as file: @@ -381,7 +384,7 @@ class TestGameLauncherPlugins(unittest.TestCase): os.environ[key] = val # Build - test_command = umu_run.build_command(self.env, self.test_local_share) + test_command = umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_version[1]) # Verify contents of the command entry_point, opt1, verb, opt2, shim, proton, verb2, exe = [*test_command] @@ -619,23 +622,23 @@ class TestGameLauncherPlugins(unittest.TestCase): # prepare for building the command self.assertEqual( self.env["EXE"], - unexpanded_exe, - "Expected path not to be expanded", + str(Path(unexpanded_exe).expanduser()), + "Expected path to be expanded", ) self.assertEqual( self.env["PROTONPATH"], - unexpanded_path, - "Expected path not to be expanded", + str(Path(unexpanded_path).expanduser()), + "Expected path to be expanded", ) self.assertEqual( self.env["WINEPREFIX"], - unexpanded_path, - "Expected path not to be expanded", + str(Path(unexpanded_path).expanduser()), + "Expected path to be expanded", ) self.assertEqual( self.env["GAMEID"], unexpanded_path, - "Expectd path not to be expanded", + "Expected path to be expanded", ) def test_set_env_toml_opts(self): @@ -699,12 +702,12 @@ class TestGameLauncherPlugins(unittest.TestCase): self.assertTrue(self.env["EXE"], "Expected EXE to be set") self.assertEqual( self.env["PROTONPATH"], - self.test_file, + str(Path(self.test_file).expanduser()), "Expected PROTONPATH to be set", ) self.assertEqual( self.env["WINEPREFIX"], - self.test_file, + str(Path(self.test_file).expanduser()), "Expected WINEPREFIX to be set", ) self.assertEqual( @@ -753,12 +756,12 @@ class TestGameLauncherPlugins(unittest.TestCase): self.assertTrue(self.env["EXE"], "Expected EXE to be set") self.assertEqual( self.env["PROTONPATH"], - self.test_file, + str(Path(self.test_file).expanduser()), "Expected PROTONPATH to be set", ) self.assertEqual( self.env["WINEPREFIX"], - self.test_file, + str(Path(self.test_file).expanduser()), "Expected WINEPREFIX to be set", ) self.assertEqual( -- 2.49.0 From 594c54e0c8495f13662145175f72130385ea07c0 Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Thu, 20 Mar 2025 14:20:29 +0200 Subject: [PATCH 07/13] umu_run: only allow no runtime tools if `UMU_NO_RUNTIME=1` is set --- umu/umu_run.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/umu/umu_run.py b/umu/umu_run.py index 8ff8217..0f7026a 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -345,11 +345,6 @@ def build_command( if env.get("UMU_NO_PROTON") == "1": return *entry_point, 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, shim, @@ -772,7 +767,10 @@ def get_umu_version_from_manifest( break if not appid: - return "host", "host", "host" + if os.environ.get("UMU_NO_RUNTIME", None) == "1": + log.warning("Runtime Platform disabled") + return "host", "host", "host" + return None if appid not in appids: return None -- 2.49.0 From 806188d0c370d80b890e63be45afc9a6154d4356 Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Thu, 20 Mar 2025 14:21:35 +0200 Subject: [PATCH 08/13] umu_tests: add test to ensure the runtime is used even if `UMU_NO_RUNTIME=1` is set if the tool requires a runtime --- umu/umu_test.py | 200 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 160 insertions(+), 40 deletions(-) diff --git a/umu/umu_test.py b/umu/umu_test.py index 8e26b10..50be67a 100644 --- a/umu/umu_test.py +++ b/umu/umu_test.py @@ -97,10 +97,14 @@ class TestGameLauncher(unittest.TestCase): # /usr/share/umu self.test_user_share = Path("./tmp.BXk2NnvW2m") # ~/.local/share/Steam/compatibilitytools.d - self.test_runtime_version = ("sniper", "steamrt3", "1628350") + self.test_runtime_versions = ( + ("sniper", "steamrt3", "1628350"), + ("soldier", "steamrt2", "1391110"), + ) + self.test_runtime_default = self.test_runtime_versions[0] self.test_local_share_parent = Path("./tmp.aDl73CbQCP") self.test_local_share = self.test_local_share_parent.joinpath( - self.test_runtime_version[1] + self.test_runtime_default[1] ) # Wine prefix self.test_winepfx = Path("./tmp.AlfLPDhDvA") @@ -771,7 +775,7 @@ class TestGameLauncher(unittest.TestCase): # Mock a new install with TemporaryDirectory() as file: # Populate our fake $XDG_DATA_HOME/umu - mock_subdir = Path(file, self.test_runtime_version[1]) + mock_subdir = Path(file, self.test_runtime_default[1]) mock_subdir.mkdir() mock_subdir.joinpath("umu").touch() # Mock the runtime ver @@ -794,7 +798,7 @@ class TestGameLauncher(unittest.TestCase): # Mock a new install with TemporaryDirectory() as file: # Populate our fake $XDG_DATA_HOME/umu - mock_subdir = Path(file, self.test_runtime_version[1]) + mock_subdir = Path(file, self.test_runtime_default[1]) mock_subdir.mkdir() mock_subdir.joinpath("umu").touch() # Mock the runtime ver @@ -815,7 +819,7 @@ class TestGameLauncher(unittest.TestCase): # Mock a new install with TemporaryDirectory() as file: - mock_subdir = Path(file, self.test_runtime_version[1]) + mock_subdir = Path(file, self.test_runtime_default[1]) mock_subdir.mkdir() mock_subdir.joinpath("umu").touch() # Mock the runtime ver @@ -1370,7 +1374,7 @@ class TestGameLauncher(unittest.TestCase): """ self.test_user_share.joinpath("pressure-vessel", "bin", "pv-verify").unlink() result = umu_runtime.check_runtime( - self.test_user_share, self.test_runtime_version + self.test_user_share, self.test_runtime_default ) self.assertEqual(result, 1, "Expected the exit code 1") @@ -1379,7 +1383,7 @@ class TestGameLauncher(unittest.TestCase): mock = CompletedProcess(["foo"], 0) with patch.object(umu_runtime, "run", return_value=mock): result = umu_runtime.check_runtime( - self.test_user_share, self.test_runtime_version + self.test_user_share, self.test_runtime_default ) self.assertEqual(result, 0, "Expected the exit code 0") @@ -1397,7 +1401,7 @@ class TestGameLauncher(unittest.TestCase): mock = CompletedProcess(["foo"], 1) with patch.object(umu_runtime, "run", return_value=mock): result = umu_runtime.check_runtime( - self.test_user_share, self.test_runtime_version + self.test_user_share, self.test_runtime_default ) self.assertEqual(result, 1, "Expected the exit code 1") @@ -1881,7 +1885,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["PROTONPATH"] = self.test_file os.environ["GAMEID"] = self.test_file os.environ["STORE"] = self.test_file - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args args = __main__.parse_args() # Config @@ -1945,7 +1949,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["PROTONPATH"] = self.test_file os.environ["GAMEID"] = self.test_file os.environ["STORE"] = self.test_file - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args args = __main__.parse_args() # Config @@ -2043,7 +2047,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["PROTONPATH"] = self.test_file os.environ["GAMEID"] = self.test_file os.environ["STORE"] = self.test_file - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args args = __main__.parse_args() # Config @@ -2120,7 +2124,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["GAMEID"] = self.test_file os.environ["STORE"] = self.test_file os.environ["UMU_NO_PROTON"] = "1" - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args result_args = __main__.parse_args() # Config @@ -2141,7 +2145,7 @@ class TestGameLauncher(unittest.TestCase): ): umu_runtime.setup_umu( self.test_local_share, - self.test_runtime_version, + self.test_runtime_default, self.test_session_pools, ) copytree( @@ -2164,7 +2168,7 @@ class TestGameLauncher(unittest.TestCase): ) # Build - test_command = umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_version[1]) + test_command = umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_default[1]) self.assertIsInstance( test_command, tuple, "Expected a tuple from build_command" ) @@ -2185,19 +2189,32 @@ class TestGameLauncher(unittest.TestCase): self.assertEqual(sep, "--", "Expected --") self.assertEqual(exe, self.env["EXE"], "Expected the EXE") - def test_build_command_nopv(self): - """Test build_command when disabling Pressure Vessel. + def test_build_command_nopv_appid(self): + """Test build_command when disabling Pressure Vessel but the tool requests a runtime. - UMU_NO_RUNTIME=1 disables Pressure Vessel, allowing - the launcher to run Proton on the host -- Flatpak environment. + UMU_NO_RUNTIME=1 disables Pressure Vessel, but the tool needs + a runtime, so use the correct runtime disregarding the env variable. - Expects the list to contain 3 string elements. + Expects the list to contain 8 string elements. """ result_args = None test_command = [] # Mock the proton file Path(self.test_file, "proton").touch() + # Mock a runtime toolmanifest.vdf + Path(self.test_file, "toolmanifest.vdf").write_text( + ''' + "manifest" + { + "version" "2" + "commandline" "/proton %verb%" + "require_tool_appid" "1628350" + "use_sessions" "1" + "compatmanager_layer_name" "proton" + } + ''' + ) with ( patch("sys.argv", ["", self.test_exe]), @@ -2208,12 +2225,14 @@ class TestGameLauncher(unittest.TestCase): os.environ["GAMEID"] = self.test_file os.environ["STORE"] = self.test_file os.environ["UMU_NO_RUNTIME"] = "1" - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + version = umu_run.resolve_umu_version(self.test_runtime_versions) + os.environ["RUNTIMEPATH"] = version[1] # Args result_args = __main__.parse_args() # Config - result_env, result_dl = umu_run.check_env(self.env) - umu_run.download_proton(result_dl, result_env, thread_pool) + _, result_dl = umu_run.check_env(self.env) + if version[1] != "host": + umu_run.download_proton(result_dl, self.env, thread_pool) # Prefix umu_run.setup_pfx(self.env["WINEPREFIX"]) # Env @@ -2227,7 +2246,7 @@ class TestGameLauncher(unittest.TestCase): ): umu_runtime.setup_umu( self.test_local_share, - self.test_runtime_version, + self.test_runtime_default, self.test_session_pools, ) copytree( @@ -2252,16 +2271,16 @@ class TestGameLauncher(unittest.TestCase): os.environ |= self.env # Build - test_command = umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_version[1]) + test_command = umu_run.build_command(self.env, self.test_local_share_parent, version[1]) self.assertIsInstance( test_command, tuple, "Expected a tuple from build_command" ) self.assertEqual( len(test_command), - 3, + 8, f"Expected 3 elements, received {len(test_command)}", ) - proton, verb, exe, *_ = [*test_command] + _, _, verb, _, _, proton, _, exe = [*test_command] self.assertIsInstance(proton, os.PathLike, "Expected proton to be PathLike") self.assertEqual( proton, @@ -2271,6 +2290,107 @@ class TestGameLauncher(unittest.TestCase): self.assertEqual(verb, "waitforexitandrun", "Expected PROTON_VERB") self.assertEqual(exe, self.env["EXE"], "Expected EXE") + def test_build_command_nopv_noappid(self): + """Test build_command when disabling Pressure Vessel and the tool doesn't request a runtime. + + UMU_NO_RUNTIME=1 disables Pressure Vessel, and the tool doesn't set + a runtime, allow the tool to run using the host's libraries as it expects. + + Expects the list to contain 4 string elements. + """ + result_args = None + test_command = [] + + # Mock the proton file + Path(self.test_file, "proton").touch() + # Mock a non-runtime toolmanifest.vdf + Path(self.test_file, "toolmanifest.vdf").write_text( + ''' + "manifest" + { + "version" "2" + "commandline" "/proton %verb%" + "use_sessions" "1" + "compatmanager_layer_name" "proton" + } + ''' + ) + + 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["GAMEID"] = self.test_file + os.environ["STORE"] = self.test_file + os.environ["UMU_NO_RUNTIME"] = "1" + version = umu_run.resolve_umu_version(self.test_runtime_versions) + os.environ["RUNTIMEPATH"] = version[1] + # Args + result_args = __main__.parse_args() + # Config + _, result_dl = umu_run.check_env(self.env) + if version[1] != "host": + umu_run.download_proton(result_dl, self.env, thread_pool) + # Prefix + umu_run.setup_pfx(self.env["WINEPREFIX"]) + # Env + umu_run.set_env(self.env, result_args) + # Game drive + umu_run.enable_steam_game_drive(self.env) + + # Mock setting up the runtime + with ( + patch.object(umu_runtime, "_install_umu", return_value=None), + ): + umu_runtime.setup_umu( + self.test_local_share, + self.test_runtime_default, + self.test_session_pools, + ) + copytree( + Path(self.test_user_share, "sniper_platform_0.20240125.75305"), + Path(self.test_local_share, "sniper_platform_0.20240125.75305"), + dirs_exist_ok=True, + symlinks=True, + ) + copy( + Path(self.test_user_share, "run"), + Path(self.test_local_share, "run"), + ) + copy( + Path(self.test_user_share, "run-in-sniper"), + Path(self.test_local_share, "run-in-sniper"), + ) + copy( + Path(self.test_user_share, "umu"), + Path(self.test_local_share, "umu"), + ) + + os.environ |= self.env + + # Build + test_command = umu_run.build_command(self.env, self.test_local_share_parent, version[1]) + self.assertIsInstance( + test_command, tuple, "Expected a tuple from build_command" + ) + self.assertEqual( + len(test_command), + 4, + f"Expected 3 elements, received {len(test_command)}", + ) + _, proton, verb, exe, *_ = [*test_command] + self.assertIsInstance(proton, os.PathLike, "Expected proton to be PathLike") + self.assertEqual( + proton, + Path(self.env["PROTONPATH"], "proton"), + "Expected PROTONPATH", + ) + self.assertEqual(verb, "waitforexitandrun", "Expected PROTON_VERB") + self.assertEqual(exe, self.env["EXE"], "Expected EXE") + + def test_build_command_noproton(self): """Test build_command when $PROTONPATH/proton is not found. @@ -2287,7 +2407,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["GAMEID"] = self.test_file os.environ["STORE"] = self.test_file os.environ["UMU_NO_RUNTIME"] = "pressure-vessel" - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args result_args = __main__.parse_args() # Config @@ -2307,7 +2427,7 @@ class TestGameLauncher(unittest.TestCase): # Since we didn't create the proton file, an exception should be raised with self.assertRaises(FileNotFoundError): - umu_run.build_command(self.env, self.test_local_share, self.test_runtime_version[1]) + umu_run.build_command(self.env, self.test_local_share, self.test_runtime_default[1]) def test_build_command(self): """Test build command. @@ -2336,7 +2456,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["PROTONPATH"] = self.test_file os.environ["GAMEID"] = self.test_file os.environ["STORE"] = self.test_file - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args result_args = __main__.parse_args() # Config @@ -2358,7 +2478,7 @@ class TestGameLauncher(unittest.TestCase): ): umu_runtime.setup_umu( self.test_local_share, - self.test_runtime_version, + self.test_runtime_default, self.test_session_pools, ) copytree( @@ -2381,7 +2501,7 @@ class TestGameLauncher(unittest.TestCase): ) # Build - test_command = umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_version[1]) + test_command = umu_run.build_command(self.env, self.test_local_share_parent, self.test_runtime_default[1]) self.assertIsInstance( test_command, tuple, "Expected a tuple from build_command" ) @@ -2429,7 +2549,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["GAMEID"] = test_str os.environ["STORE"] = test_str os.environ["PROTON_VERB"] = self.test_verb - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args result = __main__.parse_args() # Check @@ -2511,7 +2631,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["GAMEID"] = umu_id os.environ["STORE"] = test_str os.environ["PROTON_VERB"] = self.test_verb - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args result = __main__.parse_args() # Check @@ -2589,7 +2709,7 @@ class TestGameLauncher(unittest.TestCase): self.env["PROTONPATH"] + ":" + Path.home() - .joinpath(".local", "share", "umu", self.test_runtime_version[1]) + .joinpath(".local", "share", "umu", self.test_runtime_default[1]) .as_posix(), "Expected STEAM_COMPAT_TOOL_PATHS to be set", ) @@ -2620,7 +2740,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["GAMEID"] = test_str os.environ["STORE"] = test_str os.environ["PROTON_VERB"] = self.test_verb - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args result = __main__.parse_args() # Check @@ -2706,7 +2826,7 @@ class TestGameLauncher(unittest.TestCase): self.env["PROTONPATH"] + ":" + Path.home() - .joinpath(".local", "share", "umu", self.test_runtime_version[1]) + .joinpath(".local", "share", "umu", self.test_runtime_default[1]) .as_posix(), "Expected STEAM_COMPAT_TOOL_PATHS to be set", ) @@ -2737,7 +2857,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["STORE"] = test_str os.environ["PROTON_VERB"] = self.test_verb os.environ["UMU_RUNTIME_UPDATE"] = "0" - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args result = __main__.parse_args() # Check @@ -2828,7 +2948,7 @@ class TestGameLauncher(unittest.TestCase): self.env["PROTONPATH"] + ":" + Path.home() - .joinpath(".local", "share", "umu", self.test_runtime_version[1]) + .joinpath(".local", "share", "umu", self.test_runtime_default[1]) .as_posix(), "Expected STEAM_COMPAT_TOOL_PATHS to be set", ) @@ -2868,7 +2988,7 @@ class TestGameLauncher(unittest.TestCase): os.environ["PROTONPATH"] = test_dir.as_posix() os.environ["GAMEID"] = test_str os.environ["PROTON_VERB"] = proton_verb - os.environ["RUNTIMEPATH"] = self.test_runtime_version[1] + os.environ["RUNTIMEPATH"] = self.test_runtime_default[1] # Args result = __main__.parse_args() # Check @@ -2963,7 +3083,7 @@ class TestGameLauncher(unittest.TestCase): self.env["PROTONPATH"] + ":" + Path.home() - .joinpath(".local", "share", "umu", self.test_runtime_version[1]) + .joinpath(".local", "share", "umu", self.test_runtime_default[1]) .as_posix(), "Expected STEAM_COMPAT_TOOL_PATHS to be set", ) -- 2.49.0 From f5ba175d9d8ab5aa9f3d40cd953c7acc7c4e984d Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Fri, 21 Mar 2025 00:58:50 +0200 Subject: [PATCH 09/13] umu_run: do not set fault runtime path if case proton is using the host libraries --- umu/umu_run.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/umu/umu_run.py b/umu/umu_run.py index 0f7026a..7ea3941 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -235,14 +235,16 @@ def set_env( env["SteamAppId"] = env["STEAM_COMPAT_APP_ID"] env["SteamGameId"] = env["SteamAppId"] + runtime_path = f"{UMU_LOCAL}/{os.environ['RUNTIMEPATH']}" if os.environ['RUNTIMEPATH'] != "host" else "" + # PATHS env["WINEPREFIX"] = str(pfx) env["PROTONPATH"] = str(protonpath) env["STEAM_COMPAT_DATA_PATH"] = env["WINEPREFIX"] env["STEAM_COMPAT_SHADER_PATH"] = f"{env['STEAM_COMPAT_DATA_PATH']}/shadercache" - env["STEAM_COMPAT_TOOL_PATHS"] = ( - f"{env['PROTONPATH']}:{UMU_LOCAL}/{os.environ['RUNTIMEPATH']}" - ) + env["STEAM_COMPAT_TOOL_PATHS"] = ":".join( + [f"{env['PROTONPATH']}", runtime_path] + ) if runtime_path else f"{env['PROTONPATH']}" env["STEAM_COMPAT_MOUNTS"] = env["STEAM_COMPAT_TOOL_PATHS"] # Zenity @@ -261,7 +263,7 @@ def set_env( 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["RUNTIMEPATH"] = f"{UMU_LOCAL}/{os.environ['RUNTIMEPATH']}" + env["RUNTIMEPATH"] = runtime_path return env @@ -817,7 +819,7 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: } opts: list[str] = [] prereq: bool = False - version: RuntimeVersion | None = None + runtime_version: RuntimeVersion | None = None log.info("umu-launcher version %s (%s)", __version__, sys.version) @@ -865,13 +867,13 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: opts = args[1] # Reference the executable options # Resolve the runtime version for PROTONPATH - version = resolve_umu_version(__runtime_versions__) - if not version: + runtime_version = resolve_umu_version(__runtime_versions__) + if not runtime_version: err: str = ( f"Failed to match '{os.environ.get('PROTONPATH')}' with a container runtime" ) raise ValueError(err) - os.environ["RUNTIMEPATH"] = version[1] + os.environ["RUNTIMEPATH"] = runtime_version[1] # Opt to use the system's native CA bundle rather than certifi's with suppress(ModuleNotFoundError): @@ -895,11 +897,11 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: # Setup the launcher and runtime files _, do_download = check_env(env) - if version[1] != "host": - UMU_LOCAL.joinpath(version[1]).mkdir(parents=True, exist_ok=True) + if runtime_version[1] != "host": + UMU_LOCAL.joinpath(runtime_version[1]).mkdir(parents=True, exist_ok=True) future: Future = thread_pool.submit( - setup_umu, UMU_LOCAL / version[1], version, session_pools + setup_umu, UMU_LOCAL / runtime_version[1], runtime_version, session_pools ) download_proton(do_download, env, session_pools) @@ -939,7 +941,7 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: sys.exit(1) # Build the command - command: tuple[Path | str, ...] = build_command(env, UMU_LOCAL, version[1], opts) + command: tuple[Path | str, ...] = build_command(env, UMU_LOCAL, runtime_version[1], opts) log.debug("%s", command) # Run the command -- 2.49.0 From c05374fd03b4ba8839b776bf30d51b479fe06759 Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Fri, 21 Mar 2025 01:03:08 +0200 Subject: [PATCH 10/13] umu_run: make message clearer --- umu/umu_run.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/umu/umu_run.py b/umu/umu_run.py index 7ea3941..2afcc15 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -770,7 +770,10 @@ def get_umu_version_from_manifest( if not appid: if os.environ.get("UMU_NO_RUNTIME", None) == "1": - log.warning("Runtime Platform disabled") + log.warning( + "Runtime Platform disabled. This mode is UNSUPPORTED by umu and remains only for convenience. " + "Issues created while using this mode will be automatically closed." + ) return "host", "host", "host" return None -- 2.49.0 From 5fe0d2d097a5b9f7d29f26a587dae1a772b6da2b Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Fri, 21 Mar 2025 20:05:49 +0200 Subject: [PATCH 11/13] doc: add documentation around UMU_NO_RUNTIME --- README.md | 7 +++++++ docs/umu.1.scd | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/README.md b/README.md index 8b89827..287c7aa 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,13 @@ Borderlands 3 from EGS store. 3. In our umu unified database, we create a 'title' column, 'store' column, 'codename' column, 'umu-ID' column. We add a line for Borderlands 3 and fill in the details for each column. 4. Now the launcher can search 'Catnip' and 'egs' as the codename and store in the database and correlate it with Borderlands 3 and umu-12345. It can then feed umu-12345 to the `umu-run` script. +## Reporting issues + +When reporting issues for games that fail to run, be sure to attach a log with your issue report. To acquire a log from umu, add `UMU_LOG=1` to your environment variables for verbose logging. Furthermore, you can use `PROTON_LOG=1` for proton to create a verbose log in your `$HOME` directory. The log will be named `steam-.log`, where `` will be `default` if you haven't set a `GAMEID` or a number, depending on what you have set for `GAMEID`. + +Do **NOT** report issues when using `UMU_NO_RUNTIME=1`, this option is provided for convenience for compatibility tools that do not set their runtime requirements, such as Proton < `5.13`, and they do not work with any of the supported runtimes. +This mode does not make use of a container runtime, and issues while using it are irrelevant to umu-launcher in general. Thus such issues will be automatically closed. + ## Building Building umu-launcher currently requires `bash`, `make`, and `scdoc` for distribution, as well as the following Python build tools: [build](https://github.com/pypa/build), [hatchling](https://github.com/pypa/hatch), [installer](https://github.com/pypa/installer), and [pip](https://github.com/pypa/pip). diff --git a/docs/umu.1.scd b/docs/umu.1.scd index b1f557b..9bb5475 100644 --- a/docs/umu.1.scd +++ b/docs/umu.1.scd @@ -186,6 +186,13 @@ _UMU_NO_PROTON_ Set _1_ to run the executable natively within the SLR. +_UMU_NO_RUNTIME_ + Optional. Allows for the configured compatibility tool to run outside of the Steam Linux Runtime. + This option is effective only if the compatibility tool doesn't require a runtime through its configuration. + On compatibility tools that require a runtime, this option is ignored. + + Set _1_ to silence umu's error that it couldn't resolve a runtime to use, and run using the host's libraries. + # SEE ALSO _umu_(5), _winetricks_(1), _zenity_(1) -- 2.49.0 From 7ababfe0d88d77d8d1258bda4c15f508ebe7bd11 Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Wed, 9 Apr 2025 11:22:33 +0300 Subject: [PATCH 12/13] umu_run: unpack runtime_version tuple instead of accessing by index --- umu/umu_run.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/umu/umu_run.py b/umu/umu_run.py index 2afcc15..1845815 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -876,7 +876,9 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: f"Failed to match '{os.environ.get('PROTONPATH')}' with a container runtime" ) raise ValueError(err) - os.environ["RUNTIMEPATH"] = runtime_version[1] + # runtime_name, runtime_variant, runtime_appid + _, runtime_variant, _ = runtime_version + os.environ["RUNTIMEPATH"] = runtime_variant # Opt to use the system's native CA bundle rather than certifi's with suppress(ModuleNotFoundError): @@ -900,11 +902,11 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: # Setup the launcher and runtime files _, do_download = check_env(env) - if runtime_version[1] != "host": - UMU_LOCAL.joinpath(runtime_version[1]).mkdir(parents=True, exist_ok=True) + if runtime_variant != "host": + UMU_LOCAL.joinpath(runtime_variant).mkdir(parents=True, exist_ok=True) future: Future = thread_pool.submit( - setup_umu, UMU_LOCAL / runtime_version[1], runtime_version, session_pools + setup_umu, UMU_LOCAL / runtime_variant, runtime_version, session_pools ) download_proton(do_download, env, session_pools) @@ -944,7 +946,7 @@ def umu_run(args: Namespace | tuple[str, list[str]]) -> int: sys.exit(1) # Build the command - command: tuple[Path | str, ...] = build_command(env, UMU_LOCAL, runtime_version[1], opts) + command: tuple[Path | str, ...] = build_command(env, UMU_LOCAL, runtime_variant, opts) log.debug("%s", command) # Run the command -- 2.49.0 From df7e7b68363fc31fc01321589ed3cfaf5f7ad4d1 Mon Sep 17 00:00:00 2001 From: Stelios Tsampas Date: Sat, 7 Jun 2025 03:13:48 +0300 Subject: [PATCH 13/13] umu_run: don't require UMU_NO_RUNTIME to allow tools without a runtime to work --- umu/umu_run.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/umu/umu_run.py b/umu/umu_run.py index 1845815..b54e0f5 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -769,13 +769,8 @@ def get_umu_version_from_manifest( break if not appid: - if os.environ.get("UMU_NO_RUNTIME", None) == "1": - log.warning( - "Runtime Platform disabled. This mode is UNSUPPORTED by umu and remains only for convenience. " - "Issues created while using this mode will be automatically closed." - ) - return "host", "host", "host" - return None + os.environ["UMU_RUNTIME_UPDATE"] = "0" + return "host", "host", "host" if appid not in appids: return None -- 2.49.0