UWINE/ulwgl_run.py
R1kaB3rN 11bd213088
ulwgl_run: update logic for non-existent WINEPREFIX
- Prefer having two separate branches for when the prefix doesn't exist or in the case it's not set
2024-02-21 11:11:01 -08:00

361 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
import os
import argparse
from traceback import print_exception
from argparse import ArgumentParser, Namespace
import sys
from pathlib import Path
import tomllib
from typing import Dict, Any, List, Set, Union, Tuple
import ulwgl_plugins
from re import match
import subprocess
from ulwgl_dl_util import get_ulwgl_proton
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
{exe} --config /home/foo/example.toml
"""
parser: ArgumentParser = argparse.ArgumentParser(
description="Unified Linux Wine Game Launcher",
epilog=usage,
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument("--config", help="path to TOML file")
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 setup_pfx(path: str) -> None:
"""Create a symlink to the WINE prefix and tracked_files file."""
pfx: Path = Path(path).joinpath("pfx").expanduser()
if pfx.is_symlink():
pfx.unlink()
if not pfx.is_dir():
pfx.symlink_to(Path(path).expanduser())
Path(path).joinpath("tracked_files").expanduser().touch()
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 toml:
# Check for required or empty key/value pairs when reading a TOML config
# NOTE: Casing matters in the config and we don't 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
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"]
if "PROTONPATH" not in os.environ:
os.environ["PROTONPATH"] = ""
get_ulwgl_proton(env)
elif (
Path("~/.local/share/Steam/compatibilitytools.d/" + os.environ["PROTONPATH"])
.expanduser()
.is_dir()
):
env["PROTONPATH"] = (
Path("~/.local/share/Steam/compatibilitytools.d/")
.expanduser()
.joinpath(os.environ["PROTONPATH"]).as_posix()
)
elif not Path(os.environ["PROTONPATH"]).expanduser().is_dir():
os.environ["PROTONPATH"] = ""
get_ulwgl_proton(env)
else:
env["PROTONPATH"] = os.environ["PROTONPATH"]
print(env["PROTONPATH"], file=sys.stderr)
# 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 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)
check_env(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 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 isinstance(args, Namespace):
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
ulwgl_plugins.enable_steam_game_drive(env)
# Set all environment variables
# NOTE: `env` after this block should be read only
for key, val in env.items():
os.environ[key] = val
build_command(env, command, opts)
return subprocess.run(command).returncode
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e: # noqa: BLE001
print_exception(e)
sys.exit(1)