steam-backlog-enforcer/steam_backlog_enforcer/_steam_client.py
Krzysztof kuhy Rudnicki d6d9a2d1a6 pylint: clear the mechanical residue, the howlongtobeatpy 1.0.23 call, and three duplicate blocks
The pinned howlongtobeatpy (1.0.23) takes a user agent in
send_website_request_getcode and dropped parse_all_scripts; the call
still matched 1.0.20, which only worked because the deployed env was
behind its own pin. Now on the pinned API, and the env upgraded.

Three modules carried the same executable/path constant block since the
250-line split; it is _store_tools. Four carried the same "restore the
hours a refetch lost" loop; it is restore_prior_hours. Both detached
launches go through one helper that returns the process. Thirteen
try/except/else blocks are flat, dataclass fields come from
dataclasses.fields, and the rest are one-line fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 20:31:27 +02:00

120 lines
3.8 KiB
Python

"""Starting the Steam client as the desktop user, and install-state queries.
Split out of :mod:`steam_backlog_enforcer.game_install` to keep it under the
250-line cap. The enforcer runs as root, so everything here is about resolving
*which* user to become before touching the desktop session.
"""
from __future__ import annotations
import logging
import os
import pwd
import subprocess
import time
from steam_backlog_enforcer._desktop_env import (
desktop_env_args,
desktop_runtime_dir,
desktop_session_ready,
resolve_desktop_user,
)
from steam_backlog_enforcer._steam_launch import steam_is_installed
from steam_backlog_enforcer._steam_process import spawn_detached
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH
logger = logging.getLogger(__name__)
def _get_real_user() -> str | None:
"""Get the desktop user to drop privileges to.
Delegates to the shared resolver so this module honours
STEAM_ENFORCER_DESKTOP_USER. Reading only SUDO_USER/USER returned None
under systemd (which sets neither), so every ``geteuid() == 0 and
real_user`` guard below silently fell through to running as root.
"""
return resolve_desktop_user()
def _get_uid_gid_for_user(username: str) -> tuple[int, int]:
"""Get (uid, gid) for a username."""
try:
pw = pwd.getpwnam(username)
except KeyError:
return 1000, 1000
return pw.pw_uid, pw.pw_gid
def is_game_installed(app_id: int) -> bool:
"""Check if a game is installed by looking for its appmanifest.
A manifest with StateFlags != 4 (FullyInstalled) means the game is
still downloading or queued, which still counts as "install triggered".
"""
manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
return manifest.exists()
def _ensure_steam_running() -> None:
"""Start the Steam client if it is not already running.
Does nothing if Steam is not installed - there is no client to start, and
trying anyway only sleeps 15s waiting on a process that died on exec.
"""
if not steam_is_installed():
logger.info("Steam is not installed — skipping client start.")
return
# Check if any steam process is running (main client, not just helpers).
try:
result = subprocess.run(
["/usr/bin/pgrep", "-f", "steam.sh"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
logger.debug("Steam client already running")
return
except FileNotFoundError:
pass
real_user = _get_real_user()
logger.info("Starting Steam client...")
try:
if os.geteuid() == 0 and real_user and real_user != "root":
uid, _ = _get_uid_gid_for_user(real_user)
# Defer rather than launch a Steam that would come up without a
# runtime dir, and so without working audio, for the session.
if not desktop_session_ready(uid):
logger.info(
"Deferring Steam start: %s does not exist yet.",
desktop_runtime_dir(uid),
)
return
cmd = [
"sudo",
"-u",
real_user,
"env",
*desktop_env_args(real_user, uid),
"steam",
"-silent",
]
else:
cmd = ["steam", "-silent"]
spawn_detached(cmd)
# Give Steam time to initialize and start scanning manifests.
time.sleep(15)
except FileNotFoundError:
logger.exception("Steam executable not found")
# Latches the "no Steam library" warning so a 3s enforce loop logs it once
# rather than once per game per pass. A set (rather than a module-level bool
# reassigned via `global`) keeps the mutation local to the container.
_LIBRARY_WARNED: set[str] = set()