steam-backlog-enforcer/steam_backlog_enforcer/_fast_install.py
2026-09-25 18:45:27 +02:00

238 lines
8.4 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 to
completion with the terminal attached, so depotdownloader's progress shows
and a password / Steam Guard prompt can be answered.
* **Detached** (the daemon): spawn in its own session and return. Every later
pass reaps it through :func:`poll_fast_installs`, which records a failure (so
the caller falls back to ``steam://install``) and interrupts a run whose
output has stalled - most likely a login prompt nobody will ever answer,
sitting there with Steam shut down.
The installer closes Steam to do its work, so it is refused outright while a
game is running or another app is mid-update (the AoE2 corruption case).
"""
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 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,
steam_update_in_progress,
)
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
@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(app_id: int) -> 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"
if steam_update_in_progress(exclude_app_id=app_id):
return (
"another Steam download is in progress and closing Steam could corrupt 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,
)
def _spawn_detached(cmd: list[str], app_id: int) -> None:
"""Start the installer in its own session, output to a log file."""
log_path = _config.CONFIG_DIR / "fast_install.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:
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 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(app_id)
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)
return True
_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