From cf5ce8683be2c061ce20d838e09a9d57084f56c6 Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Wed, 3 Jul 2024 20:31:10 -0700 Subject: [PATCH 01/10] Add feature to find executable directory - Related to https://github.com/Open-Wine-Components/umu-launcher/issues/63 --- umu/umu_run.py | 9 ++++++ umu/umu_util.py | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/umu/umu_run.py b/umu/umu_run.py index 38a3b0c4..4c2a461c 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -29,6 +29,7 @@ from umu_proton import get_umu_proton from umu_runtime import setup_umu from umu_util import ( + find_subdir, get_libc, is_installed_verb, is_winetricks_verb, @@ -462,9 +463,17 @@ def run_command(command: list[AnyPath]) -> int: # For winetricks, change directory to $PROTONPATH/protonfixes if os.environ.get("EXE", "").endswith("winetricks"): cwd = f"{os.environ['PROTONPATH']}/protonfixes" + elif os.environ.get("STORE") == "gog" and ( + subdir := find_subdir(os.environ) + ): + cwd = f"{os.environ['STEAM_COMPAT_INSTALL_PATH']}/{subdir}" else: + # TODO: Create an environment variable to allow clients to not allow + # UMU to change directories so that the user's setting is respected. cwd = Path.cwd() + log.debug("CWD: '%s'", cwd) + # Create a subprocess but do not set it as subreaper # Unnecessary in a Flatpak and prctl() will fail if libc could not be found if FLATPAK_PATH or not libc: diff --git a/umu/umu_util.py b/umu/umu_util.py index 6349acca..e86be478 100644 --- a/umu/umu_util.py +++ b/umu/umu_util.py @@ -1,9 +1,12 @@ +import os from ctypes.util import find_library from functools import lru_cache +from json import load from pathlib import Path from re import Pattern, compile from shutil import which from subprocess import PIPE, STDOUT, Popen, TimeoutExpired +from typing import Any from umu_log import log @@ -145,3 +148,75 @@ def is_steamdeck() -> bool: break return is_sd + + +def _parse_gogcfg(env: os._Environ[str]) -> Path | None: + json: dict[str, Any] + gog_info: Path + subdir: Path | None = None + install_dir: Path = Path(env["STEAM_COMPAT_INSTALL_PATH"]) + + if not install_dir.is_dir(): + return None + + try: + # Assume that there's only 1 *.info file in the game dir + gog_info = max( + file + for file in install_dir.glob("*.info") + if file.name.startswith("goggame") + ) + except ValueError: + log.debug("No *.info files were found in '%s'", install_dir) + return None + + with gog_info.open(mode="r", encoding="utf-8") as file: + json = load(file) + + if not json: + log.debug("File '%s' is empty", gog_info) + log.debug("Will not change to a subdirectory") + return None + + if "playTasks" not in json: + log.debug("File '%s' does not have a 'playTasks' property", gog_info) + log.debug("Will not change to a subdirectory") + return None + + # Get the first result and assume it's the correct one + for item in json["playTasks"]: + if (path := item.get("path")) and len( + subpaths := Path(path).parts + ) > 1: + subdir = Path(*subpaths[:-1]) + log.debug( + "Found subdirectory '%s'", + subdir, + ) + break + + return subdir + + +def find_subdir(env: os._Environ[str]) -> Path | None: + """Find the correct directory to run the executable by parsing a file. + + Some games require to be executed in a specific way, by either requiring + the path to be relative or to be in a subdirectory within the game + directory. Otherwise, the game may fail to run. The correct subdirectory + is usually defined within a configuration file by the developer (e.g., + installscript.vdf or *.info files). + """ + subdir: Path | None + store: str = env["STORE"] + + # TODO: Parse Steam's .vdf files. + match store: + # For GOG, metadata is in *.info files + case "gog": + log.debug("GOG store detected") + subdir = _parse_gogcfg(env) + case _: + subdir = None + + return subdir From 733e8c09b6cea48fce1706bd19467a1d54932ad9 Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:13:04 -0700 Subject: [PATCH 02/10] umu_util: don't find GOG subdir --- umu/umu_util.py | 71 ------------------------------------------------- 1 file changed, 71 deletions(-) diff --git a/umu/umu_util.py b/umu/umu_util.py index e86be478..31649f8a 100644 --- a/umu/umu_util.py +++ b/umu/umu_util.py @@ -149,74 +149,3 @@ def is_steamdeck() -> bool: return is_sd - -def _parse_gogcfg(env: os._Environ[str]) -> Path | None: - json: dict[str, Any] - gog_info: Path - subdir: Path | None = None - install_dir: Path = Path(env["STEAM_COMPAT_INSTALL_PATH"]) - - if not install_dir.is_dir(): - return None - - try: - # Assume that there's only 1 *.info file in the game dir - gog_info = max( - file - for file in install_dir.glob("*.info") - if file.name.startswith("goggame") - ) - except ValueError: - log.debug("No *.info files were found in '%s'", install_dir) - return None - - with gog_info.open(mode="r", encoding="utf-8") as file: - json = load(file) - - if not json: - log.debug("File '%s' is empty", gog_info) - log.debug("Will not change to a subdirectory") - return None - - if "playTasks" not in json: - log.debug("File '%s' does not have a 'playTasks' property", gog_info) - log.debug("Will not change to a subdirectory") - return None - - # Get the first result and assume it's the correct one - for item in json["playTasks"]: - if (path := item.get("path")) and len( - subpaths := Path(path).parts - ) > 1: - subdir = Path(*subpaths[:-1]) - log.debug( - "Found subdirectory '%s'", - subdir, - ) - break - - return subdir - - -def find_subdir(env: os._Environ[str]) -> Path | None: - """Find the correct directory to run the executable by parsing a file. - - Some games require to be executed in a specific way, by either requiring - the path to be relative or to be in a subdirectory within the game - directory. Otherwise, the game may fail to run. The correct subdirectory - is usually defined within a configuration file by the developer (e.g., - installscript.vdf or *.info files). - """ - subdir: Path | None - store: str = env["STORE"] - - # TODO: Parse Steam's .vdf files. - match store: - # For GOG, metadata is in *.info files - case "gog": - log.debug("GOG store detected") - subdir = _parse_gogcfg(env) - case _: - subdir = None - - return subdir From ca6487dede0acec20b54331ca70c14471e97667d Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:13:39 -0700 Subject: [PATCH 03/10] umu_util: add function to find steam working dir --- umu/umu_util.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/umu/umu_util.py b/umu/umu_util.py index 31649f8a..5c601612 100644 --- a/umu/umu_util.py +++ b/umu/umu_util.py @@ -1,12 +1,9 @@ -import os from ctypes.util import find_library from functools import lru_cache -from json import load from pathlib import Path from re import Pattern, compile from shutil import which from subprocess import PIPE, STDOUT, Popen, TimeoutExpired -from typing import Any from umu_log import log @@ -149,3 +146,32 @@ def is_steamdeck() -> bool: return is_sd + +def find_steam_wdir(path: Path) -> str: + """Find the correct working directory for a Steam game.""" + subdir: Path + common_parts: tuple[str, ...] + path_parts: tuple[str, ...] = path.parts + + # Executable is not a Steam game + if not path.match("steamapps/common"): + log.debug("Executable '%s' is not a Steam game", path) + return "" + + log.debug("Executable is a Steam game") + common_parts = (_parts := path.parts)[: _parts.index("common") + 1] + + # Check if the exe is in the top level of the base dir and use it + if (len(path_parts) - len(common_parts)) == 2: + log.debug("Executable '%s' is not within a subdirectory", path) + log.debug("Using '%s' as working directory", path.parent) + return str(path.parent) + + # The exe must be in a subdir at this point so use that as the working dir + # NOTE: Assumes any executable within a subdir of its base dir must be run + # within its subdir + subdir = Path(*path_parts[:-1]) + log.debug("Executable '%s' is within a subdirectory", path) + log.debug("Using '%s' as working directory", subdir) + + return str(subdir) From 77ae60666dad44d42ffc8aeacbb1b34cebd95466 Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:14:11 -0700 Subject: [PATCH 04/10] umu_run: use steam working dir when running steam games --- umu/umu_run.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/umu/umu_run.py b/umu/umu_run.py index 4c2a461c..60164fb1 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -29,7 +29,7 @@ from umu_proton import get_umu_proton from umu_runtime import setup_umu from umu_util import ( - find_subdir, + find_steam_wdir, get_libc, is_installed_verb, is_winetricks_verb, @@ -252,8 +252,10 @@ def set_env( # when creating the subprocess. # e.g., Games/umu/umu-0 -> $HOME/Games/umu/umu-0 exe: Path = Path(args[0]).expanduser().resolve(strict=True) # type: ignore + steam_wkdir: str = find_steam_wdir(exe) env["EXE"] = str(exe) - env["STEAM_COMPAT_INSTALL_PATH"] = str(exe.parent) + # Use the working directory of the Steam game, depend on the client + env["STEAM_COMPAT_INSTALL_PATH"] = steam_wkdir or str(Path.cwd()) except FileNotFoundError: # Assume that the executable will be inside prefix or container env["EXE"] = args[0] # type: ignore @@ -261,8 +263,9 @@ def set_env( log.warning("Executable not found: %s", env["EXE"]) else: # Configuration file usage exe: Path = Path(env["EXE"]).expanduser() + steam_wkdir: str = find_steam_wdir(exe) env["EXE"] = str(exe) - env["STEAM_COMPAT_INSTALL_PATH"] = str(exe.parent) + env["STEAM_COMPAT_INSTALL_PATH"] = steam_wkdir or str(Path.cwd()) env["STORE"] = os.environ.get("STORE") or "" @@ -463,14 +466,10 @@ def run_command(command: list[AnyPath]) -> int: # For winetricks, change directory to $PROTONPATH/protonfixes if os.environ.get("EXE", "").endswith("winetricks"): cwd = f"{os.environ['PROTONPATH']}/protonfixes" - elif os.environ.get("STORE") == "gog" and ( - subdir := find_subdir(os.environ) - ): - cwd = f"{os.environ['STEAM_COMPAT_INSTALL_PATH']}/{subdir}" else: # TODO: Create an environment variable to allow clients to not allow # UMU to change directories so that the user's setting is respected. - cwd = Path.cwd() + cwd = os.environ["STEAM_COMPAT_INSTALL_PATH"] log.debug("CWD: '%s'", cwd) From 578996dcd124bb0cdf1c073b10318a16bbd6ccd4 Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 4 Jul 2024 12:10:45 -0700 Subject: [PATCH 05/10] umu_test: update expected value in STEAM_COMPAT_INSTALL_PATH in tests --- umu/umu_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/umu/umu_test.py b/umu/umu_test.py index c092e091..1428a9af 100644 --- a/umu/umu_test.py +++ b/umu/umu_test.py @@ -1665,7 +1665,7 @@ def test_set_env_id(self): ) self.assertEqual( self.env["STEAM_COMPAT_INSTALL_PATH"], - Path(path_exe).parent.as_posix(), + str(Path.cwd()), "Expected STEAM_COMPAT_INSTALL_PATH to be set", ) self.assertEqual( @@ -1899,7 +1899,7 @@ def test_set_env(self): ) self.assertEqual( self.env["STEAM_COMPAT_INSTALL_PATH"], - Path(path_exe).parent.as_posix(), + str(Path.cwd()), "Expected STEAM_COMPAT_INSTALL_PATH to be set", ) self.assertEqual( @@ -2025,7 +2025,7 @@ def test_set_env_winetricks(self): ) self.assertEqual( self.env["STEAM_COMPAT_INSTALL_PATH"], - Path(path_exe).parent.as_posix(), + str(Path.cwd()), "Expected STEAM_COMPAT_INSTALL_PATH to be set", ) self.assertEqual( From 693317809723488751e2deb1253166f6ddc7397b Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 4 Jul 2024 12:20:45 -0700 Subject: [PATCH 06/10] umu_run: pass environment to function --- umu/umu_run.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/umu/umu_run.py b/umu/umu_run.py index 60164fb1..6a77ffa9 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -446,7 +446,7 @@ def build_command( return command -def run_command(command: list[AnyPath]) -> int: +def run_command(env: dict[str, str], command: list[AnyPath]) -> int: """Run the executable using Proton within the Steam Runtime.""" # Configure a process via libc prctl() # See prctl(2) for more details @@ -465,11 +465,11 @@ def run_command(command: list[AnyPath]) -> int: # For winetricks, change directory to $PROTONPATH/protonfixes if os.environ.get("EXE", "").endswith("winetricks"): - cwd = f"{os.environ['PROTONPATH']}/protonfixes" + cwd = f"{env['PROTONPATH']}/protonfixes" else: # TODO: Create an environment variable to allow clients to not allow # UMU to change directories so that the user's setting is respected. - cwd = os.environ["STEAM_COMPAT_INSTALL_PATH"] + cwd = env["STEAM_COMPAT_INSTALL_PATH"] log.debug("CWD: '%s'", cwd) @@ -620,7 +620,7 @@ def main() -> int: # noqa: D103 build_command(env, UMU_LOCAL, command, opts) log.debug("%s", command) - return run_command(command) + return run_command(env, command) if __name__ == "__main__": From 1551e455de45a68d762c494b3c846f6169805106 Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 4 Jul 2024 12:21:08 -0700 Subject: [PATCH 07/10] umu_test: update run_command calls to pass environment --- umu/umu_test.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/umu/umu_test.py b/umu/umu_test.py index 1428a9af..5d065493 100644 --- a/umu/umu_test.py +++ b/umu/umu_test.py @@ -207,7 +207,7 @@ def test_run_command(self): mock_proc.wait.return_value = 0 mock_proc.pid = 1234 mock_popen.return_value = mock_proc - result = umu_run.run_command(mock_command) + result = umu_run.run_command(self.env, mock_command) mock_popen.assert_called_once() self.assertEqual( result, @@ -237,7 +237,7 @@ def test_run_command_nolibc(self): patch.object(umu_run, "run", return_value=mock_proc), patch.object(umu_run, "get_libc", return_value=""), ): - result = umu_run.run_command(mock_command) + result = umu_run.run_command(self.env, mock_command) self.assertEqual( result, 0, @@ -247,8 +247,8 @@ def test_run_command_nolibc(self): def test_run_command_none(self): """Test run_command when passed an empty list or None.""" with self.assertRaises(ValueError): - umu_run.run_command([]) - umu_run.run_command(None) + umu_run.run_command(self.env, []) + umu_run.run_command(self.env, None) def test_get_libc(self): """Test get_libc.""" @@ -1973,7 +1973,7 @@ def test_set_env_winetricks(self): # Mock a Proton directory that contains winetricks test_dir = Path("./tmp.aCAs3Q7rvz") - test_dir.joinpath("protonfixes").mkdir(parents=True) + test_dir.joinpath("protonfixes").mkdir(parents=True, exist_ok=True) test_dir.joinpath("protonfixes", "winetricks").touch() # Replicate the usage: @@ -2025,7 +2025,7 @@ def test_set_env_winetricks(self): ) self.assertEqual( self.env["STEAM_COMPAT_INSTALL_PATH"], - str(Path.cwd()), + str(test_dir.joinpath("protonfixes").resolve()), "Expected STEAM_COMPAT_INSTALL_PATH to be set", ) self.assertEqual( From 8f75fb5050ef54a687dbf8f9366affdefaea54c2 Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 4 Jul 2024 12:39:47 -0700 Subject: [PATCH 08/10] umu_util: fix steam game detection logic --- umu/umu_util.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/umu/umu_util.py b/umu/umu_util.py index 5c601612..2a380e98 100644 --- a/umu/umu_util.py +++ b/umu/umu_util.py @@ -152,9 +152,15 @@ def find_steam_wdir(path: Path) -> str: subdir: Path common_parts: tuple[str, ...] path_parts: tuple[str, ...] = path.parts + is_steam: bool = False # Executable is not a Steam game - if not path.match("steamapps/common"): + for parent in path.parents: + if parent.name == "common" and parent.parent.name == "steamapps": + is_steam = True + break + + if not is_steam: log.debug("Executable '%s' is not a Steam game", path) return "" From 299ae4abea845158b81cddf8314b73ad9ad6c8ab Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 4 Jul 2024 13:06:18 -0700 Subject: [PATCH 09/10] umu_run: update comments --- 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 6a77ffa9..31c37f71 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -254,7 +254,7 @@ def set_env( exe: Path = Path(args[0]).expanduser().resolve(strict=True) # type: ignore steam_wkdir: str = find_steam_wdir(exe) env["EXE"] = str(exe) - # Use the working directory of the Steam game, depend on the client + # Use the working directory of the Steam game or depend on client env["STEAM_COMPAT_INSTALL_PATH"] = steam_wkdir or str(Path.cwd()) except FileNotFoundError: # Assume that the executable will be inside prefix or container @@ -467,8 +467,6 @@ def run_command(env: dict[str, str], command: list[AnyPath]) -> int: if os.environ.get("EXE", "").endswith("winetricks"): cwd = f"{env['PROTONPATH']}/protonfixes" else: - # TODO: Create an environment variable to allow clients to not allow - # UMU to change directories so that the user's setting is respected. cwd = env["STEAM_COMPAT_INSTALL_PATH"] log.debug("CWD: '%s'", cwd) From 5de2293bcbfac7e57a51a48253340f83c77742ca Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 4 Jul 2024 13:21:10 -0700 Subject: [PATCH 10/10] umu_util: update path logic --- umu/umu_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/umu/umu_util.py b/umu/umu_util.py index 2a380e98..e971f2c7 100644 --- a/umu/umu_util.py +++ b/umu/umu_util.py @@ -168,7 +168,7 @@ def find_steam_wdir(path: Path) -> str: common_parts = (_parts := path.parts)[: _parts.index("common") + 1] # Check if the exe is in the top level of the base dir and use it - if (len(path_parts) - len(common_parts)) == 2: + if path_parts[:-2] == common_parts: log.debug("Executable '%s' is not within a subdirectory", path) log.debug("Using '%s' as working directory", path.parent) return str(path.parent)