From 742d63f734d78a041a8fbe6a0dc62569f69b6d01 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Wed, 18 Feb 2026 00:46:41 +0200 Subject: [PATCH 1/3] commands: add BSD compatible subreaper --- rare/commands/subreaper/__init__.py | 10 ++ rare/commands/subreaper/subreaper_bsd.py | 148 ++++++++++++++++++ .../subreaper_linux.py} | 13 +- 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 rare/commands/subreaper/__init__.py create mode 100755 rare/commands/subreaper/subreaper_bsd.py rename rare/commands/{subreaper.py => subreaper/subreaper_linux.py} (92%) 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 From 8f8326c6a460c23faeebdebea60eb5262efe5256 Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:28:51 +0200 Subject: [PATCH 2/3] compat: discover and use runtimes downloaded by umu, not only Steam Resolve compatibility tool runtime requirement using umu's runtimes too. Umu has to be set up separately right now, but that's also true for Steam. Umu's runtimes usually are newer versions than Steam's, so they are preferred if they are found. --- rare/utils/compat/steam.py | 105 ++++++++++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 19 deletions(-) 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)) From c25c28fb54f39dc084e49cfa28c2f6a98272f64c Mon Sep 17 00:00:00 2001 From: loathingKernel <142770+loathingKernel@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:29:27 +0200 Subject: [PATCH 3/3] chore: apply ruff fixes --- rare/components/dialogs/install/selective.py | 1 - rare/models/game_slim.py | 2 +- rare/utils/compat/miniproton.py | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) 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