diff --git a/rare/commands/subreaper/__init__.py b/rare/commands/subreaper/__init__.py new file mode 100644 index 0000000000..8212ea9568 --- /dev/null +++ b/rare/commands/subreaper/__init__.py @@ -0,0 +1,10 @@ +import platform + +if platform.system() == "FreeBSD": + from .subreaper_bsd import subreaper +elif platform.system() == "Linux": + from .subreaper_linux import subreaper +else: + raise RuntimeError(f"Unsupported subrepaer platform {platform.system()}") + +__all__ = ["subreaper"] diff --git a/rare/commands/subreaper/subreaper_bsd.py b/rare/commands/subreaper/subreaper_bsd.py new file mode 100755 index 0000000000..a3f22d26dd --- /dev/null +++ b/rare/commands/subreaper/subreaper_bsd.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 + +# AI Disclosure: Some source code modifications in this file are AI-generated, but also human-reviewed and tested. + +import logging +import os +import signal +import subprocess +import sys +from argparse import Namespace +from ctypes import CDLL, c_int, c_void_p +from ctypes.util import find_library +from logging import getLogger +from pathlib import Path +from typing import Any, Generator, List + +# Constants defined in sys/procctl.h +P_PID = 0 +PROC_REAP_ACQUIRE = 2 + + +def get_libc() -> str: + """Find libc.so from the user's system.""" + return find_library("c") or "" + + +def _get_pids() -> Generator[int, Any, None]: + # FreeBSD's /proc is often not mounted. If it is, entries are just PIDs. + proc_path = Path("/proc") + if proc_path.exists(): + yield from (int(p.name) for p in proc_path.glob("*") if p.name.isdigit()) + else: + # Fallback: Use ps to get PIDs if /proc isn't available + out = subprocess.check_output(["ps", "-ax", "-o", "pid"]) + for line in out.splitlines()[1:]: + yield int(line.strip()) + + +def get_pstree_from_pid(root_pid: int) -> set[int]: + """Get descendent PIDs. Uses 'ps' for better FreeBSD compatibility.""" + descendants: set[int] = set() + + try: + # -o ppid,pid gets parent and child PIDs + out = subprocess.check_output(["ps", "-ax", "-o", "ppid,pid"], encoding="utf-8") + lines = out.strip().split("\n")[1:] + pid_to_ppid = {} + for line in lines: + ppid, pid = map(int, line.split()) + pid_to_ppid[pid] = ppid + except Exception: + return descendants + + current_pid: list[int] = [root_pid] + while current_pid: + current = current_pid.pop() + # Ignore. mypy flags [arg-type] due to the reuse of pid variable + for pid, ppid in pid_to_ppid.items(): # type: ignore + if ppid == current and pid not in descendants: + descendants.add(pid) # type: ignore + current_pid.append(pid) # type: ignore + + return descendants + + +def subreaper(args: Namespace, other: List[str]) -> int: + logger = getLogger("subreaper") + logging.basicConfig( + format="[%(name)s] %(levelname)s: %(message)s", + level=logging.DEBUG if args.debug else logging.INFO, + stream=sys.stderr, + ) + + logger.debug("command: %s", args) + logger.debug("arguments: %s", other) + + def signal_handler(sig, frame): + logger.info("Caught '%s' signal.", signal.strsignal(sig)) + pstree = get_pstree_from_pid(os.getpid()) + for p in pstree: + try: + os.kill(p, sig) + except ProcessLookupError: + continue + + command: List[str] = [args.command, *other] + workdir: str = args.workdir + child_status: int = 0 + + libc_path: str = get_libc() + if not libc_path: + logger.error("Could not find libc") + return 1 + libc: CDLL = CDLL(libc_path) + + # Acquire Reaper Status + # FreeBSD equivalent of PR_SET_CHILD_SUBREAPER + # procctl(P_PID, getpid(), PROC_REAP_ACQUIRE, NULL) + procctl = libc.procctl + # procctl.restype = c_int + procctl.argtypes = [ + c_int, + c_int, + c_int, + c_void_p, + ] + + # Set Process Name (FreeBSD specific) + # FreeBSD prefers setproctitle over prctl for naming + proc_name = b"reaper" + try: + libc.setproctitle(proc_name) + except AttributeError: + logger.debug("setproctitle not found in libc") + + procctl_res = procctl(P_PID, os.getpid(), PROC_REAP_ACQUIRE, None) + logger.debug("procctl PROC_REAP_ACQUIRE exited with status: %s", procctl_res) + + pid = os.fork() # pylint: disable=E1101 + if pid == -1: + logger.error("Fork failed") + + if pid == 0: + os.chdir(workdir) + os.execvp(command[0], command) # noqa: S606 + else: + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + while True: + try: + child_pid, child_status = os.wait() # pylint: disable=E1101 + logger.info("Child %s exited with status: %s", child_pid, child_status) + except ChildProcessError as e: + logger.info(e) + break + + return child_status + + +if __name__ == "__main__": + sep = sys.argv.index("--") + argv = sys.argv[sep + 1 :] + args = Namespace(command=argv.pop(0), workdir=os.getcwd(), debug=True) + subreaper(args, argv) + + +__all__ = ["subreaper"] diff --git a/rare/commands/subreaper.py b/rare/commands/subreaper/subreaper_linux.py similarity index 92% rename from rare/commands/subreaper.py rename to rare/commands/subreaper/subreaper_linux.py index 01b2be5cdc..87402d773e 100755 --- a/rare/commands/subreaper.py +++ b/rare/commands/subreaper/subreaper_linux.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any, Generator, List -# Constant defined in prctl.h +# Constants defined in prctl.h # See prctl(2) for more details PR_SET_NAME = 15 PR_SET_CHILD_SUBREAPER = 36 @@ -74,8 +74,13 @@ def signal_handler(sig, frame): workdir: str = args.workdir child_status: int = 0 - libc: str = get_libc() - prctl = CDLL(libc).prctl + libc_path: str = get_libc() + if not libc_path: + logger.error("Could not find libc") + return 1 + libc: CDLL = CDLL(libc_path) + + prctl = libc.prctl prctl.restype = c_int prctl.argtypes = [ c_int, @@ -110,7 +115,7 @@ def signal_handler(sig, frame): while True: try: child_pid, child_status = os.wait() # pylint: disable=E1101 - logger.info("Child %s exited with wait status: %s", child_pid, child_status) + logger.info("Child %s exited with status: %s", child_pid, child_status) except ChildProcessError as e: logger.info(e) break diff --git a/rare/components/dialogs/install/selective.py b/rare/components/dialogs/install/selective.py index 4f016f11c1..0a19162d69 100644 --- a/rare/components/dialogs/install/selective.py +++ b/rare/components/dialogs/install/selective.py @@ -1,6 +1,5 @@ from typing import List, Union -from legendary.utils.selective_dl import get_sdl_appname from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QFont from PySide6.QtWidgets import QCheckBox, QVBoxLayout, QWidget diff --git a/rare/models/game_slim.py b/rare/models/game_slim.py index 270f0ed0cd..84e6f26e38 100644 --- a/rare/models/game_slim.py +++ b/rare/models/game_slim.py @@ -4,7 +4,7 @@ from datetime import datetime from enum import IntEnum from logging import getLogger -from typing import Dict, List, Optional, Tuple +from typing import List, Optional, Tuple from legendary.lfs import eos from legendary.models.game import Game, InstalledGame, SaveGameFile, SaveGameStatus diff --git a/rare/utils/compat/miniproton.py b/rare/utils/compat/miniproton.py index a5eeeab8ef..32045a29a2 100644 --- a/rare/utils/compat/miniproton.py +++ b/rare/utils/compat/miniproton.py @@ -3,7 +3,6 @@ import os import subprocess import sys -from getpass import getuser from pathlib import Path from typing import Any, Dict, List diff --git a/rare/utils/compat/steam.py b/rare/utils/compat/steam.py index 9e0d694353..64bc922442 100644 --- a/rare/utils/compat/steam.py +++ b/rare/utils/compat/steam.py @@ -13,6 +13,7 @@ logger = getLogger("SteamTools") steam_client_install_paths = [os.path.expanduser("~/.local/share/Steam")] +umu_install_paths = [os.path.expanduser("~/.local/share/umu")] def find_steam() -> Optional[str]: @@ -35,6 +36,22 @@ def find_libraries(steam_path: str) -> Set[str]: return libraries +UMU_RUNTIMES = { + "1391110": "steamrt2", + "1628350": "steamrt3", + "3810310": "steamrt3-arm64", + "4183110": "steamrt4", + "4185400": "steamrt4-arm64", +} + + +def find_umu() -> Optional[str]: + for path in umu_install_paths: + if os.path.isdir(path) and any(rt in os.listdir(path) for rt in UMU_RUNTIMES.values()): + return path + return None + + # Notes: # Anything older than 'Proton 5.13' doesn't have the 'require_tool_appid' attribute. # Anything older than 'Proton 7.0' doesn't have the 'compatmanager_layer_name' attribute. @@ -106,6 +123,12 @@ def appid(self) -> str: return self.appmanifest["AppState"]["appid"] +@dataclass +class UmuRuntime(SteamBase): + name: str + appid: str + + @dataclass class SteamAntiCheat: steam_path: str @@ -130,7 +153,7 @@ def appid(self) -> str: @dataclass class ProtonTool(SteamRuntime): - runtime: SteamRuntime = None + runtime: Union[SteamRuntime, UmuRuntime] = None anticheat: Dict[str, SteamAntiCheat] = None def __bool__(self) -> bool: @@ -147,7 +170,7 @@ def command(self, verb: SteamVerb = SteamVerb.DEFAULT) -> List[str]: @dataclass class CompatibilityTool(SteamBase): compatibilitytool: Dict - runtime: SteamRuntime = None + runtime: Union[SteamRuntime, UmuRuntime] = None anticheat: Dict[str, SteamAntiCheat] = None def __bool__(self) -> bool: @@ -229,6 +252,31 @@ def find_steam_runtimes(steam_path: str, library: str) -> Dict[str, SteamRuntime return runtimes +def find_umu_runtimes(umu_path: str) -> Dict[str, UmuRuntime]: + runtimes = {} + for appid, folder in UMU_RUNTIMES.items(): + tool_path = os.path.join(umu_path, folder) + if os.path.isdir(tool_path) and os.path.isfile(vdf_file := os.path.join(tool_path, "toolmanifest.vdf")): + with open(vdf_file, "r", encoding="utf-8") as f: + toolmanifest = vdf.load(f) + if toolmanifest["manifest"].get("version") != "2": + continue + if toolmanifest["manifest"].get("compatmanager_layer_name") == "container-runtime": + runtimes.update( + { + appid: UmuRuntime( + steam_path=None, + name=f"umu-{folder}", + appid=appid, + tool_path=tool_path, + toolmanifest=toolmanifest["manifest"], + ) + } + ) + + return runtimes + + def find_steam_tools(steam_path: str, library: str) -> List[ProtonTool]: tools = [] appmanifests = find_appmanifests(library) @@ -258,11 +306,14 @@ def find_compatibility_tools(steam_path: str) -> List[CompatibilityTool]: compatibilitytools_paths = { data_dir().joinpath("tools").as_posix(), os.path.expanduser("~/.local/share/umu/compatibilitytools"), - os.path.expanduser(os.path.join(steam_path, "compatibilitytools.d")), os.path.expanduser("~/.steam/compatibilitytools.d"), os.path.expanduser("~/.steam/root/compatibilitytools.d"), "/usr/share/steam/compatibilitytools.d", } + if steam_path: + compatibilitytools_paths.add( + os.path.expanduser(os.path.join(steam_path, "compatibilitytools.d")), + ) compatibilitytools_paths = {os.path.realpath(path) for path in compatibilitytools_paths if os.path.isdir(path)} tools = [] for path in compatibilitytools_paths: @@ -309,7 +360,10 @@ def find_compatibility_tools(steam_path: str) -> List[CompatibilityTool]: return tools -def get_runtime(tool: Union[ProtonTool, CompatibilityTool], runtimes: Dict[str, SteamRuntime]) -> Optional[SteamRuntime]: +def get_runtime( + tool: Union[ProtonTool, CompatibilityTool], + runtimes: Dict[str, Union[SteamRuntime, UmuRuntime]] +) -> Union[SteamRuntime, UmuRuntime, None]: required_tool = tool.required_tool if required_tool is None: return None @@ -339,21 +393,23 @@ def get_steam_environment( # If the tool is unset, return all affected env variable names # IMPORTANT: keep this in sync with the code below environ = {"STEAM_COMPAT_DATA_PATH": compat_path if compat_path else ""} + + environ["STORE"] = "" + environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = "" + environ["STEAM_COMPAT_LAUNCHER_SERVICE"] = "" + environ["STEAM_COMPAT_LIBRARY_PATHS"] = "" + environ["STEAM_COMPAT_MOUNTS"] = "" + environ["STEAM_COMPAT_TOOL_PATHS"] = "" + environ["PROTON_EAC_RUNTIME"] = "" + environ["PROTON_BATTLEYE_RUNTIME"] = "" if tool is None: - environ["STORE"] = "" environ["STEAM_COMPAT_DATA_PATH"] = "" - environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = "" - environ["STEAM_COMPAT_LAUNCHER_SERVICE"] = "" environ["STEAM_COMPAT_INSTALL_PATH"] = "" - environ["STEAM_COMPAT_LIBRARY_PATHS"] = "" - environ["STEAM_COMPAT_MOUNTS"] = "" - environ["STEAM_COMPAT_TOOL_PATHS"] = "" - environ["PROTON_EAC_RUNTIME"] = "" - environ["PROTON_BATTLEYE_RUNTIME"] = "" return environ environ["STORE"] = "egs" - environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = tool.steam_path + if tool.steam_path: + environ["STEAM_COMPAT_CLIENT_INSTALL_PATH"] = tool.steam_path environ["STEAM_COMPAT_LAUNCHER_SERVICE"] = tool.layer if isinstance(tool, ProtonTool): environ["STEAM_COMPAT_LIBRARY_PATHS"] = tool.steam_library @@ -375,18 +431,29 @@ def get_steam_environment( def _find_tools() -> List[Union[ProtonTool, CompatibilityTool]]: steam_path = find_steam() if steam_path is None: - logger.info("Steam could not be found") - return [] - logger.info("Found Steam in %s", steam_path) + logger.info("Steam folder could not be found") + else: + logger.info("Found Steam folder in %s", steam_path) - steam_libraries = find_libraries(steam_path) - logger.debug("Searching for tools in libraries:") - logger.debug("%s", steam_libraries) + steam_libraries = set() + if steam_path: + steam_libraries.update(find_libraries(steam_path)) + logger.debug("Searching for tools in Steam libraries:") + logger.debug("%s", steam_libraries) runtimes = {} for library in steam_libraries: runtimes.update(find_steam_runtimes(steam_path, library)) + umu_path = find_umu() + if umu_path is None: + logger.info("UMU folder could not be found") + else: + logger.info("Found UMU folder in %s", umu_path) + + if umu_path: + runtimes.update(find_umu_runtimes(umu_path)) + anticheat = {} for library in steam_libraries: anticheat.update(find_anticheat(steam_path, library))