mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
SDL3's HIDAPI lg4ff driver advertises an effect-status query it cannot answer; BeamNG.drive 0.39 native treats the failed query as a dead effect and the wheel goes limp (12k "Failed to get joystick3 FFB effect status" lines per session). Steam's environment on this box is exactly this allowlist, so the hint has to live here, not in /etc/environment. The narrow hint only demotes the Logitech wheel to the kernel driver; gamepads keep HIDAPI (rumble, LEDs over Bluetooth). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SS8M5wk88nJTXxDWz61KKk
135 lines
5.5 KiB
Python
135 lines
5.5 KiB
Python
"""Environment handed to processes launched into the user's desktop session.
|
|
|
|
The enforcer runs as root under systemd, so anything it starts on the desktop
|
|
user's behalf goes through ``sudo -u <user> env ...``. ``sudo`` resets the
|
|
environment, which makes the list below an *allowlist*: every variable the
|
|
desktop session needs must be named here explicitly, and every omission fails
|
|
silently rather than erroring.
|
|
|
|
Lives in its own module (like ``_steam_state``) so both ``library_hider`` and
|
|
``game_install`` can share one builder without forming an import cycle.
|
|
|
|
``XDG_RUNTIME_DIR`` is load-bearing and was missing here for a long time.
|
|
Without it Wine cannot reach ``$XDG_RUNTIME_DIR/pulse/native``, silently falls
|
|
back from ``winepulse.drv`` to ``winealsa.drv``, and every Proton game gets raw
|
|
ALSA devices instead of the session's PulseAudio sink. That crashed Kingdom
|
|
Come: Deliverance II during its startup video, and looked like a corrupt
|
|
install because restarting Steam by hand - the thing a reinstall incidentally
|
|
does - was what actually cleared it.
|
|
|
|
Only ``XDG_RUNTIME_DIR`` is exported for audio, deliberately: pressure-vessel
|
|
reads it to bind-mount the pulse socket into the Steam Linux Runtime container,
|
|
whereas a hardcoded ``PULSE_SERVER=unix:/run/user/<uid>/pulse/native`` would
|
|
name a *host* path that need not resolve inside that container.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import pwd
|
|
|
|
|
|
def desktop_runtime_dir(uid: int) -> str:
|
|
"""Path to the desktop user's XDG runtime directory."""
|
|
return f"/run/user/{uid}"
|
|
|
|
|
|
def desktop_session_ready(uid: int) -> bool:
|
|
"""Whether the desktop user's runtime directory exists yet.
|
|
|
|
The enforcer is a system service ordered only ``After=network-online``,
|
|
while ``/run/user/<uid>`` is created by ``user-runtime-dir@<uid>.service``
|
|
when logind opens the session. On a cold boot the enforcer can reach its
|
|
first launch before that directory exists — measured on one boot, it
|
|
started 54ms *before* the runtime-dir unit and only won by doing other
|
|
work first.
|
|
|
|
A Steam launched in that window would name a runtime dir that is not
|
|
there, which is exactly the winealsa fallback this module exists to
|
|
prevent, and it would stay broken for the whole session. Callers gate on
|
|
this and retry on the next enforce pass instead.
|
|
"""
|
|
return Path(desktop_runtime_dir(uid)).is_dir()
|
|
|
|
|
|
def desktop_env_args(user: str, uid: int) -> list[str]:
|
|
"""Build the ``env`` arguments for a command run as the desktop *user*.
|
|
|
|
Session paths are derived from *uid* rather than read from the current
|
|
environment: the enforcer normally runs as root, and inheriting root's
|
|
``XDG_RUNTIME_DIR`` would point the child at ``/run/user/0``, which it
|
|
cannot use.
|
|
"""
|
|
runtime_dir = desktop_runtime_dir(uid)
|
|
dbus_default = f"unix:path={runtime_dir}/bus"
|
|
dbus_addr = os.environ.get("DBUS_SESSION_BUS_ADDRESS", dbus_default)
|
|
xauth = os.environ.get("XAUTHORITY", f"/home/{user}/.Xauthority")
|
|
return [
|
|
f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
|
|
f"XAUTHORITY={xauth}",
|
|
f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}",
|
|
f"XDG_RUNTIME_DIR={runtime_dir}",
|
|
# Steam Runtime's srt-logger opts INTO the journal when it finds one
|
|
# (it inherits this unit's JOURNAL_STREAM), regardless of the DEVNULL
|
|
# stderr the spawn already uses. That wrote 481k "joystick FFB" lines
|
|
# in one boot and evicted two weeks of history from a 300M journal.
|
|
# Steam still keeps its own logs/console-linux.txt; this only stops
|
|
# the copy into journald.
|
|
"SRT_LOGGER_USE_JOURNAL=0",
|
|
# SDL3's HIDAPI lg4ff driver has no effect-status query; BeamNG 0.39
|
|
# native treats the failed query as a dead effect and the G29 goes
|
|
# limp (12k "Failed to get joystick3 FFB effect status" lines per
|
|
# session). Narrow hint so gamepads keep HIDAPI (rumble/LEDs); only
|
|
# the Logitech wheel falls back to the kernel driver. Name verified
|
|
# against BeamNG's SDL 3.4.12 strings, 2026-09-14.
|
|
"SDL_JOYSTICK_HIDAPI_LG4FF=0",
|
|
]
|
|
|
|
|
|
def desktop_uid(user: str | None) -> int:
|
|
"""Resolve *user* to a uid, defaulting to the usual first desktop uid."""
|
|
if user:
|
|
try:
|
|
return pwd.getpwnam(user).pw_uid
|
|
except KeyError:
|
|
pass
|
|
return 1000
|
|
|
|
|
|
def resolve_desktop_user() -> str | None:
|
|
"""Resolve which desktop user owns the Steam/X11 session.
|
|
|
|
Prefers the explicit STEAM_ENFORCER_DESKTOP_USER (set by the systemd
|
|
unit, which has no SUDO_USER/USER of its own since it is started
|
|
directly by systemd rather than via `sudo`), then falls back to
|
|
SUDO_USER/USER for interactive `sudo` invocations.
|
|
"""
|
|
return (
|
|
os.environ.get("STEAM_ENFORCER_DESKTOP_USER")
|
|
or os.environ.get("SUDO_USER")
|
|
or os.environ.get("USER")
|
|
)
|
|
|
|
|
|
def desktop_user_cmd(cmd: list[str], user: str | None) -> list[str]:
|
|
"""Wrap *cmd* so it runs as *user* when the caller is root.
|
|
|
|
Returns *cmd* unchanged when already unprivileged or when no desktop user
|
|
resolves; otherwise prefixes ``sudo -u <user> env <session vars>``.
|
|
|
|
This is argv construction only — deliberately no ``subprocess`` call, so
|
|
every spawn site stays in a module covered by the test suite's
|
|
``_no_subprocess`` guard.
|
|
"""
|
|
if os.geteuid() == 0 and user and user != "root":
|
|
return [
|
|
"sudo",
|
|
"-u",
|
|
user,
|
|
"env",
|
|
*desktop_env_args(user, desktop_uid(user)),
|
|
*cmd,
|
|
]
|
|
return cmd
|