mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
The enforce service runs as root (no User=) but sets DISPLAY=:0, so any GUI
it starts lands on the desktop session. Two resolvers disagreed about who to
drop privileges to:
library_hider._resolve_desktop_user() read STEAM_ENFORCER_DESKTOP_USER
game_install._get_real_user() read only SUDO_USER or USER
systemd sets neither of the latter, so _get_real_user() returned None under
the service and every `geteuid() == 0 and real_user and ...` guard fell
through to running as root. Steam answered with a zenity modal ("Cannot run
as root user", /usr/lib/steam/steam:261) on screen.
Confirmed from the live process tree: root `bash /usr/lib/steam/steam
-cef-disable-gpu ... steam://install/618510` with a root zenity child. It
never reached the journal, because that text goes to zenity and _run_as_user
sends child output to DEVNULL.
- _desktop_env: add resolve_desktop_user/desktop_uid/desktop_user_cmd. Only
the argv builder is shared, never the spawn call: tests/_no_subprocess.py
patches subprocess per module by name, so a spawn site in an unpatched
module could fire a real `sudo -u kuhy steam`.
- game_install._get_real_user: delegate to the shared resolver. This alone
also fixes the root `steam -silent` in _ensure_steam_running and the
no-op os.chown on new appmanifests.
- _trigger_steam_install / enforcer.send_notification: drop privileges.
send_notification had no DBUS_SESSION_BUS_ADDRESS, so it silently reached
nobody for all six of its callers.
- _run_as_user: fail closed. Running unchanged as root when no desktop user
resolves is what put the modal on screen; refuse and log instead.
Also stop the retry storm behind it. steamapps does not exist (Steam has
never completed sign-in), so is_game_installed() was always False and the
loop re-triggered every allowed game every pass: 1656 attempts in 30
minutes, service RSS 1.5 GB (peak 4.3 GB), a FileNotFoundError traceback
each time. Guard install_game and _reinstall_missing_allowed on
steam_library_ready() -- a missing library means no install can succeed by
any route, so this is a correctness check rather than a tuned retry limit.
The module splits here are forced by the pre-commit 250-line gate, which
blocks any commit touching an over-cap file: game_install, library_hider and
_enforce_loop were already over before this change. They are split by
responsibility (_cdp transport, _steam_launch lifecycle, _steam_process
spawning, _steam_errors, _steam_client, _game_names, game_uninstall,
_enforce_steps, _owned_apps_cache, _echo, _protected_apps). game_install and
library_hider re-export their old public names, so the 15 modules that
import from them are untouched by this commit.
Verified as real root under the unit's environment: argv is now
`sudo -u kuhy env DISPLAY=:0 ... xdg-open steam://install/...` for both the
installer and notify-send; three enforce passes spawn no process and log
once; _run_as_user refuses with no desktop user. After restart: 0 retries,
0 tracebacks, 0 root-owned files under ~/.local/share/Steam, RSS 342 MB.
1311 tests pass. _desktop_env and _steam_state at 100% branch coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
219 lines
7.4 KiB
Python
219 lines
7.4 KiB
Python
"""Game installation and uninstallation management."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
|
|
from steam_backlog_enforcer._desktop_env import (
|
|
desktop_session_ready,
|
|
desktop_uid,
|
|
desktop_user_cmd,
|
|
)
|
|
from steam_backlog_enforcer._echo import _echo
|
|
from steam_backlog_enforcer._steam_client import (
|
|
_ensure_steam_running,
|
|
_get_real_user,
|
|
_get_uid_gid_for_user,
|
|
is_game_installed,
|
|
)
|
|
from steam_backlog_enforcer._steam_state import (
|
|
STEAMAPPS_PATH,
|
|
steam_library_ready,
|
|
)
|
|
from steam_backlog_enforcer.game_uninstall import (
|
|
get_installed_games,
|
|
is_protected_app,
|
|
uninstall_game,
|
|
uninstall_other_games,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Re-exported for callers that predate the split of this module: many modules
|
|
# still do `from ...game_install import _echo`. __all__ is what keeps the
|
|
# linter from deleting these as unused -- they exist purely to be re-exported.
|
|
__all__ = [
|
|
"_echo",
|
|
"get_installed_games",
|
|
"install_game",
|
|
"is_game_installed",
|
|
"is_protected_app",
|
|
"uninstall_game",
|
|
"uninstall_other_games",
|
|
]
|
|
|
|
# Latches the "no Steam library" warning so a 3s enforce loop logs it once
|
|
# rather than once per game per pass.
|
|
_LIBRARY_WARNED: set[str] = set()
|
|
|
|
_UNINSTALL_EXPORTS = frozenset(
|
|
{
|
|
"get_installed_games",
|
|
"is_protected_app",
|
|
"uninstall_game",
|
|
"uninstall_other_games",
|
|
}
|
|
)
|
|
|
|
# Folder-name safety net for _remove_game_dirs. Independent of the app-id
|
|
# gating callers already do (uninstall_other_games skips allowed app ids) --
|
|
# this protects against deleting the *wrong* directory for an allowed game
|
|
# when its name has been written inconsistently (e.g. "KingdomComeDeliverance2"
|
|
# vs "Kingdom Come: Deliverance II" vs a typo'd variant), which is exactly the
|
|
# kind of multi-name confusion that caused real data loss once already.
|
|
|
|
|
|
def _trigger_steam_install(app_id: int, label: str) -> bool:
|
|
"""Ask Steam to install a game via the ``steam://install`` URI.
|
|
|
|
Returns True if the URI handler was invoked successfully.
|
|
"""
|
|
xdg_open = shutil.which("xdg-open") or "/usr/bin/xdg-open"
|
|
real_user = _get_real_user()
|
|
|
|
# Without the drop this opens steam://install as root, and Steam answers
|
|
# with a "Cannot run as root user" modal on the user's display.
|
|
if os.geteuid() == 0 and not desktop_session_ready(desktop_uid(real_user)):
|
|
logger.debug("Deferring Steam install for %s: no desktop session.", label)
|
|
return False
|
|
|
|
try:
|
|
subprocess.run(
|
|
desktop_user_cmd([xdg_open, f"steam://install/{app_id}"], real_user),
|
|
capture_output=True,
|
|
timeout=15,
|
|
check=False,
|
|
)
|
|
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
else:
|
|
logger.info("Triggered Steam install for %s via protocol handler", label)
|
|
return True
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Game install management
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def install_game(
|
|
app_id: int,
|
|
game_name: str,
|
|
steam_id: str,
|
|
*,
|
|
use_steam_protocol: bool = False,
|
|
) -> bool:
|
|
"""Install a game by triggering a Steam download.
|
|
|
|
When *use_steam_protocol* is True the ``steam://install`` URI handler
|
|
is used, which lets Steam determine the correct install directory from
|
|
its own metadata. This avoids mismatches between the display name and
|
|
the canonical ``installdir`` that can cause "Missing game executable"
|
|
errors. Falls back to writing a fabricated appmanifest if the URI
|
|
handler is unavailable.
|
|
|
|
When *use_steam_protocol* is False (the default) a minimal
|
|
appmanifest with StateFlags=1026 is written directly. This is
|
|
suitable for non-interactive / daemon contexts where opening a Steam
|
|
dialog is undesirable.
|
|
|
|
Args:
|
|
app_id: Steam application ID.
|
|
game_name: Human-readable game name.
|
|
steam_id: Steam64 ID of the account that owns the game.
|
|
use_steam_protocol: Prefer the ``steam://install`` URI handler.
|
|
|
|
Returns True if the install was triggered successfully.
|
|
"""
|
|
label = game_name or f"AppID={app_id}"
|
|
|
|
if is_game_installed(app_id):
|
|
logger.info("Game already installed: %s", label)
|
|
return True
|
|
|
|
# No library means no install can succeed — neither the steam:// handler
|
|
# nor the appmanifest write below. Without this the enforce loop retried
|
|
# every allowed game every pass (measured: 1656 times in 30 minutes).
|
|
if not steam_library_ready():
|
|
if "missing" not in _LIBRARY_WARNED:
|
|
logger.warning(
|
|
"Steam library not initialised (%s missing) — skipping installs "
|
|
"until Steam has been signed in to at least once.",
|
|
STEAMAPPS_PATH,
|
|
)
|
|
_LIBRARY_WARNED.add("missing")
|
|
else:
|
|
logger.debug("Skipping install of %s: no Steam library.", label)
|
|
return False
|
|
|
|
_LIBRARY_WARNED.discard("missing")
|
|
|
|
if use_steam_protocol:
|
|
_ensure_steam_running()
|
|
if _trigger_steam_install(app_id, label):
|
|
return True
|
|
logger.debug("steam:// protocol failed; falling back to manifest")
|
|
|
|
# Build a minimal appmanifest. StateFlags 1026 = UpdateRequired (2) +
|
|
# UpdateStarted (1024), which tells Steam "this app needs downloading".
|
|
manifest_content = (
|
|
'"AppState"\n'
|
|
"{\n"
|
|
f'\t"appid"\t\t"{app_id}"\n'
|
|
'\t"universe"\t\t"1"\n'
|
|
f'\t"name"\t\t"{game_name}"\n'
|
|
'\t"StateFlags"\t\t"1026"\n'
|
|
f'\t"installdir"\t\t"{game_name}"\n'
|
|
'\t"LastUpdated"\t\t"0"\n'
|
|
'\t"LastPlayed"\t\t"0"\n'
|
|
'\t"SizeOnDisk"\t\t"0"\n'
|
|
'\t"StagingSize"\t\t"0"\n'
|
|
'\t"buildid"\t\t"0"\n'
|
|
f'\t"LastOwner"\t\t"{steam_id}"\n'
|
|
'\t"UpdateResult"\t\t"0"\n'
|
|
'\t"BytesToDownload"\t\t"0"\n'
|
|
'\t"BytesDownloaded"\t\t"0"\n'
|
|
'\t"BytesToStage"\t\t"0"\n'
|
|
'\t"BytesStaged"\t\t"0"\n'
|
|
'\t"TargetBuildID"\t\t"0"\n'
|
|
'\t"AutoUpdateBehavior"\t\t"0"\n'
|
|
'\t"AllowOtherDownloadsWhileRunning"\t\t"0"\n'
|
|
'\t"ScheduledAutoUpdate"\t\t"0"\n'
|
|
'\t"InstalledDepots"\n'
|
|
"\t{\n"
|
|
"\t}\n"
|
|
'\t"UserConfig"\n'
|
|
"\t{\n"
|
|
"\t}\n"
|
|
'\t"MountedConfig"\n'
|
|
"\t{\n"
|
|
"\t}\n"
|
|
"}\n"
|
|
)
|
|
|
|
manifest_path = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
|
|
|
|
try:
|
|
with manifest_path.open("w", encoding="utf-8") as fh:
|
|
fh.write(manifest_content)
|
|
|
|
# Fix ownership so the Steam client (running as the real user) can
|
|
# read and update the manifest.
|
|
real_user = _get_real_user()
|
|
if os.geteuid() == 0 and real_user and real_user != "root":
|
|
uid, gid = _get_uid_gid_for_user(real_user)
|
|
os.chown(manifest_path, uid, gid)
|
|
|
|
logger.info("Created appmanifest for %s — Steam will auto-download", label)
|
|
except OSError:
|
|
logger.exception("Failed to create appmanifest for %s", label)
|
|
return False
|
|
|
|
# Make sure Steam is running so it picks up the manifest.
|
|
_ensure_steam_running()
|
|
|
|
return True
|