diff --git a/gamelauncher.py b/gamelauncher.py new file mode 100755 index 0000000..2dad652 --- /dev/null +++ b/gamelauncher.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 + +import os +import argparse +from argparse import ArgumentParser, _ArgumentGroup, Namespace +import sys +from pathlib import Path +import tomllib +from typing import Dict, Any, List, Set +import gamelauncher_plugins + +# TODO: Only set the environment variables that are not empty +import subprocess + + +def parse_args() -> Namespace: # noqa: D103 + exe: str = Path(__file__).name + usage: str = """ + example usage: + {} --config example.toml + {} --config /home/foo/example.toml --options '-opengl' + WINEPREFIX= GAMEID= PROTONPATH= {} --exe /home/foo/example.exe --options '-opengl' + WINEPREFIX= GAMEID= PROTONPATH= {} --exe "" + WINEPREFIX= GAMEID= PROTONPATH= {} --exe /home/foo/example.exe --verb waitforexitandrun + """.format(exe, exe, exe, exe, exe) + + parser: ArgumentParser = argparse.ArgumentParser( + description="Unified Linux Wine Game Launcher", + epilog=usage, + formatter_class=argparse.RawTextHelpFormatter, + ) + group: _ArgumentGroup = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--config", help="path to TOML file") + group.add_argument( + "--exe", + help="path to game executable\npass an empty string to create a prefix", + default=None, + ) + parser.add_argument( + "--verb", + help="a verb to pass to Proton (default: waitforexitandrun)", + ) + parser.add_argument( + "--options", + help="launch options for game executable\nNOTE: options must be wrapped in quotes", + ) + + return parser.parse_args(sys.argv[1:]) + + +def _setup_pfx(path: str) -> None: + """Create a symlink to the WINE prefix and tracked_files file.""" + if not (Path(path + "/pfx")).expanduser().is_symlink(): + # When creating the symlink, we want it to be in expanded form when passed unexpanded paths + # Example: pfx -> /home/.wine + Path(path + "/pfx").expanduser().symlink_to(Path(path).expanduser()) + Path(path + "/tracked_files").expanduser().touch() + + +def check_env(env: Dict[str, str]) -> Dict[str, str]: + """Before executing a game, check for environment variables and set them. + + WINEPREFIX, GAMEID and PROTONPATH are strictly required. + """ + if "WINEPREFIX" not in os.environ: + err: str = "Environment variable not set or not a directory: WINEPREFIX" + raise ValueError(err) + + if not Path(os.environ["WINEPREFIX"]).expanduser().is_dir(): + Path(os.environ["WINEPREFIX"]).mkdir(parents=True) + env["WINEPREFIX"] = os.environ["WINEPREFIX"] + + if "GAMEID" not in os.environ: + err: str = "Environment variable not set: GAMEID" + raise ValueError(err) + env["GAMEID"] = os.environ["GAMEID"] + + if ( + "PROTONPATH" not in os.environ + or not Path(os.environ["PROTONPATH"]).expanduser().is_dir() + ): + err: str = "Environment variable not set or not a directory: PROTONPATH" + raise ValueError(err) + env["PROTONPATH"] = os.environ["PROTONPATH"] + env["STEAM_COMPAT_INSTALL_PATH"] = os.environ["PROTONPATH"] + + return env + + +def set_env(env: Dict[str, str], args: Namespace) -> Dict[str, str]: + """Set various environment variables for the Steam RT. + + Expects to be invoked if not reading a TOML file + """ + _setup_pfx(env["WINEPREFIX"]) + is_create_prefix: bool = False + + if not getattr(args, "exe", None): + is_create_prefix = True + + # Sets the environment variables: EXE + for arg, val in vars(args).items(): + if arg == "exe" and not is_create_prefix: + # NOTE: options can possibly be appended at the end + env["EXE"] = val + elif arg == "options" and val and not is_create_prefix: + # NOTE: assume it's space separated + env["EXE"] = env["EXE"] + " " + " ".join(val.split(" ")) + + return env + + +def set_env_toml(env: Dict[str, str], args: Namespace) -> Dict[str, str]: + """Read a TOML file then sets the environment variables for the Steam RT. + + In the TOML file, certain keys map to Steam RT environment variables. For example: + proton -> $PROTONPATH + prefix -> $WINEPREFIX + game_id -> $GAMEID + exe -> $EXE + At the moment we expect the tables: 'ulwgl' + """ + toml: Dict[str, Any] = None + path_config: str = Path(getattr(args, "config", None)).expanduser().as_posix() + + if not Path(path_config).is_file(): + msg: str = "Path to configuration is not a file: " + getattr( + args, "config", None + ) + raise FileNotFoundError(msg) + + with Path(path_config).open(mode="rb") as file: + toml = tomllib.load(file) + + if not ( + Path(toml["ulwgl"]["prefix"]).expanduser().is_dir() + or Path(toml["ulwgl"]["proton"]).expanduser().is_dir() + ): + err: str = "Value for 'prefix' or 'proton' in TOML is not a directory." + raise NotADirectoryError(err) + + # Set the values read from TOML to environment variables + # If necessary, raise an error on invalid inputs + for key, val in toml["ulwgl"].items(): + # Handle cases for empty values + if not val and isinstance(val, str): + err: str = "Value is empty for key in TOML: " + key + raise ValueError(err) + if key == "prefix": + env["WINEPREFIX"] = val + _setup_pfx(val) + elif key == "game_id": + env["GAMEID"] = val + elif key == "proton": + env["PROTONPATH"] = val + env["STEAM_COMPAT_INSTALL_PATH"] = val + elif key == "exe": + # Raise an error for executables that do not exist + # One case this can happen is when game options are appended at the end of the exe + if not Path(val).expanduser().is_file(): + err: str = "Value for key 'exe' in TOML is not a file." + raise FileNotFoundError(err) + + # It's possible for users to pass values to --options + # Add any if they exist + if toml.get("ulwgl").get("launch_args"): + env["EXE"] = val + " " + " ".join(toml.get("ulwgl").get("launch_args")) + else: + env["EXE"] = val + + if getattr(args, "options", None): + # Assume space separated options and just trust it + env["EXE"] = ( + env["EXE"] + + " " + + " ".join(getattr(args, "options", None).split(" ")) + ) + + return env + + +def build_command(env: Dict[str, str], command: List[str], verb: str) -> List[str]: + """Build the command to be executed.""" + # NOTE: We must assume _v2-entry-point (ULWGL) is within the same dir as this launcher + # Otherwise, an error can be raised + entry_point: str = Path(Path(__file__).cwd().as_posix() + "/ULWGL").as_posix() + + if not Path(env.get("PROTONPATH") + "/proton").is_file(): + err: str = "The following file was not found in PROTONPATH: proton" + raise FileNotFoundError(err) + + command.extend([entry_point, "--verb", verb, "--"]) + command.extend( + [Path(env.get("PROTONPATH") + "/proton").as_posix(), verb, env.get("EXE")] + ) + + return command + + +def main() -> None: # noqa: D103 + env: Dict[str, str] = { + "WINEPREFIX": "", + "GAMEID": "", + "PROTON_CRASH_REPORT_DIR": "/tmp/ULWGL_crashreports", + "PROTONPATH": "", + "STEAM_COMPAT_APP_ID": "", + "STEAM_COMPAT_TOOL_PATHS": "", + "STEAM_COMPAT_LIBRARY_PATHS": "", + "STEAM_COMPAT_MOUNTS": "", + "STEAM_COMPAT_INSTALL_PATH": "", + "STEAM_COMPAT_CLIENT_INSTALL_PATH": "", + "STEAM_COMPAT_DATA_PATH": "", + "STEAM_COMPAT_SHADER_PATH": "", + "FONTCONFIG_PATH": "", + "EXE": "", + "SteamAppId": "", + "SteamGameId": "", + "STEAM_RUNTIME_LIBRARY_PATH": "", + } + command: List[str] = [] + verb: str = "waitforexitandrun" + # Represents a valid list of current supported Proton verbs + verbs: Set[str] = { + "waitforexitandrun", + "run", + "runinprefix", + "destroyprefix", + "getcompatpath", + "getnativepath", + } + args: Namespace = parse_args() + + if getattr(args, "config", None): + set_env_toml(env, args) + else: + check_env(env) + set_env(env, args) + + if getattr(args, "verb", None) and getattr(args, "verb", None) in verbs: + verb = getattr(args, "verb", None) + + env["STEAM_COMPAT_APP_ID"] = env["GAMEID"] + env["SteamAppId"] = env["STEAM_COMPAT_APP_ID"] + env["SteamGameId"] = env["SteamAppId"] + env["WINEPREFIX"] = Path(env["WINEPREFIX"]).expanduser().as_posix() + env["PROTONPATH"] = Path(env["PROTONPATH"]).expanduser().as_posix() + env["STEAM_COMPAT_DATA_PATH"] = env["WINEPREFIX"] + env["STEAM_COMPAT_SHADER_PATH"] = env["STEAM_COMPAT_DATA_PATH"] + "/shadercache" + env["STEAM_COMPAT_INSTALL_PATH"] = Path(env["EXE"]).parent.expanduser().as_posix() + env["EXE"] = Path(env["EXE"]).expanduser().as_posix() + env["STEAM_COMPAT_TOOL_PATHS"] = ( + env["PROTONPATH"] + ":" + Path(__file__).parent.as_posix() + ) + env["STEAM_COMPAT_MOUNTS"] = env["STEAM_COMPAT_TOOL_PATHS"] + + # Create an empty Proton prefix when asked + if not getattr(args, "exe", None) and not getattr(args, "config", None): + env["EXE"] = "" + env["STEAM_COMPAT_INSTALL_PATH"] = "" + verb = "waitforexitandrun" + + # Game Drive functionality + gamelauncher_plugins.enable_steam_game_drive(env) + + # Set all environment variable + # NOTE: `env` after this block should be read only + for key, val in env.items(): + print(f"Setting environment variable: {key}={val}") + os.environ[key] = val + + build_command(env, command, verb) + print(f"The following command will be executed: {command}") + subprocess.run(command, check=True, stdout=subprocess.PIPE, text=True) + + +if __name__ == "__main__": + main() diff --git a/gamelauncher_plugins.py b/gamelauncher_plugins.py new file mode 100644 index 0000000..4c176e8 --- /dev/null +++ b/gamelauncher_plugins.py @@ -0,0 +1,39 @@ +import os +from pathlib import Path +from typing import Dict, Set + + +def enable_steam_game_drive(env: Dict[str, str]): + """Enable Steam Game Drive functionality. + + Expects STEAM_COMPAT_INSTALL_PATH to be set + STEAM_RUNTIME_LIBRARY_PATH will not be set if the exe directory does not exist + """ + paths: Set[str] = set() + root: Path = Path("/") + + # Check for mount points going up toward the root + # NOTE: Subvolumes can be mount points + for path in Path(env["STEAM_COMPAT_INSTALL_PATH"]).parents: + if path.is_mount() and path != root: + if env["STEAM_COMPAT_LIBRARY_PATHS"]: + env["STEAM_COMPAT_LIBRARY_PATHS"] = ( + env["STEAM_COMPAT_LIBRARY_PATHS"] + ":" + path.as_posix() + ) + else: + env["STEAM_COMPAT_LIBRARY_PATHS"] = path.as_posix() + break + + if "LD_LIBRARY_PATH" in os.environ: + paths.add(Path(os.environ["LD_LIBRARY_PATH"]).as_posix()) + + if env["STEAM_COMPAT_INSTALL_PATH"]: + paths.add(env["STEAM_COMPAT_INSTALL_PATH"]) + + # Hard code for now because these paths seem to be pretty standard + # This way we avoid shelling to ldconfig + paths.add("/usr/lib") + paths.add("/usr/lib32") + env["STEAM_RUNTIME_LIBRARY_PATH"] = ":".join(list(paths)) + + return env diff --git a/gamelauncher_test.py b/gamelauncher_test.py new file mode 100644 index 0000000..3d56243 --- /dev/null +++ b/gamelauncher_test.py @@ -0,0 +1,1350 @@ +import unittest +import gamelauncher +import os +import argparse +from argparse import Namespace +from unittest.mock import patch +from pathlib import Path +from tomllib import TOMLDecodeError +from shutil import rmtree +import re +import gamelauncher_plugins + + +class TestGameLauncher(unittest.TestCase): + """Test suite for gamelauncher.py. + + TODO: test for mutually exclusive options + """ + + def setUp(self): + """Create the test directory, exe and environment variables.""" + self.env = { + "WINEPREFIX": "", + "GAMEID": "", + "PROTON_CRASH_REPORT_DIR": "/tmp/ULWGL_crashreports", + "PROTONPATH": "", + "STEAM_COMPAT_APP_ID": "", + "STEAM_COMPAT_TOOL_PATHS": "", + "STEAM_COMPAT_LIBRARY_PATHS": "", + "STEAM_COMPAT_MOUNTS": "", + "STEAM_COMPAT_INSTALL_PATH": "", + "STEAM_COMPAT_CLIENT_INSTALL_PATH": "", + "STEAM_COMPAT_DATA_PATH": "", + "STEAM_COMPAT_SHADER_PATH": "", + "FONTCONFIG_PATH": "", + "EXE": "", + "SteamAppId": "", + "SteamGameId": "", + "STEAM_RUNTIME_LIBRARY_PATH": "", + } + self.test_opts = "-foo -bar" + # Proton verb + # Used when testing build_command + self.test_verb = "waitforexitandrun" + # Test directory + self.test_file = "./tmp.WMYQiPb9A" + # Executable + self.test_exe = self.test_file + "/" + "foo" + Path(self.test_file).mkdir(exist_ok=True) + Path(self.test_exe).touch() + + def tearDown(self): + """Unset environment variables and delete test files after each test.""" + for key, val in self.env.items(): + if key in os.environ: + os.environ.pop(key) + + if Path(self.test_file).exists(): + rmtree(self.test_file) + + def test_game_drive_empty(self): + """Test enable_steam_game_drive. + + Empty WINE prefixes can be created by passing an empty string to --exe + During this process, we attempt to prepare setting up game drive and set the values for STEAM_RUNTIME_LIBRARY_PATH and STEAM_COMPAT_INSTALL_PATHS + The resulting value of those variables should be colon delimited string with no leading colons and contain only /usr/lib or /usr/lib32 + """ + result = None + result_set_env = None + result_check_env = None + result_gamedrive = None + Path(self.test_file + "/proton").touch() + + # Replicate main's execution and test up until enable_steam_game_drive + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(exe=""), + ): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = self.test_file + # Parse arguments + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + # Check if required env var are set + result_check_env = gamelauncher.check_env(self.env) + self.assertTrue(result_check_env is self.env, "Expected the same reference") + self.assertEqual( + self.env["WINEPREFIX"], self.test_file, "Expected WINEPREFIX to be set" + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expected GAMEID to be set" + ) + self.assertEqual( + self.env["PROTONPATH"], self.test_file, "Expected PROTONPATH to be set" + ) + + # Set the required environment variables + result_set_env = gamelauncher.set_env(self.env, result) + self.assertTrue(result_set_env is self.env, "Expected the same reference") + + # Check for expected changes + # We only check the required ones + self.assertEqual(result_set_env["WINEPREFIX"], self.test_file) + self.assertEqual(result_set_env["PROTONPATH"], self.test_file) + self.assertEqual(result_set_env["GAMEID"], self.test_file) + # Check if the EXE is empty + self.assertFalse(result_set_env["EXE"], "Expected EXE to be empty") + + # Set remaining environment variables + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + self.env["STEAM_COMPAT_APP_ID"] = self.env["GAMEID"] + self.env["SteamAppId"] = self.env["STEAM_COMPAT_APP_ID"] + self.env["SteamGameId"] = self.env["SteamAppId"] + self.env["WINEPREFIX"] = Path(self.env["WINEPREFIX"]).expanduser().as_posix() + self.env["PROTONPATH"] = Path(self.env["PROTONPATH"]).expanduser().as_posix() + self.env["STEAM_COMPAT_DATA_PATH"] = self.env["WINEPREFIX"] + self.env["STEAM_COMPAT_SHADER_PATH"] = ( + self.env["STEAM_COMPAT_DATA_PATH"] + "/shadercache" + ) + self.env["STEAM_COMPAT_INSTALL_PATH"] = ( + Path(self.env["EXE"]).parent.expanduser().as_posix() + ) + self.env["EXE"] = Path(self.env["EXE"]).expanduser().as_posix() + self.env["STEAM_COMPAT_TOOL_PATHS"] = ( + self.env["PROTONPATH"] + ":" + Path(__file__).parent.as_posix() + ) + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + + if not getattr(result, "exe", None) and not getattr(result, "config", None): + self.env["EXE"] = "" + self.env["STEAM_COMPAT_INSTALL_PATH"] = "" + self.verb = "waitforexitandrun" + + # Game Drive + result_gamedrive = gamelauncher_plugins.enable_steam_game_drive(self.env) + self.assertTrue(result_gamedrive is self.env, "Expected the same reference") + + self.assertTrue( + self.env["STEAM_RUNTIME_LIBRARY_PATH"], + "Expected two elements in STEAM_RUNTIME_LIBRARY_PATHS", + ) + + # We just expect /usr/lib and /usr/lib32 + self.assertEqual( + len(self.env["STEAM_RUNTIME_LIBRARY_PATH"].split(":")), + 2, + "Expected two values in STEAM_RUNTIME_LIBRARY_PATH", + ) + + # We need to sort the elements because the values were originally in a set + str1, str2 = [*sorted(self.env["STEAM_RUNTIME_LIBRARY_PATH"].split(":"))] + + # Check that there are no trailing colons or unexpected characters + self.assertEqual(str1, "/usr/lib", "Expected /usr/lib") + self.assertEqual(str2, "/usr/lib32", "Expected /usr/lib32") + + # Both of these values should be empty still after calling enable_steam_game_drive + self.assertFalse( + self.env["STEAM_COMPAT_INSTALL_PATH"], + "Expected STEAM_COMPAT_INSTALL_PATH to be empty when passing an empty EXE", + ) + self.assertFalse(self.env["EXE"], "Expected EXE to be empty on empty string") + + def test_build_command_verb(self): + """Test build_command. + + An error should not be raised if we pass a Proton verb we don't expect + By default, we use "waitforexitandrun" for a verb we don't expect + Currently we only expect: + "waitforexitandrun" + "run" + "runinprefix" + "destroyprefix" + "getcompatpath" + "getnativepath" + """ + test_toml = "foo.toml" + toml_str = f""" + [ulwgl] + prefix = "{self.test_file}" + proton = "{self.test_file}" + game_id = "{self.test_file}" + launch_args = ["{self.test_file}", "{self.test_file}"] + exe = "{self.test_exe}" + """ + toml_path = self.test_file + "/" + test_toml + result = None + result_set_env = None + test_command = [] + test_verb = "foo" + Path(self.test_file + "/proton").touch() + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path, verb=test_verb), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + # Check if a verb was passed + self.assertTrue(vars(result).get("verb"), "Expected a value for --verb") + result_set_env = gamelauncher.set_env_toml(self.env, result) + self.assertTrue(result_set_env is self.env, "Expected the same reference") + # Check for changes after calling + self.assertEqual( + result_set_env["EXE"], + self.test_exe + " " + self.test_file + " " + self.test_file, + ) + self.assertEqual(result_set_env["WINEPREFIX"], self.test_file) + self.assertEqual(result_set_env["PROTONPATH"], self.test_file) + self.assertEqual(result_set_env["GAMEID"], self.test_file) + + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + self.env["STEAM_COMPAT_APP_ID"] = self.env["GAMEID"] + self.env["SteamAppId"] = self.env["STEAM_COMPAT_APP_ID"] + self.env["SteamGameId"] = self.env["SteamAppId"] + self.env["WINEPREFIX"] = Path(self.env["WINEPREFIX"]).expanduser().as_posix() + self.env["PROTONPATH"] = Path(self.env["PROTONPATH"]).expanduser().as_posix() + self.env["STEAM_COMPAT_DATA_PATH"] = self.env["WINEPREFIX"] + self.env["STEAM_COMPAT_SHADER_PATH"] = ( + self.env["STEAM_COMPAT_DATA_PATH"] + "/shadercache" + ) + self.env["STEAM_COMPAT_INSTALL_PATH"] = ( + Path(self.env["EXE"]).parent.expanduser().as_posix() + ) + self.env["EXE"] = Path(self.env["EXE"]).expanduser().as_posix() + self.env["STEAM_COMPAT_TOOL_PATHS"] = ( + self.env["PROTONPATH"] + ":" + Path(__file__).parent.as_posix() + ) + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + + # Create an empty Proton prefix when asked + if not getattr(result, "exe", None) and not getattr(result, "config", None): + self.env["EXE"] = "" + self.env["STEAM_COMPAT_INSTALL_PATH"] = "" + self.verb = "waitforexitandrun" + + for key, val in self.env.items(): + os.environ[key] = val + test_command = gamelauncher.build_command(self.env, test_command, test_verb) + # The verb should be 2nd in the array + self.assertIsInstance(test_command, list, "Expected a List from build_command") + self.assertTrue(test_command[2], self.test_verb) + + def test_build_command_nofile(self): + """Test build_command. + + A FileNotFoundError should be raised if $PROTONPATH/proton does not exist + Just test the TOML case for the coverage + """ + test_toml = "foo.toml" + toml_str = f""" + [ulwgl] + prefix = "{self.test_file}" + proton = "{self.test_file}" + game_id = "{self.test_file}" + launch_args = ["{self.test_file}", "{self.test_file}"] + exe = "{self.test_exe}" + """ + toml_path = self.test_file + "/" + test_toml + result = None + result_set_env = None + test_command = [] + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + result_set_env = gamelauncher.set_env_toml(self.env, result) + self.assertTrue(result_set_env is self.env, "Expected the same reference") + # Check for changes after calling + self.assertEqual( + result_set_env["EXE"], + self.test_exe + " " + self.test_file + " " + self.test_file, + ) + self.assertEqual(result_set_env["WINEPREFIX"], self.test_file) + self.assertEqual(result_set_env["PROTONPATH"], self.test_file) + self.assertEqual(result_set_env["GAMEID"], self.test_file) + + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + self.env["STEAM_COMPAT_APP_ID"] = self.env["GAMEID"] + self.env["SteamAppId"] = self.env["STEAM_COMPAT_APP_ID"] + self.env["SteamGameId"] = self.env["SteamAppId"] + self.env["WINEPREFIX"] = Path(self.env["WINEPREFIX"]).expanduser().as_posix() + self.env["PROTONPATH"] = Path(self.env["PROTONPATH"]).expanduser().as_posix() + self.env["STEAM_COMPAT_DATA_PATH"] = self.env["WINEPREFIX"] + self.env["STEAM_COMPAT_SHADER_PATH"] = ( + self.env["STEAM_COMPAT_DATA_PATH"] + "/shadercache" + ) + self.env["STEAM_COMPAT_INSTALL_PATH"] = ( + Path(self.env["EXE"]).parent.expanduser().as_posix() + ) + self.env["EXE"] = Path(self.env["EXE"]).expanduser().as_posix() + self.env["STEAM_COMPAT_TOOL_PATHS"] = ( + self.env["PROTONPATH"] + ":" + Path(__file__).parent.as_posix() + ) + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + + # Create an empty Proton prefix when asked + if not getattr(result, "exe", None) and not getattr(result, "config", None): + self.env["EXE"] = "" + self.env["STEAM_COMPAT_INSTALL_PATH"] = "" + self.verb = "waitforexitandrun" + + for key, val in self.env.items(): + os.environ[key] = val + with self.assertRaisesRegex(FileNotFoundError, "proton"): + gamelauncher.build_command(self.env, test_command, self.test_verb) + + def test_build_command_toml(self): + """Test build_command. + + After parsing a valid TOML file, be sure we do not raise a FileNotFoundError + """ + test_toml = "foo.toml" + toml_str = f""" + [ulwgl] + prefix = "{self.test_file}" + proton = "{self.test_file}" + game_id = "{self.test_file}" + launch_args = ["{self.test_file}", "{self.test_file}"] + exe = "{self.test_exe}" + """ + toml_path = self.test_file + "/" + test_toml + result = None + result_set_env = None + test_command = [] + + Path(self.test_file + "/proton").touch() + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + result_set_env = gamelauncher.set_env_toml(self.env, result) + self.assertTrue(result_set_env is self.env, "Expected the same reference") + # Check for changes after calling + self.assertEqual( + result_set_env["EXE"], + self.test_exe + " " + self.test_file + " " + self.test_file, + ) + self.assertEqual(result_set_env["WINEPREFIX"], self.test_file) + self.assertEqual(result_set_env["PROTONPATH"], self.test_file) + self.assertEqual(result_set_env["GAMEID"], self.test_file) + + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + self.env["STEAM_COMPAT_APP_ID"] = self.env["GAMEID"] + self.env["SteamAppId"] = self.env["STEAM_COMPAT_APP_ID"] + self.env["SteamGameId"] = self.env["SteamAppId"] + self.env["WINEPREFIX"] = Path(self.env["WINEPREFIX"]).expanduser().as_posix() + self.env["PROTONPATH"] = Path(self.env["PROTONPATH"]).expanduser().as_posix() + self.env["STEAM_COMPAT_DATA_PATH"] = self.env["WINEPREFIX"] + self.env["STEAM_COMPAT_SHADER_PATH"] = ( + self.env["STEAM_COMPAT_DATA_PATH"] + "/shadercache" + ) + self.env["STEAM_COMPAT_INSTALL_PATH"] = ( + Path(self.env["EXE"]).parent.expanduser().as_posix() + ) + self.env["EXE"] = Path(self.env["EXE"]).expanduser().as_posix() + self.env["STEAM_COMPAT_TOOL_PATHS"] = ( + self.env["PROTONPATH"] + ":" + Path(__file__).parent.as_posix() + ) + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + + # Create an empty Proton prefix when asked + if not getattr(result, "exe", None) and not getattr(result, "config", None): + self.env["EXE"] = "" + self.env["STEAM_COMPAT_INSTALL_PATH"] = "" + self.verb = "waitforexitandrun" + + for key, val in self.env.items(): + os.environ[key] = val + test_command = gamelauncher.build_command( + self.env, test_command, self.test_verb + ) + self.assertIsInstance(test_command, list, "Expected a List from build_command") + # Verify contents + entry_point, opt1, verb, opt2, 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.assertEqual(opt1, "--verb", "Expected --verb") + self.assertEqual(verb, self.test_verb, "Expected a verb") + self.assertEqual(opt2, "--", "Expected --") + self.assertEqual( + proton, + Path(self.env.get("PROTONPATH") + "/proton").as_posix(), + "Expected the proton file", + ) + self.assertEqual(verb2, self.test_verb, "Expected a verb") + self.assertEqual(exe, self.env["EXE"], "Expected the EXE") + + def test_build_command(self): + """Test build_command. + + After parsing valid environment variables set by the user, be sure we do not raise a FileNotFoundError + """ + result_args = None + result_check_env = None + test_command = [] + + # Mock the /proton file + Path(self.test_file + "/proton").touch() + + # Replicate the usage WINEPREFIX= PROTONPATH= GAMEID= gamelauncher --exe=... + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(exe=self.test_exe, options=self.test_opts), + ): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = self.test_file + result_args = gamelauncher.parse_args() + self.assertIsInstance( + result_args, Namespace, "parse_args did not return a Namespace" + ) + result_check_env = gamelauncher.check_env(self.env) + self.assertTrue(result_check_env is self.env, "Expected the same reference") + self.assertEqual( + self.env["WINEPREFIX"], self.test_file, "Expected WINEPREFIX to be set" + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expected GAMEID to be set" + ) + self.assertEqual( + self.env["PROTONPATH"], self.test_file, "Expected PROTONPATH to be set" + ) + result_set_env = gamelauncher.set_env(self.env, result_args) + + # Check for changes after calling + self.assertEqual(result_set_env["WINEPREFIX"], self.test_file) + self.assertEqual(result_set_env["PROTONPATH"], self.test_file) + self.assertEqual(result_set_env["GAMEID"], self.test_file) + # Test for expected EXE with options + self.assertEqual( + self.env.get("EXE"), + "{} {}".format(self.test_exe, self.test_opts), + "Expected the concat EXE and game options to not have trailing spaces", + ) + + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + self.env["STEAM_COMPAT_APP_ID"] = self.env["GAMEID"] + self.env["SteamAppId"] = self.env["STEAM_COMPAT_APP_ID"] + self.env["SteamGameId"] = self.env["SteamAppId"] + self.env["WINEPREFIX"] = Path(self.env["WINEPREFIX"]).expanduser().as_posix() + self.env["PROTONPATH"] = Path(self.env["PROTONPATH"]).expanduser().as_posix() + self.env["STEAM_COMPAT_DATA_PATH"] = self.env["WINEPREFIX"] + self.env["STEAM_COMPAT_SHADER_PATH"] = ( + self.env["STEAM_COMPAT_DATA_PATH"] + "/shadercache" + ) + self.env["STEAM_COMPAT_INSTALL_PATH"] = ( + Path(self.env["EXE"]).parent.expanduser().as_posix() + ) + self.env["EXE"] = Path(self.env["EXE"]).expanduser().as_posix() + self.env["STEAM_COMPAT_TOOL_PATHS"] = ( + self.env["PROTONPATH"] + ":" + Path(__file__).parent.as_posix() + ) + self.env["STEAM_COMPAT_MOUNTS"] = self.env["STEAM_COMPAT_TOOL_PATHS"] + + # Create an empty Proton prefix when asked + if not getattr(result_args, "exe", None) and not getattr( + result_args, "config", None + ): + self.env["EXE"] = "" + self.env["STEAM_COMPAT_INSTALL_PATH"] = "" + self.verb = "waitforexitandrun" + + for key, val in self.env.items(): + os.environ[key] = val + + test_command = gamelauncher.build_command( + self.env, test_command, self.test_verb + ) + self.assertIsInstance(test_command, list, "Expected a List from build_command") + self.assertEqual( + len(test_command), 7, "Expected 7 elements in the list from build_command" + ) + # Verify contents + entry_point, opt1, verb, opt2, 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.assertEqual(opt1, "--verb", "Expected --verb") + self.assertEqual(verb, self.test_verb, "Expected a verb") + self.assertEqual(opt2, "--", "Expected --") + self.assertEqual( + proton, + Path(self.env.get("PROTONPATH") + "/proton").as_posix(), + "Expected the proton file", + ) + self.assertEqual(verb2, self.test_verb, "Expected a verb") + self.assertEqual(exe, self.env["EXE"], "Expected the EXE") + + def test_set_env_toml_config(self): + """Test set_env_toml when passing a configuration file. + + An FileNotFoundError should be raised when passing a TOML file that doesn't exist + """ + test_file = "foo.toml" + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=test_file), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + with self.assertRaisesRegex(FileNotFoundError, test_file): + gamelauncher.set_env_toml(self.env, result) + + def test_set_env_toml_opts_nofile(self): + """Test set_env_toml for options that are a file. + + An error should not be raised if a launch argument is a file + We allow this behavior to give users flexibility at the cost of security + """ + test_toml = "foo.toml" + toml_path = self.test_file + "/" + test_toml + toml_str = f""" + [ulwgl] + prefix = "{self.test_file}" + proton = "{self.test_file}" + game_id = "{self.test_file}" + launch_args = ["{toml_path}"] + exe = "{self.test_exe}" + """ + result = None + + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + gamelauncher.set_env_toml(self.env, result) + # Check if the TOML file we just created + self.assertTrue( + Path(self.env["EXE"].split(" ")[1]).is_file(), + "Expected a file to be appended to the executable", + ) + + def test_set_env_toml_nofile(self): + """Test set_env_toml for values that are not a file. + + A FileNotFoundError should be raised if the 'exe' is not a file + """ + test_toml = "foo.toml" + toml_str = f""" + [ulwgl] + prefix = "{self.test_file}" + proton = "{self.test_file}" + game_id = "{self.test_file}" + launch_args = ["{self.test_file}", "{self.test_file}"] + exe = "./bar" + """ + toml_path = self.test_file + "/" + test_toml + result = None + + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + with self.assertRaisesRegex(FileNotFoundError, "exe"): + gamelauncher.set_env_toml(self.env, result) + + def test_set_env_toml_empty(self): + """Test set_env_toml for empty values not required by parse_args. + + A ValueError should be thrown if 'game_id' is empty + """ + test_toml = "foo.toml" + toml_str = f""" + [ulwgl] + prefix = "{self.test_file}" + proton = "{self.test_file}" + game_id = "" + launch_args = ["{self.test_file}", "{self.test_file}"] + exe = "{self.test_file}" + """ + toml_path = self.test_file + "/" + test_toml + result = None + + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + with self.assertRaisesRegex(ValueError, "game_id"): + gamelauncher.set_env_toml(self.env, result) + + def test_set_env_toml_err(self): + """Test set_env_toml for valid TOML. + + A TOMLDecodeError should be raised for invalid values + """ + test_toml = "foo.toml" + toml_str = f""" + [ulwgl] + prefix = [[ + proton = "{self.test_file}" + game_id = "{self.test_file}" + launch_args = ["{self.test_file}", "{self.test_file}"] + """ + toml_path = self.test_file + "/" + test_toml + result = None + + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + with self.assertRaisesRegex(TOMLDecodeError, "Invalid"): + gamelauncher.set_env_toml(self.env, result) + + def test_set_env_toml_nodir(self): + """Test set_env_toml if certain key/value are not a dir. + + An IsDirectoryError should be raised if proton or prefix are not directories + """ + test_toml = "foo.toml" + toml_str = f""" + [ulwgl] + prefix = "foo" + proton = "foo" + game_id = "{self.test_file}" + launch_args = ["{self.test_file}", "{self.test_file}"] + """ + toml_path = self.test_file + "/" + test_toml + result = None + + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + with self.assertRaisesRegex(NotADirectoryError, "prefix"): + gamelauncher.set_env_toml(self.env, result) + + def test_set_env_toml_tables(self): + """Test set_env_toml for expected tables. + + A KeyError should be raised if the table 'ulwgl' is absent + """ + test_toml = "foo.toml" + toml_str = f""" + [foo] + prefix = "{self.test_file}" + proton = "{self.test_file}" + game_id = "{self.test_file}" + launch_args = ["{self.test_file}", "{self.test_file}"] + """ + toml_path = self.test_file + "/" + test_toml + result = None + + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + with self.assertRaisesRegex(KeyError, "ulwgl"): + gamelauncher.set_env_toml(self.env, result) + + def test_set_env_toml_paths(self): + """Test set_env_toml when specifying unexpanded file path values in the config file. + + Example: ~/Games/foo.exe + An error should not be raised when passing unexpanded paths to the config file as well as the prefix, proton and exe keys + """ + test_toml = "foo.toml" + pattern = r"^/home/[a-zA-Z]+" + + # Replaces the expanded path to unexpanded + # Example: ~/some/path/to/this/file + path_to_tmp = Path( + Path(__file__).cwd().as_posix() + "/" + self.test_file + ).as_posix() + path_to_exe = Path( + Path(__file__).cwd().as_posix() + "/" + self.test_exe + ).as_posix() + + # Replace /home/[a-zA-Z]+ substring in path with tilda + unexpanded_path = re.sub( + pattern, + "~", + path_to_tmp, + ) + unexpanded_exe = re.sub( + pattern, + "~", + path_to_exe, + ) + toml_str = f""" + [ulwgl] + prefix = "{unexpanded_path}" + proton = "{unexpanded_path}" + game_id = "{unexpanded_path}" + exe = "{unexpanded_exe}" + """ + # Path to TOML in unexpanded form + toml_path = unexpanded_path + "/" + test_toml + result = None + result_set_env = None + + Path(toml_path).expanduser().touch() + + with Path(toml_path).expanduser().open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + result_set_env = gamelauncher.set_env_toml(self.env, result) + self.assertTrue(result_set_env is self.env, "Expected the same reference") + # Check that the paths are still in the unexpanded form + # In main, we only expand them after this function exits to prepare for building the command + self.assertEqual( + self.env["EXE"], unexpanded_exe, "Expected path not to be expanded" + ) + self.assertEqual( + self.env["PROTONPATH"], + unexpanded_path, + "Expected path not to be expanded", + ) + self.assertEqual( + self.env["WINEPREFIX"], + unexpanded_path, + "Expected path not to be expanded", + ) + self.assertEqual( + self.env["GAMEID"], unexpanded_path, "Expectd path not to be expanded" + ) + + def test_set_env_toml(self): + """Test set_env_toml.""" + test_toml = "foo.toml" + toml_str = f""" + [ulwgl] + prefix = "{self.test_file}" + proton = "{self.test_file}" + game_id = "{self.test_file}" + launch_args = ["{self.test_file}", "{self.test_file}"] + exe = "{self.test_exe}" + """ + toml_path = self.test_file + "/" + test_toml + result = None + result_set_env = None + + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + result_set_env = gamelauncher.set_env_toml(self.env, result) + self.assertTrue(result_set_env is self.env, "Expected the same reference") + + def test_set_env_exe_nofile(self): + """Test set_env when setting no options via --options and appending options to --exe. + + gamelauncher.py --exe "foo -bar" + Options can be appended at the end of the exe if wrapping the value in quotes + No error should be raised if the --exe passed by the user doesn't exist + We trust the user that its legit and only validate the EXE in the TOML case + """ + result_args = None + result_check_env = None + result_set_env = None + + # Replicate the usage WINEPREFIX= PROTONPATH= GAMEID= gamelauncher --exe=... + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(exe=self.test_exe + " foo"), + ): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = self.test_file + result_args = gamelauncher.parse_args() + self.assertIsInstance( + result_args, Namespace, "parse_args did not return a Namespace" + ) + result_check_env = gamelauncher.check_env(self.env) + self.assertTrue(result_check_env is self.env, "Expected the same reference") + self.assertEqual( + self.env["WINEPREFIX"], self.test_file, "Expected WINEPREFIX to be set" + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expected GAMEID to be set" + ) + self.assertEqual( + self.env["PROTONPATH"], self.test_file, "Expected PROTONPATH to be set" + ) + result_set_env = gamelauncher.set_env(self.env, result_args) + self.assertTrue(result_set_env is self.env, "Expected the same reference") + self.assertEqual( + self.env["EXE"], + self.test_exe + " foo", + "Expected EXE to be set after passing garbage", + ) + self.assertTrue(Path(self.test_exe).exists(), "Expected the EXE to exist") + self.assertFalse( + Path(self.test_exe + " foo").exists(), + "Expected the concat of EXE and options to not exist", + ) + + def test_set_env_opts_nofile(self): + """Test set_env when an exe's options is a file. + + We allow options that may or may not be legit + No error should be raised in this case and we just check if options are a file + """ + result_args = None + result_check_env = None + result_set_env = None + + # File that will be passed as an option to the exe + test_opts_file = "baz" + Path(test_opts_file).touch() + + # Replicate the usage WINEPREFIX= PROTONPATH= GAMEID= gamelauncher --exe=... + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(exe=self.test_exe, options=test_opts_file), + ): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = self.test_file + result_args = gamelauncher.parse_args() + self.assertIsInstance( + result_args, Namespace, "parse_args did not return a Namespace" + ) + result_check_env = gamelauncher.check_env(self.env) + self.assertTrue(result_check_env is self.env, "Expected the same reference") + self.assertEqual( + self.env["WINEPREFIX"], self.test_file, "Expected WINEPREFIX to be set" + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expected GAMEID to be set" + ) + self.assertEqual( + self.env["PROTONPATH"], self.test_file, "Expected PROTONPATH to be set" + ) + result_set_env = gamelauncher.set_env(self.env, result_args) + self.assertTrue(result_set_env is self.env, "Expected the same reference") + self.assertEqual( + self.env["EXE"], + self.test_exe + " " + test_opts_file, + "Expected EXE to be set after appending a file as an option", + ) + # The concat of exe and options shouldn't be a file + self.assertFalse( + Path(self.env["EXE"]).is_file(), + "Expected EXE to not be a file when passing options", + ) + # However each part is a file + self.assertTrue( + Path(test_opts_file).is_file(), + "Expected a file for this test to be used as an option", + ) + self.assertTrue( + Path(self.test_exe).is_file(), + "Expected a file for this test to be used as an option", + ) + Path(test_opts_file).unlink() + + def test_set_env_opts(self): + """Test set_env. + + Ensure no failures and verify that $EXE is set with options passed + """ + result_args = None + result_check_env = None + result = None + # Replicate the usage WINEPREFIX= PROTONPATH= GAMEID= gamelauncher --exe=... + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(exe=self.test_exe, options=self.test_opts), + ): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = self.test_file + result_args = gamelauncher.parse_args() + self.assertIsInstance( + result_args, Namespace, "parse_args did not return a Namespace" + ) + result_check_env = gamelauncher.check_env(self.env) + self.assertTrue(result_check_env is self.env, "Expected the same reference") + self.assertEqual( + self.env["WINEPREFIX"], self.test_file, "Expected WINEPREFIX to be set" + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expected GAMEID to be set" + ) + self.assertEqual( + self.env["PROTONPATH"], self.test_file, "Expected PROTONPATH to be set" + ) + result = gamelauncher.set_env(self.env, result_args) + self.assertIsInstance(result, dict, "Expected a Dictionary from set_env") + self.assertTrue(self.env.get("EXE"), "Expected EXE to not be empty") + self.assertEqual( + self.env.get("EXE"), + self.test_exe + " " + self.test_opts, + "Expected EXE to not have trailing spaces", + ) + + def test_set_env_exe(self): + """Test set_env. + + Ensure no failures and verify that $EXE + """ + result_args = None + result_check_env = None + result = None + # Replicate the usage WINEPREFIX= PROTONPATH= GAMEID= gamelauncher --exe=... + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(exe=self.test_exe), + ): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = self.test_file + result_args = gamelauncher.parse_args() + self.assertIsInstance( + result_args, Namespace, "parse_args did not return a Namespace" + ) + result_check_env = gamelauncher.check_env(self.env) + self.assertTrue(result_check_env is self.env, "Expected the same reference") + self.assertEqual( + self.env["WINEPREFIX"], self.test_file, "Expected WINEPREFIX to be set" + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expected GAMEID to be set" + ) + self.assertEqual( + self.env["PROTONPATH"], self.test_file, "Expected PROTONPATH to be set" + ) + result = gamelauncher.set_env(self.env, result_args) + self.assertTrue(result is self.env, "Expected the same reference") + self.assertTrue(self.env.get("EXE"), "Expected EXE to not be empty") + + def test_set_env(self): + """Test set_env. + + Ensure no failures when passing --exe and setting $WINEPREFIX and $PROTONPATH + """ + result_args = None + result_check_env = None + result = None + # Replicate the usage WINEPREFIX= PROTONPATH= GAMEID= gamelauncher --exe=... + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(game=self.test_file), + ): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = self.test_file + result_args = gamelauncher.parse_args() + self.assertIsInstance(result_args, Namespace) + result_check_env = gamelauncher.check_env(self.env) + self.assertTrue(result_check_env is self.env, "Expected the same reference") + self.assertEqual( + self.env["WINEPREFIX"], self.test_file, "Expected WINEPREFIX to be set" + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expected GAMEID to be set" + ) + self.assertEqual( + self.env["PROTONPATH"], self.test_file, "Expected PROTONPATH to be set" + ) + result = gamelauncher.set_env(self.env, result_args) + self.assertTrue(result is self.env, "Expected the same reference") + + def test_setup_pfx_symlinks(self): + """Test _setup_pfx for valid symlinks. + + Ensure that symbolic links to the WINE prefix (pfx) are always in expanded form when passed an unexpanded path. + For example: + if WINEPREFIX is /home/foo/.wine + pfx -> /home/foo/.wine + + We do not want the symbolic link such as: + pfx -> ~/.wine + """ + result = None + pattern = r"^/home/[a-zA-Z]+" + unexpanded_path = re.sub( + pattern, + "~", + Path( + Path(self.test_file).cwd().as_posix() + "/" + self.test_file + ).as_posix(), + ) + result = gamelauncher._setup_pfx(unexpanded_path) + # Replaces the expanded path to unexpanded + # Example: ~/some/path/to/this/file + self.assertIsNone( + result, + "Expected None when creating symbolic link to WINE prefix and tracked_files file", + ) + self.assertTrue( + Path(self.test_file + "/pfx").is_symlink(), "Expected pfx to be a symlink" + ) + self.assertTrue( + Path(self.test_file + "/tracked_files").is_file(), + "Expected tracked_files to be a file", + ) + self.assertTrue( + Path(self.test_file + "/pfx").is_symlink(), "Expected pfx to be a symlink" + ) + # Check if the symlink is in its unexpanded form + self.assertEqual( + Path(self.test_file + "/pfx").readlink().as_posix(), + Path(unexpanded_path).expanduser().as_posix(), + ) + + def test_setup_pfx_paths(self): + """Test _setup_pfx on unexpanded paths. + + An error should not be raised when passing paths such as ~/path/to/prefix. + """ + result = None + pattern = r"^/home/[a-zA-Z]+" + unexpanded_path = re.sub( + pattern, + "~", + Path(Path(self.test_file).as_posix()).as_posix(), + ) + result = gamelauncher._setup_pfx(unexpanded_path) + # Replaces the expanded path to unexpanded + # Example: ~/some/path/to/this/file + self.assertIsNone( + result, + "Expected None when creating symbolic link to WINE prefix and tracked_files file", + ) + self.assertTrue( + Path(self.test_file + "/pfx").is_symlink(), "Expected pfx to be a symlink" + ) + self.assertTrue( + Path(self.test_file + "/tracked_files").is_file(), + "Expected tracked_files to be a file", + ) + + def test_setup_pfx(self): + """Test _setup_pfx.""" + result = None + result = gamelauncher._setup_pfx(self.test_file) + self.assertIsNone( + result, + "Expected None when creating symbolic link to WINE prefix and tracked_files file", + ) + self.assertTrue( + Path(self.test_file + "/pfx").is_symlink(), "Expected pfx to be a symlink" + ) + self.assertTrue( + Path(self.test_file + "/tracked_files").is_file(), + "Expected tracked_files to be a file", + ) + + def test_parse_args_verb(self): + """Test parse_args --verb.""" + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(exe=self.test_exe, verb=self.test_verb), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertEqual( + result.verb, + self.test_verb, + "Expected the same value when setting --verb", + ) + + def test_parse_args_options(self): + """Test parse_args --options.""" + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(exe=self.test_exe, options=self.test_opts), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertEqual( + result.options, + self.test_opts, + "Expected the same value when setting --options", + ) + + def test_parse_args(self): + """Test parse_args with no options. + + There's a requirement to create an empty prefix + A SystemExit should be raised in this case: + ./gamelauncher.py + """ + with self.assertRaises(SystemExit): + gamelauncher.parse_args() + + def test_parse_args_config(self): + """Test parse_args --config.""" + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(config=self.test_file), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + + def test_parse_args_game(self): + """Test parse_args --exe.""" + with patch.object( + gamelauncher, + "parse_args", + return_value=argparse.Namespace(exe=self.test_file), + ): + result = gamelauncher.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + + def test_env_proton_dir(self): + """Test check_env when $PROTONPATH is not a directory. + + An ValueError should occur if the value is not a directory + """ + with self.assertRaisesRegex(ValueError, "PROTONPATH"): + os.environ["WINEPREFIX"] = self.test_file + os.environ["GAMEID"] = self.test_file + os.environ["PROTONPATH"] = "./foo" + gamelauncher.check_env(self.env) + self.assertFalse( + Path(os.environ["PROTONPATH"]).is_dir(), + "Expected PROTONPATH to not be a directory", + ) + + def test_env_wine_dir(self): + """Test check_env when $WINEPREFIX is not a directory. + + An error should not be raised if a WINEPREFIX is set but the path has not been created. + """ + os.environ["WINEPREFIX"] = "./foo" + os.environ["GAMEID"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + gamelauncher.check_env(self.env) + self.assertEqual( + Path(os.environ["WINEPREFIX"]).is_dir(), + True, + "Expected WINEPREFIX to be created if not already exist", + ) + if Path(os.environ["WINEPREFIX"]).is_dir(): + rmtree(os.environ["WINEPREFIX"]) + + def test_env_vars_paths(self): + """Test check_env when setting unexpanded paths for $WINEPREFIX and $PROTONPATH.""" + # Replaces the expanded path to unexpanded + # Example: ~/some/path/to/this/file + pattern = r"^/home/[a-zA-Z]+" + path_to_tmp = Path( + Path(__file__).cwd().as_posix() + "/" + self.test_file + ).as_posix() + + # Replace /home/[a-zA-Z]+ substring in path with tilda + unexpanded_path = re.sub( + pattern, + "~", + path_to_tmp, + ) + + result = None + os.environ["WINEPREFIX"] = unexpanded_path + os.environ["GAMEID"] = self.test_file + os.environ["PROTONPATH"] = unexpanded_path + result = gamelauncher.check_env(self.env) + self.assertTrue(result is self.env, "Expected the same reference") + self.assertEqual( + self.env["WINEPREFIX"], unexpanded_path, "Expected WINEPREFIX to be set" + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expected GAMEID to be set" + ) + self.assertEqual( + self.env["PROTONPATH"], unexpanded_path, "Expected PROTONPATH to be set" + ) + + def test_env_vars(self): + """Test check_env when setting $WINEPREFIX, $GAMEID and $PROTONPATH.""" + result = None + os.environ["WINEPREFIX"] = self.test_file + os.environ["GAMEID"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + result = gamelauncher.check_env(self.env) + self.assertTrue(result is self.env, "Expected the same reference") + self.assertEqual( + self.env["WINEPREFIX"], self.test_file, "Expected WINEPREFIX to be set" + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expected GAMEID to be set" + ) + self.assertEqual( + self.env["PROTONPATH"], self.test_file, "Expected PROTONPATH to be set" + ) + + def test_env_vars_proton(self): + """Test check_env when setting only $WINEPREFIX and $GAMEID.""" + with self.assertRaisesRegex(ValueError, "PROTONPATH"): + os.environ["WINEPREFIX"] = self.test_file + os.environ["GAMEID"] = self.test_file + gamelauncher.check_env(self.env) + self.assertEqual( + self.env["WINEPREFIX"], self.test_file, "Expected WINEPREFIX to be set" + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expected GAMEID to be set" + ) + + def test_env_vars_wine(self): + """Test check_env when setting only $WINEPREFIX.""" + with self.assertRaisesRegex(ValueError, "GAMEID"): + os.environ["WINEPREFIX"] = self.test_file + gamelauncher.check_env(self.env) + self.assertEqual( + self.env["WINEPREFIX"], self.test_file, "Expected WINEPREFIX to be set" + ) + + def test_env_vars_none(self): + """Tests check_env when setting no env vars.""" + with self.assertRaisesRegex(ValueError, "WINEPREFIX"): + gamelauncher.check_env(self.env) + + +if __name__ == "__main__": + unittest.main() diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..4b90c49 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,77 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +# Same as Black. +line-length = 88 +indent-width = 4 + +# Assume Python 3.8 +target-version = "py38" + +[lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or +# McCabe complexity (`C901`) by default. +select = ["E4", "E7", "E9", "F", "RET", "PTH", "D", "W", "BLE001", "EM"] +ignore = ["D100", "D203", "D213"] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = false + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic"