diff --git a/umu/umu_proton.py b/umu/umu_proton.py index 782b5aa15..67b4c0063 100644 --- a/umu/umu_proton.py +++ b/umu/umu_proton.py @@ -340,11 +340,7 @@ def _update_proton( protons: list[Path], thread_pool: ThreadPoolExecutor, ) -> None: - """Create a symbolic link and remove the previous UMU-Proton. - - The symbolic link will be used by clients to reference the PROTONPATH which - can be used for tasks such as killing the running wineserver in the prefix. - The link will be recreated each run. + """Remove previous stable UMU-Proton builds. Assumes that the directories that are named ULWGL/UMU-Proton are ours and will be removed, so users should not be storing important files there. diff --git a/umu/umu_run.py b/umu/umu_run.py index 5a00ba0e2..26b104dd5 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -7,6 +7,7 @@ import zipfile from _ctypes import CFuncPtr from argparse import ArgumentParser, Namespace, RawTextHelpFormatter +from collections.abc import MutableMapping from concurrent.futures import Future, ThreadPoolExecutor from contextlib import suppress from ctypes import CDLL, c_int, c_ulong @@ -501,7 +502,7 @@ def rearrange_gamescope_baselayer_order( """Rearrange a gamescope base layer sequence retrieved from a window.""" # Note: 'sequence' is actually an array type with unsigned integers rearranged: list[int] = list(sequence) - steam_layer_id: int = get_steam_layer_id() + steam_layer_id: int = get_steam_layer_id(os.environ) log.debug("Base layer sequence: %s", sequence) @@ -544,24 +545,24 @@ def set_gamescope_baselayer_order( log.exception(e) -def get_steam_layer_id() -> int: +def get_steam_layer_id(env: MutableMapping) -> int: """Get the Steam layer ID from the host environment variables.""" steam_layer_id: int = 0 - if path := os.environ.get("STEAM_COMPAT_TRANSCODED_MEDIA_PATH"): + if path := env.get("STEAM_COMPAT_TRANSCODED_MEDIA_PATH"): # Suppress cases when value is not a number or empty tuple with suppress(ValueError, IndexError): return int(Path(path).parts[-1]) - if path := os.environ.get("STEAM_COMPAT_MEDIA_PATH"): + if path := env.get("STEAM_COMPAT_MEDIA_PATH"): with suppress(ValueError, IndexError): return int(Path(path).parts[-2]) - if path := os.environ.get("STEAM_FOSSILIZE_DUMP_PATH"): + if path := env.get("STEAM_FOSSILIZE_DUMP_PATH"): with suppress(ValueError, IndexError): return int(Path(path).parts[-3]) - if path := os.environ.get("DXVK_STATE_CACHE_PATH"): + if path := env.get("DXVK_STATE_CACHE_PATH"): with suppress(ValueError, IndexError): return int(Path(path).parts[-2]) @@ -623,7 +624,7 @@ def monitor_windows( ) -> None: """Monitor for new windows and assign them Steam's layer ID.""" window_ids: set[str] | None = None - steam_assigned_layer_id: int = get_steam_layer_id() + steam_assigned_layer_id: int = get_steam_layer_id(os.environ) log.debug( "Waiting for windows under display '%s'...", diff --git a/umu/umu_runtime.py b/umu/umu_runtime.py index cb82a68f0..c68fe469f 100644 --- a/umu/umu_runtime.py +++ b/umu/umu_runtime.py @@ -76,6 +76,7 @@ def create_shim(file_path: Path | None = None): # Make the script executable file_path.chmod(0o700) + def _install_umu( json: dict[str, Any], thread_pool: ThreadPoolExecutor, @@ -220,6 +221,7 @@ def _install_umu( # Rename _v2-entry-point log.debug("Renaming: _v2-entry-point -> umu") UMU_LOCAL.joinpath("_v2-entry-point").rename(UMU_LOCAL.joinpath("umu")) + create_shim() # Validate the runtime after moving the files @@ -522,9 +524,6 @@ def check_runtime(src: Path, json: dict[str, Any]) -> int: return ret log.console(f"{runtime.name}: mtree is OK") - if not UMU_LOCAL.joinpath("umu-shim").exists(): - create_shim() - return ret @@ -542,6 +541,3 @@ def _restore_umu( return _install_umu(json, thread_pool, client_session) log.debug("Released file lock '%s'", lock.lock_file) - - if not UMU_LOCAL.joinpath("umu-shim").exists(): - create_shim() diff --git a/umu/umu_test.py b/umu/umu_test.py index 86268687b..9bdea2b7b 100644 --- a/umu/umu_test.py +++ b/umu/umu_test.py @@ -11,6 +11,7 @@ from pwd import getpwuid from shutil import copy, copytree, move, rmtree from subprocess import CompletedProcess +from tempfile import TemporaryDirectory, mkdtemp from unittest.mock import MagicMock, patch sys.path.append(str(Path(__file__).parent.parent)) @@ -53,6 +54,9 @@ def setUp(self): "UMU_NO_RUNTIME": "", "UMU_RUNTIME_UPDATE": "", "STEAM_COMPAT_TRANSCODED_MEDIA_PATH": "", + "STEAM_COMPAT_MEDIA_PATH": "", + "STEAM_FOSSILIZE_DUMP_PATH": "", + "DXVK_STATE_CACHE_PATH": "", } self.user = getpwuid(os.getuid()).pw_name self.test_opts = "-foo -bar" @@ -193,6 +197,83 @@ def tearDown(self): if self.test_cache_home.exists(): rmtree(self.test_cache_home.as_posix()) + def test_get_steam_layer_id(self): + """Test get_steam_layer_id. + + An IndexError and a ValueError should be handled when + Steam environment variables are empty values or non-integers. + """ + os.environ["STEAM_COMPAT_TRANSCODED_MEDIA_PATH"] = "" + os.environ["STEAM_COMPAT_MEDIA_PATH"] = "foo" + os.environ["STEAM_FOSSILIZE_DUMP_PATH"] = "bar" + os.environ["DXVK_STATE_CACHE_PATH"] = "baz" + result = umu_run.get_steam_layer_id(os.environ) + + self.assertEqual( + result, + 0, + "Expected 0 when Steam environment variables are empty or non-int", + ) + + def test_create_shim_exe(self): + """Test create_shim and ensure it's executable.""" + shim = None + + with TemporaryDirectory() as tmp: + shim = Path(tmp, "umu-shim") + umu_runtime.create_shim(shim) + self.assertTrue( + os.access(shim, os.X_OK), f"Expected '{shim}' to be executable" + ) + + def test_create_shim_none(self): + """Test create_shim when not passed a Path.""" + shim = None + + # When not passed a Path, the function should default to creating $HOME/.local/share/umu/umu-shim + with ( + TemporaryDirectory() as tmp, + patch.object(Path, "joinpath", return_value=Path(tmp, "umu-shim")), + ): + umu_runtime.create_shim() + self.assertTrue( + Path(tmp, "umu-shim").is_file(), + f"Expected '{shim}' to be a file", + ) + # Ensure there's data + self.assertTrue( + Path(tmp, "umu-shim").stat().st_size > 0, + f"Expected '{shim}' to have data", + ) + + def test_create_shim(self): + """Test create_shim.""" + shim = None + + with TemporaryDirectory() as tmp: + shim = Path(tmp, "umu-shim") + umu_runtime.create_shim(shim) + self.assertTrue(shim.is_file(), f"Expected '{shim}' to be a file") + # Ensure there's data + self.assertTrue( + shim.stat().st_size > 0, f"Expected '{shim}' to have data" + ) + + def test_rearrange_gamescope_baselayer_order_none(self): + """Test rearrange_gamescope_baselayer_order for layer ID mismatches.""" + steam_window_id = 769 + # Mock a real assigned non-Steam app ID + steam_layer_id = 1234 + # Mock an overridden value STEAM_COMPAT_TRANSCODED_MEDIA_PATH. + # The app ID for this env var is the last segment and should be found + # in GAMESCOPECTRL_BASELAYER_APPID. When it's not, then that indicates + # it has been tampered by the client or by some middleware. + os.environ["STEAM_COMPAT_TRANSCODED_MEDIA_PATH"] = "/123" + baselayer = [1, steam_window_id, steam_layer_id] + result = umu_run.rearrange_gamescope_baselayer_order(baselayer) + + self.assertTrue(result is None, f"Expected None, received '{result}'") + def test_rearrange_gamescope_baselayer_order_broken(self): """Test rearrange_gamescope_baselayer_order when passed broken seq. @@ -203,7 +284,7 @@ def test_rearrange_gamescope_baselayer_order_broken(self): """ steam_window_id = 769 os.environ["STEAM_COMPAT_TRANSCODED_MEDIA_PATH"] = "/123" - steam_layer_id = umu_run.get_steam_layer_id() + steam_layer_id = umu_run.get_steam_layer_id(os.environ) baselayer = [1, steam_window_id, steam_layer_id] expected = ( [baselayer[0], steam_layer_id, steam_window_id], @@ -230,7 +311,7 @@ def test_rearrange_gamescope_baselayer_order(self): """Test rearrange_gamescope_baselayer_order when passed a sequence.""" steam_window_id = 769 os.environ["STEAM_COMPAT_TRANSCODED_MEDIA_PATH"] = "/123" - steam_layer_id = umu_run.get_steam_layer_id() + steam_layer_id = umu_run.get_steam_layer_id(os.environ) baselayer = [1, steam_layer_id, steam_window_id] result = umu_run.rearrange_gamescope_baselayer_order(baselayer) @@ -473,6 +554,35 @@ def test_move(self): "qux did not move to dst", ) + def test_update_proton(self): + """Test _update_proton.""" + mock_protons = [Path(mkdtemp()), Path(mkdtemp())] + thread_pool = ThreadPoolExecutor() + result = [] + + for mock in mock_protons: + self.assertTrue( + mock.is_dir(), f"Directory '{mock}' does not exist" + ) + + result = umu_proton._update_proton(mock_protons, thread_pool) + + self.assertTrue(result is None, f"Expected None, received '{result}'") + + # The directories should be removed after the update + for mock in mock_protons: + self.assertFalse(mock.is_dir(), f"Directory '{mock}' still exist") + + def test_update_proton_empty(self): + """Test _update_proton when passed an empty list.""" + # In the real usage, an empty list means that there were no + # UMU/ULWGL-Proton found in compatibilitytools.d + result = umu_proton._update_proton([], None) + + self.assertTrue( + result is None, "Expected None when passed an empty list" + ) + def test_ge_proton(self): """Test check_env when the code name GE-Proton is set for PROTONPATH. @@ -2379,7 +2489,6 @@ def test_parse_args_winetricks(self): ): umu_run.parse_args() - def test_parse_args_noopts(self): """Test parse_args with no options.