From f4fb1585c6e4627f577ec80f8228f8c72bf83001 Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 6 Jun 2024 18:16:11 -0700 Subject: [PATCH] Add functionality to use winetricks (#109) * umu_proton: add proton class * umu_util: add winetricks functionality * umu_run: add checks when using winetricks * umu_run: set environment variables for winetricks * umu_run: config winetricks to run unattended * umu_run: change directory when using winetricks * umu_run: fix exception when passing no winetricks verbs * umu_run: set WINEDLLPATH * umu_run: fix exit 3 status code for winetricks * umu_run: delete winetricks and protonfix paths * umu_run: update comments * umu_util: add missing type * umu_run: fix not setting EXE and STEAM_COMPAT_INSTALL_PATH - Without these variables set, winetricks will be missing and will result in a 'ShellExecuteEx failed: File not found' error. * umu_util: allow processing winetricks verbs in bulk - Allows passing verbs separated by spaces to apply verbs in bulk to the prefix * umu_run: update logic when swapping option and verb * Ruff lint * umu_run: respect order of winetricks verbs * umu_run: add winetricks as positional argument * umu_run: prefer surrounding verb with quotes * umu_run: update comment * umu_run: prefer assigning a new list than list.insert * umu_util: fix bug when parsing verbs * umu_util: update logic * umu_util: rename function * umu_util: add checks for is_installed_verb * umu_test: add test for is_installed_verb * umu_test: add test for is_winetricks_verb * umu_test: add tests when passing winetricks as positional argument * docs: add example of installing winetricks verbs * umu_util: fix logic when checking winetricks verbs - It doesn't make sense to check if the value in the log file is a verb and not the input. * umu_util: refactor is_winetricks_verb to log messages * umu_run: remove error messages for winetricks * umu_util: always process in bulk * umu_util: always pass verbs as a list * umu_test: update tests * Ruff lint * umu_util: fix AttributeError when checking winetricks verbs * umu_test: update tests * docs: update example --- docs/umu.1.scd | 6 ++ umu/umu_proton.py | 18 ++++ umu/umu_run.py | 80 ++++++++++++++- umu/umu_test.py | 251 +++++++++++++++++++++++++++++++++++++++++++++- umu/umu_util.py | 59 +++++++++++ 5 files changed, 408 insertions(+), 6 deletions(-) diff --git a/docs/umu.1.scd b/docs/umu.1.scd index 810ede945..9327e7d76 100644 --- a/docs/umu.1.scd +++ b/docs/umu.1.scd @@ -118,6 +118,12 @@ $ WINEPREFIX=~/.wine GAMEID=0 PROTONPATH=GE-Proton9-1 umu-run ~/foo.exe $ WINEPREFIX=~/.wine GAMEID=0 PROTONPATH=GE-Proton umu-run ~/foo.exe ``` +*Example 11. Run winetricks verbs* + +``` +$ GAMEID=0 PROTONPATH=GE-Proton umu-run winetricks quartz wmp11 qasf +``` + # SEE ALSO _umu_(5) diff --git a/umu/umu_proton.py b/umu/umu_proton.py index 57a4a15e4..17199765d 100644 --- a/umu/umu_proton.py +++ b/umu/umu_proton.py @@ -25,6 +25,24 @@ tar_filter: Callable[[str, str], TarInfo] = None +class Proton: + """Model paths to relevant files and directories for Proton.""" + + def __init__(self, base_dir: str) -> None: # noqa: D107 + self.base_dir = base_dir + "/" + self.dist_dir = self.path("files/") + self.bin_dir = self.path("files/bin/") + self.lib_dir = self.path("files/lib/") + self.lib64_dir = self.path("files/lib64/") + self.version_file = self.path("version") + self.wine_bin = self.bin_dir + "wine" + self.wine64_bin = self.bin_dir + "wine64" + self.wineserver_bin = self.bin_dir + "wineserver" + + def path(self, dir: str) -> str: # noqa: D102 + return self.base_dir + dir + + def get_umu_proton( env: dict[str, str], thread_pool: ThreadPoolExecutor ) -> dict[str, str]: diff --git a/umu/umu_run.py b/umu/umu_run.py index ecb4806d0..41d27ae5a 100755 --- a/umu/umu_run.py +++ b/umu/umu_run.py @@ -26,9 +26,9 @@ ) from umu_log import CustomFormatter, console_handler, log from umu_plugins import set_env_toml -from umu_proton import get_umu_proton +from umu_proton import Proton, get_umu_proton from umu_runtime import setup_umu -from umu_util import get_libc +from umu_util import get_libc, is_installed_verb, is_winetricks_verb THREAD_POOL: ThreadPoolExecutor = ThreadPoolExecutor() @@ -46,11 +46,30 @@ def parse_args() -> Namespace | tuple[str, list[str]]: # noqa: D103 parser.add_argument( "--config", help=("path to TOML file (requires Python 3.11+)") ) + parser.add_argument( + "winetricks", + help=("run winetricks (requires UMU-Proton or GE-Proton)"), + nargs="?", + default=None, + ) if not sys.argv[1:]: parser.print_help(sys.stderr) sys.exit(1) + # Winetricks + # Exit if no winetricks verbs were passed + if sys.argv[1].endswith("winetricks") and not sys.argv[2:]: + err: str = "No winetricks verb specified" + log.error(err) + sys.exit(1) + + # Exit if argument is not a verb + if sys.argv[1].endswith("winetricks") and not is_winetricks_verb( + sys.argv[2:] + ): + sys.exit(1) + if sys.argv[1:][0] in opt_args: return parser.parse_args(sys.argv[1:]) @@ -80,8 +99,6 @@ def set_log() -> None: log.addHandler(console_handler) log.setLevel(level=DEBUG) - os.environ.pop("UMU_LOG") - def setup_pfx(path: str) -> None: """Create a symlink to the WINE prefix and tracked_files file.""" @@ -203,6 +220,21 @@ def set_env( env["EXE"] = "" env["STEAM_COMPAT_INSTALL_PATH"] = "" env["PROTON_VERB"] = "waitforexitandrun" + elif isinstance(args, tuple) and args[0] == "winetricks": + # Make an absolute path to winetricks that is within GE-Proton or + # UMU-Proton, which includes the dependencies bundled within the + # protonfixes directory. Fixes exit 3 status codes after applying + # winetricks verbs + bin: str = ( + Path(env["PROTONPATH"], "protonfixes", "winetricks") + .expanduser() + .resolve(strict=True) + .as_posix() + ) + log.debug("EXE: %s -> %s", args[0], bin) + args: tuple[str, list[str]] = (bin, args[1]) + env["EXE"] = bin + env["STEAM_COMPAT_INSTALL_PATH"] = Path(env["EXE"]).parent.as_posix() elif isinstance(args, tuple): try: env["EXE"] = ( @@ -257,6 +289,24 @@ def set_env( # Game drive enable_steam_game_drive(env) + # Winetricks + if env.get("EXE").endswith("winetricks"): + proton: Proton = Proton(os.environ["PROTONPATH"]) + env["WINE"] = proton.wine_bin + env["WINELOADER"] = proton.wine_bin + env["WINESERVER"] = proton.wineserver_bin + env["WINETRICKS_LATEST_VERSION_CHECK"] = "disabled" + env["LD_PRELOAD"] = "" + env["WINEDLLPATH"] = ":".join( + [ + Path(proton.lib_dir, "wine").as_posix(), + Path(proton.lib64_dir, "wine").as_posix(), + ] + ) + env["WINETRICKS_SUPER_QUIET"] = ( + "" if os.environ.get("UMU_LOG") == "debug" else "1" + ) + return env @@ -338,6 +388,12 @@ def build_command( err: str = "The following file was not found in PROTONPATH: proton" raise FileNotFoundError(err) + # Configure winetricks to not be prompted for any windows + if env.get("EXE").endswith("winetricks") and opts: + # The position of arguments matter for winetricks + # Usage: ./winetricks [options] [command|verb|path-to-verb] ... + opts = ["-q", *opts] + if opts: command.extend( [ @@ -378,6 +434,7 @@ def run_command(command: list[str]) -> int: proc: Popen = None ret: int = 0 libc: str = get_libc() + cwd: str = "" if not command: err: str = f"Command list is empty or None: {command}" @@ -386,6 +443,12 @@ def run_command(command: list[str]) -> int: if not libc: log.warning("Will not set subprocess as subreaper") + # For winetricks, change directory to $PROTONPATH/protonfixes + if os.environ.get("EXE").endswith("winetricks"): + cwd = Path(os.environ.get("PROTONPATH"), "protonfixes").as_posix() + else: + cwd = Path.cwd().as_posix() + # 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: @@ -408,6 +471,7 @@ def run_command(command: list[str]) -> int: command, start_new_session=True, preexec_fn=lambda: prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0, 0), + cwd=cwd, ) ret = proc.wait() log.debug("Child %s exited with wait status: %s", proc.pid, ret) @@ -441,7 +505,7 @@ def main() -> int: # noqa: D103 "UMU_ZENITY": "", } command: list[str] = [] - opts: list[str] = None + opts: list[str] = [] root: Path = Path(__file__).resolve(strict=True).parent future: Future = None args: Namespace | tuple[str, list[str]] = parse_args() @@ -519,6 +583,12 @@ def main() -> int: # noqa: D103 future.result() THREAD_POOL.shutdown() + # Exit if the winetricks verb is already installed to avoid reapplying it + if env.get("EXE").endswith("winetricks") and is_installed_verb( + opts, Path(env.get("WINEPREFIX")) + ): + sys.exit(1) + # Run build_command(env, UMU_LOCAL, command, opts) log.debug("%s", command) diff --git a/umu/umu_test.py b/umu/umu_test.py index 4aebfe230..337e4b48a 100644 --- a/umu/umu_test.py +++ b/umu/umu_test.py @@ -15,6 +15,7 @@ import umu_proton import umu_run import umu_runtime +import umu_util class TestGameLauncher(unittest.TestCase): @@ -43,6 +44,13 @@ def setUp(self): "UMU_ID": "", "STORE": "", "PROTON_VERB": "", + "WINE": "", + "WINELOADER": "", + "WINESERVER": "", + "WINETRICKS_LATEST_VERSION_CHECK": "", + "LD_PRELOAD": "", + "WINEDLLPATH": "", + "WINETRICKS_SUPER_QUIET": "", } self.user = getpwuid(os.getuid()).pw_name self.test_opts = "-foo -bar" @@ -67,6 +75,8 @@ def setUp(self): self.test_user_share = Path("./tmp.BXk2NnvW2m") # ~/.local/share/Steam/compatibilitytools.d self.test_local_share = Path("./tmp.aDl73CbQCP") + # Wine prefix + self.test_winepfx = Path("./tmp.AlfLPDhDvA") # Dictionary that represents the umu_versionS.json self.root_config = { @@ -81,6 +91,7 @@ def setUp(self): # umu_version.json self.test_config = json.dumps(self.root_config, indent=4) + self.test_winepfx.mkdir(exist_ok=True) self.test_user_share.mkdir(exist_ok=True) self.test_local_share.mkdir(exist_ok=True) self.test_cache.mkdir(exist_ok=True) @@ -143,7 +154,7 @@ def setUp(self): def tearDown(self): """Unset environment variables and delete test files after tests.""" - for key, val in self.env.items(): + for key in self.env: if key in os.environ: os.environ.pop(key) @@ -165,6 +176,80 @@ def tearDown(self): if self.test_local_share.exists(): rmtree(self.test_local_share.as_posix()) + if self.test_winepfx.exists(): + rmtree(self.test_winepfx.as_posix()) + + def test_is_installed_verb_noverb(self): + """Test is_installed_verb when passed an empty verb.""" + verb = [] + + with self.assertRaises(ValueError): + umu_util.is_installed_verb(verb, self.test_winepfx) + + def test_ist_installed_verb_nopfx(self): + """Test is_installed_verb when passed a non-existent pfx.""" + verb = ["foo"] + result = True + + # Handle the None type + # In the real usage, this should not happen + with self.assertRaises(FileNotFoundError): + umu_util.is_installed_verb(verb, None) + + # An exception should not be raised for a non-existent directory. When + # the prefix does not exist, umu will create the default prefix as + # ~/Games/umu/$GAMEID and will be created by Proton. + result = umu_util.is_installed_verb(verb, Path("./foo")) + self.assertFalse(result, "wine prefix exists") + + def test_is_installed_verb_nofile(self): + """Test is_installed_verb when the log file is absent.""" + verb = ["foo"] + result = True + + result = umu_util.is_installed_verb(verb, self.test_winepfx) + self.assertFalse(result, "winetricks.log file was found") + + def test_is_installed_verb(self): + """Test is_installed_verb. + + Reads the winetricks.log file within the wine prefix to find the verb + that was passed from the command line. + """ + verbs = ["foo", "bar"] + wt_log = self.test_winepfx.joinpath("winetricks.log") + result = False + + wt_log.write_text("\n".join(verbs)) + result = umu_util.is_installed_verb(verbs, self.test_winepfx) + self.assertTrue(result, "winetricks verb was not installed") + + def test_is_not_winetricks_verb(self): + """Test is_winetricks_verb when not passed a valid verb.""" + verbs = ["--help", ";bash", "list-all"] + result = False + + result = umu_util.is_winetricks_verb(verbs) + self.assertFalse(result, f"{verbs} contains a winetricks verb") + + # Handle None and empty cases + result = umu_util.is_winetricks_verb(None) + self.assertFalse(result, f"{verbs} contains a winetricks verb") + + result = umu_util.is_winetricks_verb([]) + self.assertFalse(result, f"{verbs} contains a winetricks verb") + + def test_is_winetricks_verb(self): + """Test is_winetricks_verb when passed valid verbs. + + Expects winetricks verbs to follow ^[a-zA-Z_0-9]+(=[a-zA-Z0-9]+)?$. + """ + verbs = ["foo", "foo=bar", "baz=qux"] + result = True + + result = umu_util.is_winetricks_verb(verbs) + self.assertTrue(result, f"'{verbs}' is not a winetricks verb") + def test_check_runtime(self): """Test check_runtime when pv-verify does not exist. @@ -1671,6 +1756,152 @@ def test_set_env(self): "Expected STEAM_COMPAT_MOUNTS to be set", ) + def test_set_env_winetricks(self): + """Test set_env when using winetricks.""" + result = None + test_str = "foo" + verb = "foo" + test_exe = "winetricks" + + # Mock a Proton directory that contains winetricks + test_dir = Path("./tmp.aCAs3Q7rvz") + test_dir.joinpath("protonfixes").mkdir(parents=True) + test_dir.joinpath("protonfixes", "winetricks").touch() + + # Replicate the usage: + # GAMEID= umu_run winetricks ... + with patch("sys.argv", ["", "winetricks", verb]): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = test_dir.as_posix() + os.environ["GAMEID"] = test_str + os.environ["STORE"] = test_str + os.environ["PROTON_VERB"] = self.test_verb + # Args + result = umu_run.parse_args() + # Check + umu_run.check_env(self.env) + # Prefix + umu_run.setup_pfx(self.env["WINEPREFIX"]) + # Env + self.assertNotEqual( + Path(test_exe), + Path(test_exe).resolve(), + "Expected path to exe to be non-normalized", + ) + self.assertNotEqual( + Path(os.environ["WINEPREFIX"]), + Path(os.environ["WINEPREFIX"]).resolve(), + "Expected path to exe to be non-normalized", + ) + self.assertNotEqual( + Path(os.environ["PROTONPATH"]), + Path(os.environ["PROTONPATH"]).resolve(), + "Expected path to exe to be non-normalized", + ) + result = umu_run.set_env(self.env, result[0:]) + self.assertTrue(result is self.env, "Expected the same reference") + + path_exe = ( + test_dir.joinpath("protonfixes", "winetricks") + .expanduser() + .resolve() + .as_posix() + ) + path_file = Path(self.test_file).expanduser().resolve().as_posix() + + # After calling set_env all paths should be expanded POSIX form + self.assertEqual( + self.env["EXE"], + path_exe, + "Expected EXE to be normalized and expanded", + ) + self.assertEqual( + self.env["STEAM_COMPAT_INSTALL_PATH"], + Path(path_exe).parent.as_posix(), + "Expected STEAM_COMPAT_INSTALL_PATH to be set", + ) + self.assertEqual( + self.env["STORE"], test_str, "Expected STORE to be set" + ) + self.assertEqual( + self.env["PROTONPATH"], + Path(path_exe).parent.parent.as_posix(), + "Expected PROTONPATH to be normalized and expanded", + ) + self.assertEqual( + self.env["WINEPREFIX"], + path_file, + "Expected WINEPREFIX to be normalized and expanded", + ) + self.assertEqual( + self.env["GAMEID"], test_str, "Expected GAMEID to be set" + ) + self.assertEqual( + self.env["PROTON_VERB"], + self.test_verb, + "Expected PROTON_VERB to be set", + ) + # umu + self.assertEqual( + self.env["UMU_ID"], + self.env["GAMEID"], + "Expected UMU_ID to be GAMEID", + ) + self.assertEqual( + self.env["STEAM_COMPAT_APP_ID"], + "0", + "Expected STEAM_COMPAT_APP_ID to be 0", + ) + self.assertEqual( + self.env["SteamAppId"], + self.env["STEAM_COMPAT_APP_ID"], + "Expected SteamAppId to be STEAM_COMPAT_APP_ID", + ) + self.assertEqual( + self.env["SteamGameId"], + self.env["SteamAppId"], + "Expected SteamGameId to be STEAM_COMPAT_APP_ID", + ) + + # PATHS + self.assertEqual( + self.env["STEAM_COMPAT_SHADER_PATH"], + self.env["STEAM_COMPAT_DATA_PATH"] + "/shadercache", + "Expected STEAM_COMPAT_SHADER_PATH to be set", + ) + self.assertEqual( + self.env["STEAM_COMPAT_TOOL_PATHS"], + self.env["PROTONPATH"] + + ":" + + Path.home().joinpath(".local", "share", "umu").as_posix(), + "Expected STEAM_COMPAT_TOOL_PATHS to be set", + ) + self.assertEqual( + self.env["STEAM_COMPAT_MOUNTS"], + self.env["STEAM_COMPAT_TOOL_PATHS"], + "Expected STEAM_COMPAT_MOUNTS to be set", + ) + + # Winetricks + self.assertTrue(self.env["WINE"], "WINE is not set") + self.assertTrue(self.env["WINELOADER"], "WINELOADER is not set") + self.assertTrue(self.env["WINESERVER"], "WINESERVER is not set") + self.assertTrue( + self.env["WINETRICKS_LATEST_VERSION_CHECK"], + "WINETRICKS_LATEST_VERSION_CHECK is not set", + ) + self.assertTrue( + self.env["LD_PRELOAD"] == "", "LD_PRELOAD is not set" + ) + self.assertTrue(self.env["WINEDLLPATH"], "WINEDLLPATH is not set") + self.assertTrue( + self.env["WINETRICKS_SUPER_QUIET"], + "WINETRICKS_SUPER_QUIET is not set", + ) + + if test_dir.exists(): + rmtree(test_dir.as_posix()) + def test_setup_pfx_mv(self): """Test setup_pfx when moving the WINEPREFIX after creating it. @@ -1955,6 +2186,24 @@ def test_setup_pfx(self): "Expected symlink of username -> steamuser", ) + def test_parse_args_winetricks(self): + """Test parse_args when winetricks is the argument. + + An SystemExit should be raised when no winetricks verb is passed or if + the value is not a winetricks verb. + """ + with ( + patch("sys.argv", ["", "winetricks"]), + self.assertRaises(SystemExit), + ): + umu_run.parse_args() + + with ( + patch("sys.argv", ["", "winetricks", "--help"]), + self.assertRaises(SystemExit), + ): + umu_run.parse_args() + def test_parse_args(self): """Test parse_args with no options. diff --git a/umu/umu_util.py b/umu/umu_util.py index 77e94ef0a..634f3c917 100644 --- a/umu/umu_util.py +++ b/umu/umu_util.py @@ -1,5 +1,7 @@ from ctypes.util import find_library from functools import lru_cache +from pathlib import Path +from re import Pattern, compile from shutil import which from subprocess import PIPE, STDOUT, Popen, TimeoutExpired @@ -61,3 +63,60 @@ def run_zenity(command: str, opts: list[str], msg: str) -> int: log.warning("zenity exited with the status code: %s", ret) return ret + + +def is_installed_verb(verb: list[str], pfx: Path) -> bool: + """Check if a winetricks verb is installed in the umu prefix. + + Determines the installation of verbs by reading winetricks.log file. + """ + wt_log: Path = None + is_installed: bool = False + verbs: set[str] = {} + + if not pfx: + err: str = f"Value is '{pfx}' for WINE prefix" + raise FileNotFoundError(err) + + if not verb: + err: str = "winetricks was passed an empty verb" + raise ValueError(err) + + wt_log = pfx.joinpath("winetricks.log") + verbs = {_ for _ in verb} + + if not wt_log.is_file(): + return is_installed + + with wt_log.open(mode="r", encoding="utf-8") as file: + for line in file: + _: str = line.strip() + if _ in verbs: + is_installed = True + err: str = ( + f"winetricks verb '{_}' is already installed in '{pfx}'" + ) + log.error(err) + break + + return is_installed + + +def is_winetricks_verb( + verbs: list[str], pattern: str = r"^[a-zA-Z_0-9]+(=[a-zA-Z0-9]+)?$" +) -> bool: + """Check if a string is a winetricks verb.""" + regex: Pattern = None + + if not verbs: + return False + + # When passed a sequence, check each verb and log the non-verbs + regex = compile(pattern) + for verb in verbs: + if not regex.match(verb): + err: str = f"Value is not a winetricks verb: '{verb}'" + log.error(err) + return False + + return True