mirror of
https://github.com/tcsenpai/UWINE.git
synced 2025-06-08 12:35:20 +00:00
RFC: Rewrite gamelauncher in Python (#8)
- In its current form, executing the gamelauncher script can be tedious especially when not using a modern shell that supports auto completions. For instance: $ WINEPREFIX=$HOME/Games/epic-games-store GAMEID=egs PROTONPATH=$HOME/.steam/steam/compatibilitytools.d/GE-Proton8-28 ./gamelauncher.sh $HOME/Games/epic-games-store/drive_c/Program Files (x86)/Epic Games/Launcher/Portal/Binaries/Win32/EpicGamesLauncher.exe -opengl -SkipBuildPatchPrereq - By rewriting the gamelauncher script in Python, we can support reading from a configuration file(s) instead which increases ease of use and provides better organization. This effectively results in this: $ gamelauncher.py --config example.toml - Additionally, due to the rich Python ecosystem, the rewrite leaves room for future features and formal testing if needed.
This commit is contained in:
parent
a702bab465
commit
066e869485
277
gamelauncher.py
Executable file
277
gamelauncher.py
Executable file
@ -0,0 +1,277 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
from argparse import ArgumentParser, _ArgumentGroup, Namespace
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import tomllib
|
||||||
|
from typing import Dict, Any, List, Set
|
||||||
|
import gamelauncher_plugins
|
||||||
|
|
||||||
|
# TODO: Only set the environment variables that are not empty
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> Namespace: # noqa: D103
|
||||||
|
exe: str = Path(__file__).name
|
||||||
|
usage: str = """
|
||||||
|
example usage:
|
||||||
|
{} --config example.toml
|
||||||
|
{} --config /home/foo/example.toml --options '-opengl'
|
||||||
|
WINEPREFIX= GAMEID= PROTONPATH= {} --exe /home/foo/example.exe --options '-opengl'
|
||||||
|
WINEPREFIX= GAMEID= PROTONPATH= {} --exe ""
|
||||||
|
WINEPREFIX= GAMEID= PROTONPATH= {} --exe /home/foo/example.exe --verb waitforexitandrun
|
||||||
|
""".format(exe, exe, exe, exe, exe)
|
||||||
|
|
||||||
|
parser: ArgumentParser = argparse.ArgumentParser(
|
||||||
|
description="Unified Linux Wine Game Launcher",
|
||||||
|
epilog=usage,
|
||||||
|
formatter_class=argparse.RawTextHelpFormatter,
|
||||||
|
)
|
||||||
|
group: _ArgumentGroup = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
group.add_argument("--config", help="path to TOML file")
|
||||||
|
group.add_argument(
|
||||||
|
"--exe",
|
||||||
|
help="path to game executable\npass an empty string to create a prefix",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--verb",
|
||||||
|
help="a verb to pass to Proton (default: waitforexitandrun)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--options",
|
||||||
|
help="launch options for game executable\nNOTE: options must be wrapped in quotes",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser.parse_args(sys.argv[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_pfx(path: str) -> None:
|
||||||
|
"""Create a symlink to the WINE prefix and tracked_files file."""
|
||||||
|
if not (Path(path + "/pfx")).expanduser().is_symlink():
|
||||||
|
# When creating the symlink, we want it to be in expanded form when passed unexpanded paths
|
||||||
|
# Example: pfx -> /home/.wine
|
||||||
|
Path(path + "/pfx").expanduser().symlink_to(Path(path).expanduser())
|
||||||
|
Path(path + "/tracked_files").expanduser().touch()
|
||||||
|
|
||||||
|
|
||||||
|
def check_env(env: Dict[str, str]) -> Dict[str, str]:
|
||||||
|
"""Before executing a game, check for environment variables and set them.
|
||||||
|
|
||||||
|
WINEPREFIX, GAMEID and PROTONPATH are strictly required.
|
||||||
|
"""
|
||||||
|
if "WINEPREFIX" not in os.environ:
|
||||||
|
err: str = "Environment variable not set or not a directory: WINEPREFIX"
|
||||||
|
raise ValueError(err)
|
||||||
|
|
||||||
|
if not Path(os.environ["WINEPREFIX"]).expanduser().is_dir():
|
||||||
|
Path(os.environ["WINEPREFIX"]).mkdir(parents=True)
|
||||||
|
env["WINEPREFIX"] = os.environ["WINEPREFIX"]
|
||||||
|
|
||||||
|
if "GAMEID" not in os.environ:
|
||||||
|
err: str = "Environment variable not set: GAMEID"
|
||||||
|
raise ValueError(err)
|
||||||
|
env["GAMEID"] = os.environ["GAMEID"]
|
||||||
|
|
||||||
|
if (
|
||||||
|
"PROTONPATH" not in os.environ
|
||||||
|
or not Path(os.environ["PROTONPATH"]).expanduser().is_dir()
|
||||||
|
):
|
||||||
|
err: str = "Environment variable not set or not a directory: PROTONPATH"
|
||||||
|
raise ValueError(err)
|
||||||
|
env["PROTONPATH"] = os.environ["PROTONPATH"]
|
||||||
|
env["STEAM_COMPAT_INSTALL_PATH"] = os.environ["PROTONPATH"]
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def set_env(env: Dict[str, str], args: Namespace) -> Dict[str, str]:
|
||||||
|
"""Set various environment variables for the Steam RT.
|
||||||
|
|
||||||
|
Expects to be invoked if not reading a TOML file
|
||||||
|
"""
|
||||||
|
_setup_pfx(env["WINEPREFIX"])
|
||||||
|
is_create_prefix: bool = False
|
||||||
|
|
||||||
|
if not getattr(args, "exe", None):
|
||||||
|
is_create_prefix = True
|
||||||
|
|
||||||
|
# Sets the environment variables: EXE
|
||||||
|
for arg, val in vars(args).items():
|
||||||
|
if arg == "exe" and not is_create_prefix:
|
||||||
|
# NOTE: options can possibly be appended at the end
|
||||||
|
env["EXE"] = val
|
||||||
|
elif arg == "options" and val and not is_create_prefix:
|
||||||
|
# NOTE: assume it's space separated
|
||||||
|
env["EXE"] = env["EXE"] + " " + " ".join(val.split(" "))
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def set_env_toml(env: Dict[str, str], args: Namespace) -> Dict[str, str]:
|
||||||
|
"""Read a TOML file then sets the environment variables for the Steam RT.
|
||||||
|
|
||||||
|
In the TOML file, certain keys map to Steam RT environment variables. For example:
|
||||||
|
proton -> $PROTONPATH
|
||||||
|
prefix -> $WINEPREFIX
|
||||||
|
game_id -> $GAMEID
|
||||||
|
exe -> $EXE
|
||||||
|
At the moment we expect the tables: 'ulwgl'
|
||||||
|
"""
|
||||||
|
toml: Dict[str, Any] = None
|
||||||
|
path_config: str = Path(getattr(args, "config", None)).expanduser().as_posix()
|
||||||
|
|
||||||
|
if not Path(path_config).is_file():
|
||||||
|
msg: str = "Path to configuration is not a file: " + getattr(
|
||||||
|
args, "config", None
|
||||||
|
)
|
||||||
|
raise FileNotFoundError(msg)
|
||||||
|
|
||||||
|
with Path(path_config).open(mode="rb") as file:
|
||||||
|
toml = tomllib.load(file)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
Path(toml["ulwgl"]["prefix"]).expanduser().is_dir()
|
||||||
|
or Path(toml["ulwgl"]["proton"]).expanduser().is_dir()
|
||||||
|
):
|
||||||
|
err: str = "Value for 'prefix' or 'proton' in TOML is not a directory."
|
||||||
|
raise NotADirectoryError(err)
|
||||||
|
|
||||||
|
# Set the values read from TOML to environment variables
|
||||||
|
# If necessary, raise an error on invalid inputs
|
||||||
|
for key, val in toml["ulwgl"].items():
|
||||||
|
# Handle cases for empty values
|
||||||
|
if not val and isinstance(val, str):
|
||||||
|
err: str = "Value is empty for key in TOML: " + key
|
||||||
|
raise ValueError(err)
|
||||||
|
if key == "prefix":
|
||||||
|
env["WINEPREFIX"] = val
|
||||||
|
_setup_pfx(val)
|
||||||
|
elif key == "game_id":
|
||||||
|
env["GAMEID"] = val
|
||||||
|
elif key == "proton":
|
||||||
|
env["PROTONPATH"] = val
|
||||||
|
env["STEAM_COMPAT_INSTALL_PATH"] = val
|
||||||
|
elif key == "exe":
|
||||||
|
# Raise an error for executables that do not exist
|
||||||
|
# One case this can happen is when game options are appended at the end of the exe
|
||||||
|
if not Path(val).expanduser().is_file():
|
||||||
|
err: str = "Value for key 'exe' in TOML is not a file."
|
||||||
|
raise FileNotFoundError(err)
|
||||||
|
|
||||||
|
# It's possible for users to pass values to --options
|
||||||
|
# Add any if they exist
|
||||||
|
if toml.get("ulwgl").get("launch_args"):
|
||||||
|
env["EXE"] = val + " " + " ".join(toml.get("ulwgl").get("launch_args"))
|
||||||
|
else:
|
||||||
|
env["EXE"] = val
|
||||||
|
|
||||||
|
if getattr(args, "options", None):
|
||||||
|
# Assume space separated options and just trust it
|
||||||
|
env["EXE"] = (
|
||||||
|
env["EXE"]
|
||||||
|
+ " "
|
||||||
|
+ " ".join(getattr(args, "options", None).split(" "))
|
||||||
|
)
|
||||||
|
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def build_command(env: Dict[str, str], command: List[str], verb: str) -> List[str]:
|
||||||
|
"""Build the command to be executed."""
|
||||||
|
# NOTE: We must assume _v2-entry-point (ULWGL) is within the same dir as this launcher
|
||||||
|
# Otherwise, an error can be raised
|
||||||
|
entry_point: str = Path(Path(__file__).cwd().as_posix() + "/ULWGL").as_posix()
|
||||||
|
|
||||||
|
if not Path(env.get("PROTONPATH") + "/proton").is_file():
|
||||||
|
err: str = "The following file was not found in PROTONPATH: proton"
|
||||||
|
raise FileNotFoundError(err)
|
||||||
|
|
||||||
|
command.extend([entry_point, "--verb", verb, "--"])
|
||||||
|
command.extend(
|
||||||
|
[Path(env.get("PROTONPATH") + "/proton").as_posix(), verb, env.get("EXE")]
|
||||||
|
)
|
||||||
|
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None: # noqa: D103
|
||||||
|
env: Dict[str, str] = {
|
||||||
|
"WINEPREFIX": "",
|
||||||
|
"GAMEID": "",
|
||||||
|
"PROTON_CRASH_REPORT_DIR": "/tmp/ULWGL_crashreports",
|
||||||
|
"PROTONPATH": "",
|
||||||
|
"STEAM_COMPAT_APP_ID": "",
|
||||||
|
"STEAM_COMPAT_TOOL_PATHS": "",
|
||||||
|
"STEAM_COMPAT_LIBRARY_PATHS": "",
|
||||||
|
"STEAM_COMPAT_MOUNTS": "",
|
||||||
|
"STEAM_COMPAT_INSTALL_PATH": "",
|
||||||
|
"STEAM_COMPAT_CLIENT_INSTALL_PATH": "",
|
||||||
|
"STEAM_COMPAT_DATA_PATH": "",
|
||||||
|
"STEAM_COMPAT_SHADER_PATH": "",
|
||||||
|
"FONTCONFIG_PATH": "",
|
||||||
|
"EXE": "",
|
||||||
|
"SteamAppId": "",
|
||||||
|
"SteamGameId": "",
|
||||||
|
"STEAM_RUNTIME_LIBRARY_PATH": "",
|
||||||
|
}
|
||||||
|
command: List[str] = []
|
||||||
|
verb: str = "waitforexitandrun"
|
||||||
|
# Represents a valid list of current supported Proton verbs
|
||||||
|
verbs: Set[str] = {
|
||||||
|
"waitforexitandrun",
|
||||||
|
"run",
|
||||||
|
"runinprefix",
|
||||||
|
"destroyprefix",
|
||||||
|
"getcompatpath",
|
||||||
|
"getnativepath",
|
||||||
|
}
|
||||||
|
args: Namespace = parse_args()
|
||||||
|
|
||||||
|
if getattr(args, "config", None):
|
||||||
|
set_env_toml(env, args)
|
||||||
|
else:
|
||||||
|
check_env(env)
|
||||||
|
set_env(env, args)
|
||||||
|
|
||||||
|
if getattr(args, "verb", None) and getattr(args, "verb", None) in verbs:
|
||||||
|
verb = getattr(args, "verb", None)
|
||||||
|
|
||||||
|
env["STEAM_COMPAT_APP_ID"] = env["GAMEID"]
|
||||||
|
env["SteamAppId"] = env["STEAM_COMPAT_APP_ID"]
|
||||||
|
env["SteamGameId"] = env["SteamAppId"]
|
||||||
|
env["WINEPREFIX"] = Path(env["WINEPREFIX"]).expanduser().as_posix()
|
||||||
|
env["PROTONPATH"] = Path(env["PROTONPATH"]).expanduser().as_posix()
|
||||||
|
env["STEAM_COMPAT_DATA_PATH"] = env["WINEPREFIX"]
|
||||||
|
env["STEAM_COMPAT_SHADER_PATH"] = env["STEAM_COMPAT_DATA_PATH"] + "/shadercache"
|
||||||
|
env["STEAM_COMPAT_INSTALL_PATH"] = Path(env["EXE"]).parent.expanduser().as_posix()
|
||||||
|
env["EXE"] = Path(env["EXE"]).expanduser().as_posix()
|
||||||
|
env["STEAM_COMPAT_TOOL_PATHS"] = (
|
||||||
|
env["PROTONPATH"] + ":" + Path(__file__).parent.as_posix()
|
||||||
|
)
|
||||||
|
env["STEAM_COMPAT_MOUNTS"] = env["STEAM_COMPAT_TOOL_PATHS"]
|
||||||
|
|
||||||
|
# Create an empty Proton prefix when asked
|
||||||
|
if not getattr(args, "exe", None) and not getattr(args, "config", None):
|
||||||
|
env["EXE"] = ""
|
||||||
|
env["STEAM_COMPAT_INSTALL_PATH"] = ""
|
||||||
|
verb = "waitforexitandrun"
|
||||||
|
|
||||||
|
# Game Drive functionality
|
||||||
|
gamelauncher_plugins.enable_steam_game_drive(env)
|
||||||
|
|
||||||
|
# Set all environment variable
|
||||||
|
# NOTE: `env` after this block should be read only
|
||||||
|
for key, val in env.items():
|
||||||
|
print(f"Setting environment variable: {key}={val}")
|
||||||
|
os.environ[key] = val
|
||||||
|
|
||||||
|
build_command(env, command, verb)
|
||||||
|
print(f"The following command will be executed: {command}")
|
||||||
|
subprocess.run(command, check=True, stdout=subprocess.PIPE, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
39
gamelauncher_plugins.py
Normal file
39
gamelauncher_plugins.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Set
|
||||||
|
|
||||||
|
|
||||||
|
def enable_steam_game_drive(env: Dict[str, str]):
|
||||||
|
"""Enable Steam Game Drive functionality.
|
||||||
|
|
||||||
|
Expects STEAM_COMPAT_INSTALL_PATH to be set
|
||||||
|
STEAM_RUNTIME_LIBRARY_PATH will not be set if the exe directory does not exist
|
||||||
|
"""
|
||||||
|
paths: Set[str] = set()
|
||||||
|
root: Path = Path("/")
|
||||||
|
|
||||||
|
# Check for mount points going up toward the root
|
||||||
|
# NOTE: Subvolumes can be mount points
|
||||||
|
for path in Path(env["STEAM_COMPAT_INSTALL_PATH"]).parents:
|
||||||
|
if path.is_mount() and path != root:
|
||||||
|
if env["STEAM_COMPAT_LIBRARY_PATHS"]:
|
||||||
|
env["STEAM_COMPAT_LIBRARY_PATHS"] = (
|
||||||
|
env["STEAM_COMPAT_LIBRARY_PATHS"] + ":" + path.as_posix()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
env["STEAM_COMPAT_LIBRARY_PATHS"] = path.as_posix()
|
||||||
|
break
|
||||||
|
|
||||||
|
if "LD_LIBRARY_PATH" in os.environ:
|
||||||
|
paths.add(Path(os.environ["LD_LIBRARY_PATH"]).as_posix())
|
||||||
|
|
||||||
|
if env["STEAM_COMPAT_INSTALL_PATH"]:
|
||||||
|
paths.add(env["STEAM_COMPAT_INSTALL_PATH"])
|
||||||
|
|
||||||
|
# Hard code for now because these paths seem to be pretty standard
|
||||||
|
# This way we avoid shelling to ldconfig
|
||||||
|
paths.add("/usr/lib")
|
||||||
|
paths.add("/usr/lib32")
|
||||||
|
env["STEAM_RUNTIME_LIBRARY_PATH"] = ":".join(list(paths))
|
||||||
|
|
||||||
|
return env
|
1350
gamelauncher_test.py
Normal file
1350
gamelauncher_test.py
Normal file
File diff suppressed because it is too large
Load Diff
77
ruff.toml
Normal file
77
ruff.toml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
# Exclude a variety of commonly ignored directories.
|
||||||
|
exclude = [
|
||||||
|
".bzr",
|
||||||
|
".direnv",
|
||||||
|
".eggs",
|
||||||
|
".git",
|
||||||
|
".git-rewrite",
|
||||||
|
".hg",
|
||||||
|
".ipynb_checkpoints",
|
||||||
|
".mypy_cache",
|
||||||
|
".nox",
|
||||||
|
".pants.d",
|
||||||
|
".pyenv",
|
||||||
|
".pytest_cache",
|
||||||
|
".pytype",
|
||||||
|
".ruff_cache",
|
||||||
|
".svn",
|
||||||
|
".tox",
|
||||||
|
".venv",
|
||||||
|
".vscode",
|
||||||
|
"__pypackages__",
|
||||||
|
"_build",
|
||||||
|
"buck-out",
|
||||||
|
"build",
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
"site-packages",
|
||||||
|
"venv",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Same as Black.
|
||||||
|
line-length = 88
|
||||||
|
indent-width = 4
|
||||||
|
|
||||||
|
# Assume Python 3.8
|
||||||
|
target-version = "py38"
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
|
||||||
|
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
|
||||||
|
# McCabe complexity (`C901`) by default.
|
||||||
|
select = ["E4", "E7", "E9", "F", "RET", "PTH", "D", "W", "BLE001", "EM"]
|
||||||
|
ignore = ["D100", "D203", "D213"]
|
||||||
|
|
||||||
|
# Allow fix for all enabled rules (when `--fix`) is provided.
|
||||||
|
fixable = ["ALL"]
|
||||||
|
unfixable = []
|
||||||
|
|
||||||
|
# Allow unused variables when underscore-prefixed.
|
||||||
|
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||||
|
|
||||||
|
[format]
|
||||||
|
# Like Black, use double quotes for strings.
|
||||||
|
quote-style = "double"
|
||||||
|
|
||||||
|
# Like Black, indent with spaces, rather than tabs.
|
||||||
|
indent-style = "space"
|
||||||
|
|
||||||
|
# Like Black, respect magic trailing commas.
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
|
||||||
|
# Like Black, automatically detect the appropriate line ending.
|
||||||
|
line-ending = "auto"
|
||||||
|
|
||||||
|
# Enable auto-formatting of code examples in docstrings. Markdown,
|
||||||
|
# reStructuredText code/literal blocks and doctests are all supported.
|
||||||
|
#
|
||||||
|
# This is currently disabled by default, but it is planned for this
|
||||||
|
# to be opt-out in the future.
|
||||||
|
docstring-code-format = false
|
||||||
|
|
||||||
|
# Set the line length limit used when formatting code snippets in
|
||||||
|
# docstrings.
|
||||||
|
#
|
||||||
|
# This only has an effect when the `docstring-code-format` setting is
|
||||||
|
# enabled.
|
||||||
|
docstring-code-line-length = "dynamic"
|
Loading…
x
Reference in New Issue
Block a user