diff --git a/ulwgl_consts.py b/ulwgl_consts.py new file mode 100644 index 00000000..dd1a4c57 --- /dev/null +++ b/ulwgl_consts.py @@ -0,0 +1,26 @@ +from enum import Enum +from logging import INFO, WARNING, DEBUG, ERROR + +SIMPLE_FORMAT = "%(levelname)s: %(message)s" + +DEBUG_FORMAT = "%(levelname)s [%(module)s.%(funcName)s:%(lineno)s]:%(message)s" + + +class Level(Enum): + """Represent the Log level values for the logger module.""" + + INFO = INFO + WARNING = WARNING + DEBUG = DEBUG + ERROR = ERROR + + +class Color(Enum): + """Represent the color to be applied to a string.""" + + RESET = "\u001b[0m" + INFO = "\u001b[34m" + WARNING = "\033[33m" + ERROR = "\033[31m" + BOLD = "\033[1m" + DEBUG = "\u001b[35m" diff --git a/ulwgl_dl_util.py b/ulwgl_dl_util.py new file mode 100644 index 00000000..eed17e24 --- /dev/null +++ b/ulwgl_dl_util.py @@ -0,0 +1,261 @@ +from pathlib import Path +from os import environ +from tarfile import open as tar_open +from typing import Dict, List, Tuple, Any, Union +from hashlib import sha512 +from shutil import rmtree +from http.client import HTTPSConnection, HTTPResponse, HTTPException, HTTPConnection +from ssl import create_default_context +from json import loads as loads_json +from urllib.request import urlretrieve +from sys import stderr + + +def get_ulwgl_proton(env: Dict[str, str]) -> Union[Dict[str, str]]: + """Attempt to find existing Proton from the system or downloads the latest if PROTONPATH is not set. + + Only fetches the latest if not first found in .local/share/Steam/compatibilitytools.d + .cache/ULWGL is referenced for the latest then as fallback + """ + files: List[Tuple[str, str]] = [] + + try: + files = _fetch_releases() + except HTTPException: + print("Offline.\nContinuing ...", file=stderr) + + cache: Path = Path.home().joinpath(".cache/ULWGL") + steam_compat: Path = Path.home().joinpath(".local/share/Steam/compatibilitytools.d") + + cache.mkdir(exist_ok=True, parents=True) + steam_compat.mkdir(exist_ok=True, parents=True) + + # Prioritize the Steam compat + if _get_from_steamcompat(env, steam_compat, cache, files): + return env + + # Use the latest Proton in the cache if it exists + if _get_from_cache(env, steam_compat, cache, files, True): + return env + + # Download the latest if Proton is not in Steam compat + # If the digests mismatched, refer to the cache in the next block + if _get_latest(env, steam_compat, cache, files): + return env + + # Refer to an old version previously downloaded + # Reached on digest mismatch, user interrupt or download failure/no internet + if _get_from_cache(env, steam_compat, cache, files, False): + return env + + # No internet and cache/compat tool is empty, just return and raise an exception from the caller + return env + + +def _fetch_releases() -> List[Tuple[str, str]]: + """Fetch the latest releases from the Github API.""" + files: List[Tuple[str, str]] = [] + resp: HTTPResponse = None + conn: HTTPConnection = HTTPSConnection( + "api.github.com", timeout=30, context=create_default_context() + ) + + conn.request( + "GET", + "/repos/Open-Wine-Components/ULWGL-Proton/releases", + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "", + }, + ) + + resp = conn.getresponse() + + if resp and resp.status != 200: + return files + + # Attempt to acquire the tarball and checksum from the JSON data + releases: List[Dict[str, Any]] = loads_json(resp.read().decode("utf-8")) + for release in releases: + if "assets" in release: + assets: List[Dict[str, Any]] = release["assets"] + + for asset in assets: + if ( + "name" in asset + and ( + asset["name"].endswith("sum") + or ( + asset["name"].endswith("tar.gz") + and asset["name"].startswith("ULWGL-Proton") + ) + ) + and "browser_download_url" in asset + ): + if asset["name"].endswith("sum"): + files.append((asset["name"], asset["browser_download_url"])) + else: + files.append((asset["name"], asset["browser_download_url"])) + + if len(files) == 2: + break + break + conn.close() + + return files + + +def _fetch_proton( + env: Dict[str, str], steam_compat: Path, cache: Path, files: List[Tuple[str, str]] +) -> Dict[str, str]: + """Download the latest ULWGL-Proton and set it as PROTONPATH.""" + hash, hash_url = files[0] + proton, proton_url = files[1] + proton_dir: str = proton[: proton.find(".tar.gz")] # Proton dir + + # TODO: Parallelize this + print(f"Downloading {hash} ...", file=stderr) + urlretrieve(hash_url, cache.joinpath(hash).as_posix()) + print(f"Downloading {proton} ...", file=stderr) + urlretrieve(proton_url, cache.joinpath(proton).as_posix()) + + print("Completed.", file=stderr) + + with cache.joinpath(proton).open(mode="rb") as file: + if ( + sha512(file.read()).hexdigest() + != cache.joinpath(hash).read_text().split(" ")[0] + ): + err: str = "Digests mismatched.\nFalling back to cache ..." + raise ValueError(err) + print(f"{proton}: SHA512 is OK", file=stderr) + + _extract_dir(cache.joinpath(proton), steam_compat) + environ["PROTONPATH"] = steam_compat.joinpath(proton_dir).as_posix() + env["PROTONPATH"] = environ["PROTONPATH"] + + return env + + +def _extract_dir(proton: Path, steam_compat: Path) -> None: + """Extract from the cache to another location.""" + with tar_open(proton.as_posix(), "r:gz") as tar: + print(f"Extracting {proton} -> {steam_compat.as_posix()} ...", file=stderr) + tar.extractall(path=steam_compat.as_posix()) + print("Completed.", file=stderr) + + +def _cleanup(tarball: str, proton: str, cache: Path, steam_compat: Path) -> None: + """Remove files that may have been left in an incomplete state to avoid corruption. + + We want to do this when a download for a new release is interrupted + """ + print("Keyboard Interrupt.\nCleaning ...", file=stderr) + + if cache.joinpath(tarball).is_file(): + print(f"Purging {tarball} in {cache} ...", file=stderr) + cache.joinpath(tarball).unlink() + if steam_compat.joinpath(proton).is_dir(): + print(f"Purging {proton} in {steam_compat} ...", file=stderr) + rmtree(steam_compat.joinpath(proton).as_posix()) + + +def _get_from_steamcompat( + env: Dict[str, str], steam_compat: Path, cache: Path, files: List[Tuple[str, str]] +) -> Union[Dict[str, str], None]: + """Refer to Steam compat folder for any existing Proton directories.""" + proton_dir: str = "" # Latest Proton + + if len(files) == 2: + proton_dir: str = files[1][0][: files[1][0].find(".tar.gz")] + + for proton in steam_compat.glob("ULWGL-Proton*"): + print(f"{proton.name} found in: {steam_compat.as_posix()}", file=stderr) + environ["PROTONPATH"] = proton.as_posix() + env["PROTONPATH"] = environ["PROTONPATH"] + + # Notify the user that they're not using the latest + if proton_dir and proton.name != proton_dir: + print( + "ULWGL-Proton is outdated.\nFor latest release, please download " + + files[1][1], + file=stderr, + ) + + return env + + return None + + +def _get_from_cache( + env: Dict[str, str], + steam_compat: Path, + cache: Path, + files: List[Tuple[str, str]], + use_latest=True, +) -> Union[Dict[str, str], None]: + """Refer to ULWGL cache directory. + + Use the latest in the cache when present. When download fails, use an old version + Older Proton versions are only referred to when: digests mismatch, user interrupt, or download failure/no internet + """ + path: Path = None + name: str = "" + + for tarball in cache.glob("ULWGL-Proton*.tar.gz"): + if files and tarball == cache.joinpath(files[1][0]) and use_latest: + path = tarball + name = tarball.name + break + if tarball != cache.joinpath(files[1][0]) and not use_latest: + path = tarball + name = tarball.name + break + + if path: + proton_dir: str = name[: name.find(".tar.gz")] # Proton dir + + print(f"{name} found in: {path}", file=stderr) + try: + _extract_dir(path, steam_compat) + environ["PROTONPATH"] = steam_compat.joinpath(proton_dir).as_posix() + env["PROTONPATH"] = environ["PROTONPATH"] + + return env + except KeyboardInterrupt: + if steam_compat.joinpath(proton_dir).is_dir(): + print(f"Purging {proton_dir} in {steam_compat} ...", file=stderr) + rmtree(steam_compat.joinpath(proton_dir).as_posix()) + raise + + return None + + +def _get_latest( + env: Dict[str, str], steam_compat: Path, cache: Path, files: List[Tuple[str, str]] +) -> Union[Dict[str, str], None]: + """Download the latest Proton for new installs -- empty cache and Steam compat. + + When the digests mismatched or when interrupted, refer to cache for an old version + """ + if files: + print("Fetching latest release ...", file=stderr) + try: + _fetch_proton(env, steam_compat, cache, files) + env["PROTONPATH"] = environ["PROTONPATH"] + except ValueError: + # Digest mismatched or download failed + # Refer to the cache for old version next + return None + except KeyboardInterrupt: + tarball: str = files[1][0] + proton_dir: str = tarball[: tarball.find(".tar.gz")] # Proton dir + + # Exit cleanly + # Clean up extracted data and cache to prevent corruption/errors + # Refer to the cache for old version next + _cleanup(tarball, proton_dir, cache, steam_compat) + return None + + return env diff --git a/ulwgl_log.py b/ulwgl_log.py new file mode 100644 index 00000000..8f2fa760 --- /dev/null +++ b/ulwgl_log.py @@ -0,0 +1,13 @@ +import logging +from sys import stderr +from ulwgl_consts import SIMPLE_FORMAT, DEBUG_FORMAT + +simple_formatter = logging.Formatter(SIMPLE_FORMAT) +debug_formatter = logging.Formatter(DEBUG_FORMAT) + +log = logging.getLogger(__name__) + +console_handler = logging.StreamHandler(stream=stderr) +console_handler.setFormatter(simple_formatter) +log.addHandler(console_handler) +log.setLevel(logging.CRITICAL + 1) diff --git a/ulwgl_plugins.py b/ulwgl_plugins.py new file mode 100644 index 00000000..a682c2b2 --- /dev/null +++ b/ulwgl_plugins.py @@ -0,0 +1,129 @@ +import os +from pathlib import Path +from typing import Dict, Set, Any, List +from argparse import Namespace + + +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' + """ + try: + import tomllib + except ModuleNotFoundError: + msg: str = "tomllib requires Python 3.11" + raise ModuleNotFoundError(msg) + + 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) + + _check_env_toml(env, toml) + + for key, val in toml["ulwgl"].items(): + if key == "prefix": + env["WINEPREFIX"] = val + elif key == "game_id": + env["GAMEID"] = val + elif key == "proton": + env["PROTONPATH"] = val + elif key == "store": + env["STORE"] = val + elif key == "exe": + if toml.get("ulwgl").get("launch_args"): + env["EXE"] = val + " " + " ".join(toml.get("ulwgl").get("launch_args")) + else: + env["EXE"] = val + return env + + +def _check_env_toml(env: Dict[str, str], toml: Dict[str, Any]): + """Check for required or empty key/value pairs when reading a TOML config. + + NOTE: Casing matters in the config and we do not check if the game id is set + """ + table: str = "ulwgl" + required_keys: List[str] = ["proton", "prefix", "exe"] + + if table not in toml: + err: str = f"Table '{table}' in TOML is not defined." + raise ValueError(err) + + for key in required_keys: + if key not in toml[table]: + err: str = f"The following key in table '{table}' is required: {key}" + raise ValueError(err) + + # 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 + # Users should use launch_args for that + if key == "exe" and not Path(toml[table][key]).expanduser().is_file(): + val: str = toml[table][key] + err: str = f"Value for key '{key}' in TOML is not a file: {val}" + raise FileNotFoundError(err) + + # The proton and wine prefix need to be folders + if (key == "proton" and not Path(toml[table][key]).expanduser().is_dir()) or ( + key == "prefix" and not Path(toml[table][key]).expanduser().is_dir() + ): + dir: str = Path(toml[table][key]).expanduser().as_posix() + err: str = f"Value for key '{key}' in TOML is not a directory: {dir}" + raise NotADirectoryError(err) + + # Check for empty keys + for key, val in toml[table].items(): + if not val and isinstance(val, str): + err: str = f"Value is empty for '{key}' in TOML.\nPlease specify a value or remove the following entry:\n{key} = {val}" + raise ValueError(err) + + return toml + + +def enable_steam_game_drive(env: Dict[str, str]) -> 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/ulwgl_run.py b/ulwgl_run.py new file mode 100755 index 00000000..91c17fec --- /dev/null +++ b/ulwgl_run.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 + +import os +import sys +from traceback import print_exception +from argparse import ArgumentParser, Namespace, RawTextHelpFormatter +from pathlib import Path +from typing import Dict, Any, List, Set, Union, Tuple +from ulwgl_plugins import enable_steam_game_drive, set_env_toml +from re import match +from subprocess import run +from ulwgl_dl_util import get_ulwgl_proton +from ulwgl_consts import Level +from ulwgl_util import msg +from ulwgl_log import log, console_handler, debug_formatter +from ulwgl_util import UnixUser + +verbs: Set[str] = { + "waitforexitandrun", + "run", + "runinprefix", + "destroyprefix", + "getcompatpath", + "getnativepath", +} + + +def parse_args() -> Union[Namespace, Tuple[str, List[str]]]: # noqa: D103 + opt_args: Set[str] = {"--help", "-h", "--config"} + exe: str = Path(__file__).name + usage: str = f""" +example usage: + GAMEID= {exe} /home/foo/example.exe + WINEPREFIX= GAMEID= {exe} /home/foo/example.exe + WINEPREFIX= GAMEID= PROTONPATH= {exe} /home/foo/example.exe + WINEPREFIX= GAMEID= PROTONPATH= {exe} /home/foo/example.exe -opengl + WINEPREFIX= GAMEID= PROTONPATH= {exe} "" + WINEPREFIX= GAMEID= PROTONPATH= PROTON_VERB= {exe} /home/foo/example.exe + WINEPREFIX= GAMEID= PROTONPATH= STORE= {exe} /home/foo/example.exe + ULWGL_LOG= GAMEID= {exe} /home/foo/example.exe + {exe} --config /home/foo/example.toml + """ + parser: ArgumentParser = ArgumentParser( + description="Unified Linux Wine Game Launcher", + epilog=usage, + formatter_class=RawTextHelpFormatter, + ) + parser.add_argument("--config", help="path to TOML file (requires Python 3.11)") + + if not sys.argv[1:]: + err: str = "Please see project README.md for more info and examples.\nhttps://github.com/Open-Wine-Components/ULWGL-launcher" + parser.print_help(sys.stderr) + raise SystemExit(err) + + if sys.argv[1:][0] in opt_args: + return parser.parse_args(sys.argv[1:]) + + if sys.argv[1] in verbs: + if "PROTON_VERB" not in os.environ: + os.environ["PROTON_VERB"] = sys.argv[1] + sys.argv.pop(1) + + return sys.argv[1], sys.argv[2:] + + +def set_log() -> None: + """Adjust the log level for the logger.""" + levels: Set[str] = {"1", "warn", "debug"} + + if os.environ["ULWGL_LOG"] not in levels: + return + + if os.environ["ULWGL_LOG"] == "1": + # Show the envvars and command at this level + log.setLevel(level=Level.INFO.value) + elif os.environ["ULWGL_LOG"] == "warn": + log.setLevel(level=Level.WARNING.value) + elif os.environ["ULWGL_LOG"] == "debug": + # Show all logs + console_handler.setFormatter(debug_formatter) + log.addHandler(console_handler) + log.setLevel(level=Level.DEBUG.value) + + os.environ.pop("ULWGL_LOG") + + +def setup_pfx(path: str) -> None: + """Create a symlink to the WINE prefix and tracked_files file.""" + pfx: Path = Path(path).joinpath("pfx").expanduser() + steam: Path = Path(path).expanduser().joinpath("drive_c/users/steamuser") + user: UnixUser = UnixUser() + wineuser: Path = ( + Path(path).expanduser().joinpath(f"drive_c/users/{user.get_user()}") + ) + + if pfx.is_symlink(): + pfx.unlink() + + if not pfx.is_dir(): + pfx.symlink_to(Path(path).expanduser()) + + Path(path).joinpath("tracked_files").expanduser().touch() + + # Create a symlink of the current user to the steamuser dir or vice versa + # Default for a new prefix is: unixuser -> steamuser + if ( + not wineuser.is_dir() + and not steam.is_dir() + and not (wineuser.is_symlink() or steam.is_symlink()) + ): + # For new prefixes with our Proton: user -> steamuser + steam.mkdir(parents=True) + wineuser.unlink(missing_ok=True) + wineuser.symlink_to("steamuser") + elif wineuser.is_dir() and not steam.is_dir() and not steam.is_symlink(): + # When there's a user dir: steamuser -> user + # Be sure it's relative + steam.unlink(missing_ok=True) + steam.symlink_to(user.get_user()) + elif not wineuser.exists() and not wineuser.is_symlink() and steam.is_dir(): + wineuser.unlink(missing_ok=True) + wineuser.symlink_to("steamuser") + else: + paths: List[str] = [steam.as_posix(), wineuser.as_posix()] + log.warning( + msg( + f"Skipping link creation for prefix: {pfx}", + Level.WARNING, + ) + ) + log.warning( + msg( + f"Following paths already exist: {paths}", + Level.WARNING, + ) + ) + + +def check_env( + env: Dict[str, str], toml: Dict[str, Any] = None +) -> Union[Dict[str, str], Dict[str, Any]]: + """Before executing a game, check for environment variables and set them. + + WINEPREFIX, GAMEID and PROTONPATH are strictly required. + """ + if "GAMEID" not in os.environ: + err: str = "Environment variable not set: GAMEID" + raise ValueError(err) + env["GAMEID"] = os.environ["GAMEID"] + + if "WINEPREFIX" not in os.environ: + pfx: Path = Path.home().joinpath("Games/ULWGL/" + env["GAMEID"]) + pfx.mkdir(parents=True, exist_ok=True) + os.environ["WINEPREFIX"] = pfx.as_posix() + if not Path(os.environ["WINEPREFIX"]).expanduser().is_dir(): + pfx: Path = Path(os.environ["WINEPREFIX"]) + pfx.mkdir(parents=True, exist_ok=True) + os.environ["WINEPREFIX"] = pfx.as_posix() + + env["WINEPREFIX"] = os.environ["WINEPREFIX"] + + # Proton Version + if ( + "PROTONPATH" in os.environ + and os.environ["PROTONPATH"] + and Path( + "~/.local/share/Steam/compatibilitytools.d/" + os.environ["PROTONPATH"] + ) + .expanduser() + .is_dir() + ): + log.debug(msg("Proton version selected", Level.DEBUG)) + os.environ["PROTONPATH"] = ( + Path("~/.local/share/Steam/compatibilitytools.d") + .joinpath(os.environ["PROTONPATH"]) + .expanduser() + .as_posix() + ) + + if "PROTONPATH" not in os.environ: + os.environ["PROTONPATH"] = "" + get_ulwgl_proton(env) + + env["PROTONPATH"] = os.environ["PROTONPATH"] + + # If download fails/doesn't exist in the system, raise an error + if not os.environ["PROTONPATH"]: + err: str = "Download failed.\nProton could not be found in cache or compatibilitytools.d\nPlease set $PROTONPATH or visit https://github.com/Open-Wine-Components/ULWGL-Proton/releases" + raise FileNotFoundError(err) + + return env + + +def set_env( + env: Dict[str, str], args: Union[Namespace, Tuple[str, List[str]]] +) -> Dict[str, str]: + """Set various environment variables for the Steam RT. + + Filesystem paths will be formatted and expanded as POSIX + """ + # PROTON_VERB + # For invalid Proton verbs, just assign the waitforexitandrun + if "PROTON_VERB" in os.environ and os.environ["PROTON_VERB"] in verbs: + env["PROTON_VERB"] = os.environ["PROTON_VERB"] + else: + env["PROTON_VERB"] = "waitforexitandrun" + + # EXE + # Empty string for EXE will be used to create a prefix + if isinstance(args, tuple) and isinstance(args[0], str) and not args[0]: + env["EXE"] = "" + env["STEAM_COMPAT_INSTALL_PATH"] = "" + env["PROTON_VERB"] = "waitforexitandrun" + elif isinstance(args, tuple): + env["EXE"] = Path(args[0]).expanduser().as_posix() + env["STEAM_COMPAT_INSTALL_PATH"] = Path(env["EXE"]).parent.as_posix() + else: + # Config branch + env["EXE"] = Path(env["EXE"]).expanduser().as_posix() + env["STEAM_COMPAT_INSTALL_PATH"] = Path(env["EXE"]).parent.as_posix() + + if "STORE" in os.environ: + env["STORE"] = os.environ["STORE"] + + # ULWGL_ID + env["ULWGL_ID"] = env["GAMEID"] + env["STEAM_COMPAT_APP_ID"] = "0" + + if match(r"^ulwgl-[\d\w]+$", env["ULWGL_ID"]): + env["STEAM_COMPAT_APP_ID"] = env["ULWGL_ID"][env["ULWGL_ID"].find("-") + 1 :] + env["SteamAppId"] = env["STEAM_COMPAT_APP_ID"] + env["SteamGameId"] = env["SteamAppId"] + + # PATHS + 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_TOOL_PATHS"] = ( + env["PROTONPATH"] + ":" + Path(__file__).parent.as_posix() + ) + env["STEAM_COMPAT_MOUNTS"] = env["STEAM_COMPAT_TOOL_PATHS"] + + return env + + +def build_command( + env: Dict[str, str], command: List[str], opts: List[str] = None +) -> List[str]: + """Build the command to be executed.""" + paths: List[Path] = [ + Path.home().joinpath(".local/share/ULWGL/ULWGL"), + Path(__file__).parent.joinpath("ULWGL"), + ] + entry_point: str = "" + verb: str = env["PROTON_VERB"] + + # Find the ULWGL script in $HOME/.local/share then cwd + for path in paths: + if path.is_file(): + entry_point = path.as_posix() + break + + # Raise an error if the _v2-entry-point cannot be found + if not entry_point: + home: str = Path.home().as_posix() + dir: str = Path(__file__).parent.as_posix() + msg: str = ( + f"Path to _v2-entry-point cannot be found in: {home}/.local/share or {dir}" + ) + raise FileNotFoundError(msg) + + if not Path(env.get("PROTONPATH")).joinpath("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")).joinpath("proton").as_posix(), + verb, + env.get("EXE"), + ] + ) + + if opts: + command.extend([*opts]) + + return command + + +def main() -> int: # 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": "", + "STORE": "", + "PROTON_VERB": "", + "ULWGL_ID": "", + } + command: List[str] = [] + args: Union[Namespace, Tuple[str, List[str]]] = parse_args() + opts: List[str] = None + + if "ULWGL_LOG" in os.environ: + set_log() + + if isinstance(args, Namespace) and getattr(args, "config", None): + set_env_toml(env, args) + else: + # Reference the game options + opts = args[1] + check_env(env) + + setup_pfx(env["WINEPREFIX"]) + set_env(env, args) + + # Game Drive + enable_steam_game_drive(env) + + # Set all environment variables + # NOTE: `env` after this block should be read only + for key, val in env.items(): + log.info(msg(f"{key}={val}", Level.INFO)) + os.environ[key] = val + + build_command(env, command, opts) + log.debug(msg(command, Level.DEBUG)) + return run(command).returncode + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + # Until Reaper is part of the command sequence, spawned process may still be alive afterwards + log.warning(msg("Keyboard Interrupt", Level.WARNING)) + sys.exit(1) + except Exception as e: # noqa: BLE001 + print_exception(e) + sys.exit(1) diff --git a/ulwgl_test.py b/ulwgl_test.py new file mode 100644 index 00000000..1d016692 --- /dev/null +++ b/ulwgl_test.py @@ -0,0 +1,1221 @@ +import unittest +import ulwgl_run +import os +import argparse +from argparse import Namespace +from unittest.mock import patch +from pathlib import Path +from shutil import rmtree +import re +import ulwgl_plugins +import ulwgl_dl_util +import tarfile +import ulwgl_util + + +class TestGameLauncher(unittest.TestCase): + """Test suite for ulwgl_run.py.""" + + 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": "", + "ULWGL_ID": "", + "STORE": "", + "PROTON_VERB": "", + } + 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" + # Cache + self.test_cache = Path("./tmp.5HYdpddgvs") + # Steam compat dir + self.test_compat = Path("./tmp.ZssGZoiNod") + # ULWGL-Proton dir + self.test_proton_dir = Path("ULWGL-Proton-5HYdpddgvs") + # ULWGL-Proton release + self.test_archive = Path(self.test_cache).joinpath( + f"{self.test_proton_dir}.tar.gz" + ) + + self.test_cache.mkdir(exist_ok=True) + self.test_compat.mkdir(exist_ok=True) + self.test_proton_dir.mkdir(exist_ok=True) + + # Mock the proton file in the dir + self.test_proton_dir.joinpath("proton").touch(exist_ok=True) + + # Mock the release downloaded in the cache: tmp.5HYdpddgvs/ULWGL-Proton-5HYdpddgvs.tar.gz + # Expected directory structure within the archive: + # + # +-- ULWGL-Proton-5HYdpddgvs (root directory) + # | +-- proton (normal file) + with tarfile.open(self.test_archive.as_posix(), "w:gz") as tar: + tar.add( + self.test_proton_dir.as_posix(), arcname=self.test_proton_dir.as_posix() + ) + + 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) + + if self.test_cache.exists(): + rmtree(self.test_cache.as_posix()) + + if self.test_compat.exists(): + rmtree(self.test_compat.as_posix()) + + if self.test_proton_dir.exists(): + rmtree(self.test_proton_dir.as_posix()) + + def test_latest_interrupt(self): + """Test _get_latest in the event the user interrupts the download/extraction process. + + Assumes a file is being downloaded or extracted in this case. + A KeyboardInterrupt should be raised, and the cache/compat dir should be cleaned afterwards. + """ + result = None + # In the real usage, should be populated after successful callout for latest Proton releases + # In this case, assume the test variable will be downloaded + files = [("", ""), (self.test_archive.name, "")] + + # In the event of an interrupt, both the cache/compat dir will be checked for the latest release for removal + # We do this since the extraction process can be interrupted as well + ulwgl_dl_util._extract_dir(self.test_archive, self.test_compat) + + with patch("ulwgl_dl_util._fetch_proton") as mock_function: + # Mock the interrupt + # We want the dir we tried to extract to be cleaned + mock_function.side_effect = KeyboardInterrupt + result = ulwgl_dl_util._get_latest( + self.env, self.test_compat, self.test_cache, files + ) + self.assertFalse(self.env["PROTONPATH"], "Expected PROTONPATH to be empty") + self.assertFalse(result, "Expected None when a ValueError occurs") + + # Verify the state of the compat dir/cache + self.assertFalse( + self.test_compat.joinpath( + self.test_archive.name[: self.test_archive.name.find(".tar.gz")] + ).exists(), + "Expected Proton dir in compat to be cleaned", + ) + self.assertFalse( + self.test_cache.joinpath(self.test_archive.name).exists(), + "Expected Proton dir in compat to be cleaned", + ) + + def test_latest_val_err(self): + """Test _get_latest in the event something goes wrong in the download process for the latest Proton. + + Assumes a file is being downloaded in this case. + A ValueError should be raised, and one case it can happen is if the digests mismatched for some reason + """ + result = None + # In the real usage, should be populated after successful callout for latest Proton releases + # When empty, it means the callout failed for some reason (e.g. no internet) + files = [("", ""), (self.test_archive.name, "")] + + with patch("ulwgl_dl_util._fetch_proton") as mock_function: + # Mock the interrupt + mock_function.side_effect = ValueError + result = ulwgl_dl_util._get_latest( + self.env, self.test_compat, self.test_cache, files + ) + self.assertFalse(self.env["PROTONPATH"], "Expected PROTONPATH to be empty") + self.assertFalse(result, "Expected None when a ValueError occurs") + + def test_latest_offline(self): + """Test _get_latest when the user doesn't have internet.""" + result = None + # In the real usage, should be populated after successful callout for latest Proton releases + # When empty, it means the callout failed for some reason (e.g. no internet) + files = [] + + os.environ["PROTONPATH"] = "" + + with patch("ulwgl_dl_util._fetch_proton"): + result = ulwgl_dl_util._get_latest( + self.env, self.test_compat, self.test_cache, files + ) + self.assertFalse(self.env["PROTONPATH"], "Expected PROTONPATH to be empty") + self.assertTrue(result is self.env, "Expected the same reference") + + def test_cache_interrupt(self): + """Test _get_from_cache on keyboard interrupt on extraction from the cache to the compat dir.""" + # In the real usage, should be populated after successful callout for latest Proton releases + # Just mock it and assumes its the latest + files = [("", ""), (self.test_archive.name, "")] + + ulwgl_dl_util._extract_dir(self.test_archive, self.test_compat) + + self.assertTrue( + self.test_compat.joinpath( + self.test_archive.name[: self.test_archive.name.find(".tar.gz")] + ).exists(), + "Expected Proton dir to exist in compat", + ) + + with patch("ulwgl_dl_util._extract_dir") as mock_function: + with self.assertRaisesRegex(KeyboardInterrupt, ""): + # Mock the interrupt + # We want to simulate an interrupt mid-extraction in this case + # We want the dir we tried to extract to be cleaned + mock_function.side_effect = KeyboardInterrupt + ulwgl_dl_util._get_from_cache( + self.env, self.test_compat, self.test_cache, files, True + ) + + # After interrupt, we attempt to clean the compat dir for the file we tried to extract because it could be in an incomplete state + # Verify that the dir we tried to extract from cache is removed to avoid corruption on next launch + self.assertFalse( + self.test_compat.joinpath( + self.test_archive.name[: self.test_archive.name.find(".tar.gz")] + ).exists(), + "Expected Proton dir in compat to be cleaned", + ) + + def test_cache_old(self): + """Test _get_from_cache when the cache is empty. + + In real usage, this only happens as a last resort when: download fails, digests mismatched, etc. + """ + result = None + # In the real usage, should be populated after successful callout for latest Proton releases + # Just mock it and assumes its the latest + files = [("", ""), (self.test_archive.name, "")] + + # Mock old Proton versions in the cache + test_proton_dir = Path("ULWGL-Proton-foo") + test_proton_dir.mkdir(exist_ok=True) + test_archive = Path(self.test_cache).joinpath( + f"{test_proton_dir.as_posix()}.tar.gz" + ) + + with tarfile.open(test_archive.as_posix(), "w:gz") as tar: + tar.add(test_proton_dir.as_posix(), arcname=test_proton_dir.as_posix()) + + result = ulwgl_dl_util._get_from_cache( + self.env, self.test_compat, self.test_cache, files, False + ) + + # Verify that the old Proton was assigned + self.assertTrue(result is self.env, "Expected the same reference") + self.assertEqual( + self.env["PROTONPATH"], + self.test_compat.joinpath( + test_archive.name[: test_archive.name.find(".tar.gz")] + ).as_posix(), + "Expected PROTONPATH to be proton dir in compat", + ) + + test_archive.unlink() + test_proton_dir.rmdir() + + def test_cache_empty(self): + """Test _get_from_cache when the cache is empty.""" + result = None + # In the real usage, should be populated after successful callout for latest Proton releases + # Just mock it and assumes its the latest + files = [("", ""), (self.test_archive.name, "")] + + self.test_archive.unlink() + + result = ulwgl_dl_util._get_from_cache( + self.env, self.test_compat, self.test_cache, files, True + ) + self.assertFalse(result, "Expected None when calling _get_from_cache") + self.assertFalse( + self.env["PROTONPATH"], + "Expected PROTONPATH to be empty when the cache is empty", + ) + + def test_cache(self): + """Test _get_from_cache. + + Tests the case when the latest Proton already exists in the cache + """ + result = None + # In the real usage, should be populated after successful callout for latest Proton releases + # Just mock it and assumes its the latest + files = [("", ""), (self.test_archive.name, "")] + + result = ulwgl_dl_util._get_from_cache( + self.env, self.test_compat, self.test_cache, files, True + ) + self.assertTrue(result is self.env, "Expected the same reference") + self.assertEqual( + self.env["PROTONPATH"], + self.test_compat.joinpath( + self.test_archive.name[: self.test_archive.name.find(".tar.gz")] + ).as_posix(), + "Expected PROTONPATH to be proton dir in compat", + ) + + def test_steamcompat_nodir(self): + """Test _get_from_steamcompat when a Proton doesn't exist in the Steam compat dir. + + In this case, the None should be returned to signal that we should continue with downloading the latest Proton + """ + result = None + files = [("", ""), (self.test_archive.name, "")] + + result = ulwgl_dl_util._get_from_steamcompat( + self.env, self.test_compat, self.test_cache, files + ) + + self.assertFalse(result, "Expected None after calling _get_from_steamcompat") + self.assertFalse(self.env["PROTONPATH"], "Expected PROTONPATH to not be set") + + def test_steamcompat(self): + """Test _get_from_steamcompat. + + When a Proton exist in .local/share/Steam/compatibilitytools.d, use it when PROTONPATH is unset + """ + result = None + files = [("", ""), (self.test_archive.name, "")] + + ulwgl_dl_util._extract_dir(self.test_archive, self.test_compat) + + result = ulwgl_dl_util._get_from_steamcompat( + self.env, self.test_compat, self.test_cache, files + ) + + self.assertTrue(result is self.env, "Expected the same reference") + self.assertEqual( + self.env["PROTONPATH"], + self.test_compat.joinpath( + self.test_archive.name[: self.test_archive.name.find(".tar.gz")] + ).as_posix(), + "Expected PROTONPATH to be proton dir in compat", + ) + + def test_cleanup_no_exists(self): + """Test _cleanup when passed files that do not exist. + + In the event of an interrupt during the download/extract process, we only want to clean the files that exist + NOTE: This is **extremely** important, as we do **not** want to delete anything else but the files we downloaded/extracted -- the incomplete tarball/extracted dir + """ + result = None + + ulwgl_dl_util._extract_dir(self.test_archive, self.test_compat) + + # Create a file in the cache and compat + self.test_cache.joinpath("foo").touch() + self.test_compat.joinpath("foo").touch() + + # Before cleaning + # On setUp, an archive is created and a dir should exist in compat after extraction + self.assertTrue( + self.test_compat.joinpath("foo").exists(), + "Expected test file to exist in compat before cleaning", + ) + self.assertTrue( + self.test_cache.joinpath("foo").exists(), + "Expected test file to exist in cache before cleaning", + ) + self.assertTrue( + self.test_archive.exists(), + "Expected archive to exist in cache before cleaning", + ) + self.assertTrue( + self.test_compat.joinpath(self.test_proton_dir).joinpath("proton").exists(), + "Expected 'proton' to exist before cleaning", + ) + + # Pass files that do not exist + result = ulwgl_dl_util._cleanup( + "foo.tar.gz", + "foo", + self.test_cache, + self.test_compat, + ) + + # Verify state of cache and compat after cleaning + self.assertFalse(result, "Expected None after cleaning") + self.assertTrue( + self.test_compat.joinpath("foo").exists(), + "Expected test file to exist in compat after cleaning", + ) + self.assertTrue( + self.test_cache.joinpath("foo").exists(), + "Expected test file to exist in cache after cleaning", + ) + self.assertTrue( + self.test_compat.joinpath(self.test_proton_dir).exists(), + "Expected proton dir to still exist after cleaning", + ) + self.assertTrue( + self.test_archive.exists(), + "Expected archive to still exist after cleaning", + ) + self.assertTrue( + self.test_compat.joinpath(self.test_proton_dir).joinpath("proton").exists(), + "Expected 'proton' to still exist after cleaning", + ) + + def test_cleanup(self): + """Test _cleanup. + + In the event of an interrupt during the download/extract process, we want to clean the cache or the extracted dir in Steam compat to avoid incomplete files + """ + result = None + + ulwgl_dl_util._extract_dir(self.test_archive, self.test_compat) + result = ulwgl_dl_util._cleanup( + self.test_proton_dir.as_posix() + ".tar.gz", + self.test_proton_dir.as_posix(), + self.test_cache, + self.test_compat, + ) + self.assertFalse(result, "Expected None after cleaning") + self.assertFalse( + self.test_compat.joinpath(self.test_proton_dir).exists(), + "Expected proton dir to be cleaned in compat", + ) + self.assertFalse( + self.test_archive.exists(), + "Expected archive to be cleaned in cache", + ) + self.assertFalse( + self.test_compat.joinpath(self.test_proton_dir).joinpath("proton").exists(), + "Expected 'proton' to not exist after cleaned", + ) + + def test_extract_err(self): + """Test _extract_dir when passed a non-gzip compressed archive. + + An error should be raised as we only expect .tar.gz releases + """ + test_archive = self.test_cache.joinpath(f"{self.test_proton_dir}.tar") + # Do not apply compression + with tarfile.open(test_archive.as_posix(), "w") as tar: + tar.add( + self.test_proton_dir.as_posix(), arcname=self.test_proton_dir.as_posix() + ) + + with self.assertRaisesRegex(tarfile.ReadError, "gzip"): + ulwgl_dl_util._extract_dir(test_archive, self.test_compat) + + if test_archive.exists(): + test_archive.unlink() + + def test_extract(self): + """Test _extract_dir. + + An error should not be raised when the Proton release is extracted to the Steam compat dir + """ + result = None + + result = ulwgl_dl_util._extract_dir(self.test_archive, self.test_compat) + self.assertFalse(result, "Expected None after extracting") + self.assertTrue( + self.test_compat.joinpath(self.test_proton_dir).exists(), + "Expected proton dir to exists in compat", + ) + self.assertTrue( + self.test_compat.joinpath(self.test_proton_dir).joinpath("proton").exists(), + "Expected 'proton' file to exists in the proton dir", + ) + + def test_game_drive_empty(self): + """Test enable_steam_game_drive. + + WINE prefixes can be created by passing an empty string + Example: + WINEPREFIX= PROTONPATH= GAMEID= ulwgl-run "" + + 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 + + Ignores LD_LIBRARY_PATH, relevant to Game Drive, which is sourced in Ubuntu and maybe its derivatives + """ + args = None + result_gamedrive = None + Path(self.test_file + "/proton").touch() + + # Replicate main's execution and test up until enable_steam_game_drive + with patch("sys.argv", ["", ""]): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = self.test_file + os.environ["STORE"] = self.test_file + # Args + args = ulwgl_run.parse_args() + # Config + ulwgl_run.check_env(self.env) + # Prefix + ulwgl_run.setup_pfx(self.env["WINEPREFIX"]) + # Env + ulwgl_run.set_env(self.env, args) + + # Some distributions source this variable (e.g. Ubuntu) and will be added to the result of STEAM_RUNTIME_LIBRARY_PATH + # Only test the case without it set + if "LD_LIBRARY_PATH" in os.environ: + os.environ.pop("LD_LIBRARY_PATH") + + # Game drive + result_gamedrive = ulwgl_plugins.enable_steam_game_drive(self.env) + + # Ubuntu sources this variable and will be added once Game Drive is enabled + # Just test the case without it + if "LD_LIBRARY_PATH" in os.environ: + os.environ.pop("LD_LIBRARY_PATH") + + for key, val in self.env.items(): + os.environ[key] = val + + # Game drive + 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 since LD_LIBRARY_PATH is unset + 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(self): + """Test build_command. + + After parsing valid environment variables set by the user, be sure we do not raise a FileNotFoundError + NOTE: Also, FileNotFoundError will be raised if the _v2-entry-point (ULWGL) is not in $HOME/.local/share/ULWGL or in cwd + """ + result_args = None + test_command = [] + + # Mock the /proton file + Path(self.test_file + "/proton").touch() + + with patch("sys.argv", ["", self.test_exe]): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = self.test_file + os.environ["STORE"] = self.test_file + # Args + result_args = ulwgl_run.parse_args() + # Config + ulwgl_run.check_env(self.env) + # Prefix + ulwgl_run.setup_pfx(self.env["WINEPREFIX"]) + # Env + ulwgl_run.set_env(self.env, result_args) + # Game drive + ulwgl_plugins.enable_steam_game_drive(self.env) + + for key, val in self.env.items(): + os.environ[key] = val + + # Build + test_command = ulwgl_run.build_command(self.env, test_command) + 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_opts(self): + """Test set_env. + + Ensure no failures and verify that an option is passed to the executable + """ + result = None + test_str = "foo" + + # Replicate the usage WINEPREFIX= PROTONPATH= GAMEID= STORE= PROTON_VERB= ulwgl_run foo.exe -foo + with patch("sys.argv", ["", self.test_exe, test_str]): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = test_str + os.environ["STORE"] = test_str + os.environ["PROTON_VERB"] = self.test_verb + # Args + result = ulwgl_run.parse_args() + self.assertIsInstance(result, tuple, "Expected a tuple") + self.assertIsInstance(result[0], str, "Expected a string") + self.assertIsInstance(result[1], list, "Expected a list as options") + self.assertEqual( + result[0], "./tmp.WMYQiPb9A/foo", "Expected EXE to be unexpanded" + ) + self.assertEqual( + *result[1], + test_str, + "Expected the test string when passed as an option", + ) + # Check + ulwgl_run.check_env(self.env) + # Prefix + ulwgl_run.setup_pfx(self.env["WINEPREFIX"]) + # Env + result = ulwgl_run.set_env(self.env, result[0:]) + self.assertTrue(result is self.env, "Expected the same reference") + + path_exe = Path(self.test_exe).expanduser().as_posix() + path_file = Path(self.test_file).expanduser().as_posix() + + # After calling set_env all paths should be expanded POSIX form + self.assertEqual(self.env["EXE"], path_exe, "Expected EXE to be expanded") + self.assertEqual(self.env["STORE"], test_str, "Expected STORE to be set") + self.assertEqual( + self.env["PROTONPATH"], path_file, "Expected PROTONPATH to be set" + ) + self.assertEqual( + self.env["WINEPREFIX"], path_file, "Expected WINEPREFIX to be set" + ) + 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", + ) + + def test_set_env_id(self): + """Test set_env. + + Verify that environment variables (dictionary) are set after calling set_env when passing a valid ULWGL_ID + When a valid ULWGL_ID is set, the STEAM_COMPAT_APP_ID variables should be the stripped ULWGL_ID + """ + result = None + test_str = "foo" + ulwgl_id = "ulwgl-271590" + + # Replicate the usage WINEPREFIX= PROTONPATH= GAMEID= STORE= PROTON_VERB= ulwgl_run foo.exe + with patch("sys.argv", ["", self.test_exe]): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = ulwgl_id + os.environ["STORE"] = test_str + os.environ["PROTON_VERB"] = self.test_verb + # Args + result = ulwgl_run.parse_args() + self.assertIsInstance(result, tuple, "Expected a tuple") + self.assertIsInstance(result[0], str, "Expected a string") + self.assertIsInstance(result[1], list, "Expected a list as options") + self.assertEqual( + result[0], "./tmp.WMYQiPb9A/foo", "Expected EXE to be unexpanded" + ) + self.assertFalse( + result[1], "Expected an empty list when passing no options" + ) + # Check + ulwgl_run.check_env(self.env) + # Prefix + ulwgl_run.setup_pfx(self.env["WINEPREFIX"]) + # Env + result = ulwgl_run.set_env(self.env, result[0:]) + self.assertTrue(result is self.env, "Expected the same reference") + + path_exe = Path(self.test_exe).expanduser().as_posix() + path_file = Path(self.test_file).expanduser().as_posix() + + # After calling set_env all paths should be expanded POSIX form + self.assertEqual(self.env["EXE"], path_exe, "Expected EXE to be expanded") + self.assertEqual(self.env["STORE"], test_str, "Expected STORE to be set") + self.assertEqual( + self.env["PROTONPATH"], path_file, "Expected PROTONPATH to be set" + ) + self.assertEqual( + self.env["WINEPREFIX"], path_file, "Expected WINEPREFIX to be set" + ) + self.assertEqual(self.env["GAMEID"], ulwgl_id, "Expected GAMEID to be set") + self.assertEqual( + self.env["PROTON_VERB"], + self.test_verb, + "Expected PROTON_VERB to be set", + ) + # ULWGL + self.assertEqual( + self.env["ULWGL_ID"], + self.env["GAMEID"], + "Expected ULWGL_ID to be GAMEID", + ) + self.assertEqual(self.env["ULWGL_ID"], ulwgl_id, "Expected ULWGL_ID") + # Should be stripped -- everything after the hyphen + self.assertEqual( + self.env["STEAM_COMPAT_APP_ID"], + ulwgl_id[ulwgl_id.find("-") + 1 :], + "Expected STEAM_COMPAT_APP_ID to be the stripped ULWGL_ID", + ) + 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(__file__).parent.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", + ) + + def test_set_env(self): + """Test set_env. + + Verify that environment variables (dictionary) are set after calling set_env + """ + result = None + test_str = "foo" + + # Replicate the usage WINEPREFIX= PROTONPATH= GAMEID= STORE= PROTON_VERB= ulwgl_run foo.exe + with patch("sys.argv", ["", self.test_exe]): + os.environ["WINEPREFIX"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + os.environ["GAMEID"] = test_str + os.environ["STORE"] = test_str + os.environ["PROTON_VERB"] = self.test_verb + # Args + result = ulwgl_run.parse_args() + self.assertIsInstance(result, tuple, "Expected a tuple") + self.assertIsInstance(result[0], str, "Expected a string") + self.assertIsInstance(result[1], list, "Expected a list as options") + self.assertEqual( + result[0], "./tmp.WMYQiPb9A/foo", "Expected EXE to be unexpanded" + ) + self.assertFalse( + result[1], "Expected an empty list when passing no options" + ) + # Check + ulwgl_run.check_env(self.env) + # Prefix + ulwgl_run.setup_pfx(self.env["WINEPREFIX"]) + # Env + result = ulwgl_run.set_env(self.env, result[0:]) + self.assertTrue(result is self.env, "Expected the same reference") + + path_exe = Path(self.test_exe).expanduser().as_posix() + path_file = Path(self.test_file).expanduser().as_posix() + + # After calling set_env all paths should be expanded POSIX form + self.assertEqual(self.env["EXE"], path_exe, "Expected EXE to be expanded") + self.assertEqual(self.env["STORE"], test_str, "Expected STORE to be set") + self.assertEqual( + self.env["PROTONPATH"], path_file, "Expected PROTONPATH to be set" + ) + self.assertEqual( + self.env["WINEPREFIX"], path_file, "Expected WINEPREFIX to be set" + ) + 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", + ) + # ULWGL + self.assertEqual( + self.env["ULWGL_ID"], + self.env["GAMEID"], + "Expected ULWGL_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(__file__).parent.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", + ) + + def test_setup_pfx_mv(self): + """Test setup_pfx when moving the WINEPREFIX after creating it. + + After setting up the prefix then moving it to a different path, ensure that the symbolic link points to that new location + """ + result = None + pattern = r"^/home/[\w\d]+" # Expects only unicode decimals and alphanumerics + unexpanded_path = re.sub( + pattern, + "~", + Path(self.test_file).cwd().joinpath(self.test_file).as_posix(), + ) + result = ulwgl_run.setup_pfx(unexpanded_path) + + # Replaces the expanded path to unexpanded + # Example: ~/some/path/to/this/file -> /home/foo/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" + ) + + def test_setup_pfx_symlinks_else(self): + """Test setup_pfx in the case both steamuser and unixuser exist in some form. + + Tests the case when they are symlinks + An error should not be raised and we should just do nothing + """ + result = None + pattern = r"^/home/[\w\d]+" + user = ulwgl_util.UnixUser() + unexpanded_path = re.sub( + pattern, + "~", + Path( + Path(self.test_file).cwd().as_posix() + "/" + self.test_file + ).as_posix(), + ) + + # Create only the dir + Path(unexpanded_path).joinpath("drive_c/users").expanduser().mkdir( + parents=True, exist_ok=True + ) + + # Create the symlink to the test file itself + Path(unexpanded_path).joinpath("drive_c/users").joinpath( + user.get_user() + ).expanduser().symlink_to(Path(self.test_file).absolute()) + Path(unexpanded_path).joinpath("drive_c/users").joinpath( + "steamuser" + ).expanduser().symlink_to(Path(self.test_file).absolute()) + + result = ulwgl_run.setup_pfx(unexpanded_path) + + self.assertIsNone( + result, + "Expected None when calling setup_pfx", + ) + + def test_setup_pfx_symlinks_unixuser(self): + """Test setup_pfx for symbolic link to steamuser. + + Tests the case when the steamuser dir does not exist and user dir exists + In this case, create: steamuser -> user + """ + result = None + pattern = r"^/home/[\w\d]+" + user = ulwgl_util.UnixUser() + unexpanded_path = re.sub( + pattern, + "~", + Path( + Path(self.test_file).cwd().as_posix() + "/" + self.test_file + ).as_posix(), + ) + + # Create only the user dir + Path(unexpanded_path).joinpath("drive_c/users").joinpath( + user.get_user() + ).expanduser().mkdir(parents=True, exist_ok=True) + + result = ulwgl_run.setup_pfx(unexpanded_path) + + self.assertIsNone( + result, + "Expected None when creating symbolic link to WINE prefix and tracked_files file", + ) + + # Verify steamuser -> unix user + self.assertTrue( + Path(self.test_file).joinpath("drive_c/users/steamuser").is_symlink(), + "Expected steamuser to be a symbolic link", + ) + self.assertEqual( + Path(self.test_file).joinpath("drive_c/users/steamuser").readlink(), + Path(user.get_user()), + "Expected steamuser -> user", + ) + + def test_setup_pfx_symlinks_steamuser(self): + """Test setup_pfx for symbolic link to wine. + + Tests the case when only steamuser exist and the user dir does not exist + """ + result = None + user = ulwgl_util.UnixUser() + pattern = r"^/home/[\w\d]+" + unexpanded_path = re.sub( + pattern, + "~", + Path( + Path(self.test_file).cwd().as_posix() + "/" + self.test_file + ).as_posix(), + ) + + # Create the steamuser dir + Path(unexpanded_path + "/drive_c/users/steamuser").expanduser().mkdir( + parents=True, exist_ok=True + ) + + result = ulwgl_run.setup_pfx(unexpanded_path) + + self.assertIsNone( + result, + "Expected None when creating symbolic link to WINE prefix and tracked_files file", + ) + + # Verify unixuser -> steamuser + self.assertTrue( + Path(self.test_file + "/drive_c/users/steamuser").is_dir(), + "Expected steamuser to be created", + ) + self.assertTrue( + Path(unexpanded_path + "/drive_c/users/" + user.get_user()) + .expanduser() + .is_symlink(), + "Expected symbolic link for unixuser", + ) + self.assertEqual( + Path(self.test_file) + .joinpath(f"drive_c/users/{user.get_user()}") + .readlink(), + Path("steamuser"), + "Expected unixuser -> steamuser", + ) + + 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/[\w\d]+" # Expects only unicode decimals and alphanumerics + unexpanded_path = re.sub( + pattern, + "~", + Path(self.test_file).cwd().joinpath(self.test_file).as_posix(), + ) + result = ulwgl_run.setup_pfx(unexpanded_path) + + # Replaces the expanded path to unexpanded + # Example: ~/some/path/to/this/file -> /home/foo/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/[\w\d]+" # Expects only unicode decimals and alphanumerics + unexpanded_path = re.sub( + pattern, + "~", + Path(self.test_file).as_posix(), + ) + result = ulwgl_run.setup_pfx(unexpanded_path) + + # Replaces the expanded path to unexpanded + # Example: ~/some/path/to/this/file -> /home/foo/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 + user = ulwgl_util.UnixUser() + result = ulwgl_run.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", + ) + # For new prefixes, steamuser should exist and a user symlink + self.assertTrue( + Path(self.test_file + "/drive_c/users/steamuser").is_dir(), + "Expected steamuser to be created", + ) + self.assertTrue( + Path(self.test_file + "/drive_c/users/" + user.get_user()) + .expanduser() + .is_symlink(), + "Expected symlink of username -> steamuser", + ) + + 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: + ./ulwgl_run.py + """ + with self.assertRaises(SystemExit): + ulwgl_run.parse_args() + + def test_parse_args_config(self): + """Test parse_args --config.""" + with patch.object( + ulwgl_run, + "parse_args", + return_value=argparse.Namespace(config=self.test_file), + ): + result = ulwgl_run.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + + def test_env_proton_nodir(self): + """Test check_env when $PROTONPATH in the case we failed to set it. + + An FileNotFoundError should be raised when we fail to set PROTONPATH + """ + # Mock getting the Proton + with self.assertRaises(FileNotFoundError): + with patch.object( + ulwgl_run, + "get_ulwgl_proton", + return_value=self.env, + ): + os.environ["WINEPREFIX"] = self.test_file + os.environ["GAMEID"] = self.test_file + ulwgl_run.check_env(self.env) + + def test_env_wine_dir(self): + """Test check_env when $WINEPREFIX is not a directory. + + When the user specifies a WINEPREFIX that doesn't exist, make the dirs on their behalf and set it + An error should not be raised in the process + """ + # Set a path does not exist + os.environ["WINEPREFIX"] = "./foo" + os.environ["GAMEID"] = self.test_file + os.environ["PROTONPATH"] = self.test_file + + self.assertFalse( + Path(os.environ["WINEPREFIX"]).exists(), + "Expected WINEPREFIX to not exist before check_env", + ) + + ulwgl_run.check_env(self.env) + + # After this, the WINEPREFIX and new dirs should be created for the user + self.assertTrue( + Path(self.env["WINEPREFIX"]).exists(), + "Expected WINEPREFIX to exist after check_env", + ) + self.assertEqual( + self.env["WINEPREFIX"], + os.environ["WINEPREFIX"], + "Expected the WINEPREFIX to be set", + ) + + if Path(self.env["WINEPREFIX"]).is_dir(): + Path(self.env["WINEPREFIX"]).rmdir() + + def test_env_vars_paths(self): + """Test check_env when setting unexpanded paths for $WINEPREFIX and $PROTONPATH.""" + pattern = r"^/home/[\w\d]+" # Expects only unicode decimals and alphanumerics + path_to_tmp = Path(__file__).cwd().joinpath(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 = ulwgl_run.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 = ulwgl_run.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(FileNotFoundError, "Proton"): + os.environ["WINEPREFIX"] = self.test_file + os.environ["GAMEID"] = self.test_file + # Mock getting the Proton + with patch.object( + ulwgl_run, + "get_ulwgl_proton", + return_value=self.env, + ): + os.environ["WINEPREFIX"] = self.test_file + os.environ["GAMEID"] = self.test_file + result = ulwgl_run.check_env(self.env) + self.assertTrue(result is self.env, "Expected the same reference") + self.assertFalse(os.environ["PROTONPATH"]) + + def test_env_vars_wine(self): + """Test check_env when setting only $WINEPREFIX.""" + with self.assertRaisesRegex(ValueError, "GAMEID"): + os.environ["WINEPREFIX"] = self.test_file + ulwgl_run.check_env(self.env) + + def test_env_vars_none(self): + """Tests check_env when setting no env vars. + + GAMEID should be the only strictly required env var + """ + with self.assertRaisesRegex(ValueError, "GAMEID"): + ulwgl_run.check_env(self.env) + + +if __name__ == "__main__": + unittest.main() diff --git a/ulwgl_test_plugins.py b/ulwgl_test_plugins.py new file mode 100644 index 00000000..9ad84a8c --- /dev/null +++ b/ulwgl_test_plugins.py @@ -0,0 +1,533 @@ +import unittest +import ulwgl_run +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 ulwgl_plugins +import tarfile + + +class TestGameLauncherPlugins(unittest.TestCase): + """Test suite ulwgl_run.py plugins.""" + + 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": "", + "ULWGL_ID": "", + "STORE": "", + "PROTON_VERB": "", + } + self.test_opts = "-foo -bar" + # Proton verb + # Used when testing build_command + self.test_verb = "waitforexitandrun" + # Test directory + self.test_file = "./tmp.AKN6tnueyO" + # Executable + self.test_exe = self.test_file + "/" + "foo" + # Cache + self.test_cache = Path("./tmp.ND7tcK5m3K") + # Steam compat dir + self.test_compat = Path("./tmp.1A5cflhwQa") + # ULWGL-Proton dir + self.test_proton_dir = Path("ULWGL-Proton-jPTxUsKDdn") + # ULWGL-Proton release + self.test_archive = Path(self.test_cache).joinpath( + f"{self.test_proton_dir}.tar.gz" + ) + + self.test_cache.mkdir(exist_ok=True) + self.test_compat.mkdir(exist_ok=True) + self.test_proton_dir.mkdir(exist_ok=True) + + # Mock the proton file in the dir + self.test_proton_dir.joinpath("proton").touch(exist_ok=True) + + # Mock the release downloaded in the cache: tmp.5HYdpddgvs/ULWGL-Proton-jPTxUsKDdn.tar.gz + # Expected directory structure within the archive: + # + # +-- ULWGL-Proton-5HYdpddgvs (root directory) + # | +-- proton (normal file) + with tarfile.open(self.test_archive.as_posix(), "w:gz") as tar: + tar.add( + self.test_proton_dir.as_posix(), arcname=self.test_proton_dir.as_posix() + ) + + 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) + + if self.test_cache.exists(): + rmtree(self.test_cache.as_posix()) + + if self.test_compat.exists(): + rmtree(self.test_compat.as_posix()) + + if self.test_proton_dir.exists(): + rmtree(self.test_proton_dir.as_posix()) + + def test_build_command_nofile(self): + """Test build_command. + + A FileNotFoundError should be raised if $PROTONPATH/proton does not exist + NOTE: Also, FileNotFoundError will be raised if the _v2-entry-point (ULWGL) is not in $HOME/.local/share/ULWGL or in cwd + """ + 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}"] + exe = "{self.test_exe}" + """ + toml_path = self.test_file + "/" + test_toml + result = None + test_command = [] + Path(toml_path).touch() + + with Path(toml_path).open(mode="w") as file: + file.write(toml_str) + + with patch.object( + ulwgl_run, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + # Args + result = ulwgl_run.parse_args() + # Config + ulwgl_plugins.set_env_toml(self.env, result) + # Prefix + ulwgl_run.setup_pfx(self.env["WINEPREFIX"]) + # Env + ulwgl_run.set_env(self.env, result) + # Game drive + ulwgl_plugins.enable_steam_game_drive(self.env) + + for key, val in self.env.items(): + os.environ[key] = val + + # Build + with self.assertRaisesRegex(FileNotFoundError, "proton"): + ulwgl_run.build_command(self.env, test_command) + + 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 + test_command = [] + test_command_result = None + + 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( + ulwgl_run, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + # Args + result = ulwgl_run.parse_args() + # Config + ulwgl_plugins.set_env_toml(self.env, result) + # Prefix + ulwgl_run.setup_pfx(self.env["WINEPREFIX"]) + # Env + ulwgl_run.set_env(self.env, result) + # Game drive + ulwgl_plugins.enable_steam_game_drive(self.env) + + for key, val in self.env.items(): + os.environ[key] = val + + # Build + test_command_result = ulwgl_run.build_command(self.env, test_command) + self.assertTrue( + test_command_result is test_command, "Expected the same reference" + ) + + # Verify contents of the command + 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_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( + ulwgl_run, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + # Args + result = ulwgl_run.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + # Env + ulwgl_plugins.set_env_toml(self.env, result) + + # Check if its 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( + ulwgl_run, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + # Args + result = ulwgl_run.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + # Env + with self.assertRaisesRegex(FileNotFoundError, "exe"): + ulwgl_plugins.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( + ulwgl_run, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + # Args + result = ulwgl_run.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + # Env + with self.assertRaisesRegex(TOMLDecodeError, "Invalid"): + ulwgl_plugins.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 the following keys are not dir: proton, prefix + """ + 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( + ulwgl_run, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + # Args + result = ulwgl_run.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + # Env + with self.assertRaisesRegex(NotADirectoryError, "proton"): + ulwgl_plugins.set_env_toml(self.env, result) + + def test_set_env_toml_tables(self): + """Test set_env_toml for expected tables. + + A ValueError should be raised if the following tables are absent: ulwgl + """ + 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( + ulwgl_run, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + # Args + result = ulwgl_run.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + # Env + with self.assertRaisesRegex(ValueError, "ulwgl"): + ulwgl_plugins.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/[\w\d]+" # Expects only unicode decimals and alphanumerics + + # Replaces the expanded path to unexpanded + # Example: ~/some/path/to/this/file -> /home/foo/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( + ulwgl_run, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + # Args + result = ulwgl_run.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + # Env + result_set_env = ulwgl_plugins.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 after setting the env + # 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( + ulwgl_run, + "parse_args", + return_value=argparse.Namespace(config=toml_path), + ): + # Args + result = ulwgl_run.parse_args() + self.assertIsInstance( + result, Namespace, "Expected a Namespace from parse_arg" + ) + self.assertTrue(vars(result).get("config"), "Expected a value for --config") + # Env + result_set_env = ulwgl_plugins.set_env_toml(self.env, result) + self.assertTrue(result_set_env is self.env, "Expected the same reference") + self.assertTrue(self.env["EXE"], "Expected EXE to be set") + self.assertEqual( + self.env["EXE"], + self.test_exe + " " + " ".join([self.test_file, self.test_file]), + "Expectd GAMEID to be set", + ) + self.assertEqual( + self.env["PROTONPATH"], + self.test_file, + "Expected PROTONPATH to be set", + ) + self.assertEqual( + self.env["WINEPREFIX"], + self.test_file, + "Expected WINEPREFIX to be set", + ) + self.assertEqual( + self.env["GAMEID"], self.test_file, "Expectd GAMEID to be set" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ulwgl_util.py b/ulwgl_util.py new file mode 100644 index 00000000..54c28255 --- /dev/null +++ b/ulwgl_util.py @@ -0,0 +1,53 @@ +from ulwgl_consts import Color, Level +from typing import Any +from os import getuid +from pathlib import Path +from pwd import struct_passwd, getpwuid + + +def msg(msg: Any, level: Level): + """Return a log message depending on the log level. + + The message will bolden the typeface and apply a color. + Expects the first parameter to be a string or implement __str__ + """ + log: str = "" + + if level == Level.INFO: + log = f"{Color.BOLD.value}{Color.INFO.value}{msg}{Color.RESET.value}" + elif level == Level.WARNING: + log = f"{Color.BOLD.value}{Color.WARNING.value}{msg}{Color.RESET.value}" + elif level == Level.DEBUG: + log = f"{Color.BOLD.value}{Color.DEBUG.value}{msg}{Color.RESET.value}" + + return log + + +class UnixUser: + """Represents the User of the system as determined by the password database rather than environment variables or file system paths.""" + + def __init__(self): + """Immutable properties of the user determined by the password database that's derived from the real user id.""" + uid: int = getuid() + entry: struct_passwd = getpwuid(uid) + # Immutable properties, hence no setters + self.name: str = entry.pw_name + self.puid: str = entry.pw_uid # Should be equivalent to the value from getuid + self.dir: str = entry.pw_dir + self.is_user: bool = self.puid == uid + + def get_home_dir(self) -> Path: + """User home directory as determined by the password database that's derived from the current process's real user id.""" + return Path(self.dir).as_posix() + + def get_user(self) -> str: + """User (login name) as determined by the password database that's derived from the current process's real user id.""" + return self.name + + def get_puid(self) -> int: + """Numerical user ID as determined by the password database that's derived from the current process's real user id.""" + return self.puid + + def is_user(self, uid: int) -> bool: + """Compare the UID passed in to this instance.""" + return uid == self.puid