steam-backlog-enforcer/steam_backlog_enforcer/_steam_state.py
Krzysztof kuhy Rudnicki c31ae2170f fix: fast install ignores other downloads; install offers any allowed game
- Fast install no longer refuses while Steam downloads something else:
  the installer leaves via `steam -shutdown`, which Steam resumes from.
  Only a running game still refuses.
- Non-daemon detached runs (web jobs) wait for the installer with the
  same stall reaper the daemon uses, and log to a per-uid file: the
  shared root-owned log made every job spawn raise.
- `install` offers every allowed game that is not fully installed
  (assignment + manual picks), so a skipped fast install can be started
  later; it takes over Steam's slow download.
- Uninstall progress names only removed games; protected runtimes are
  logged as kept instead of appearing as the last "uninstalled" item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fHzMuPSHq68S2AcPqGe3Q
2026-10-16 15:21:00 +02:00

125 lines
4.7 KiB
Python

"""Read-only helpers for Steam's on-disk state (install/update progress).
Kept in its own leaf module (no intra-package imports) so both
``game_install`` and ``library_hider`` can use it without forming an import
cycle. ``STEAMAPPS_PATH`` lives here as the single source of truth for the
Steam library location.
"""
from __future__ import annotations
import os
from pathlib import Path
import re
STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser()
# The real library path, captured before any test fixture redirects
# STEAMAPPS_PATH, so destructive helpers can refuse to touch it.
_REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser()
def _assert_not_real_steam(path: Path) -> None:
"""Raise if *path* is inside the real Steam directory during tests.
Defence-in-depth guard: when running under pytest, even if test
fixtures fail to redirect ``STEAMAPPS_PATH``, destructive
operations (uninstall, rmtree, unlink) will refuse to touch
real files. In production runs this is a no-op.
"""
if "PYTEST_CURRENT_TEST" not in os.environ:
return # production run — real Steam paths are expected
try:
path.resolve().relative_to(_REAL_STEAMAPPS.resolve())
except ValueError:
return # path is NOT under real Steam — safe to proceed
if STEAMAPPS_PATH.resolve() == _REAL_STEAMAPPS.resolve():
msg = (
f"SAFETY: refusing destructive operation on real Steam path "
f"{path!s} — STEAMAPPS_PATH was not redirected by test fixtures"
)
raise RuntimeError(msg)
def steam_library_ready() -> bool:
"""Return True if Steam has an initialised library directory.
When ``steamapps`` does not exist Steam has never completed first-run
setup, so an install cannot succeed by any route: the ``steam://`` handler
has nowhere to install to, and writing an appmanifest raises
FileNotFoundError. Callers use this to skip provably-futile work rather
than retrying it on every enforce pass.
"""
return STEAMAPPS_PATH.is_dir()
def _manifest_transfer_active(manifest: Path) -> bool:
"""Return True if *manifest* shows unfinished download or staging bytes.
Args:
manifest: Path to an ``appmanifest_*.acf`` file.
Returns:
True if the app still has bytes left to download or stage to disk.
"""
try:
content = manifest.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
def _field(name: str) -> int:
match = re.search(rf'"{name}"\s+"(\d+)"', content)
return int(match.group(1)) if match else 0
# BytesTo* are the totals for the in-flight update; BytesDownloaded/Staged
# track progress. Progress strictly below the total means the update (or
# its on-disk commit) is still running.
return _field("BytesToDownload") > _field("BytesDownloaded") or _field(
"BytesToStage"
) > _field("BytesStaged")
def steam_update_in_progress() -> bool:
"""Return True if any installed Steam app is mid-update.
Steam records live transfer progress in each ``appmanifest_*.acf``: a game
is actively updating while it still has bytes left to download or commit to
disk. Restarting Steam during that window suspends the transfer and can
leave a partially-written install — corrupting it. The enforcer checks this
before bouncing Steam so it never interrupts an in-flight update (the
failure that corrupted Age of Empires II DE into a deterministic launch
crash).
Returns:
True if at least one appmanifest shows an unfinished download/stage.
"""
try:
manifests = list(STEAMAPPS_PATH.glob("appmanifest_*.acf"))
except OSError:
return False
return any(_manifest_transfer_active(m) for m in manifests)
def is_game_fully_installed(app_id: int) -> bool:
"""Whether *app_id* is completely installed, not merely queued.
:func:`_steam_client.is_game_installed` counts any manifest, which is right
for "has an install been triggered". This is the stricter question a fast
install needs: a manifest Steam wrote for its own slow download (no
FullyInstalled bit, or bytes still in flight) is work to take over.
Args:
app_id: Steam application ID.
Returns:
True if the manifest has StateFlags bit 4 and no transfer in flight.
"""
manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
try:
content = manifest.read_text(encoding="utf-8", errors="replace")
except OSError:
return False
match = re.search(r'"StateFlags"\s+"(\d+)"', content)
flags = int(match.group(1)) if match else 0
return bool(flags & 4) and not _manifest_transfer_active(manifest)