steam-backlog-enforcer/steam_backlog_enforcer/_fast_install.py
Krzysztof kuhy Rudnicki 1bdf360193 pick keeps already-picked games; fast install reports progress
pick and pick-manual no longer uninstall games that are already allowed:
the outgoing assignment is kept as a released pick (installed, no lock)
until 'pick --replace', done/check, or abandon-pick. The allowed set is
capped at max_manual_picks + 1; a pick over it is refused before the
candidate list / confirmation, naming the games. status and the web
Picks card list kept games with an Abandon button.

Fast install now tracks the installer log from the spawn offset: web
jobs drive the progress bar (per-depot %, phase) and echo a line every
30 s; daemon-started runs are exposed via ctl status / /api/daemon and
shown on the Dashboard.

Also: web deps bumped for dependency freshness (react-query 5.104.5,
typescript-eslint 8.72.0, vite 8.3.5, node 24.22.1 in CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb
2026-10-23 20:14:12 +02:00

251 lines
9.3 KiB
Python

"""Installing games through steam-game-installer, for download speed.
depotdownloader saturates the link far better than the Steam client, and
steam-game-installer (``~/src/steam-game-installer``) wraps it so Steam accepts
the result as a real install. It also adopts whatever Steam already staged, so
taking over a slow client download loses nothing.
Two modes, picked by whether anyone can answer a prompt:
* **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run with
the terminal attached, so depotdownloader's progress shows and a password /
Steam Guard prompt can be answered.
* **Detached** (no terminal): spawn in its own session. The root daemon returns
at once; :func:`poll_fast_installs` reaps the run on later passes, records a
failure (the caller falls back to ``steam://install``) and interrupts a run
whose output stalled - likely a login prompt nobody will answer, with Steam
shut down. A web job loops that reaper until the run ends. Either way the
log is followed for progress (:mod:`_fast_install_progress`).
The installer closes Steam, so it is refused while a game is running. Another
app mid-download is no reason to wait: asked to leave with ``steam -shutdown``,
Steam resumes the transfer when it comes back.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import os
from pathlib import Path
import pwd
import re
import shutil
import signal
import subprocess
import sys
import time
from steam_backlog_enforcer import _fast_install_progress as _fip
from steam_backlog_enforcer import config as _config
from steam_backlog_enforcer._desktop_env import desktop_user_cmd, resolve_desktop_user
from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._steam_restart_guard import game_is_running
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH
logger = logging.getLogger(__name__)
_INSTALLER = Path("src", "steam-game-installer", "run.sh")
_INSTALLER_LOG = "~/.local/state/steam-game-installer/latest.log"
# Depot content only: the app itself is ours to choose, the language is not
# worth a prompt nobody is there to answer.
_LANGUAGE = "english"
# depotdownloader prints a line per finished chunk, so a healthy download is
# never silent this long; a login prompt waiting on /dev/null is.
STALL_SECONDS = 15 * 60
# How often a waiting (non-daemon) caller reaps its run.
_WAIT_POLL_SECONDS = 5
@dataclass
class _DetachedRun:
"""A daemon-spawned installer run, kept so it can be reaped."""
app_id: int
proc: subprocess.Popen[bytes]
log_path: Path
_RUNS: list[_DetachedRun] = []
# Apps whose installer run failed in this process. They go straight to the
# steam:// fallback from then on, so a broken login cannot close Steam on
# every 3-second pass.
_FAILED: set[int] = set()
def _installer_path(user: str | None) -> Path | None:
"""steam-game-installer's ``run.sh`` in the desktop user's home, if present.
Resolved from the passwd entry rather than ``Path.home()``: the daemon runs
as root, and the installer, its venv and its credentials are per-user.
"""
try:
home = Path(pwd.getpwnam(user).pw_dir) if user else Path.home()
except KeyError:
return None
path = home / _INSTALLER
return path if path.is_file() else None
def _steam_account_name() -> str | None:
"""The account Steam itself last signed in with, from ``loginusers.vdf``.
Read from Steam's own state rather than a copy in the enforcer config, so
it cannot drift from the account that actually owns the games.
"""
path = STEAMAPPS_PATH.parent / "config" / "loginusers.vdf"
try:
content = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
match = re.search(r'"AccountName"\s+"([^"]+)"', content)
return match.group(1) if match else None
def _refusal() -> str | None:
"""Why closing Steam right now would do damage, or None if it is safe."""
if game_is_running():
return "a game is running and closing Steam would kill it"
return None
def _build_cmd(installer: Path, app_id: int, user: str | None) -> list[str]:
"""The installer invocation, dropped to the desktop user when root."""
cmd = [
str(installer),
# install.sh reaches for yay; dependencies are installed once, by hand.
"--no-deps",
"--appid",
str(app_id),
"--yes",
"--language",
_LANGUAGE,
# Finishes the manifest from depotdownloader's own data, under the same
# fail-closed checks: no second login, no 70 GB re-hash by steamcmd.
"--skip-steamcmd",
]
account = _steam_account_name()
if account:
cmd += ["--username", account]
return desktop_user_cmd(cmd, user)
def is_fast_installing(app_id: int) -> bool:
"""Whether this process has a live detached installer run for *app_id*."""
return any(run.app_id == app_id for run in _RUNS)
def _stalled(run: _DetachedRun) -> bool:
"""Whether a live run has printed nothing for :data:`STALL_SECONDS`."""
try:
return time.time() - run.log_path.stat().st_mtime > STALL_SECONDS
except OSError:
return False
def poll_fast_installs() -> None:
"""Reap finished detached runs and interrupt stalled ones.
SIGINT, not SIGTERM: the installer turns it into KeyboardInterrupt, and its
``finally`` restarts the Steam client it shut down. SIGTERM would leave
Steam closed.
"""
for run in list(_RUNS):
returncode = run.proc.poll()
if returncode is None:
if _stalled(run):
logger.warning(
"steam-game-installer for AppID=%d silent for %ds — "
"interrupting (see %s).",
run.app_id,
STALL_SECONDS,
run.log_path,
)
os.killpg(run.proc.pid, signal.SIGINT)
continue
_RUNS.remove(run)
if returncode == 0:
logger.info("steam-game-installer finished AppID=%d.", run.app_id)
continue
_FAILED.add(run.app_id)
logger.warning(
"steam-game-installer failed for AppID=%d (exit %d); falling back "
"to Steam's own download. Log: %s",
run.app_id,
returncode,
run.log_path,
)
_fip.poll_trackers({run.app_id for run in _RUNS})
def _spawn_detached(cmd: list[str], app_id: int, label: str = "") -> None:
"""Start the installer in its own session, output to a log file."""
# Per user: the daemon's log is root-owned, and a web job runs as the
# desktop user - sharing one file made every job spawn raise.
log_path = _config.CONFIG_DIR / f"fast_install-{os.geteuid()}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
# The unit is KillMode=control-group: restarting the daemon would SIGTERM
# an installer left in its cgroup, skipping the `finally` that restarts
# Steam. A transient scope moves it out; --scope execs in place, so the
# Popen pid below is still the session leader killpg needs.
if os.geteuid() == 0 and shutil.which("systemd-run"):
cmd = ["systemd-run", "--scope", "--quiet", "--collect", *cmd]
with log_path.open("ab") as log:
# Append mode starts at the end: earlier runs' lines are never parsed.
_fip.track(app_id, label, log_path, offset=log.tell(), now=time.monotonic())
proc = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
_RUNS.append(_DetachedRun(app_id, proc, log_path))
logger.info("Started steam-game-installer for AppID=%d (log: %s)", app_id, log_path)
def _wait_detached(app_id: int, label: str) -> bool:
"""Reap this process's run for *app_id* until it ends; True on success."""
reporter = _fip.WaitReporter(app_id, label, started=time.monotonic())
while is_fast_installing(app_id):
time.sleep(_WAIT_POLL_SECONDS)
poll_fast_installs()
reporter.report(time.monotonic())
return app_id not in _FAILED
def fast_install(app_id: int, label: str) -> bool:
"""Install *app_id* with steam-game-installer.
Returns:
True if the game was installed (foreground) or the run was started
(detached); False if the caller should fall back to Steam's download.
"""
if app_id in _FAILED:
return False
user = resolve_desktop_user()
installer = _installer_path(user)
if installer is None:
logger.warning("steam-game-installer not found; using Steam's download.")
return False
reason = _refusal()
if reason is not None:
_echo(f" Fast install of {label} skipped: {reason}.")
return False
cmd = _build_cmd(installer, app_id, user)
if not sys.stdin.isatty():
_spawn_detached(cmd, app_id, label)
return os.geteuid() == 0 or _wait_detached(app_id, label)
_echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...")
returncode = subprocess.run(cmd, check=False).returncode
if returncode == 0:
return True
_FAILED.add(app_id)
_echo(
f" steam-game-installer failed (exit {returncode}); falling back to "
f"Steam's own download. Log: {_INSTALLER_LOG}"
)
return False