Stop the enforcer launching Steam and notifications as root

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
This commit is contained in:
Krzysztof Rudnicki 2026-08-24 13:55:44 +02:00
parent 2ad87524e4
commit 21fe2f7742
42 changed files with 3232 additions and 2273 deletions

View File

@ -0,0 +1,95 @@
"""Chrome DevTools Protocol transport for Steam's embedded browser.
Modern Steam runs its UI in CEF, so collections can only be changed by
evaluating JS inside it. Split out of
:mod:`steam_backlog_enforcer.library_hider` to keep both files under the
250-line cap: this module is pure transport and knows nothing about
collections.
"""
from __future__ import annotations
import asyncio
import json
import logging
import requests
import websockets
logger = logging.getLogger(__name__)
# NOTE: was 8080, which collided with a different local service (Open WebUI)
# already bound to 0.0.0.0:8080. requests to 127.0.0.1:8080 resolved to that
# service instead of steamwebhelper's CDP endpoint (which only bound
# [::1]:8080), so CDP detection silently never worked. 9222 is the
# conventional Chrome DevTools debug port and was confirmed free.
_CDP_PORT = 9222
_CDP_TIMEOUT = 120
# ──────────────────────────────────────────────────────────────
# CDP (Chrome DevTools Protocol) helpers
# ──────────────────────────────────────────────────────────────
def _get_shared_js_ws_url() -> str | None:
"""Query the CDP HTTP endpoint and return the SharedJSContext WS URL."""
try:
resp = requests.get(f"http://127.0.0.1:{_CDP_PORT}/json", timeout=5)
targets = resp.json()
except (OSError, ValueError):
return None
for target in targets:
if target.get("title") == "SharedJSContext":
ws_url: str = target["webSocketDebuggerUrl"]
return ws_url
return None
async def _evaluate_js_async(ws_url: str, expression: str) -> dict:
"""Connect to a CDP WebSocket target and evaluate *expression*."""
async with websockets.connect(ws_url) as ws:
msg = json.dumps(
{
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": expression,
"returnByValue": True,
"awaitPromise": True,
},
}
)
await ws.send(msg)
resp = await asyncio.wait_for(ws.recv(), timeout=_CDP_TIMEOUT)
return json.loads(resp)
def _evaluate_js(expression: str) -> dict:
"""Synchronous wrapper around :func:`_evaluate_js_async`."""
ws_url = _get_shared_js_ws_url()
if ws_url is None:
msg = "SharedJSContext not found on CDP port"
raise RuntimeError(msg)
return asyncio.run(_evaluate_js_async(ws_url, expression))
def _cdp_result_value(result: dict) -> str:
"""Extract the return value from a CDP Runtime.evaluate response."""
outer = result.get("result", {})
inner = outer.get("result", {})
if "exceptionDetails" in outer:
exc_details = outer["exceptionDetails"]
exc = exc_details.get("exception", {})
desc = (
inner.get("description")
or exc.get("description")
or exc_details.get("text")
or repr(exc_details)
)
logger.debug("CDP exception details: %s", exc_details)
msg = f"JS evaluation error: {desc}"
raise RuntimeError(msg)
value: str = inner.get("value", "")
return value

View File

@ -27,6 +27,7 @@ from __future__ import annotations
import os
from pathlib import Path
import pwd
def desktop_runtime_dir(uid: int) -> str:
@ -70,3 +71,50 @@ def desktop_env_args(user: str, uid: int) -> list[str]:
f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}",
f"XDG_RUNTIME_DIR={runtime_dir}",
]
def desktop_uid(user: str | None) -> int:
"""Resolve *user* to a uid, defaulting to the usual first desktop uid."""
if user:
try:
return pwd.getpwnam(user).pw_uid
except KeyError:
pass
return 1000
def resolve_desktop_user() -> str | None:
"""Resolve which desktop user owns the Steam/X11 session.
Prefers the explicit STEAM_ENFORCER_DESKTOP_USER (set by the systemd
unit, which has no SUDO_USER/USER of its own since it is started
directly by systemd rather than via `sudo`), then falls back to
SUDO_USER/USER for interactive `sudo` invocations.
"""
return (
os.environ.get("STEAM_ENFORCER_DESKTOP_USER")
or os.environ.get("SUDO_USER")
or os.environ.get("USER")
)
def desktop_user_cmd(cmd: list[str], user: str | None) -> list[str]:
"""Wrap *cmd* so it runs as *user* when the caller is root.
Returns *cmd* unchanged when already unprivileged or when no desktop user
resolves; otherwise prefixes ``sudo -u <user> env <session vars>``.
This is argv construction only — deliberately no ``subprocess`` call, so
every spawn site stays in a module covered by the test suite's
``_no_subprocess`` guard.
"""
if os.geteuid() == 0 and user and user != "root":
return [
"sudo",
"-u",
user,
"env",
*desktop_env_args(user, desktop_uid(user)),
*cmd,
]
return cmd

View File

@ -0,0 +1,26 @@
"""User-facing CLI output.
A one-function leaf module so every command can print without importing the
install machinery. Split out of :mod:`steam_backlog_enforcer.game_install` to
keep that module under the 250-line cap.
"""
from __future__ import annotations
import sys
def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None:
"""Write user-facing CLI output to stdout.
Args:
msg: Text to output.
end: String appended after the message.
flush: Whether to flush stdout immediately.
"""
sys.stdout.write(msg + end)
if flush:
sys.stdout.flush()
# Steam infrastructure app IDs that should NEVER be uninstalled.

View File

@ -5,10 +5,19 @@ from __future__ import annotations
import json
import logging
import time
from typing import Any
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games
from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._enforce_steps import (
_enforce_setup,
_reinstall_missing_allowed,
)
# Re-exported: five modules and the test suite import get_all_owned_app_ids
# from here, so splitting it into _owned_apps_cache stays invisible to them.
from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids
from steam_backlog_enforcer._playtime import playtime_tick
from steam_backlog_enforcer._steam_launch import steam_is_installed
from steam_backlog_enforcer._total_block import (
end_total_block_cleanup,
enforce_total_block_tick,
@ -19,132 +28,31 @@ from steam_backlog_enforcer._whitelist import (
lock_enforcement_files,
)
from steam_backlog_enforcer.config import (
CONFIG_DIR,
CONFIG_FILE,
Config,
State,
_atomic_write,
load_snapshot,
)
from steam_backlog_enforcer.enforcer import (
enforce_allowed_game,
send_notification,
)
from steam_backlog_enforcer.game_install import (
_echo,
from steam_backlog_enforcer.game_uninstall import (
get_installed_games,
install_game,
is_game_installed,
is_protected_app,
uninstall_game,
uninstall_other_games,
)
from steam_backlog_enforcer.library_hider import (
steam_is_installed,
try_hide_other_games,
)
from steam_backlog_enforcer.steam_api import SteamAPIClient
from steam_backlog_enforcer.store_blocker import block_store
logger = logging.getLogger(__name__)
_OWNED_IDS_CACHE_FILE = CONFIG_DIR / "owned_app_ids_cache.json"
_OWNED_IDS_CACHE_TTL_SECONDS = 3600
def _load_owned_app_ids_cache(steam_id: str) -> list[int] | None:
"""Return fresh cached owned app IDs for this steam_id, if available."""
if not steam_id or not _OWNED_IDS_CACHE_FILE.exists():
return None
try:
data: dict[str, Any] = json.loads(
_OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8")
)
except (json.JSONDecodeError, OSError, ValueError):
return None
cached_steam_id = str(data.get("steam_id", ""))
if cached_steam_id != steam_id:
return None
fetched_at = float(data.get("fetched_at", 0.0) or 0.0)
age = time.time() - fetched_at
if age > _OWNED_IDS_CACHE_TTL_SECONDS:
return None
raw_ids = data.get("app_ids", [])
if not isinstance(raw_ids, list):
return None
return [int(app_id) for app_id in raw_ids]
def _save_owned_app_ids_cache(steam_id: str, app_ids: list[int]) -> None:
"""Persist owned app IDs cache for this steam_id."""
payload = {
"steam_id": steam_id,
"fetched_at": time.time(),
"app_ids": app_ids,
}
_atomic_write(_OWNED_IDS_CACHE_FILE, json.dumps(payload, indent=2) + "\n")
# ──────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────
def get_all_owned_app_ids(config: Config) -> list[int]:
"""Get all owned game app IDs from Steam API plus snapshot fallback.
Snapshot data contains only games with achievements, so API data is the
primary source for library hiding. Snapshot IDs are merged in to keep
behavior resilient when the API result is partial.
"""
snapshot = load_snapshot() or []
snapshot_ids = [int(d["app_id"]) for d in snapshot if "app_id" in d]
cached_ids = _load_owned_app_ids_cache(config.steam_id)
if cached_ids is not None:
merged_ids: list[int] = []
seen: set[int] = set()
for app_id in [*cached_ids, *snapshot_ids]:
if app_id in seen:
continue
seen.add(app_id)
merged_ids.append(app_id)
logger.info("Using cached Steam owned IDs (%d entries).", len(cached_ids))
return merged_ids
try:
client = SteamAPIClient(config.steam_api_key, config.steam_id)
owned = client.get_owned_games()
api_ids = [int(g["appid"]) for g in owned if "appid" in g]
_save_owned_app_ids_cache(config.steam_id, api_ids)
merged_ids: list[int] = []
seen: set[int] = set()
for app_id in [*api_ids, *snapshot_ids]:
if app_id in seen:
continue
seen.add(app_id)
merged_ids.append(app_id)
except (OSError, RuntimeError, ValueError):
if snapshot_ids:
return snapshot_ids
logger.warning("Could not fetch owned game list for hiding.")
return []
else:
return merged_ids
# ──────────────────────────────────────────────────────────────
# Enforce mode (daemon loop)
# ──────────────────────────────────────────────────────────────
# How often the enforce loop runs (seconds).
# Seconds between enforce passes.
ENFORCE_INTERVAL = 3
__all__ = [
"ENFORCE_INTERVAL",
"do_enforce",
"get_all_owned_app_ids",
]
def _allowed_names(state: State) -> str:
"""Return a human-readable list of the games the user may play.
@ -191,99 +99,6 @@ def _guard_installed_games(allowed_app_ids: set[int]) -> int:
return count
def _reinstall_missing_allowed(config: Config, state: State) -> None:
"""Re-install any allowed game that vanished between loop iterations.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
for app_id, name in allowed_games(state):
if is_game_installed(app_id):
continue
logger.info("Allowed game disappeared — re-installing %s", name)
install_game(app_id, name, config.steam_id)
def _enforce_setup(config: Config, state: State) -> None:
"""Perform initial setup for enforcement mode.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
# Initial store block.
if config.block_store:
if block_store():
_echo(" Steam store: BLOCKED")
else:
_echo(" Steam store: FAILED (need sudo?)")
# Initial cleanup.
if config.uninstall_other_games:
_echo(" Uninstalling non-assigned games...")
count = uninstall_other_games(allowed_app_ids(state))
_echo(f" Uninstalled {count} games")
# Auto-install the assigned game.
_enforce_auto_install(config, state)
# Hide all other games in the Steam library.
_enforce_hide_games(config, state)
def _enforce_auto_install(config: Config, state: State) -> None:
"""Auto-install every allowed game that is not installed yet.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
for app_id, name in allowed_games(state):
if is_game_installed(app_id):
_echo(f" Allowed game already installed: {name}")
continue
_echo(f" Auto-installing {name}...")
if install_game(
app_id,
name,
config.steam_id,
use_steam_protocol=True,
):
send_notification("Game Installing", f"{name} is being downloaded.")
else:
_echo(" Could not auto-install. Install manually from Steam.")
def _enforce_hide_games(config: Config, state: State) -> None:
"""Hide non-assigned games in the Steam library.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
owned_ids = get_all_owned_app_ids(config)
if not owned_ids:
_echo(" Library hiding: skipped (no owned game list — run 'scan' first)")
return
# An unreachable Steam is not fatal: with no client there is no library to
# hide, and everything else the enforcer does (store block, install guard)
# still works. Letting this escape used to exit(1) into Restart=always,
# which spun the service through ~1000 restarts against a Steam that had
# been uninstalled - each attempt leaving a dead process named "steam"
# behind that /proc scanners misread as a live Steam.
hidden, skipped = try_hide_other_games(owned_ids, allowed_app_ids(state))
if skipped is not None:
_echo(f" Library hiding: skipped ({skipped})")
return
if hidden > 0:
_echo(f" Library: hid {hidden} games (only assigned game visible)")
else:
_echo(" Library: games already hidden")
def _enforce_loop_iteration(
config: Config,
state: State,

View File

@ -0,0 +1,127 @@
"""The individual steps one enforce pass performs.
Split out of :mod:`steam_backlog_enforcer._enforce_loop` to keep both files
under the 250-line cap: this module holds *what* each pass does, while the
loop module owns the cadence and the surrounding lifecycle.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games
from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids
from steam_backlog_enforcer._steam_client import is_game_installed
from steam_backlog_enforcer._steam_state import steam_library_ready
from steam_backlog_enforcer.enforcer import send_notification
from steam_backlog_enforcer.game_install import install_game
from steam_backlog_enforcer.game_uninstall import uninstall_other_games
from steam_backlog_enforcer.library_hider import try_hide_other_games
from steam_backlog_enforcer.store_blocker import block_store
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config, State
logger = logging.getLogger(__name__)
def _reinstall_missing_allowed(config: Config, state: State) -> None:
"""Re-install any allowed game that vanished between loop iterations.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
# Nothing is reinstallable without a Steam library, and every game looks
# "missing" when steamapps does not exist — logging per game per 3s pass
# is just noise. install_game guards this too; returning here keeps the
# log quiet as well.
if not steam_library_ready():
return
for app_id, name in allowed_games(state):
if is_game_installed(app_id):
continue
logger.info("Allowed game disappeared — re-installing %s", name)
install_game(app_id, name, config.steam_id)
def _enforce_setup(config: Config, state: State) -> None:
"""Perform initial setup for enforcement mode.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
# Initial store block.
if config.block_store:
if block_store():
_echo(" Steam store: BLOCKED")
else:
_echo(" Steam store: FAILED (need sudo?)")
# Initial cleanup.
if config.uninstall_other_games:
_echo(" Uninstalling non-assigned games...")
count = uninstall_other_games(allowed_app_ids(state))
_echo(f" Uninstalled {count} games")
# Auto-install the assigned game.
_enforce_auto_install(config, state)
# Hide all other games in the Steam library.
_enforce_hide_games(config, state)
def _enforce_auto_install(config: Config, state: State) -> None:
"""Auto-install every allowed game that is not installed yet.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
for app_id, name in allowed_games(state):
if is_game_installed(app_id):
_echo(f" Allowed game already installed: {name}")
continue
_echo(f" Auto-installing {name}...")
if install_game(
app_id,
name,
config.steam_id,
use_steam_protocol=True,
):
send_notification("Game Installing", f"{name} is being downloaded.")
else:
_echo(" Could not auto-install. Install manually from Steam.")
def _enforce_hide_games(config: Config, state: State) -> None:
"""Hide non-assigned games in the Steam library.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
owned_ids = get_all_owned_app_ids(config)
if not owned_ids:
_echo(" Library hiding: skipped (no owned game list — run 'scan' first)")
return
# An unreachable Steam is not fatal: with no client there is no library to
# hide, and everything else the enforcer does (store block, install guard)
# still works. Letting this escape used to exit(1) into Restart=always,
# which spun the service through ~1000 restarts against a Steam that had
# been uninstalled - each attempt leaving a dead process named "steam"
# behind that /proc scanners misread as a live Steam.
hidden, skipped = try_hide_other_games(owned_ids, allowed_app_ids(state))
if skipped is not None:
_echo(f" Library hiding: skipped ({skipped})")
return
if hidden > 0:
_echo(f" Library: hid {hidden} games (only assigned game visible)")
else:
_echo(" Library: games already hidden")

View File

@ -0,0 +1,101 @@
"""Game-name normalisation and the protected-name check.
Split out of :mod:`steam_backlog_enforcer.game_install` to keep it under the
250-line cap. Pure string handling with no filesystem or Steam knowledge, so
it stays a leaf module.
"""
from __future__ import annotations
import difflib
import logging
import re
# _allowed_games, not _actions: _actions imports game_uninstall, which
# imports this module. The leaf module has the same function and no cycle.
from steam_backlog_enforcer._allowed_games import allowed_games
from steam_backlog_enforcer.config import State
logger = logging.getLogger(__name__)
_NAME_FUZZY_MATCH_THRESHOLD = 0.82
_NUMBER_WORDS = {
"one": "1",
"two": "2",
"three": "3",
"four": "4",
"five": "5",
"six": "6",
"seven": "7",
"eight": "8",
"nine": "9",
"ten": "10",
}
_ROMAN_NUMERALS = {
"i": "1",
"ii": "2",
"iii": "3",
"iv": "4",
"v": "5",
"vi": "6",
"vii": "7",
"viii": "8",
"ix": "9",
"x": "10",
}
def _normalize_game_name(name: str) -> str:
"""Canonicalize a game/folder name for fuzzy comparison.
Splits into alphanumeric words, maps spelled-out numbers ("two") and
roman numerals ("II") to digits, then joins with no separators. Makes
"Kingdom Come: Deliverance II", "KingdomComeDeliverance2", and "kingdom
come deliverance two" all normalize to the same string.
"""
words = re.findall(r"[A-Za-z0-9]+", name.lower())
mapped = [_NUMBER_WORDS.get(w) or _ROMAN_NUMERALS.get(w) or w for w in words]
return "".join(mapped)
def _protected_name_stems() -> list[str]:
"""Every name of every currently-allowed game, for the deletion safety net.
Returns [] (protects nothing extra beyond exact matches already checked
by callers) if state can't be loaded -- this is a best-effort safety net,
not a hard dependency.
"""
try:
return [name for _, name in allowed_games(State.load()) if name]
except Exception:
logger.exception("Could not load allowed games for the deletion safety net")
return []
def _is_protected_name(candidate: str) -> bool:
"""True if *candidate* plausibly names one of the currently-allowed games.
Errs toward over-matching on purpose: a false positive here just skips a
deletion that can be done manually; a false negative deletes real files.
"""
normalized_candidate = _normalize_game_name(candidate)
if not normalized_candidate:
return False
for protected in _protected_name_stems():
normalized_protected = _normalize_game_name(protected)
if not normalized_protected:
continue
if normalized_candidate == normalized_protected:
return True
ratio = difflib.SequenceMatcher(
None, normalized_candidate, normalized_protected
).ratio()
if ratio >= _NAME_FUZZY_MATCH_THRESHOLD:
return True
return False
# Real Steam directory — used as a safety check to block destructive
# operations that leak through during testing.

View File

@ -0,0 +1,121 @@
"""Cached lookup of the Steam account's full owned-app list.
Split out of :mod:`steam_backlog_enforcer._enforce_loop` to keep both files
under the 250-line cap. The enforce loop runs every 3s but the owned-games
list changes rarely, so it is cached on disk with a TTL rather than re-fetched
from the Steam Web API each pass.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any
from steam_backlog_enforcer.config import (
CONFIG_DIR,
Config,
_atomic_write,
load_snapshot,
)
from steam_backlog_enforcer.steam_api import SteamAPIClient
logger = logging.getLogger(__name__)
_OWNED_IDS_CACHE_FILE = CONFIG_DIR / "owned_app_ids_cache.json"
_OWNED_IDS_CACHE_TTL_SECONDS = 3600
def _load_owned_app_ids_cache(steam_id: str) -> list[int] | None:
"""Return fresh cached owned app IDs for this steam_id, if available."""
if not steam_id or not _OWNED_IDS_CACHE_FILE.exists():
return None
try:
data: dict[str, Any] = json.loads(
_OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8")
)
except (json.JSONDecodeError, OSError, ValueError):
return None
cached_steam_id = str(data.get("steam_id", ""))
if cached_steam_id != steam_id:
return None
fetched_at = float(data.get("fetched_at", 0.0) or 0.0)
age = time.time() - fetched_at
if age > _OWNED_IDS_CACHE_TTL_SECONDS:
return None
raw_ids = data.get("app_ids", [])
if not isinstance(raw_ids, list):
return None
return [int(app_id) for app_id in raw_ids]
def _save_owned_app_ids_cache(steam_id: str, app_ids: list[int]) -> None:
"""Persist owned app IDs cache for this steam_id."""
payload = {
"steam_id": steam_id,
"fetched_at": time.time(),
"app_ids": app_ids,
}
_atomic_write(_OWNED_IDS_CACHE_FILE, json.dumps(payload, indent=2) + "\n")
# ──────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────
def get_all_owned_app_ids(config: Config) -> list[int]:
"""Get all owned game app IDs from Steam API plus snapshot fallback.
Snapshot data contains only games with achievements, so API data is the
primary source for library hiding. Snapshot IDs are merged in to keep
behavior resilient when the API result is partial.
"""
snapshot = load_snapshot() or []
snapshot_ids = [int(d["app_id"]) for d in snapshot if "app_id" in d]
cached_ids = _load_owned_app_ids_cache(config.steam_id)
if cached_ids is not None:
merged_ids: list[int] = []
seen: set[int] = set()
for app_id in [*cached_ids, *snapshot_ids]:
if app_id in seen:
continue
seen.add(app_id)
merged_ids.append(app_id)
logger.info("Using cached Steam owned IDs (%d entries).", len(cached_ids))
return merged_ids
try:
client = SteamAPIClient(config.steam_api_key, config.steam_id)
owned = client.get_owned_games()
api_ids = [int(g["appid"]) for g in owned if "appid" in g]
_save_owned_app_ids_cache(config.steam_id, api_ids)
merged_ids: list[int] = []
seen: set[int] = set()
for app_id in [*api_ids, *snapshot_ids]:
if app_id in seen:
continue
seen.add(app_id)
merged_ids.append(app_id)
except (OSError, RuntimeError, ValueError):
if snapshot_ids:
return snapshot_ids
logger.warning("Could not fetch owned game list for hiding.")
return []
else:
return merged_ids
# ──────────────────────────────────────────────────────────────
# Enforce mode (daemon loop)
# ──────────────────────────────────────────────────────────────
# How often the enforce loop runs (seconds).

View File

@ -9,7 +9,8 @@ from __future__ import annotations
import logging
from steam_backlog_enforcer.library_hider import _resolve_desktop_user, _run_as_user
from steam_backlog_enforcer._steam_launch import _resolve_desktop_user
from steam_backlog_enforcer._steam_process import _run_as_user
logger = logging.getLogger(__name__)
@ -20,10 +21,9 @@ _MINUTES_PER_HOUR = 60
def notify_desktop_user(title: str, body: str) -> None:
"""Send a desktop notification into the real user's session.
``enforcer.send_notification`` runs ``notify-send`` as root with no
``DBUS_SESSION_BUS_ADDRESS``, which cannot reach the user's session bus from
a system service. This routes through the same ``sudo -u <user> env ...``
mechanism that ``library_hider`` uses to launch Steam.
``enforcer.send_notification`` now drops privileges too, so this differs
only in delivery: it is fire-and-forget (``Popen``) where that one waits up
to 5s, which matters on the playtime-cutoff path.
Args:
title: Notification title.

View File

@ -0,0 +1,44 @@
"""Steam AppIDs that must never be uninstalled.
Runtimes and compatibility tools, not games: removing one silently breaks
every Proton title. Split out of :mod:`steam_backlog_enforcer.game_install`
to keep that module under the 250-line cap; this is data, not logic.
"""
from __future__ import annotations
PROTECTED_APP_IDS = {
# Steam runtimes and tooling (never uninstall these)
228980, # Steamworks Common Redistributables
1070560, # Steam Linux Runtime 1.0 (scout)
1391110, # Steam Linux Runtime 2.0 (soldier)
1628350, # Steam Linux Runtime 3.0 (sniper)
4183110, # Steam Linux Runtime 4.0
4185400, # Steam Linux Runtime 4.0 - Arm64
961940, # Steam Linux Runtime (legacy)
4690330, # Legacy Steam Runtime
613220, # Steam 360 Video Player
250820, # SteamVR
1007, # Steamworks SDK Redist
# Proton versions (never uninstall these)
858280, # Proton 3.7 (Beta)
930400, # Proton 3.16 (Beta)
1054830, # Proton 4.2
1113280, # Proton 4.11
1245040, # Proton 5.0
1420170, # Proton 5.13
1580130, # Proton 6.3
1887720, # Proton 7.0
2230260, # Proton 7.0 (alt)
2348590, # Proton 8.0
2805730, # Proton 9.0
3201940, # Proton 9.0 (alt)
3658110, # Proton 10.0
4628710, # Proton 11.0
4628740, # Proton 11.0 (ARM64)
2180100, # Proton Hotfix
1493710, # Proton Experimental
1161040, # Proton BattlEye Runtime
1007020, # Proton EasyAntiCheat Runtime
1826330, # Proton EasyAntiCheat Runtime
}

View File

@ -0,0 +1,123 @@
"""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_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
else:
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"]
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# 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()

View File

@ -0,0 +1,40 @@
"""Errors raised when Steam cannot be driven.
A leaf module so both :mod:`steam_backlog_enforcer.library_hider` and
:mod:`steam_backlog_enforcer._steam_launch` can raise and catch these without
importing each other. Split out to keep both under the 250-line cap.
"""
from __future__ import annotations
class SteamUnavailableError(RuntimeError):
"""Raised when Steam cannot be driven over CDP.
Covers both "Steam is not installed" and "Steam is installed but never
opened its debug port". Callers are expected to degrade gracefully rather
than abort: an unreachable Steam means there is no library to hide, which
is not a fatal condition for the enforcer.
"""
class SteamUpdateInProgressError(SteamUnavailableError):
"""Raised to defer a Steam restart while a game update is in flight.
Subclasses :class:`SteamUnavailableError` so existing callers already
degrade gracefully (skip this pass, retry next loop). Restarting Steam
mid-update suspends and can corrupt the transfer, so the enforcer waits
for updates to finish before bouncing Steam to open the CDP port.
"""
class DesktopSessionNotReadyError(SteamUnavailableError):
"""Raised to defer a Steam launch until the user's session exists.
Subclasses :class:`SteamUnavailableError` so existing callers already
degrade gracefully (skip this pass, retry next loop). Launching Steam
before ``/run/user/<uid>`` exists hands every Wine child a runtime dir
that is not there, so it falls back from winepulse to winealsa and stays
that way for the whole session — the Kingdom Come: Deliverance II startup
crash. Waiting a pass costs 3s; getting it wrong costs a reboot.
"""

View File

@ -0,0 +1,248 @@
"""Getting Steam running with its devtools port open.
Split out of :mod:`steam_backlog_enforcer.library_hider` to keep both files
under the 250-line cap: this module owns the Steam *lifecycle* (is it up, does
it have a CDP port, restart it as the desktop user), not what to do with it.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
import shutil
import subprocess
import time
from steam_backlog_enforcer._cdp import (
_CDP_PORT,
_cdp_result_value,
_evaluate_js,
_get_shared_js_ws_url,
)
from steam_backlog_enforcer._desktop_env import (
desktop_runtime_dir,
desktop_session_ready,
desktop_uid,
)
from steam_backlog_enforcer._steam_errors import (
DesktopSessionNotReadyError,
SteamUnavailableError,
SteamUpdateInProgressError,
)
from steam_backlog_enforcer._steam_process import _run_as_user
from steam_backlog_enforcer._steam_state import steam_update_in_progress
logger = logging.getLogger(__name__)
_CDP_TIMEOUT = 120
_STEAM_STARTUP_WAIT = 45
# Real Steam client binary, as shipped by the distro's `steam` package.
#
# Deliberately NOT probed with shutil.which("steam"): a launcher wrapper on
# $PATH (e.g. /usr/local/bin/steam adding -cef-* flags) keeps `which` truthy
# long after the package itself is gone - as happens when a total block
# uninstalls Steam. Checking the real binary is what actually answers
# "can we launch Steam at all?".
_STEAM_BINARY = "/usr/bin/steam"
def steam_is_installed() -> bool:
"""Return True if the real Steam client binary is present."""
return Path(_STEAM_BINARY).exists()
# Handles for fire-and-forget launches, kept only so they can be reaped.
_SPAWNED: list[subprocess.Popen[bytes]] = []
# ──────────────────────────────────────────────────────────────
# Ensure Steam is running with devtools port
# ──────────────────────────────────────────────────────────────
def _is_steam_running() -> bool:
"""Check whether any Steam process is alive."""
pgrep = shutil.which("pgrep") or "/usr/bin/pgrep"
result = subprocess.run(
[pgrep, "-x", "steam"],
capture_output=True,
check=False,
)
return result.returncode == 0
def _steam_has_debug_port() -> bool:
"""Check whether steamwebhelper is listening on the CDP port."""
return _get_shared_js_ws_url() is not None
def _wait_for_cdp_ready() -> bool:
"""Wait up to *_STEAM_STARTUP_WAIT* seconds for CDP to become ready."""
for _ in range(_STEAM_STARTUP_WAIT):
if _get_shared_js_ws_url() is not None:
return True
time.sleep(1)
return False
def _wait_for_collections_ready() -> bool:
"""Wait until ``collectionStore`` is fully initialised.
Right after Steam starts, the CDP port may be open but the
internal collection data hasn't loaded yet. Poll a lightweight
JS check until ``GetCollection`` stops throwing.
"""
js = (
"(() => { try { collectionStore.GetCollection('hidden');"
" return 'ok'; } catch(e) { return 'not_ready'; } })()"
)
for _ in range(_STEAM_STARTUP_WAIT):
try:
result = _evaluate_js(js)
if _cdp_result_value(result) == "ok":
return True
except RuntimeError:
pass
time.sleep(1)
return False
def _resolve_desktop_user() -> str | None:
"""Resolve which desktop user owns the Steam/X11 session.
Prefers the explicit STEAM_ENFORCER_DESKTOP_USER (set by the systemd
unit, which has no SUDO_USER/USER of its own since it is started
directly by systemd rather than via `sudo`), then falls back to
SUDO_USER/USER for interactive `sudo` invocations.
"""
return (
os.environ.get("STEAM_ENFORCER_DESKTOP_USER")
or os.environ.get("SUDO_USER")
or os.environ.get("USER")
)
def _shutdown_steam() -> None:
"""Send ``steam -shutdown`` and wait for the process to exit."""
real_user = _resolve_desktop_user()
try:
_run_as_user(["steam", "-shutdown"], real_user)
except FileNotFoundError:
return
pgrep = shutil.which("pgrep") or "/usr/bin/pgrep"
for _ in range(30):
result = subprocess.run(
[pgrep, "-x", "steam"],
capture_output=True,
check=False,
)
if result.returncode != 0:
return
time.sleep(1)
def _launch_steam_with_debug() -> None:
"""Launch Steam with CEF debugging enabled."""
real_user = _resolve_desktop_user()
_run_as_user(
[
"steam",
"-cef-enable-debugging",
f"-devtools-port={_CDP_PORT}",
"-silent",
],
real_user,
)
def ensure_steam_debug_port() -> None:
"""Make sure Steam is running with the CDP debug port open.
If Steam is running without the port, it is restarted.
If Steam is not running, it is launched.
Raises:
SteamUnavailableError: If Steam is not installed, or is installed but
never opens its debug port.
"""
if _steam_has_debug_port():
logger.debug("Steam CDP port already available.")
return
# Bail out before the ~45s launch-and-wait: with no binary to exec there
# is nothing to wait for, and retrying every pass only burns time and
# leaves dead processes behind.
if not steam_is_installed():
msg = f"Steam is not installed ({_STEAM_BINARY} does not exist)"
raise SteamUnavailableError(msg)
# Never launch Steam into a session that does not exist yet: it would get
# a missing XDG_RUNTIME_DIR, silently fall back to winealsa, and stay
# audio-broken until the next restart. Defer instead — the loop retries.
real_user = _resolve_desktop_user()
if os.geteuid() == 0 and not desktop_session_ready(desktop_uid(real_user)):
msg = (
"Deferring Steam launch: the desktop session's runtime directory "
f"({desktop_runtime_dir(desktop_uid(real_user))}) does not exist "
"yet. Launching now would leave Steam without audio; will retry."
)
logger.info(msg)
raise DesktopSessionNotReadyError(msg)
logger.info("Steam CDP port not available — (re)starting Steam...")
if _is_steam_running():
# Never bounce a running Steam while a game update is downloading or
# committing: the shutdown suspends it and can leave a partially
# written install (the root cause of the AoE2 launch crash). Defer and
# retry on the next enforce pass.
if steam_update_in_progress():
msg = (
"Deferring Steam restart: a game update is in progress. "
"Restarting now would interrupt and can corrupt it; will "
"retry once the update settles."
)
logger.info(msg)
raise SteamUpdateInProgressError(msg)
_shutdown_steam()
_launch_steam_with_debug()
if not _wait_for_cdp_ready():
msg = "Timed out waiting for Steam CDP port to become ready"
raise SteamUnavailableError(msg)
logger.info("Steam CDP port ready.")
if not _wait_for_collections_ready():
msg = "Timed out waiting for Steam collections to initialise"
raise SteamUnavailableError(msg)
logger.info("Steam collection store ready.")
# ──────────────────────────────────────────────────────────────
# Steam restart helper
# ──────────────────────────────────────────────────────────────
def restart_steam() -> None:
"""Gracefully restart the Steam client with CEF debugging enabled.
Skips the restart if a game update is downloading or committing, so the
update is not interrupted (interrupting it can corrupt the install).
"""
if steam_update_in_progress():
logger.warning(
"Skipping Steam restart — a game update is in progress; "
"restarting now could corrupt it.",
)
return
logger.info("Restarting Steam client with debug port...")
_shutdown_steam()
_launch_steam_with_debug()
if not _wait_for_cdp_ready():
logger.warning("Steam restarted but CDP port not ready.")
else:
logger.info("Steam restarted with CDP port ready.")

View File

@ -0,0 +1,58 @@
"""Spawning processes as the desktop user, and reaping them.
Split out of :mod:`steam_backlog_enforcer._steam_launch` to keep both under
the 250-line cap. Owns the fire-and-forget process registry so it has a single
owner rather than a global shared across responsibilities.
"""
from __future__ import annotations
import logging
import os
import subprocess
from steam_backlog_enforcer._desktop_env import desktop_user_cmd
logger = logging.getLogger(__name__)
# Handles for fire-and-forget launches, kept only so they can be reaped.
_SPAWNED: list[subprocess.Popen[bytes]] = []
def _reap_spawned() -> None:
"""Clear out previously launched processes that have since exited.
Launches here are fire-and-forget: Steam is meant to outlive the call, so
it is never waited on. That leaves any launch which dies immediately - a
missing binary, a broken wrapper - sitting as a zombie that still carries
the name ``steam``, which anything scanning /proc reads as "Steam is
running". Polling the old handles reaps them and retires the name.
"""
_SPAWNED[:] = [proc for proc in _SPAWNED if proc.poll() is None]
def _run_as_user(cmd: list[str], user: str | None) -> None:
"""Run a command, dropping to *user* if currently root.
Refuses to run at all when root and no desktop user resolves: the command
would otherwise execute *as root* on the user's session, which is how the
enforcer put a root Steam ("Cannot run as root user") on screen.
Failing closed costs one skipped pass; failing open costs a modal.
"""
_reap_spawned()
if os.geteuid() == 0 and (not user or user == "root"):
logger.error(
"Refusing to run %s as root: no desktop user resolved "
"(is STEAM_ENFORCER_DESKTOP_USER set?)",
cmd[0] if cmd else "<empty>",
)
return
full_cmd = desktop_user_cmd(cmd, user)
_SPAWNED.append(
subprocess.Popen(
full_cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
)

View File

@ -8,11 +8,50 @@ 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.

View File

@ -9,9 +9,11 @@ import shutil
import signal
import subprocess
from steam_backlog_enforcer.game_install import (
is_protected_app,
from steam_backlog_enforcer._desktop_env import (
desktop_user_cmd,
resolve_desktop_user,
)
from steam_backlog_enforcer.game_uninstall import is_protected_app
logger = logging.getLogger(__name__)
@ -149,14 +151,28 @@ def kill_process(pid: int, app_id: int) -> None:
def send_notification(title: str, body: str) -> None:
"""Send a desktop notification."""
"""Send a desktop notification into the real user's session.
The enforcer runs as root under systemd, where a bare ``notify-send`` has
no DBUS_SESSION_BUS_ADDRESS and so cannot reach the user's session bus —
it failed silently for every caller. Dropping to the desktop user supplies
the session variables it needs.
"""
_notify_send = shutil.which("notify-send") or "/usr/bin/notify-send"
cmd = desktop_user_cmd(
[_notify_send, title, body, "--icon=dialog-warning"],
resolve_desktop_user(),
)
try:
subprocess.run(
[_notify_send, title, body, "--icon=dialog-warning"],
cmd,
capture_output=True,
timeout=5,
check=False,
)
except (FileNotFoundError, OSError):
logger.debug("notify-send not available.")
except subprocess.TimeoutExpired:
# sudo can block where a bare notify-send would not; a dropped
# notification is better than an exception escaping the enforce loop.
logger.debug("notify-send timed out.")

View File

@ -2,192 +2,68 @@
from __future__ import annotations
import contextlib
import difflib
import logging
import os
from pathlib import Path
import pwd
import re
import shutil
import subprocess
import sys
import time
from steam_backlog_enforcer._allowed_games import allowed_games
from steam_backlog_enforcer._desktop_env import (
desktop_env_args,
desktop_runtime_dir,
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,
)
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH
from steam_backlog_enforcer._whitelist import get_approved_exception_ids
from steam_backlog_enforcer.config import State
from steam_backlog_enforcer.library_hider import steam_is_installed
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.
_NAME_FUZZY_MATCH_THRESHOLD = 0.82
_NUMBER_WORDS = {
"one": "1",
"two": "2",
"three": "3",
"four": "4",
"five": "5",
"six": "6",
"seven": "7",
"eight": "8",
"nine": "9",
"ten": "10",
}
_ROMAN_NUMERALS = {
"i": "1",
"ii": "2",
"iii": "3",
"iv": "4",
"v": "5",
"vi": "6",
"vii": "7",
"viii": "8",
"ix": "9",
"x": "10",
}
def _normalize_game_name(name: str) -> str:
"""Canonicalize a game/folder name for fuzzy comparison.
Splits into alphanumeric words, maps spelled-out numbers ("two") and
roman numerals ("II") to digits, then joins with no separators. Makes
"Kingdom Come: Deliverance II", "KingdomComeDeliverance2", and "kingdom
come deliverance two" all normalize to the same string.
"""
words = re.findall(r"[A-Za-z0-9]+", name.lower())
mapped = [_NUMBER_WORDS.get(w) or _ROMAN_NUMERALS.get(w) or w for w in words]
return "".join(mapped)
def _protected_name_stems() -> list[str]:
"""Every name of every currently-allowed game, for the deletion safety net.
Returns [] (protects nothing extra beyond exact matches already checked
by callers) if state can't be loaded -- this is a best-effort safety net,
not a hard dependency.
"""
try:
return [name for _, name in allowed_games(State.load()) if name]
except Exception:
logger.exception("Could not load allowed games for the deletion safety net")
return []
def _is_protected_name(candidate: str) -> bool:
"""True if *candidate* plausibly names one of the currently-allowed games.
Errs toward over-matching on purpose: a false positive here just skips a
deletion that can be done manually; a false negative deletes real files.
"""
normalized_candidate = _normalize_game_name(candidate)
if not normalized_candidate:
return False
for protected in _protected_name_stems():
normalized_protected = _normalize_game_name(protected)
if not normalized_protected:
continue
if normalized_candidate == normalized_protected:
return True
ratio = difflib.SequenceMatcher(
None, normalized_candidate, normalized_protected
).ratio()
if ratio >= _NAME_FUZZY_MATCH_THRESHOLD:
return True
return False
# Real Steam directory — used as a safety check to block destructive
# operations that leak through during testing.
_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 _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None:
"""Write user-facing CLI output to stdout.
Args:
msg: Text to output.
end: String appended after the message.
flush: Whether to flush stdout immediately.
"""
sys.stdout.write(msg + end)
if flush:
sys.stdout.flush()
# Steam infrastructure app IDs that should NEVER be uninstalled.
PROTECTED_APP_IDS = {
# Steam runtimes and tooling (never uninstall these)
228980, # Steamworks Common Redistributables
1070560, # Steam Linux Runtime 1.0 (scout)
1391110, # Steam Linux Runtime 2.0 (soldier)
1628350, # Steam Linux Runtime 3.0 (sniper)
4183110, # Steam Linux Runtime 4.0
4185400, # Steam Linux Runtime 4.0 - Arm64
961940, # Steam Linux Runtime (legacy)
4690330, # Legacy Steam Runtime
613220, # Steam 360 Video Player
250820, # SteamVR
1007, # Steamworks SDK Redist
# Proton versions (never uninstall these)
858280, # Proton 3.7 (Beta)
930400, # Proton 3.16 (Beta)
1054830, # Proton 4.2
1113280, # Proton 4.11
1245040, # Proton 5.0
1420170, # Proton 5.13
1580130, # Proton 6.3
1887720, # Proton 7.0
2230260, # Proton 7.0 (alt)
2348590, # Proton 8.0
2805730, # Proton 9.0
3201940, # Proton 9.0 (alt)
3658110, # Proton 10.0
4628710, # Proton 11.0
4628740, # Proton 11.0 (ARM64)
2180100, # Proton Hotfix
1493710, # Proton Experimental
1161040, # Proton BattlEye Runtime
1007020, # Proton EasyAntiCheat Runtime
1826330, # Proton EasyAntiCheat Runtime
}
def _trigger_steam_install(app_id: int, label: str) -> bool:
@ -196,9 +72,17 @@ def _trigger_steam_install(app_id: int, label: str) -> bool:
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(
[xdg_open, f"steam://install/{app_id}"],
desktop_user_cmd([xdg_open, f"steam://install/{app_id}"], real_user),
capture_output=True,
timeout=15,
check=False,
@ -215,92 +99,6 @@ def _trigger_steam_install(app_id: int, label: str) -> bool:
# ──────────────────────────────────────────────────────────────
def _get_real_user() -> str | None:
"""Get the real (non-root) user when running under sudo."""
return os.environ.get("SUDO_USER") or os.environ.get("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
else:
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"]
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Give Steam time to initialize and start scanning manifests.
time.sleep(15)
except FileNotFoundError:
logger.exception("Steam executable not found")
def install_game(
app_id: int,
game_name: str,
@ -336,6 +134,23 @@ def install_game(
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):
@ -401,153 +216,3 @@ def install_game(
_ensure_steam_running()
return True
# ──────────────────────────────────────────────────────────────
# Game uninstall management
# ──────────────────────────────────────────────────────────────
def get_installed_games() -> list[tuple[int, str]]:
"""Parse appmanifest files to find installed games.
Returns: list of (app_id, game_name) tuples.
"""
installed: list[tuple[int, str]] = []
for manifest_file in STEAMAPPS_PATH.glob("appmanifest_*.acf"):
with contextlib.suppress(OSError):
content = manifest_file.read_text(encoding="utf-8")
app_id_match = re.search(r'"appid"\s+"(\d+)"', content)
name_match = re.search(r'"name"\s+"([^"]+)"', content)
if app_id_match:
app_id = int(app_id_match.group(1))
name = name_match.group(1) if name_match else f"Unknown ({app_id})"
installed.append((app_id, name))
installed.sort(key=lambda x: x[1].lower())
return installed
def _read_install_dir(manifest: Path) -> Path | None:
"""Read installdir from a game's appmanifest file."""
if not manifest.exists():
return None
try:
content = manifest.read_text(encoding="utf-8")
match = re.search(r'"installdir"\s+"([^"]+)"', content)
if match:
return STEAMAPPS_PATH / "common" / match.group(1)
except OSError:
pass
return None
def _remove_manifest(manifest: Path, game_name: str, app_id: int) -> bool:
"""Remove a game manifest file.
Args:
manifest: Path to the appmanifest file.
game_name: Human-readable game name for logging.
app_id: Steam application ID.
"""
_assert_not_real_steam(manifest)
try:
if manifest.exists():
manifest.unlink()
logger.info(
"Removed manifest for %s (AppID=%d)", game_name or app_id, app_id
)
except OSError:
logger.exception("Failed to remove manifest for AppID=%d", app_id)
return False
return True
def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool:
"""Remove game installation directory and cache directories.
Args:
install_dir: Path to the game's install directory, or None.
app_id: Steam application ID.
"""
success = True
if install_dir and install_dir.is_dir():
_assert_not_real_steam(install_dir)
if _is_protected_name(install_dir.name):
logger.warning(
"Refusing to remove %s: name matches an allowed game", install_dir
)
return False
try:
shutil.rmtree(install_dir)
logger.info("Removed game files: %s", install_dir)
except OSError:
logger.exception("Failed to remove game dir %s", install_dir)
success = False
for subdir in ("shadercache", "compatdata"):
cache_path = STEAMAPPS_PATH / subdir / str(app_id)
if cache_path.is_dir():
_assert_not_real_steam(cache_path)
with contextlib.suppress(OSError):
shutil.rmtree(cache_path)
logger.debug("Removed %s/%d", subdir, app_id)
return success
def uninstall_game(app_id: int, game_name: str = "") -> bool:
"""Uninstall a single game by removing its manifest and game files.
Uses direct file removal instead of ``steam://uninstall`` URI to avoid
GUI popups and to work when Steam is not running.
"""
manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
install_dir = _read_install_dir(manifest)
success = _remove_manifest(manifest, game_name, app_id)
if not _remove_game_dirs(install_dir, app_id):
success = False
return success
def uninstall_other_games(allowed_app_ids: set[int]) -> int:
"""Uninstall all installed games except the allowed ones and protected IDs.
Args:
allowed_app_ids: Every app id that must survive — the assignment plus
any concurrent manual picks. Empty means "keep nothing".
Returns: number of games uninstalled.
"""
installed = get_installed_games()
count = 0
for app_id, name in installed:
if app_id in allowed_app_ids:
logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id)
continue
if is_protected_app(app_id):
logger.debug("Skipping protected: %s (AppID=%d)", name, app_id)
continue
logger.info("UNINSTALLING: %s (AppID=%d)", name, app_id)
if uninstall_game(app_id, name):
count += 1
return count
def is_protected_app(app_id: int) -> bool:
"""Return True if *app_id* must never be uninstalled.
Combines the hardcoded Steam infrastructure set with any app IDs that
have been approved via the time-locked exception mechanism.
Args:
app_id: Steam application ID to check.
Returns:
True if the app should be left alone by the enforcer.
"""
return app_id in PROTECTED_APP_IDS or app_id in get_approved_exception_ids()

View File

@ -0,0 +1,174 @@
"""Uninstalling games and querying what is installed.
Split out of :mod:`steam_backlog_enforcer.game_install` to keep both files
under the 250-line cap: that module owns getting a game *onto* disk, this one
owns taking it off and reporting what is there.
"""
from __future__ import annotations
import contextlib
import logging
import re
import shutil
from typing import TYPE_CHECKING
from steam_backlog_enforcer._game_names import _is_protected_name
from steam_backlog_enforcer._protected_apps import PROTECTED_APP_IDS
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH, _assert_not_real_steam
from steam_backlog_enforcer._whitelist import get_approved_exception_ids
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────
# Game uninstall management
# ──────────────────────────────────────────────────────────────
def get_installed_games() -> list[tuple[int, str]]:
"""Parse appmanifest files to find installed games.
Returns: list of (app_id, game_name) tuples.
"""
installed: list[tuple[int, str]] = []
for manifest_file in STEAMAPPS_PATH.glob("appmanifest_*.acf"):
with contextlib.suppress(OSError):
content = manifest_file.read_text(encoding="utf-8")
app_id_match = re.search(r'"appid"\s+"(\d+)"', content)
name_match = re.search(r'"name"\s+"([^"]+)"', content)
if app_id_match:
app_id = int(app_id_match.group(1))
name = name_match.group(1) if name_match else f"Unknown ({app_id})"
installed.append((app_id, name))
installed.sort(key=lambda x: x[1].lower())
return installed
def _read_install_dir(manifest: Path) -> Path | None:
"""Read installdir from a game's appmanifest file."""
if not manifest.exists():
return None
try:
content = manifest.read_text(encoding="utf-8")
match = re.search(r'"installdir"\s+"([^"]+)"', content)
if match:
return STEAMAPPS_PATH / "common" / match.group(1)
except OSError:
pass
return None
def _remove_manifest(manifest: Path, game_name: str, app_id: int) -> bool:
"""Remove a game manifest file.
Args:
manifest: Path to the appmanifest file.
game_name: Human-readable game name for logging.
app_id: Steam application ID.
"""
_assert_not_real_steam(manifest)
try:
if manifest.exists():
manifest.unlink()
logger.info(
"Removed manifest for %s (AppID=%d)", game_name or app_id, app_id
)
except OSError:
logger.exception("Failed to remove manifest for AppID=%d", app_id)
return False
return True
def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool:
"""Remove game installation directory and cache directories.
Args:
install_dir: Path to the game's install directory, or None.
app_id: Steam application ID.
"""
success = True
if install_dir and install_dir.is_dir():
_assert_not_real_steam(install_dir)
if _is_protected_name(install_dir.name):
logger.warning(
"Refusing to remove %s: name matches an allowed game", install_dir
)
return False
try:
shutil.rmtree(install_dir)
logger.info("Removed game files: %s", install_dir)
except OSError:
logger.exception("Failed to remove game dir %s", install_dir)
success = False
for subdir in ("shadercache", "compatdata"):
cache_path = STEAMAPPS_PATH / subdir / str(app_id)
if cache_path.is_dir():
_assert_not_real_steam(cache_path)
with contextlib.suppress(OSError):
shutil.rmtree(cache_path)
logger.debug("Removed %s/%d", subdir, app_id)
return success
def uninstall_game(app_id: int, game_name: str = "") -> bool:
"""Uninstall a single game by removing its manifest and game files.
Uses direct file removal instead of ``steam://uninstall`` URI to avoid
GUI popups and to work when Steam is not running.
"""
manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
install_dir = _read_install_dir(manifest)
success = _remove_manifest(manifest, game_name, app_id)
if not _remove_game_dirs(install_dir, app_id):
success = False
return success
def uninstall_other_games(allowed_app_ids: set[int]) -> int:
"""Uninstall all installed games except the allowed ones and protected IDs.
Args:
allowed_app_ids: Every app id that must survive — the assignment plus
any concurrent manual picks. Empty means "keep nothing".
Returns: number of games uninstalled.
"""
installed = get_installed_games()
count = 0
for app_id, name in installed:
if app_id in allowed_app_ids:
logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id)
continue
if is_protected_app(app_id):
logger.debug("Skipping protected: %s (AppID=%d)", name, app_id)
continue
logger.info("UNINSTALLING: %s (AppID=%d)", name, app_id)
if uninstall_game(app_id, name):
count += 1
return count
def is_protected_app(app_id: int) -> bool:
"""Return True if *app_id* must never be uninstalled.
Combines the hardcoded Steam infrastructure set with any app IDs that
have been approved via the time-locked exception mechanism.
Args:
app_id: Steam application ID to check.
Returns:
True if the app should be left alone by the enforcer.
"""
return app_id in PROTECTED_APP_IDS or app_id in get_approved_exception_ids()

View File

@ -16,328 +16,35 @@ will shut Steam down and relaunch it with the required flags.
from __future__ import annotations
import asyncio
import json
import logging
import os
from pathlib import Path
import pwd
import shutil
import subprocess
import time
import requests
import websockets
from steam_backlog_enforcer._desktop_env import (
desktop_env_args,
desktop_runtime_dir,
desktop_session_ready,
from steam_backlog_enforcer._cdp import _cdp_result_value, _evaluate_js
from steam_backlog_enforcer._steam_errors import (
SteamUnavailableError,
)
from steam_backlog_enforcer._steam_launch import (
ensure_steam_debug_port,
restart_steam,
steam_is_installed,
)
from steam_backlog_enforcer._steam_state import steam_update_in_progress
logger = logging.getLogger(__name__)
# Re-exported: the Steam lifecycle now lives in _steam_launch, but callers
# still import these from here. __all__ stops the linter deleting them.
__all__ = [
"hide_other_games",
"restart_steam",
"steam_is_installed",
"try_hide_other_games",
"unhide_all_games",
]
_CDP_PORT = 9222
# NOTE: was 8080, which collided with a different local service (Open WebUI)
# already bound to 0.0.0.0:8080. requests to 127.0.0.1:8080 resolved to that
# service instead of steamwebhelper's CDP endpoint (which only bound
# [::1]:8080), so CDP detection silently never worked. 9222 is the
# conventional Chrome DevTools debug port and was confirmed free.
_CDP_TIMEOUT = 120
_STEAM_STARTUP_WAIT = 45
# Real Steam client binary, as shipped by the distro's `steam` package.
#
# Deliberately NOT probed with shutil.which("steam"): a launcher wrapper on
# $PATH (e.g. /usr/local/bin/steam adding -cef-* flags) keeps `which` truthy
# long after the package itself is gone - as happens when a total block
# uninstalls Steam. Checking the real binary is what actually answers
# "can we launch Steam at all?".
_STEAM_BINARY = "/usr/bin/steam"
# Handles for fire-and-forget launches, kept only so they can be reaped.
_SPAWNED: list[subprocess.Popen[bytes]] = []
class SteamUnavailableError(RuntimeError):
"""Raised when Steam cannot be driven over CDP.
Covers both "Steam is not installed" and "Steam is installed but never
opened its debug port". Callers are expected to degrade gracefully rather
than abort: an unreachable Steam means there is no library to hide, which
is not a fatal condition for the enforcer.
"""
class SteamUpdateInProgressError(SteamUnavailableError):
"""Raised to defer a Steam restart while a game update is in flight.
Subclasses :class:`SteamUnavailableError` so existing callers already
degrade gracefully (skip this pass, retry next loop). Restarting Steam
mid-update suspends and can corrupt the transfer, so the enforcer waits
for updates to finish before bouncing Steam to open the CDP port.
"""
class DesktopSessionNotReadyError(SteamUnavailableError):
"""Raised to defer a Steam launch until the user's session exists.
Subclasses :class:`SteamUnavailableError` so existing callers already
degrade gracefully (skip this pass, retry next loop). Launching Steam
before ``/run/user/<uid>`` exists hands every Wine child a runtime dir
that is not there, so it falls back from winepulse to winealsa and stays
that way for the whole session — the Kingdom Come: Deliverance II startup
crash. Waiting a pass costs 3s; getting it wrong costs a reboot.
"""
def _desktop_uid(user: str | None) -> int:
"""Resolve *user* to a uid, defaulting to the usual first desktop uid."""
if user:
try:
return pwd.getpwnam(user).pw_uid
except KeyError:
pass
return 1000
def steam_is_installed() -> bool:
"""Return True if the real Steam client binary is present."""
return Path(_STEAM_BINARY).exists()
# ──────────────────────────────────────────────────────────────
# CDP (Chrome DevTools Protocol) helpers
# ──────────────────────────────────────────────────────────────
def _get_shared_js_ws_url() -> str | None:
"""Query the CDP HTTP endpoint and return the SharedJSContext WS URL."""
try:
resp = requests.get(f"http://127.0.0.1:{_CDP_PORT}/json", timeout=5)
targets = resp.json()
except (OSError, ValueError):
return None
for target in targets:
if target.get("title") == "SharedJSContext":
ws_url: str = target["webSocketDebuggerUrl"]
return ws_url
return None
async def _evaluate_js_async(ws_url: str, expression: str) -> dict:
"""Connect to a CDP WebSocket target and evaluate *expression*."""
async with websockets.connect(ws_url) as ws:
msg = json.dumps(
{
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": expression,
"returnByValue": True,
"awaitPromise": True,
},
}
)
await ws.send(msg)
resp = await asyncio.wait_for(ws.recv(), timeout=_CDP_TIMEOUT)
return json.loads(resp)
def _evaluate_js(expression: str) -> dict:
"""Synchronous wrapper around :func:`_evaluate_js_async`."""
ws_url = _get_shared_js_ws_url()
if ws_url is None:
msg = "SharedJSContext not found on CDP port"
raise RuntimeError(msg)
return asyncio.run(_evaluate_js_async(ws_url, expression))
def _cdp_result_value(result: dict) -> str:
"""Extract the return value from a CDP Runtime.evaluate response."""
outer = result.get("result", {})
inner = outer.get("result", {})
if "exceptionDetails" in outer:
exc_details = outer["exceptionDetails"]
exc = exc_details.get("exception", {})
desc = (
inner.get("description")
or exc.get("description")
or exc_details.get("text")
or repr(exc_details)
)
logger.debug("CDP exception details: %s", exc_details)
msg = f"JS evaluation error: {desc}"
raise RuntimeError(msg)
value: str = inner.get("value", "")
return value
# ──────────────────────────────────────────────────────────────
# Ensure Steam is running with devtools port
# ──────────────────────────────────────────────────────────────
def _is_steam_running() -> bool:
"""Check whether any Steam process is alive."""
pgrep = shutil.which("pgrep") or "/usr/bin/pgrep"
result = subprocess.run(
[pgrep, "-x", "steam"],
capture_output=True,
check=False,
)
return result.returncode == 0
def _steam_has_debug_port() -> bool:
"""Check whether steamwebhelper is listening on the CDP port."""
return _get_shared_js_ws_url() is not None
def _wait_for_cdp_ready() -> bool:
"""Wait up to *_STEAM_STARTUP_WAIT* seconds for CDP to become ready."""
for _ in range(_STEAM_STARTUP_WAIT):
if _get_shared_js_ws_url() is not None:
return True
time.sleep(1)
return False
def _wait_for_collections_ready() -> bool:
"""Wait until ``collectionStore`` is fully initialised.
Right after Steam starts, the CDP port may be open but the
internal collection data hasn't loaded yet. Poll a lightweight
JS check until ``GetCollection`` stops throwing.
"""
js = (
"(() => { try { collectionStore.GetCollection('hidden');"
" return 'ok'; } catch(e) { return 'not_ready'; } })()"
)
for _ in range(_STEAM_STARTUP_WAIT):
try:
result = _evaluate_js(js)
if _cdp_result_value(result) == "ok":
return True
except RuntimeError:
pass
time.sleep(1)
return False
def _resolve_desktop_user() -> str | None:
"""Resolve which desktop user owns the Steam/X11 session.
Prefers the explicit STEAM_ENFORCER_DESKTOP_USER (set by the systemd
unit, which has no SUDO_USER/USER of its own since it is started
directly by systemd rather than via `sudo`), then falls back to
SUDO_USER/USER for interactive `sudo` invocations.
"""
return (
os.environ.get("STEAM_ENFORCER_DESKTOP_USER")
or os.environ.get("SUDO_USER")
or os.environ.get("USER")
)
def _shutdown_steam() -> None:
"""Send ``steam -shutdown`` and wait for the process to exit."""
real_user = _resolve_desktop_user()
try:
_run_as_user(["steam", "-shutdown"], real_user)
except FileNotFoundError:
return
pgrep = shutil.which("pgrep") or "/usr/bin/pgrep"
for _ in range(30):
result = subprocess.run(
[pgrep, "-x", "steam"],
capture_output=True,
check=False,
)
if result.returncode != 0:
return
time.sleep(1)
def _launch_steam_with_debug() -> None:
"""Launch Steam with CEF debugging enabled."""
real_user = _resolve_desktop_user()
_run_as_user(
[
"steam",
"-cef-enable-debugging",
f"-devtools-port={_CDP_PORT}",
"-silent",
],
real_user,
)
def ensure_steam_debug_port() -> None:
"""Make sure Steam is running with the CDP debug port open.
If Steam is running without the port, it is restarted.
If Steam is not running, it is launched.
Raises:
SteamUnavailableError: If Steam is not installed, or is installed but
never opens its debug port.
"""
if _steam_has_debug_port():
logger.debug("Steam CDP port already available.")
return
# Bail out before the ~45s launch-and-wait: with no binary to exec there
# is nothing to wait for, and retrying every pass only burns time and
# leaves dead processes behind.
if not steam_is_installed():
msg = f"Steam is not installed ({_STEAM_BINARY} does not exist)"
raise SteamUnavailableError(msg)
# Never launch Steam into a session that does not exist yet: it would get
# a missing XDG_RUNTIME_DIR, silently fall back to winealsa, and stay
# audio-broken until the next restart. Defer instead — the loop retries.
real_user = _resolve_desktop_user()
if os.geteuid() == 0 and not desktop_session_ready(_desktop_uid(real_user)):
msg = (
"Deferring Steam launch: the desktop session's runtime directory "
f"({desktop_runtime_dir(_desktop_uid(real_user))}) does not exist "
"yet. Launching now would leave Steam without audio; will retry."
)
logger.info(msg)
raise DesktopSessionNotReadyError(msg)
logger.info("Steam CDP port not available — (re)starting Steam...")
if _is_steam_running():
# Never bounce a running Steam while a game update is downloading or
# committing: the shutdown suspends it and can leave a partially
# written install (the root cause of the AoE2 launch crash). Defer and
# retry on the next enforce pass.
if steam_update_in_progress():
msg = (
"Deferring Steam restart: a game update is in progress. "
"Restarting now would interrupt and can corrupt it; will "
"retry once the update settles."
)
logger.info(msg)
raise SteamUpdateInProgressError(msg)
_shutdown_steam()
_launch_steam_with_debug()
if not _wait_for_cdp_ready():
msg = "Timed out waiting for Steam CDP port to become ready"
raise SteamUnavailableError(msg)
logger.info("Steam CDP port ready.")
if not _wait_for_collections_ready():
msg = "Timed out waiting for Steam collections to initialise"
raise SteamUnavailableError(msg)
logger.info("Steam collection store ready.")
# ──────────────────────────────────────────────────────────────
# Hide / unhide logic
@ -488,68 +195,3 @@ def unhide_all_games(owned_app_ids: list[int]) -> int:
count: int = parsed["count"]
logger.info("Unhidden %d games via CDP.", count)
return count
# ──────────────────────────────────────────────────────────────
# Steam restart helper
# ──────────────────────────────────────────────────────────────
def restart_steam() -> None:
"""Gracefully restart the Steam client with CEF debugging enabled.
Skips the restart if a game update is downloading or committing, so the
update is not interrupted (interrupting it can corrupt the install).
"""
if steam_update_in_progress():
logger.warning(
"Skipping Steam restart — a game update is in progress; "
"restarting now could corrupt it.",
)
return
logger.info("Restarting Steam client with debug port...")
_shutdown_steam()
_launch_steam_with_debug()
if not _wait_for_cdp_ready():
logger.warning("Steam restarted but CDP port not ready.")
else:
logger.info("Steam restarted with CDP port ready.")
def _reap_spawned() -> None:
"""Clear out previously launched processes that have since exited.
Launches here are fire-and-forget: Steam is meant to outlive the call, so
it is never waited on. That leaves any launch which dies immediately - a
missing binary, a broken wrapper - sitting as a zombie that still carries
the name ``steam``, which anything scanning /proc reads as "Steam is
running". Polling the old handles reaps them and retires the name.
"""
_SPAWNED[:] = [proc for proc in _SPAWNED if proc.poll() is None]
def _run_as_user(cmd: list[str], user: str | None) -> None:
"""Run a command, dropping to *user* if currently root."""
_reap_spawned()
if os.geteuid() == 0 and user and user != "root":
uid = _desktop_uid(user)
full_cmd = [
"sudo",
"-u",
user,
"env",
*desktop_env_args(user, uid),
*cmd,
]
else:
full_cmd = cmd
_SPAWNED.append(
subprocess.Popen(
full_cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
)

View File

@ -59,12 +59,28 @@ def _block_real_subprocesses() -> Iterator[None]:
"steam_backlog_enforcer._total_block_iptables.subprocess.run",
noop_run,
),
# library_hider no longer spawns: its launch/process code lives in
# _steam_launch / _steam_process, and _steam_client starts the client.
# Every module that can spawn must be patched here, or a test could
# fire a real `sudo -u kuhy steam`.
patch(
"steam_backlog_enforcer.library_hider.subprocess.run",
"steam_backlog_enforcer._steam_launch.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer.library_hider.subprocess.Popen",
"steam_backlog_enforcer._steam_launch.subprocess.Popen",
noop_popen,
),
patch(
"steam_backlog_enforcer._steam_process.subprocess.Popen",
noop_popen,
),
patch(
"steam_backlog_enforcer._steam_client.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer._steam_client.subprocess.Popen",
noop_popen,
),
# The single most important patch in this file. _playtime_block._run

View File

@ -164,8 +164,8 @@ def _no_real_sleep() -> Iterator[None]:
"""
noop = MagicMock()
with (
patch("steam_backlog_enforcer.game_install.time.sleep", noop),
patch("steam_backlog_enforcer.library_hider.time.sleep", noop),
patch("steam_backlog_enforcer._steam_client.time.sleep", noop),
patch("steam_backlog_enforcer._steam_launch.time.sleep", noop),
patch("steam_backlog_enforcer.steam_api.time.sleep", noop),
patch("steam_backlog_enforcer._enforce_loop.time.sleep", noop),
):

View File

@ -5,21 +5,16 @@ from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from steam_backlog_enforcer._desktop_env import (
desktop_env_args,
desktop_runtime_dir,
desktop_session_ready,
)
from steam_backlog_enforcer.library_hider import (
DesktopSessionNotReadyError,
_desktop_uid,
_run_as_user,
ensure_steam_debug_port,
)
from steam_backlog_enforcer._desktop_env import desktop_uid as _desktop_uid
from steam_backlog_enforcer._steam_process import _run_as_user
PKG = "steam_backlog_enforcer.library_hider"
DESKTOP_ENV_PKG = "steam_backlog_enforcer._desktop_env"
STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch"
STEAM_PROCESS_PKG = "steam_backlog_enforcer._steam_process"
ENV_PKG = "steam_backlog_enforcer._desktop_env"
@ -120,9 +115,9 @@ class TestRunAsUserUsesSharedBuilder:
mock_pw = MagicMock()
mock_pw.pw_uid = 1000
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
patch(f"{DESKTOP_ENV_PKG}.pwd.getpwnam", return_value=mock_pw),
patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "bob")
cmd = mock_popen.call_args[0][0]
@ -133,9 +128,9 @@ class TestRunAsUserUsesSharedBuilder:
def test_unknown_user_falls_back_to_uid_1000(self) -> None:
"""An unresolvable user still yields a usable runtime dir."""
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
patch(f"{DESKTOP_ENV_PKG}.pwd.getpwnam", side_effect=KeyError),
patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "ghost")
cmd = mock_popen.call_args[0][0]
@ -148,88 +143,12 @@ class TestDesktopUid:
def test_resolves_known_user(self) -> None:
mock_pw = MagicMock()
mock_pw.pw_uid = 1234
with patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw):
with patch(f"{DESKTOP_ENV_PKG}.pwd.getpwnam", return_value=mock_pw):
assert _desktop_uid("bob") == 1234
def test_unknown_user_falls_back(self) -> None:
with patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError):
with patch(f"{DESKTOP_ENV_PKG}.pwd.getpwnam", side_effect=KeyError):
assert _desktop_uid("ghost") == 1000
def test_no_user_falls_back(self) -> None:
assert _desktop_uid(None) == 1000
class TestDesktopSessionReady:
"""Tests for desktop_session_ready."""
def test_ready_when_runtime_dir_exists(self) -> None:
with patch(f"{ENV_PKG}.Path.is_dir", return_value=True):
assert desktop_session_ready(1000) is True
def test_not_ready_when_runtime_dir_missing(self) -> None:
with patch(f"{ENV_PKG}.Path.is_dir", return_value=False):
assert desktop_session_ready(1000) is False
def test_runtime_dir_path(self) -> None:
assert desktop_runtime_dir(1000) == "/run/user/1000"
class TestLaunchDefersUntilSessionReady:
"""The enforcer must not launch Steam into a session that does not exist.
Regression guard for the boot race: the enforcer is a system service
ordered only after the network, and on a measured boot it started 54ms
before user-runtime-dir@1000. A Steam launched in that window comes up
with a runtime dir that is not there, silently falls back to winealsa,
and stays audio-broken until something restarts it.
"""
def test_defers_when_runtime_dir_missing(self) -> None:
with (
patch(f"{PKG}._steam_has_debug_port", return_value=False),
patch(f"{PKG}.steam_is_installed", return_value=True),
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}._resolve_desktop_user", return_value="bob"),
patch(f"{PKG}._desktop_uid", return_value=1000),
patch(f"{PKG}.desktop_session_ready", return_value=False),
patch(f"{PKG}._shutdown_steam") as mock_shutdown,
patch(f"{PKG}._launch_steam_with_debug") as mock_launch,
pytest.raises(DesktopSessionNotReadyError),
):
ensure_steam_debug_port()
# Neither bounce a working Steam nor start a broken one.
mock_launch.assert_not_called()
mock_shutdown.assert_not_called()
def test_launches_when_runtime_dir_present(self) -> None:
with (
patch(f"{PKG}._steam_has_debug_port", return_value=False),
patch(f"{PKG}.steam_is_installed", return_value=True),
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}._resolve_desktop_user", return_value="bob"),
patch(f"{PKG}._desktop_uid", return_value=1000),
patch(f"{PKG}.desktop_session_ready", return_value=True),
patch(f"{PKG}._is_steam_running", return_value=False),
patch(f"{PKG}._launch_steam_with_debug") as mock_launch,
patch(f"{PKG}._wait_for_cdp_ready", return_value=True),
patch(f"{PKG}._wait_for_collections_ready", return_value=True),
):
ensure_steam_debug_port()
mock_launch.assert_called_once()
def test_non_root_does_not_gate(self) -> None:
"""Run interactively, the ambient session is already the user's."""
with (
patch(f"{PKG}._steam_has_debug_port", return_value=False),
patch(f"{PKG}.steam_is_installed", return_value=True),
patch(f"{PKG}.os.geteuid", return_value=1000),
patch(f"{PKG}._resolve_desktop_user", return_value="bob"),
patch(f"{PKG}.desktop_session_ready", return_value=False),
patch(f"{PKG}._is_steam_running", return_value=False),
patch(f"{PKG}._launch_steam_with_debug") as mock_launch,
patch(f"{PKG}._wait_for_cdp_ready", return_value=True),
patch(f"{PKG}._wait_for_collections_ready", return_value=True),
):
ensure_steam_debug_port()
mock_launch.assert_called_once()

View File

@ -0,0 +1,161 @@
"""Tests for gating launches on the desktop session being ready.
Split to keep every test file under the 250-line cap.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from steam_backlog_enforcer._desktop_env import (
desktop_runtime_dir,
desktop_session_ready,
)
from steam_backlog_enforcer._steam_errors import DesktopSessionNotReadyError
from steam_backlog_enforcer._steam_launch import ensure_steam_debug_port
PKG = "steam_backlog_enforcer.library_hider"
DESKTOP_ENV_PKG = "steam_backlog_enforcer._desktop_env"
STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch"
STEAM_PROCESS_PKG = "steam_backlog_enforcer._steam_process"
ENV_PKG = "steam_backlog_enforcer._desktop_env"
def _env_value(args: list[str], name: str) -> str | None:
"""Return the value assigned to *name* in a list of ``VAR=value`` args."""
prefix = f"{name}="
for arg in args:
if arg.startswith(prefix):
return arg[len(prefix) :]
return None
class TestDesktopSessionReady:
"""Tests for desktop_session_ready."""
def test_ready_when_runtime_dir_exists(self) -> None:
with patch(f"{ENV_PKG}.Path.is_dir", return_value=True):
assert desktop_session_ready(1000) is True
def test_not_ready_when_runtime_dir_missing(self) -> None:
with patch(f"{ENV_PKG}.Path.is_dir", return_value=False):
assert desktop_session_ready(1000) is False
def test_runtime_dir_path(self) -> None:
assert desktop_runtime_dir(1000) == "/run/user/1000"
class TestLaunchDefersUntilSessionReady:
"""The enforcer must not launch Steam into a session that does not exist.
Regression guard for the boot race: the enforcer is a system service
ordered only after the network, and on a measured boot it started 54ms
before user-runtime-dir@1000. A Steam launched in that window comes up
with a runtime dir that is not there, silently falls back to winealsa,
and stays audio-broken until something restarts it.
"""
def test_defers_when_runtime_dir_missing(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch.steam_is_installed",
return_value=True,
),
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
patch(
"steam_backlog_enforcer._steam_launch._resolve_desktop_user",
return_value="bob",
),
patch(f"{DESKTOP_ENV_PKG}.desktop_uid", return_value=1000),
patch(f"{STEAM_LAUNCH_PKG}.desktop_session_ready", return_value=False),
patch(
"steam_backlog_enforcer._steam_launch._shutdown_steam"
) as mock_shutdown,
patch(
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug"
) as mock_launch,
pytest.raises(DesktopSessionNotReadyError),
):
ensure_steam_debug_port()
# Neither bounce a working Steam nor start a broken one.
mock_launch.assert_not_called()
mock_shutdown.assert_not_called()
def test_launches_when_runtime_dir_present(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch.steam_is_installed",
return_value=True,
),
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
patch(
"steam_backlog_enforcer._steam_launch._resolve_desktop_user",
return_value="bob",
),
patch(f"{DESKTOP_ENV_PKG}.desktop_uid", return_value=1000),
patch(f"{STEAM_LAUNCH_PKG}.desktop_session_ready", return_value=True),
patch(
"steam_backlog_enforcer._steam_launch._is_steam_running",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug"
) as mock_launch,
patch(
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
return_value=True,
),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_collections_ready",
return_value=True,
),
):
ensure_steam_debug_port()
mock_launch.assert_called_once()
def test_non_root_does_not_gate(self) -> None:
"""Run interactively, the ambient session is already the user's."""
with (
patch(
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch.steam_is_installed",
return_value=True,
),
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=1000),
patch(
"steam_backlog_enforcer._steam_launch._resolve_desktop_user",
return_value="bob",
),
patch(f"{STEAM_LAUNCH_PKG}.desktop_session_ready", return_value=False),
patch(
"steam_backlog_enforcer._steam_launch._is_steam_running",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug"
) as mock_launch,
patch(
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
return_value=True,
),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_collections_ready",
return_value=True,
),
):
ensure_steam_debug_port()
mock_launch.assert_called_once()

View File

@ -0,0 +1,113 @@
"""Tests for the desktop privilege-drop builder.
Split from test_desktop_env.py to keep both files under the 250-line cap.
"""
from __future__ import annotations
import os
from unittest.mock import patch
from steam_backlog_enforcer._desktop_env import (
desktop_uid,
desktop_user_cmd,
resolve_desktop_user,
)
ENV_PKG = "steam_backlog_enforcer._desktop_env"
def _env_value(args: list[str], name: str) -> str | None:
"""Return the value assigned to *name* in a list of ``VAR=value`` args."""
prefix = f"{name}="
for arg in args:
if arg.startswith(prefix):
return arg[len(prefix) :]
return None
class TestResolveDesktopUser:
"""Tests for resolve_desktop_user's precedence."""
def test_prefers_enforcer_var(self) -> None:
"""The systemd unit's variable wins: it is the only one systemd sets."""
with patch.dict(
os.environ,
{
"STEAM_ENFORCER_DESKTOP_USER": "kuhy",
"SUDO_USER": "alice",
"USER": "bob",
},
):
assert resolve_desktop_user() == "kuhy"
def test_falls_back_to_sudo_user(self) -> None:
env = {
k: v for k, v in os.environ.items() if k != "STEAM_ENFORCER_DESKTOP_USER"
}
env.update({"SUDO_USER": "alice", "USER": "bob"})
with patch.dict(os.environ, env, clear=True):
assert resolve_desktop_user() == "alice"
def test_falls_back_to_user(self) -> None:
env = {
k: v
for k, v in os.environ.items()
if k not in ("STEAM_ENFORCER_DESKTOP_USER", "SUDO_USER")
}
env["USER"] = "bob"
with patch.dict(os.environ, env, clear=True):
assert resolve_desktop_user() == "bob"
def test_returns_none_when_unset(self) -> None:
env = {
k: v
for k, v in os.environ.items()
if k not in ("STEAM_ENFORCER_DESKTOP_USER", "SUDO_USER", "USER")
}
with patch.dict(os.environ, env, clear=True):
assert resolve_desktop_user() is None
class TestDesktopUserCmd:
"""Tests for desktop_user_cmd — all four branches."""
def test_non_root_returns_cmd_unchanged(self) -> None:
with patch(f"{ENV_PKG}.os.geteuid", return_value=1000):
assert desktop_user_cmd(["steam"], "kuhy") == ["steam"]
def test_root_with_user_wraps_in_sudo(self) -> None:
with (
patch(f"{ENV_PKG}.os.geteuid", return_value=0),
patch(f"{ENV_PKG}.desktop_uid", return_value=1000),
):
result = desktop_user_cmd(["steam", "-silent"], "kuhy")
assert result[:4] == ["sudo", "-u", "kuhy", "env"]
assert result[-2:] == ["steam", "-silent"]
assert _env_value(result, "XDG_RUNTIME_DIR") == "/run/user/1000"
def test_root_without_user_returns_cmd_unchanged(self) -> None:
"""Callers must gate on this: bare argv as root is what the guard exists for."""
with patch(f"{ENV_PKG}.os.geteuid", return_value=0):
assert desktop_user_cmd(["steam"], None) == ["steam"]
def test_root_with_root_user_returns_cmd_unchanged(self) -> None:
with patch(f"{ENV_PKG}.os.geteuid", return_value=0):
assert desktop_user_cmd(["steam"], "root") == ["steam"]
class TestDesktopUid:
"""Tests for desktop_uid's lookup and fallbacks."""
def test_known_user(self) -> None:
with patch(f"{ENV_PKG}.pwd.getpwnam") as mock_getpwnam:
mock_getpwnam.return_value.pw_uid = 1234
assert desktop_uid("kuhy") == 1234
def test_unknown_user_falls_back(self) -> None:
"""An unknown name must not raise — the enforce loop would die."""
with patch(f"{ENV_PKG}.pwd.getpwnam", side_effect=KeyError):
assert desktop_uid("nosuchuser") == 1000
def test_none_falls_back(self) -> None:
assert desktop_uid(None) == 1000

View File

@ -0,0 +1,153 @@
"""Tests for the do_enforce driver loop.
Split to keep every test file under the 250-line cap.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from steam_backlog_enforcer._enforce_loop import (
do_enforce,
)
from steam_backlog_enforcer.config import Config, State
if TYPE_CHECKING:
from collections.abc import Iterator
PKG = "steam_backlog_enforcer._enforce_loop"
ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps"
class TestDoEnforce:
"""Tests for do_enforce."""
@pytest.fixture(autouse=True)
def _steam_present(self) -> Iterator[None]:
"""Pretend Steam is installed for every test in this class.
Without it do_enforce takes the "not installed" branch and never
reaches the normal enforcement path these tests are about. The
absent case is covered by test_steam_absent_idles_without_setup,
which opts back out.
"""
with patch(f"{PKG}.steam_is_installed", return_value=True):
yield
def test_no_game_says_so_but_keeps_looping(self) -> None:
"""No assignment is reported, but the loop still runs.
The daily gaming budget is accounted for from inside this loop, so
returning here would stop enforcing it whenever a game is finished
but not yet rescanned.
"""
state = State()
with (
patch(f"{PKG}._echo") as mock_echo,
patch.object(State, "load", return_value=state),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=KeyboardInterrupt,
) as mock_iter,
patch(f"{PKG}.time.sleep"),
):
do_enforce(Config(), state)
assert any("No game" in str(c) for c in mock_echo.call_args_list)
mock_iter.assert_called_once()
def test_steam_absent_idles_without_setup(self) -> None:
"""With Steam gone, say so and keep looping - never exit.
Returning here would end the process, and under Restart=always that
is the crash loop again by another name. Staying alive also lets a
later reinstall be picked up without a restart.
"""
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_total_block_active", return_value=False),
patch(f"{PKG}.steam_is_installed", return_value=False),
patch(f"{PKG}._enforce_setup") as mock_setup,
patch(f"{PKG}._echo") as mock_echo,
patch.object(State, "load", return_value=state),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=KeyboardInterrupt,
),
patch(f"{PKG}.time.sleep"),
):
do_enforce(Config(), state)
mock_setup.assert_not_called()
assert any("not installed" in str(c) for c in mock_echo.call_args_list)
def test_keyboard_interrupt(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config()
fresh = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", return_value=fresh),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=KeyboardInterrupt,
),
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
def test_runs_iterations(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config()
fresh = State(current_app_id=1, current_game_name="G")
call_count = 0
def side_effect(*_args: object, **_kwargs: object) -> None:
nonlocal call_count
call_count += 1
if call_count >= 2:
raise KeyboardInterrupt
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", return_value=fresh),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=side_effect,
),
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
assert call_count == 2
def test_state_load_failure_continues(self) -> None:
"""Corrupt state file should not crash the daemon."""
import json as json_mod
state = State(current_app_id=1, current_game_name="G")
config = Config()
call_count = 0
def load_side_effect() -> State:
nonlocal call_count
call_count += 1
if call_count == 1:
msg = "bad"
raise json_mod.JSONDecodeError(msg, "", 0)
if call_count == 2:
raise KeyboardInterrupt
return State(current_app_id=1) # pragma: no cover
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", side_effect=load_side_effect),
patch(f"{PKG}._enforce_loop_iteration") as mock_iter,
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
mock_iter.assert_not_called()

View File

@ -6,21 +6,19 @@ import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer._enforce_loop import (
_enforce_auto_install,
_enforce_hide_games,
_enforce_setup,
_guard_installed_games,
from steam_backlog_enforcer._owned_apps_cache import (
_load_owned_app_ids_cache,
_save_owned_app_ids_cache,
get_all_owned_app_ids,
)
from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.config import Config
if TYPE_CHECKING:
from pathlib import Path
PKG = "steam_backlog_enforcer._enforce_loop"
ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps"
OWNED_APPS_CACHE_PKG = "steam_backlog_enforcer._owned_apps_cache"
class TestGetAllOwnedAppIds:
@ -29,9 +27,13 @@ class TestGetAllOwnedAppIds:
def test_snapshot_used_when_api_fails(self) -> None:
snap = [{"app_id": 1}, {"app_id": 2}]
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{PKG}.SteamAPIClient", side_effect=OSError("boom")),
patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=snap),
patch(
f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None
),
patch(
f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", side_effect=OSError("boom")
),
):
assert get_all_owned_app_ids(Config()) == [1, 2]
@ -42,9 +44,11 @@ class TestGetAllOwnedAppIds:
{"appid": 20},
]
with (
patch(f"{PKG}.load_snapshot", return_value=None),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=None),
patch(
f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None
),
patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", return_value=mock_client),
):
result = get_all_owned_app_ids(
Config(steam_api_key="k", steam_id="i"),
@ -53,10 +57,12 @@ class TestGetAllOwnedAppIds:
def test_api_fails(self) -> None:
with (
patch(f"{PKG}.load_snapshot", return_value=None),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=None),
patch(
f"{PKG}.SteamAPIClient",
f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None
),
patch(
f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient",
side_effect=OSError("fail"),
),
):
@ -66,9 +72,11 @@ class TestGetAllOwnedAppIds:
mock_client = MagicMock()
mock_client.get_owned_games.return_value = [{"appid": 5}]
with (
patch(f"{PKG}.load_snapshot", return_value=[]),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=[]),
patch(
f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None
),
patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", return_value=mock_client),
):
assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [5]
@ -77,10 +85,13 @@ class TestGetAllOwnedAppIds:
mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}]
with (
patch(
f"{PKG}.load_snapshot", return_value=[{"app_id": 20}, {"app_id": 30}]
f"{OWNED_APPS_CACHE_PKG}.load_snapshot",
return_value=[{"app_id": 20}, {"app_id": 30}],
),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(
f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None
),
patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", return_value=mock_client),
):
assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [
10,
@ -90,9 +101,14 @@ class TestGetAllOwnedAppIds:
def test_uses_owned_ids_cache_without_api_call(self) -> None:
with (
patch(f"{PKG}.load_snapshot", return_value=[{"app_id": 30}]),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=[10, 20]),
patch(f"{PKG}.SteamAPIClient") as mock_client,
patch(
f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=[{"app_id": 30}]
),
patch(
f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache",
return_value=[10, 20],
),
patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient") as mock_client,
):
result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i"))
@ -102,10 +118,14 @@ class TestGetAllOwnedAppIds:
def test_cached_ids_merge_deduplicates_entries(self) -> None:
with (
patch(
f"{PKG}.load_snapshot", return_value=[{"app_id": 20}, {"app_id": 30}]
f"{OWNED_APPS_CACHE_PKG}.load_snapshot",
return_value=[{"app_id": 20}, {"app_id": 30}],
),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=[10, 20, 20]),
patch(f"{PKG}.SteamAPIClient") as mock_client,
patch(
f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache",
return_value=[10, 20, 20],
),
patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient") as mock_client,
):
result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i"))
@ -116,10 +136,12 @@ class TestGetAllOwnedAppIds:
mock_client = MagicMock()
mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}]
with (
patch(f"{PKG}.load_snapshot", return_value=[]),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{PKG}._save_owned_app_ids_cache") as mock_save,
patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=[]),
patch(
f"{OWNED_APPS_CACHE_PKG}._load_owned_app_ids_cache", return_value=None
),
patch(f"{OWNED_APPS_CACHE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{OWNED_APPS_CACHE_PKG}._save_owned_app_ids_cache") as mock_save,
):
result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i"))
@ -131,17 +153,21 @@ class TestOwnedIdsCacheHelpers:
"""Tests for owned app IDs cache helper functions."""
def test_load_cache_no_steam_id(self, tmp_path: Path) -> None:
with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"):
with patch(
f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"
):
assert _load_owned_app_ids_cache("") is None
def test_load_cache_missing_file(self, tmp_path: Path) -> None:
with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"):
with patch(
f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"
):
assert _load_owned_app_ids_cache("sid") is None
def test_load_cache_invalid_json(self, tmp_path: Path) -> None:
cache_file = tmp_path / "owned.json"
cache_file.write_text("{invalid", encoding="utf-8")
with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file):
with patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file):
assert _load_owned_app_ids_cache("sid") is None
def test_load_cache_wrong_steam_id(self, tmp_path: Path) -> None:
@ -150,7 +176,7 @@ class TestOwnedIdsCacheHelpers:
json.dumps({"steam_id": "other", "fetched_at": 1e12, "app_ids": [1]}),
encoding="utf-8",
)
with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file):
with patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file):
assert _load_owned_app_ids_cache("sid") is None
def test_load_cache_stale(self, tmp_path: Path) -> None:
@ -160,9 +186,9 @@ class TestOwnedIdsCacheHelpers:
encoding="utf-8",
)
with (
patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{PKG}.time.time", return_value=10_000.0),
patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
):
assert _load_owned_app_ids_cache("sid") is None
@ -173,9 +199,9 @@ class TestOwnedIdsCacheHelpers:
encoding="utf-8",
)
with (
patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{PKG}.time.time", return_value=10_010.0),
patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
):
assert _load_owned_app_ids_cache("sid") is None
@ -188,18 +214,18 @@ class TestOwnedIdsCacheHelpers:
encoding="utf-8",
)
with (
patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{PKG}.time.time", return_value=10_010.0),
patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
):
assert _load_owned_app_ids_cache("sid") == [1, 2]
def test_save_cache_writes_atomic_payload(self, tmp_path: Path) -> None:
cache_file = tmp_path / "owned.json"
with (
patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{PKG}.time.time", return_value=123.0),
patch(f"{PKG}._atomic_write") as mock_atomic,
patch(f"{OWNED_APPS_CACHE_PKG}._atomic_write") as mock_atomic,
):
_save_owned_app_ids_cache("sid", [10, 20])
@ -209,185 +235,3 @@ class TestOwnedIdsCacheHelpers:
assert path_arg == cache_file
assert '"steam_id": "sid"' in payload_arg
assert '"app_ids": [\n 10,\n 20\n ]' in payload_arg
class TestGuardInstalledGames:
"""Tests for _guard_installed_games."""
def test_removes_unauthorized(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(999, "Bad Game")],
),
patch(f"{PKG}.uninstall_game", return_value=True),
patch(f"{PKG}.send_notification"),
):
assert _guard_installed_games({440}) == 1
def test_skips_allowed(self) -> None:
with patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2")],
):
assert _guard_installed_games({440}) == 0
def test_skips_protected(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(228980, "Runtime")],
),
patch(f"{PKG}.is_protected_app", side_effect=lambda aid: aid == 228980),
):
assert _guard_installed_games({440}) == 0
def test_uninstall_fails(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(999, "Bad")],
),
patch(f"{PKG}.uninstall_game", return_value=False),
):
assert _guard_installed_games({440}) == 0
def test_allowed_none_skips(self) -> None:
assert _guard_installed_games(set()) == 0
class TestEnforceSetup:
"""Tests for _enforce_setup."""
def test_block_store_success(self) -> None:
config = Config(block_store=True, uninstall_other_games=False)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.block_store", return_value=True),
patch(f"{PKG}._echo"),
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
def test_block_store_fail(self) -> None:
config = Config(block_store=True, uninstall_other_games=False)
state = State()
with (
patch(f"{PKG}.block_store", return_value=False),
patch(f"{PKG}._echo") as mock_echo,
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
assert any("FAILED" in str(c) for c in mock_echo.call_args_list)
def test_no_block_store(self) -> None:
config = Config(block_store=False, uninstall_other_games=False)
state = State()
with (
patch(f"{PKG}.block_store") as mock_block,
patch(f"{PKG}._echo"),
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
mock_block.assert_not_called()
def test_uninstall_other_games(self) -> None:
config = Config(uninstall_other_games=True, block_store=False)
state = State(current_app_id=1)
with (
patch(f"{PKG}.uninstall_other_games", return_value=3),
patch(f"{PKG}._echo"),
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
class TestEnforceAutoInstall:
"""Tests for _enforce_auto_install."""
def test_no_app_id(self) -> None:
_enforce_auto_install(Config(), State())
def test_already_installed(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{PKG}._echo"),
):
_enforce_auto_install(Config(), state)
def test_installs_successfully(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=True),
patch(f"{PKG}.send_notification"),
patch(f"{PKG}._echo"),
):
_enforce_auto_install(Config(steam_id="i"), state)
def test_install_fails(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=False),
patch(f"{PKG}._echo") as mock_echo,
):
_enforce_auto_install(Config(steam_id="i"), state)
assert any("manually" in str(c) for c in mock_echo.call_args_list)
class TestEnforceHideGames:
"""Tests for _enforce_hide_games."""
def test_hides_some(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
patch(f"{PKG}.try_hide_other_games", return_value=(2, None)),
patch(f"{PKG}._echo"),
):
_enforce_hide_games(Config(), state)
def test_already_hidden(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{PKG}.try_hide_other_games", return_value=(0, None)),
patch(f"{PKG}._echo") as mock_echo,
):
_enforce_hide_games(Config(), state)
assert any("already" in str(c) for c in mock_echo.call_args_list)
def test_no_owned_ids(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}._echo") as mock_echo,
):
_enforce_hide_games(Config(), state)
assert any("skipped" in str(c) for c in mock_echo.call_args_list)
def test_unreachable_steam_is_not_fatal(self) -> None:
"""An unreachable Steam must degrade, never propagate.
Regression guard: this exception used to escape all the way out of
do_enforce, exiting the process into Restart=always - which spun the
service through ~1000 restarts against an uninstalled Steam.
"""
state = State(current_app_id=1)
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
patch(
f"{PKG}.try_hide_other_games",
return_value=(0, "Steam is not installed"),
),
patch(f"{PKG}._echo") as mock_echo,
):
_enforce_hide_games(Config(), state)
assert any("skipped" in str(c) for c in mock_echo.call_args_list)

View File

@ -9,7 +9,6 @@ import pytest
from steam_backlog_enforcer._enforce_loop import (
_enforce_loop_iteration,
do_enforce,
)
from steam_backlog_enforcer.config import Config, State
@ -17,6 +16,7 @@ if TYPE_CHECKING:
from collections.abc import Iterator
PKG = "steam_backlog_enforcer._enforce_loop"
ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps"
class TestEnforceLoopIteration:
@ -44,9 +44,9 @@ class TestEnforceLoopIteration:
f"{PKG}.enforce_allowed_game",
return_value=[(1234, 999)],
),
patch(f"{PKG}.send_notification"),
patch(f"{ENFORCE_STEPS_PKG}.send_notification"),
patch(f"{PKG}._echo"),
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
@ -58,7 +58,7 @@ class TestEnforceLoopIteration:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.enforce_allowed_game") as mock_enforce,
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
mock_enforce.assert_not_called()
@ -72,7 +72,7 @@ class TestEnforceLoopIteration:
with (
patch(f"{PKG}._guard_installed_games", return_value=1),
patch(f"{PKG}._echo"),
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
@ -84,7 +84,7 @@ class TestEnforceLoopIteration:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._guard_installed_games", return_value=0),
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
@ -103,8 +103,8 @@ class TestEnforceLoopIteration:
patch(f"{PKG}.steam_is_installed", return_value=False),
patch(f"{PKG}.enforce_allowed_game") as mock_enforce,
patch(f"{PKG}._guard_installed_games") as mock_guard,
patch(f"{PKG}.is_game_installed") as mock_installed,
patch(f"{PKG}.install_game") as mock_install,
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed") as mock_installed,
patch(f"{ENFORCE_STEPS_PKG}.install_game") as mock_install,
):
_enforce_loop_iteration(config, state)
@ -120,8 +120,8 @@ class TestEnforceLoopIteration:
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game") as mock_install,
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=False),
patch(f"{ENFORCE_STEPS_PKG}.install_game") as mock_install,
):
_enforce_loop_iteration(config, state)
mock_install.assert_called_once()
@ -135,140 +135,9 @@ class TestEnforceLoopIteration:
with (
patch(f"{PKG}.enforce_allowed_game") as mock_enforce,
patch(f"{PKG}._guard_installed_games") as mock_guard,
patch(f"{PKG}.is_game_installed") as mock_installed,
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed") as mock_installed,
):
_enforce_loop_iteration(config, state)
mock_enforce.assert_not_called()
mock_guard.assert_not_called()
mock_installed.assert_not_called()
class TestDoEnforce:
"""Tests for do_enforce."""
@pytest.fixture(autouse=True)
def _steam_present(self) -> Iterator[None]:
"""Pretend Steam is installed for every test in this class.
Without it do_enforce takes the "not installed" branch and never
reaches the normal enforcement path these tests are about. The
absent case is covered by test_steam_absent_idles_without_setup,
which opts back out.
"""
with patch(f"{PKG}.steam_is_installed", return_value=True):
yield
def test_no_game_says_so_but_keeps_looping(self) -> None:
"""No assignment is reported, but the loop still runs.
The daily gaming budget is accounted for from inside this loop, so
returning here would stop enforcing it whenever a game is finished
but not yet rescanned.
"""
state = State()
with (
patch(f"{PKG}._echo") as mock_echo,
patch.object(State, "load", return_value=state),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=KeyboardInterrupt,
) as mock_iter,
patch(f"{PKG}.time.sleep"),
):
do_enforce(Config(), state)
assert any("No game" in str(c) for c in mock_echo.call_args_list)
mock_iter.assert_called_once()
def test_steam_absent_idles_without_setup(self) -> None:
"""With Steam gone, say so and keep looping - never exit.
Returning here would end the process, and under Restart=always that
is the crash loop again by another name. Staying alive also lets a
later reinstall be picked up without a restart.
"""
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_total_block_active", return_value=False),
patch(f"{PKG}.steam_is_installed", return_value=False),
patch(f"{PKG}._enforce_setup") as mock_setup,
patch(f"{PKG}._echo") as mock_echo,
patch.object(State, "load", return_value=state),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=KeyboardInterrupt,
),
patch(f"{PKG}.time.sleep"),
):
do_enforce(Config(), state)
mock_setup.assert_not_called()
assert any("not installed" in str(c) for c in mock_echo.call_args_list)
def test_keyboard_interrupt(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config()
fresh = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", return_value=fresh),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=KeyboardInterrupt,
),
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
def test_runs_iterations(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config()
fresh = State(current_app_id=1, current_game_name="G")
call_count = 0
def side_effect(*_args: object, **_kwargs: object) -> None:
nonlocal call_count
call_count += 1
if call_count >= 2:
raise KeyboardInterrupt
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", return_value=fresh),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=side_effect,
),
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
assert call_count == 2
def test_state_load_failure_continues(self) -> None:
"""Corrupt state file should not crash the daemon."""
import json as json_mod
state = State(current_app_id=1, current_game_name="G")
config = Config()
call_count = 0
def load_side_effect() -> State:
nonlocal call_count
call_count += 1
if call_count == 1:
msg = "bad"
raise json_mod.JSONDecodeError(msg, "", 0)
if call_count == 2:
raise KeyboardInterrupt
return State(current_app_id=1) # pragma: no cover
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", side_effect=load_side_effect),
patch(f"{PKG}._enforce_loop_iteration") as mock_iter,
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
mock_iter.assert_not_called()

View File

@ -4,10 +4,14 @@ from __future__ import annotations
from unittest.mock import patch
from steam_backlog_enforcer._enforce_loop import _enforce_loop_iteration, do_enforce
from steam_backlog_enforcer._enforce_loop import (
_enforce_loop_iteration,
do_enforce,
)
from steam_backlog_enforcer.config import Config, State
PKG = "steam_backlog_enforcer._enforce_loop"
ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps"
class TestEnforceLoopIterationTotalBlock:
@ -21,7 +25,7 @@ class TestEnforceLoopIterationTotalBlock:
patch(f"{PKG}.enforce_total_block_tick") as mock_tick,
patch(f"{PKG}.enforce_allowed_game") as mock_enforce,
patch(f"{PKG}._guard_installed_games") as mock_guard,
patch(f"{PKG}.is_game_installed") as mock_installed,
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed") as mock_installed,
):
_enforce_loop_iteration(config, state)
mock_tick.assert_called_once()
@ -36,7 +40,7 @@ class TestEnforceLoopIterationTotalBlock:
patch(f"{PKG}.is_total_block_active", return_value=False),
patch(f"{PKG}.total_block_needs_cleanup", return_value=False),
patch(f"{PKG}.enforce_total_block_tick") as mock_tick,
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
mock_tick.assert_not_called()
@ -48,7 +52,7 @@ class TestEnforceLoopIterationTotalBlock:
patch(f"{PKG}.is_total_block_active", return_value=False),
patch(f"{PKG}.total_block_needs_cleanup", return_value=True),
patch(f"{PKG}.end_total_block_cleanup") as mock_cleanup,
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
mock_cleanup.assert_called_once()
@ -60,7 +64,7 @@ class TestEnforceLoopIterationTotalBlock:
patch(f"{PKG}.is_total_block_active", return_value=False),
patch(f"{PKG}.total_block_needs_cleanup", return_value=False),
patch(f"{PKG}.end_total_block_cleanup") as mock_cleanup,
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
mock_cleanup.assert_not_called()

View File

@ -0,0 +1,202 @@
"""Tests for the individual steps one enforce pass performs.
Split to keep every test file under the 250-line cap.
"""
from __future__ import annotations
from unittest.mock import patch
from steam_backlog_enforcer._enforce_loop import _guard_installed_games
from steam_backlog_enforcer._enforce_steps import (
_enforce_auto_install,
_enforce_hide_games,
_enforce_setup,
)
from steam_backlog_enforcer.config import Config, State
PKG = "steam_backlog_enforcer._enforce_loop"
ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps"
OWNED_APPS_CACHE_PKG = "steam_backlog_enforcer._owned_apps_cache"
class TestGuardInstalledGames:
"""Tests for _guard_installed_games."""
def test_removes_unauthorized(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(999, "Bad Game")],
),
patch(f"{PKG}.uninstall_game", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}.send_notification"),
):
assert _guard_installed_games({440}) == 1
def test_skips_allowed(self) -> None:
with patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2")],
):
assert _guard_installed_games({440}) == 0
def test_skips_protected(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(228980, "Runtime")],
),
patch(f"{PKG}.is_protected_app", side_effect=lambda aid: aid == 228980),
):
assert _guard_installed_games({440}) == 0
def test_uninstall_fails(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(999, "Bad")],
),
patch(f"{PKG}.uninstall_game", return_value=False),
):
assert _guard_installed_games({440}) == 0
def test_allowed_none_skips(self) -> None:
assert _guard_installed_games(set()) == 0
class TestEnforceSetup:
"""Tests for _enforce_setup."""
def test_block_store_success(self) -> None:
config = Config(block_store=True, uninstall_other_games=False)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{ENFORCE_STEPS_PKG}.block_store", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}._echo"),
patch(f"{ENFORCE_STEPS_PKG}._enforce_auto_install"),
patch(f"{ENFORCE_STEPS_PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
def test_block_store_fail(self) -> None:
config = Config(block_store=True, uninstall_other_games=False)
state = State()
with (
patch(f"{ENFORCE_STEPS_PKG}.block_store", return_value=False),
patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo,
patch(f"{ENFORCE_STEPS_PKG}._enforce_auto_install"),
patch(f"{ENFORCE_STEPS_PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
assert any("FAILED" in str(c) for c in mock_echo.call_args_list)
def test_no_block_store(self) -> None:
config = Config(block_store=False, uninstall_other_games=False)
state = State()
with (
patch(f"{ENFORCE_STEPS_PKG}.block_store") as mock_block,
patch(f"{ENFORCE_STEPS_PKG}._echo"),
patch(f"{ENFORCE_STEPS_PKG}._enforce_auto_install"),
patch(f"{ENFORCE_STEPS_PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
mock_block.assert_not_called()
def test_uninstall_other_games(self) -> None:
config = Config(uninstall_other_games=True, block_store=False)
state = State(current_app_id=1)
with (
patch(f"{ENFORCE_STEPS_PKG}.uninstall_other_games", return_value=3),
patch(f"{ENFORCE_STEPS_PKG}._echo"),
patch(f"{ENFORCE_STEPS_PKG}._enforce_auto_install"),
patch(f"{ENFORCE_STEPS_PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
class TestEnforceAutoInstall:
"""Tests for _enforce_auto_install."""
def test_no_app_id(self) -> None:
_enforce_auto_install(Config(), State())
def test_already_installed(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}._echo"),
):
_enforce_auto_install(Config(), state)
def test_installs_successfully(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=False),
patch(f"{ENFORCE_STEPS_PKG}.install_game", return_value=True),
patch(f"{ENFORCE_STEPS_PKG}.send_notification"),
patch(f"{ENFORCE_STEPS_PKG}._echo"),
):
_enforce_auto_install(Config(steam_id="i"), state)
def test_install_fails(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=False),
patch(f"{ENFORCE_STEPS_PKG}.install_game", return_value=False),
patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo,
):
_enforce_auto_install(Config(steam_id="i"), state)
assert any("manually" in str(c) for c in mock_echo.call_args_list)
class TestEnforceHideGames:
"""Tests for _enforce_hide_games."""
def test_hides_some(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
patch(f"{ENFORCE_STEPS_PKG}.try_hide_other_games", return_value=(2, None)),
patch(f"{ENFORCE_STEPS_PKG}._echo"),
):
_enforce_hide_games(Config(), state)
def test_already_hidden(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{ENFORCE_STEPS_PKG}.try_hide_other_games", return_value=(0, None)),
patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo,
):
_enforce_hide_games(Config(), state)
assert any("already" in str(c) for c in mock_echo.call_args_list)
def test_no_owned_ids(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo,
):
_enforce_hide_games(Config(), state)
assert any("skipped" in str(c) for c in mock_echo.call_args_list)
def test_unreachable_steam_is_not_fatal(self) -> None:
"""An unreachable Steam must degrade, never propagate.
Regression guard: this exception used to escape all the way out of
do_enforce, exiting the process into Restart=always - which spun the
service through ~1000 restarts against an uninstalled Steam.
"""
state = State(current_app_id=1)
with (
patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
patch(
f"{ENFORCE_STEPS_PKG}.try_hide_other_games",
return_value=(0, "Steam is not installed"),
),
patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo,
):
_enforce_hide_games(Config(), state)
assert any("skipped" in str(c) for c in mock_echo.call_args_list)

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
from unittest.mock import patch
@ -193,3 +194,45 @@ class TestSendNotification:
side_effect=OSError,
):
send_notification("Title", "Body") # Should not raise
class TestSendNotificationPrivilegeDrop:
"""As root, notifications must be delivered into the user's session bus.
A bare root ``notify-send`` has no DBUS_SESSION_BUS_ADDRESS and silently
reached nobody, for every caller of send_notification.
"""
_PKG = "steam_backlog_enforcer.enforcer"
_ENV = "steam_backlog_enforcer._desktop_env"
def test_root_drops_to_desktop_user(self) -> None:
with (
patch(f"{self._ENV}.os.geteuid", return_value=0),
patch(f"{self._ENV}.desktop_uid", return_value=1000),
patch(f"{self._PKG}.resolve_desktop_user", return_value="kuhy"),
patch(f"{self._PKG}.subprocess.run") as mock_run,
):
send_notification("Title", "Body")
argv = mock_run.call_args[0][0]
assert argv[:4] == ["sudo", "-u", "kuhy", "env"]
assert "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus" in argv
def test_non_root_does_not_wrap(self) -> None:
with (
patch(f"{self._ENV}.os.geteuid", return_value=1000),
patch(f"{self._PKG}.resolve_desktop_user", return_value="kuhy"),
patch(f"{self._PKG}.subprocess.run") as mock_run,
):
send_notification("Title", "Body")
assert mock_run.call_args[0][0][0] != "sudo"
def test_handles_timeout(self) -> None:
"""sudo can block where a bare notify-send would not."""
with patch(
f"{self._PKG}.subprocess.run",
side_effect=subprocess.TimeoutExpired("cmd", 5),
):
send_notification("Title", "Body") # Should not raise

View File

@ -0,0 +1,167 @@
"""Tests for ensure_steam_debug_port: getting Steam drivable over CDP.
Split to keep every test file under the 250-line cap.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from steam_backlog_enforcer._steam_errors import SteamUnavailableError
from steam_backlog_enforcer._steam_launch import (
ensure_steam_debug_port,
)
if TYPE_CHECKING:
from collections.abc import Iterator
class TestEnsureSteamDebugPort:
"""Tests for ensure_steam_debug_port."""
@pytest.fixture(autouse=True)
def _steam_present(self) -> Iterator[None]:
"""Pretend Steam is installed for every test in this class.
Without it the function raises before reaching any launch logic,
which on a test machine without Steam would make the cases below
vacuous. The absent case has its own test.
"""
with patch(
"steam_backlog_enforcer._steam_launch.steam_is_installed",
return_value=True,
):
yield
def test_raises_when_steam_absent(self) -> None:
"""An uninstalled Steam must fail fast, before any launch attempt.
Regression guard: without this the caller spent ~45s waiting on a
CDP port that no process was ever going to open.
"""
with (
patch(
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch.steam_is_installed",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug",
) as mock_launch,
pytest.raises(SteamUnavailableError, match="not installed"),
):
ensure_steam_debug_port()
mock_launch.assert_not_called()
def test_already_available(self) -> None:
with patch(
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
return_value=True,
):
ensure_steam_debug_port()
def test_starts_fresh(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch._is_steam_running",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug",
),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
return_value=True,
),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_collections_ready",
return_value=True,
),
):
ensure_steam_debug_port()
def test_restarts_running_steam(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch._is_steam_running",
return_value=True,
),
patch(
"steam_backlog_enforcer._steam_launch._shutdown_steam",
),
patch(
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug",
),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
return_value=True,
),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_collections_ready",
return_value=True,
),
):
ensure_steam_debug_port()
def test_cdp_timeout(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch._is_steam_running",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug",
),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
return_value=False,
),
pytest.raises(RuntimeError, match="Timed out waiting for Steam CDP"),
):
ensure_steam_debug_port()
def test_collections_timeout(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch._is_steam_running",
return_value=False,
),
patch(
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug",
),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
return_value=True,
),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_collections_ready",
return_value=False,
),
pytest.raises(
RuntimeError, match="Timed out waiting for Steam collections"
),
):
ensure_steam_debug_port()

View File

@ -0,0 +1,189 @@
"""Tests for _ensure_steam_running and the steam:// privilege drop.
Split from test_game_install.py to keep both files under the 250-line cap.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from steam_backlog_enforcer._steam_client import _ensure_steam_running
if TYPE_CHECKING:
from collections.abc import Iterator
PKG = "steam_backlog_enforcer._steam_client"
class TestEnsureSteamRunning:
"""Tests for _ensure_steam_running."""
@pytest.fixture(autouse=True)
def _steam_present(self) -> Iterator[None]:
"""Pretend Steam is installed for every test in this class.
Without it the function returns before any launch, which on a test
machine without Steam would make the launch assertions vacuous. The
absent case is covered by test_skips_when_steam_absent.
"""
with patch(f"{PKG}.steam_is_installed", return_value=True):
yield
def test_skips_when_steam_absent(self) -> None:
"""With Steam uninstalled, do not try to launch a missing client.
Regression guard: this path used to exec a dead launcher wrapper and
then sleep 15s, leaving a zombie named "steam" behind each time.
"""
with (
patch(f"{PKG}.steam_is_installed", return_value=False),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{PKG}.time.sleep") as mock_sleep,
):
_ensure_steam_running()
mock_popen.assert_not_called()
mock_sleep.assert_not_called()
def test_already_running(self) -> None:
mock_result = MagicMock(returncode=0)
with patch(
"steam_backlog_enforcer._steam_client.subprocess.run",
return_value=mock_result,
):
_ensure_steam_running()
def test_not_running_starts_as_non_root(self) -> None:
mock_result = MagicMock(returncode=1)
with (
patch(
"steam_backlog_enforcer._steam_client.subprocess.run",
return_value=mock_result,
),
patch(
"steam_backlog_enforcer._steam_client.subprocess.Popen"
) as mock_popen,
patch(
"steam_backlog_enforcer._steam_client.os.geteuid",
return_value=1000,
),
patch("steam_backlog_enforcer._steam_client.time.sleep"),
):
_ensure_steam_running()
mock_popen.assert_called_once()
def test_not_running_starts_as_root(self) -> None:
mock_result = MagicMock(returncode=1)
mock_pw = MagicMock()
mock_pw.pw_uid = 1000
mock_pw.pw_gid = 1000
with (
patch(
"steam_backlog_enforcer._steam_client.subprocess.run",
return_value=mock_result,
),
patch(
"steam_backlog_enforcer._steam_client.subprocess.Popen"
) as mock_popen,
patch(
"steam_backlog_enforcer._steam_client.os.geteuid",
return_value=0,
),
patch(
"steam_backlog_enforcer._steam_client._get_real_user",
return_value="alice",
),
patch(
"steam_backlog_enforcer._steam_client._get_uid_gid_for_user",
return_value=(1000, 1000),
),
patch(
"steam_backlog_enforcer._steam_client.desktop_session_ready",
return_value=True,
),
patch("steam_backlog_enforcer._steam_client.time.sleep"),
):
_ensure_steam_running()
mock_popen.assert_called_once()
# Assert the env vector, not just that a launch happened: dropping
# XDG_RUNTIME_DIR here is silent, and cost a Proton game its audio
# backend (winepulse -> winealsa) and a crash on startup.
cmd = mock_popen.call_args[0][0]
assert cmd[:4] == ["sudo", "-u", "alice", "env"]
assert "XDG_RUNTIME_DIR=/run/user/1000" in cmd
def test_defers_when_runtime_dir_missing(self) -> None:
"""Do not start Steam before the desktop session's runtime dir exists.
Launching in that window yields a Steam whose Wine children cannot
find PulseAudio, and it stays that way for the whole session. The
enforce loop retries every 3s, so deferring is nearly free.
"""
mock_result = MagicMock(returncode=1)
with (
patch(
"steam_backlog_enforcer._steam_client.subprocess.run",
return_value=mock_result,
),
patch(
"steam_backlog_enforcer._steam_client.subprocess.Popen"
) as mock_popen,
patch(
"steam_backlog_enforcer._steam_client.os.geteuid",
return_value=0,
),
patch(
"steam_backlog_enforcer._steam_client._get_real_user",
return_value="alice",
),
patch(
"steam_backlog_enforcer._steam_client._get_uid_gid_for_user",
return_value=(1000, 1000),
),
patch(
"steam_backlog_enforcer._steam_client.desktop_session_ready",
return_value=False,
),
patch("steam_backlog_enforcer._steam_client.time.sleep") as mock_sleep,
):
_ensure_steam_running()
mock_popen.assert_not_called()
mock_sleep.assert_not_called()
def test_pgrep_not_found(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_client.subprocess.run",
side_effect=FileNotFoundError,
),
patch("steam_backlog_enforcer._steam_client.subprocess.Popen"),
patch(
"steam_backlog_enforcer._steam_client.os.geteuid",
return_value=1000,
),
patch("steam_backlog_enforcer._steam_client.time.sleep"),
):
_ensure_steam_running()
def test_steam_executable_not_found(self) -> None:
mock_result = MagicMock(returncode=1)
with (
patch(
"steam_backlog_enforcer._steam_client.subprocess.run",
return_value=mock_result,
),
patch(
"steam_backlog_enforcer._steam_client.subprocess.Popen",
side_effect=FileNotFoundError,
),
patch(
"steam_backlog_enforcer._steam_client.os.geteuid",
return_value=1000,
),
):
_ensure_steam_running()

View File

@ -8,22 +8,24 @@ from unittest.mock import MagicMock, patch
import pytest
from steam_backlog_enforcer.game_install import (
_assert_not_real_steam,
_echo,
_ensure_steam_running,
from steam_backlog_enforcer._echo import _echo
from steam_backlog_enforcer._steam_client import (
_get_real_user,
_get_uid_gid_for_user,
_trigger_steam_install,
is_game_installed,
)
from steam_backlog_enforcer._steam_state import _assert_not_real_steam
from steam_backlog_enforcer.game_install import (
_trigger_steam_install,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
PKG = "steam_backlog_enforcer.game_install"
ECHO_PKG = "steam_backlog_enforcer._echo"
STEAM_CLIENT_PKG = "steam_backlog_enforcer._steam_client"
class TestAssertNotRealSteam:
@ -40,8 +42,8 @@ class TestAssertNotRealSteam:
fake_manifest = real / "appmanifest_440.acf"
fake_manifest.touch()
with (
patch(f"{PKG}._REAL_STEAMAPPS", real),
patch(f"{PKG}.STEAMAPPS_PATH", real),
patch("steam_backlog_enforcer._steam_state._REAL_STEAMAPPS", real),
patch("steam_backlog_enforcer._steam_state.STEAMAPPS_PATH", real),
pytest.raises(RuntimeError, match="SAFETY"),
):
_assert_not_real_steam(fake_manifest)
@ -55,8 +57,8 @@ class TestAssertNotRealSteam:
redirected = tmp_path / "fake_steam"
redirected.mkdir()
with (
patch(f"{PKG}._REAL_STEAMAPPS", real),
patch(f"{PKG}.STEAMAPPS_PATH", redirected),
patch("steam_backlog_enforcer._steam_state._REAL_STEAMAPPS", real),
patch("steam_backlog_enforcer._steam_state.STEAMAPPS_PATH", redirected),
):
_assert_not_real_steam(fake_manifest)
@ -69,8 +71,8 @@ class TestAssertNotRealSteam:
env = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"}
with (
patch.dict(os.environ, env, clear=True),
patch(f"{PKG}._REAL_STEAMAPPS", real),
patch(f"{PKG}.STEAMAPPS_PATH", real),
patch("steam_backlog_enforcer._steam_state._REAL_STEAMAPPS", real),
patch("steam_backlog_enforcer._steam_state.STEAMAPPS_PATH", real),
):
_assert_not_real_steam(fake_manifest)
@ -154,14 +156,14 @@ class TestGetUidGid:
mock_pw.pw_uid = 1001
mock_pw.pw_gid = 1001
with patch(
"steam_backlog_enforcer.game_install.pwd.getpwnam",
"steam_backlog_enforcer._steam_client.pwd.getpwnam",
return_value=mock_pw,
):
assert _get_uid_gid_for_user("alice") == (1001, 1001)
def test_unknown_user(self) -> None:
with patch(
"steam_backlog_enforcer.game_install.pwd.getpwnam",
"steam_backlog_enforcer._steam_client.pwd.getpwnam",
side_effect=KeyError,
):
assert _get_uid_gid_for_user("nobody") == (1000, 1000)
@ -173,174 +175,55 @@ class TestIsGameInstalled:
def test_installed(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.touch()
with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer._steam_client.STEAMAPPS_PATH", tmp_path):
assert is_game_installed(440) is True
def test_not_installed(self, tmp_path: Path) -> None:
with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer._steam_client.STEAMAPPS_PATH", tmp_path):
assert is_game_installed(440) is False
class TestEnsureSteamRunning:
"""Tests for _ensure_steam_running."""
class TestTriggerSteamInstallPrivilegeDrop:
"""Regression tests: never invoke the steam:// handler as root.
@pytest.fixture(autouse=True)
def _steam_present(self) -> Iterator[None]:
"""Pretend Steam is installed for every test in this class.
A bare root ``xdg-open steam://install/...`` makes Steam answer with a
"Cannot run as root user" modal on the user's display.
"""
Without it the function returns before any launch, which on a test
machine without Steam would make the launch assertions vacuous. The
absent case is covered by test_skips_when_steam_absent.
"""
with patch(f"{PKG}.steam_is_installed", return_value=True):
yield
_PKG = "steam_backlog_enforcer.game_install"
def test_skips_when_steam_absent(self) -> None:
"""With Steam uninstalled, do not try to launch a missing client.
Regression guard: this path used to exec a dead launcher wrapper and
then sleep 15s, leaving a zombie named "steam" behind each time.
"""
def test_root_drops_to_desktop_user(self) -> None:
with (
patch(f"{PKG}.steam_is_installed", return_value=False),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{PKG}.time.sleep") as mock_sleep,
patch(f"{self._PKG}.os.geteuid", return_value=0),
patch(f"{self._PKG}._get_real_user", return_value="kuhy"),
patch(f"{self._PKG}.desktop_session_ready", return_value=True),
patch(f"{self._PKG}.desktop_uid", return_value=1000),
patch(f"{self._PKG}.subprocess.run") as mock_run,
):
_ensure_steam_running()
assert _trigger_steam_install(440, "TF2") is True
mock_popen.assert_not_called()
mock_sleep.assert_not_called()
argv = mock_run.call_args[0][0]
assert argv[:4] == ["sudo", "-u", "kuhy", "env"]
assert argv[-1] == "steam://install/440"
def test_already_running(self) -> None:
mock_result = MagicMock(returncode=0)
with patch(
"steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
):
_ensure_steam_running()
def test_not_running_starts_as_non_root(self) -> None:
mock_result = MagicMock(returncode=1)
def test_non_root_does_not_wrap(self) -> None:
with (
patch(
"steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
),
patch("steam_backlog_enforcer.game_install.subprocess.Popen") as mock_popen,
patch(
"steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
patch("steam_backlog_enforcer.game_install.time.sleep"),
patch(f"{self._PKG}.os.geteuid", return_value=1000),
patch(f"{self._PKG}._get_real_user", return_value="kuhy"),
patch(f"{self._PKG}.subprocess.run") as mock_run,
):
_ensure_steam_running()
mock_popen.assert_called_once()
assert _trigger_steam_install(440, "TF2") is True
def test_not_running_starts_as_root(self) -> None:
mock_result = MagicMock(returncode=1)
mock_pw = MagicMock()
mock_pw.pw_uid = 1000
mock_pw.pw_gid = 1000
assert mock_run.call_args[0][0][0] != "sudo"
def test_defers_when_session_not_ready(self) -> None:
"""No runtime dir means a Steam launched now loses audio all session."""
with (
patch(
"steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
),
patch("steam_backlog_enforcer.game_install.subprocess.Popen") as mock_popen,
patch(
"steam_backlog_enforcer.game_install.os.geteuid",
return_value=0,
),
patch(
"steam_backlog_enforcer.game_install._get_real_user",
return_value="alice",
),
patch(
"steam_backlog_enforcer.game_install._get_uid_gid_for_user",
return_value=(1000, 1000),
),
patch(
"steam_backlog_enforcer.game_install.desktop_session_ready",
return_value=True,
),
patch("steam_backlog_enforcer.game_install.time.sleep"),
patch(f"{self._PKG}.os.geteuid", return_value=0),
patch(f"{self._PKG}._get_real_user", return_value="kuhy"),
patch(f"{self._PKG}.desktop_session_ready", return_value=False),
patch(f"{self._PKG}.subprocess.run") as mock_run,
):
_ensure_steam_running()
mock_popen.assert_called_once()
assert _trigger_steam_install(440, "TF2") is False
# Assert the env vector, not just that a launch happened: dropping
# XDG_RUNTIME_DIR here is silent, and cost a Proton game its audio
# backend (winepulse -> winealsa) and a crash on startup.
cmd = mock_popen.call_args[0][0]
assert cmd[:4] == ["sudo", "-u", "alice", "env"]
assert "XDG_RUNTIME_DIR=/run/user/1000" in cmd
def test_defers_when_runtime_dir_missing(self) -> None:
"""Do not start Steam before the desktop session's runtime dir exists.
Launching in that window yields a Steam whose Wine children cannot
find PulseAudio, and it stays that way for the whole session. The
enforce loop retries every 3s, so deferring is nearly free.
"""
mock_result = MagicMock(returncode=1)
with (
patch(
"steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
),
patch("steam_backlog_enforcer.game_install.subprocess.Popen") as mock_popen,
patch(
"steam_backlog_enforcer.game_install.os.geteuid",
return_value=0,
),
patch(
"steam_backlog_enforcer.game_install._get_real_user",
return_value="alice",
),
patch(
"steam_backlog_enforcer.game_install._get_uid_gid_for_user",
return_value=(1000, 1000),
),
patch(
"steam_backlog_enforcer.game_install.desktop_session_ready",
return_value=False,
),
patch("steam_backlog_enforcer.game_install.time.sleep") as mock_sleep,
):
_ensure_steam_running()
mock_popen.assert_not_called()
mock_sleep.assert_not_called()
def test_pgrep_not_found(self) -> None:
with (
patch(
"steam_backlog_enforcer.game_install.subprocess.run",
side_effect=FileNotFoundError,
),
patch("steam_backlog_enforcer.game_install.subprocess.Popen"),
patch(
"steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
patch("steam_backlog_enforcer.game_install.time.sleep"),
):
_ensure_steam_running()
def test_steam_executable_not_found(self) -> None:
mock_result = MagicMock(returncode=1)
with (
patch(
"steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
),
patch(
"steam_backlog_enforcer.game_install.subprocess.Popen",
side_effect=FileNotFoundError,
),
patch(
"steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
):
_ensure_steam_running()
mock_run.assert_not_called()

View File

@ -0,0 +1,86 @@
"""Tests for install_game's Steam-library readiness guard.
Split from test_game_install_part3.py to keep both files under the 250-line cap.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer.game_install import install_game
if TYPE_CHECKING:
from pathlib import Path
class TestInstallGameLibraryGuard:
"""install_game must not retry when Steam has no library at all.
Without this the enforce loop re-triggered every allowed game on every
pass (measured: 1656 attempts in 30 minutes), each one launching Steam
as root and raising FileNotFoundError on the manifest write.
"""
_PKG = "steam_backlog_enforcer.game_install"
def test_skips_when_library_missing(self, tmp_path: Path) -> None:
missing = tmp_path / "steamapps" # deliberately not created
with (
patch(f"{self._PKG}.STEAMAPPS_PATH", missing),
patch(f"{self._PKG}.steam_library_ready", return_value=False),
patch(f"{self._PKG}._ensure_steam_running") as mock_ensure,
patch(f"{self._PKG}._trigger_steam_install") as mock_trigger,
):
assert (
install_game(440, "TF2", "steam123", use_steam_protocol=True) is False
)
mock_ensure.assert_not_called()
mock_trigger.assert_not_called()
def test_proceeds_when_library_present(self, tmp_path: Path) -> None:
with (
patch(f"{self._PKG}.STEAMAPPS_PATH", tmp_path),
patch(f"{self._PKG}.steam_library_ready", return_value=True),
patch(f"{self._PKG}._ensure_steam_running"),
patch(f"{self._PKG}._trigger_steam_install", return_value=True),
):
result = install_game(440, "TF2", "steam123", use_steam_protocol=True)
assert result is True
class TestReinstallMissingAllowedGuard:
"""_reinstall_missing_allowed must stay quiet when there is no library.
install_game guards this too, but the caller logs one INFO line per game
*before* calling it — at a 3s cadence that is its own log storm.
"""
_PKG = "steam_backlog_enforcer._enforce_steps"
def test_skips_when_library_missing(self) -> None:
from steam_backlog_enforcer._enforce_steps import _reinstall_missing_allowed
with (
patch(f"{self._PKG}.steam_library_ready", return_value=False),
patch(f"{self._PKG}.allowed_games") as mock_allowed,
patch(f"{self._PKG}.install_game") as mock_install,
):
_reinstall_missing_allowed(MagicMock(), MagicMock())
mock_allowed.assert_not_called()
mock_install.assert_not_called()
def test_proceeds_when_library_present(self) -> None:
from steam_backlog_enforcer._enforce_steps import _reinstall_missing_allowed
with (
patch(f"{self._PKG}.steam_library_ready", return_value=True),
patch(f"{self._PKG}.allowed_games", return_value=[(440, "TF2")]),
patch(f"{self._PKG}.is_game_installed", return_value=False),
patch(f"{self._PKG}.install_game") as mock_install,
):
_reinstall_missing_allowed(MagicMock(), MagicMock())
mock_install.assert_called_once()

View File

@ -5,10 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer.game_install import (
_is_protected_name,
_normalize_game_name,
_protected_name_stems,
from steam_backlog_enforcer.game_uninstall import (
_remove_game_dirs,
uninstall_game,
uninstall_other_games,
@ -18,6 +15,8 @@ if TYPE_CHECKING:
from pathlib import Path
PKG = "steam_backlog_enforcer.game_install"
GAME_NAMES_PKG = "steam_backlog_enforcer._game_names"
GAME_UNINSTALL_PKG = "steam_backlog_enforcer.game_uninstall"
class TestRemoveGameDirs:
@ -27,19 +26,19 @@ class TestRemoveGameDirs:
install_dir = tmp_path / "common" / "MyGame"
install_dir.mkdir(parents=True)
(install_dir / "game.exe").touch()
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(install_dir, 440)
assert result is True
assert not install_dir.exists()
def test_install_dir_none(self, tmp_path: Path) -> None:
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(None, 440)
assert result is True
def test_install_dir_not_exists(self, tmp_path: Path) -> None:
missing = tmp_path / "common" / "Missing"
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(missing, 440)
assert result is True
@ -47,7 +46,7 @@ class TestRemoveGameDirs:
install_dir = tmp_path / "common" / "MyGame"
install_dir.mkdir(parents=True)
with (
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path),
patch(f"{PKG}.shutil.rmtree", side_effect=OSError("perm")),
):
result = _remove_game_dirs(install_dir, 440)
@ -56,7 +55,7 @@ class TestRemoveGameDirs:
def test_removes_cache_dirs(self, tmp_path: Path) -> None:
for subdir in ("shadercache", "compatdata"):
(tmp_path / subdir / "440").mkdir(parents=True)
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(None, 440)
assert result is True
assert not (tmp_path / "shadercache" / "440").exists()
@ -73,7 +72,7 @@ class TestRemoveGameDirs:
raise OSError(msg)
with (
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path),
patch(f"{PKG}.shutil.rmtree", side_effect=fake_rmtree),
):
result = _remove_game_dirs(None, 440)
@ -88,7 +87,7 @@ class TestUninstallGame:
manifest.write_text('"installdir"\t\t"TF2"\n', encoding="utf-8")
install_dir = tmp_path / "common" / "TF2"
install_dir.mkdir(parents=True)
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
result = uninstall_game(440, "TF2")
assert result is True
@ -97,19 +96,19 @@ class TestUninstallGame:
mock_manifest.exists.return_value = True
mock_manifest.unlink.side_effect = OSError
with (
patch(f"{PKG}.STEAMAPPS_PATH", MagicMock()),
patch(f"{PKG}._read_install_dir", return_value=None),
patch(f"{PKG}._remove_manifest", return_value=False),
patch(f"{PKG}._remove_game_dirs", return_value=True),
patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", MagicMock()),
patch(f"{GAME_UNINSTALL_PKG}._read_install_dir", return_value=None),
patch(f"{GAME_UNINSTALL_PKG}._remove_manifest", return_value=False),
patch(f"{GAME_UNINSTALL_PKG}._remove_game_dirs", return_value=True),
):
result = uninstall_game(440, "TF2")
assert result is False
def test_game_dirs_removal_fails(self) -> None:
with (
patch(f"{PKG}._read_install_dir", return_value=None),
patch(f"{PKG}._remove_manifest", return_value=True),
patch(f"{PKG}._remove_game_dirs", return_value=False),
patch(f"{GAME_UNINSTALL_PKG}._read_install_dir", return_value=None),
patch(f"{GAME_UNINSTALL_PKG}._remove_manifest", return_value=True),
patch(f"{GAME_UNINSTALL_PKG}._remove_game_dirs", return_value=False),
):
result = uninstall_game(440, "TF2")
assert result is False
@ -121,10 +120,12 @@ class TestUninstallOtherGames:
def test_keeps_allowed(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
f"{GAME_UNINSTALL_PKG}.get_installed_games",
return_value=[(440, "TF2"), (730, "CS")],
),
patch(f"{PKG}.uninstall_game", return_value=True) as mock_uninstall,
patch(
f"{GAME_UNINSTALL_PKG}.uninstall_game", return_value=True
) as mock_uninstall,
):
count = uninstall_other_games({440})
assert count == 1
@ -133,10 +134,10 @@ class TestUninstallOtherGames:
def test_skips_protected(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
f"{GAME_UNINSTALL_PKG}.get_installed_games",
return_value=[(228980, "Redist")],
),
patch(f"{PKG}.uninstall_game") as mock_uninstall,
patch(f"{GAME_UNINSTALL_PKG}.uninstall_game") as mock_uninstall,
):
count = uninstall_other_games(set())
assert count == 0
@ -145,10 +146,10 @@ class TestUninstallOtherGames:
def test_uninstall_fails(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
f"{GAME_UNINSTALL_PKG}.get_installed_games",
return_value=[(999, "GameX")],
),
patch(f"{PKG}.uninstall_game", return_value=False),
patch(f"{GAME_UNINSTALL_PKG}.uninstall_game", return_value=False),
):
count = uninstall_other_games(set())
assert count == 0
@ -156,100 +157,11 @@ class TestUninstallOtherGames:
def test_all_allowed_or_protected(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
f"{GAME_UNINSTALL_PKG}.get_installed_games",
return_value=[(440, "TF2"), (228980, "Redist")],
),
patch(f"{PKG}.uninstall_game") as mock_uninstall,
patch(f"{GAME_UNINSTALL_PKG}.uninstall_game") as mock_uninstall,
):
count = uninstall_other_games({440})
assert count == 0
mock_uninstall.assert_not_called()
class TestNormalizeGameName:
"""Tests for _normalize_game_name."""
def test_lowercases_and_strips_punctuation(self) -> None:
assert (
_normalize_game_name("Kingdom Come: Deliverance II")
== "kingdomcomedeliverance2"
)
def test_matches_no_space_variant(self) -> None:
assert _normalize_game_name("KingdomComeDeliverance2") == _normalize_game_name(
"Kingdom Come: Deliverance II"
)
def test_spelled_out_number_matches_digit(self) -> None:
assert _normalize_game_name("Portal Two") == _normalize_game_name("Portal 2")
class TestProtectedNameStems:
"""Tests for _protected_name_stems."""
def test_returns_allowed_game_names(self) -> None:
with (
patch("steam_backlog_enforcer.config.State.load", return_value=MagicMock()),
patch(
f"{PKG}.allowed_games",
return_value=[(1771300, "Kingdom Come: Deliverance II"), (440, "")],
),
):
stems = _protected_name_stems()
assert stems == ["Kingdom Come: Deliverance II"]
def test_returns_empty_list_when_state_cannot_load(self) -> None:
with patch(
"steam_backlog_enforcer.config.State.load", side_effect=RuntimeError("boom")
):
stems = _protected_name_stems()
assert stems == []
class TestIsProtectedName:
"""Tests for _is_protected_name."""
def test_blank_candidate_is_not_protected(self) -> None:
assert _is_protected_name(" ") is False
def test_matches_exact_normalized_name(self) -> None:
with patch(
f"{PKG}._protected_name_stems",
return_value=["Kingdom Come: Deliverance II"],
):
assert _is_protected_name("KingdomComeDeliverance2") is True
def test_matches_typo_via_fuzzy_ratio(self) -> None:
with patch(
f"{PKG}._protected_name_stems",
return_value=["Kingdom Come: Deliverance II"],
):
assert _is_protected_name("Kingdom Come Delievarnce 2") is True
def test_no_match_for_unrelated_name(self) -> None:
with patch(
f"{PKG}._protected_name_stems",
return_value=["Kingdom Come: Deliverance II"],
):
assert _is_protected_name("Cogmind") is False
def test_skips_blank_protected_name(self) -> None:
with patch(f"{PKG}._protected_name_stems", return_value=["", "Cogmind"]):
assert _is_protected_name("Cogmind") is True
class TestRemoveGameDirsProtectedName:
"""Tests for the _is_protected_name safety net inside _remove_game_dirs."""
def test_refuses_to_remove_protected_name(self, tmp_path: Path) -> None:
install_dir = tmp_path / "common" / "Kingdom Come: Deliverance II"
install_dir.mkdir(parents=True)
(install_dir / "game.exe").touch()
with (
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
patch(f"{PKG}._is_protected_name", return_value=True),
):
result = _remove_game_dirs(install_dir, 1771300)
assert result is False
assert install_dir.exists()
assert (install_dir / "game.exe").exists()

View File

@ -5,11 +5,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer.game_install import (
from steam_backlog_enforcer.game_install import install_game
from steam_backlog_enforcer.game_uninstall import (
_read_install_dir,
_remove_manifest,
get_installed_games,
install_game,
)
if TYPE_CHECKING:
@ -158,26 +158,26 @@ class TestGetInstalledGames:
def test_parses_manifests(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"appid"\t\t"440"\n"name"\t\t"Team Fortress 2"\n')
with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
result = get_installed_games()
assert result == [(440, "Team Fortress 2")]
def test_no_name(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"appid"\t\t"440"\n')
with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
result = get_installed_games()
assert result == [(440, "Unknown (440)")]
def test_empty_dir(self, tmp_path: Path) -> None:
with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
result = get_installed_games()
assert result == []
def test_no_appid_match(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"name"\t\t"NoAppId"\n')
with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
result = get_installed_games()
assert result == []
@ -188,14 +188,14 @@ class TestReadInstallDir:
def test_reads_dir(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"installdir"\t\t"Team Fortress 2"\n')
with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
result = _read_install_dir(manifest)
assert result == tmp_path / "common" / "Team Fortress 2"
def test_no_match(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"appid"\t\t"440"\n')
with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path):
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
assert _read_install_dir(manifest) is None
def test_missing_file(self, tmp_path: Path) -> None:

View File

@ -0,0 +1,119 @@
"""Tests for game-name normalisation and the protected-name safety net.
Split to keep every test file under the 250-line cap.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer._game_names import (
_is_protected_name,
_normalize_game_name,
_protected_name_stems,
)
from steam_backlog_enforcer.game_uninstall import (
_remove_game_dirs,
)
if TYPE_CHECKING:
from pathlib import Path
PKG = "steam_backlog_enforcer.game_install"
GAME_NAMES_PKG = "steam_backlog_enforcer._game_names"
GAME_UNINSTALL_PKG = "steam_backlog_enforcer.game_uninstall"
class TestNormalizeGameName:
"""Tests for _normalize_game_name."""
def test_lowercases_and_strips_punctuation(self) -> None:
assert (
_normalize_game_name("Kingdom Come: Deliverance II")
== "kingdomcomedeliverance2"
)
def test_matches_no_space_variant(self) -> None:
assert _normalize_game_name("KingdomComeDeliverance2") == _normalize_game_name(
"Kingdom Come: Deliverance II"
)
def test_spelled_out_number_matches_digit(self) -> None:
assert _normalize_game_name("Portal Two") == _normalize_game_name("Portal 2")
class TestProtectedNameStems:
"""Tests for _protected_name_stems."""
def test_returns_allowed_game_names(self) -> None:
with (
patch("steam_backlog_enforcer.config.State.load", return_value=MagicMock()),
patch(
"steam_backlog_enforcer._game_names.allowed_games",
return_value=[(1771300, "Kingdom Come: Deliverance II"), (440, "")],
),
):
stems = _protected_name_stems()
assert stems == ["Kingdom Come: Deliverance II"]
def test_returns_empty_list_when_state_cannot_load(self) -> None:
with patch(
"steam_backlog_enforcer.config.State.load", side_effect=RuntimeError("boom")
):
stems = _protected_name_stems()
assert stems == []
class TestIsProtectedName:
"""Tests for _is_protected_name."""
def test_blank_candidate_is_not_protected(self) -> None:
assert _is_protected_name(" ") is False
def test_matches_exact_normalized_name(self) -> None:
with patch(
f"{GAME_NAMES_PKG}._protected_name_stems",
return_value=["Kingdom Come: Deliverance II"],
):
assert _is_protected_name("KingdomComeDeliverance2") is True
def test_matches_typo_via_fuzzy_ratio(self) -> None:
with patch(
f"{GAME_NAMES_PKG}._protected_name_stems",
return_value=["Kingdom Come: Deliverance II"],
):
assert _is_protected_name("Kingdom Come Delievarnce 2") is True
def test_no_match_for_unrelated_name(self) -> None:
with patch(
f"{GAME_NAMES_PKG}._protected_name_stems",
return_value=["Kingdom Come: Deliverance II"],
):
assert _is_protected_name("Cogmind") is False
def test_skips_blank_protected_name(self) -> None:
with patch(
f"{GAME_NAMES_PKG}._protected_name_stems", return_value=["", "Cogmind"]
):
assert _is_protected_name("Cogmind") is True
class TestRemoveGameDirsProtectedName:
"""Tests for the _is_protected_name safety net inside _remove_game_dirs."""
def test_refuses_to_remove_protected_name(self, tmp_path: Path) -> None:
install_dir = tmp_path / "common" / "Kingdom Come: Deliverance II"
install_dir.mkdir(parents=True)
(install_dir / "game.exe").touch()
with (
patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path),
patch(
"steam_backlog_enforcer.game_uninstall._is_protected_name",
return_value=True,
),
):
result = _remove_game_dirs(install_dir, 1771300)
assert result is False
assert install_dir.exists()
assert (install_dir / "game.exe").exists()

View File

@ -4,29 +4,17 @@ from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from steam_backlog_enforcer.library_hider import (
SteamUnavailableError,
from steam_backlog_enforcer._cdp import (
_cdp_result_value,
_evaluate_js,
_evaluate_js_async,
_get_shared_js_ws_url,
_is_steam_running,
_launch_steam_with_debug,
_shutdown_steam,
_steam_has_debug_port,
_wait_for_cdp_ready,
_wait_for_collections_ready,
ensure_steam_debug_port,
)
if TYPE_CHECKING:
from collections.abc import Iterator
class TestGetSharedJsWsUrl:
"""Tests for _get_shared_js_ws_url."""
@ -42,7 +30,7 @@ class TestGetSharedJsWsUrl:
mock_resp = MagicMock()
mock_resp.json.return_value = targets
with patch(
"steam_backlog_enforcer.library_hider.requests.get",
"steam_backlog_enforcer._cdp.requests.get",
return_value=mock_resp,
):
result = _get_shared_js_ws_url()
@ -53,14 +41,14 @@ class TestGetSharedJsWsUrl:
mock_resp = MagicMock()
mock_resp.json.return_value = targets
with patch(
"steam_backlog_enforcer.library_hider.requests.get",
"steam_backlog_enforcer._cdp.requests.get",
return_value=mock_resp,
):
assert _get_shared_js_ws_url() is None
def test_connection_error(self) -> None:
with patch(
"steam_backlog_enforcer.library_hider.requests.get",
"steam_backlog_enforcer._cdp.requests.get",
side_effect=OSError,
):
assert _get_shared_js_ws_url() is None
@ -79,7 +67,7 @@ class TestEvaluateJsAsync:
mock_ws.__aexit__ = AsyncMock(return_value=False)
with patch(
"steam_backlog_enforcer.library_hider.websockets.connect",
"steam_backlog_enforcer._cdp.websockets.connect",
return_value=mock_ws,
):
result = asyncio.run(_evaluate_js_async("ws://test", "1+1"))
@ -92,11 +80,11 @@ class TestEvaluateJs:
def test_success(self) -> None:
with (
patch(
"steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
"steam_backlog_enforcer._cdp._get_shared_js_ws_url",
return_value="ws://test",
),
patch(
"steam_backlog_enforcer.library_hider.asyncio.run",
"steam_backlog_enforcer._cdp.asyncio.run",
return_value={"result": {"result": {"value": "ok"}}},
),
):
@ -106,7 +94,7 @@ class TestEvaluateJs:
def test_no_ws_url(self) -> None:
with (
patch(
"steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
"steam_backlog_enforcer._cdp._get_shared_js_ws_url",
return_value=None,
),
pytest.raises(RuntimeError, match="SharedJSContext not found"),
@ -133,336 +121,3 @@ class TestCdpResultValue:
def test_empty(self) -> None:
assert _cdp_result_value({}) == ""
class TestIsSteamRunning:
"""Tests for _is_steam_running."""
def test_running(self) -> None:
mock_result = MagicMock(returncode=0)
with patch(
"steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
):
assert _is_steam_running() is True
def test_not_running(self) -> None:
mock_result = MagicMock(returncode=1)
with patch(
"steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
):
assert _is_steam_running() is False
class TestSteamHasDebugPort:
"""Tests for _steam_has_debug_port."""
def test_has_port(self) -> None:
with patch(
"steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value="ws://test",
):
assert _steam_has_debug_port() is True
def test_no_port(self) -> None:
with patch(
"steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value=None,
):
assert _steam_has_debug_port() is False
class TestWaitForCdpReady:
"""Tests for _wait_for_cdp_ready."""
def test_ready_immediately(self) -> None:
with patch(
"steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value="ws://test",
):
assert _wait_for_cdp_ready() is True
def test_timeout(self) -> None:
with (
patch(
"steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value=None,
),
patch(
"steam_backlog_enforcer.library_hider.time.sleep",
),
patch(
"steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_cdp_ready() is False
class TestWaitForCollectionsReady:
"""Tests for _wait_for_collections_ready."""
def test_ready(self) -> None:
with (
patch(
"steam_backlog_enforcer.library_hider._evaluate_js",
return_value={"result": {"result": {"value": "ok"}}},
),
patch(
"steam_backlog_enforcer.library_hider._cdp_result_value",
return_value="ok",
),
):
assert _wait_for_collections_ready() is True
def test_not_ready_then_ready(self) -> None:
with (
patch(
"steam_backlog_enforcer.library_hider._evaluate_js",
return_value={"result": {"result": {"value": "not_ready"}}},
),
patch(
"steam_backlog_enforcer.library_hider._cdp_result_value",
side_effect=["not_ready", "ok"],
),
patch(
"steam_backlog_enforcer.library_hider.time.sleep",
),
patch(
"steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_collections_ready() is True
def test_timeout(self) -> None:
with (
patch(
"steam_backlog_enforcer.library_hider._evaluate_js",
side_effect=RuntimeError,
),
patch(
"steam_backlog_enforcer.library_hider.time.sleep",
),
patch(
"steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_collections_ready() is False
class TestShutdownSteam:
"""Tests for _shutdown_steam."""
def test_exits_immediately(self) -> None:
mock_result = MagicMock(returncode=1) # Not running
with (
patch(
"steam_backlog_enforcer.library_hider._run_as_user",
),
patch(
"steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
),
):
_shutdown_steam()
def test_waits_for_exit(self) -> None:
results = [MagicMock(returncode=0), MagicMock(returncode=1)]
with (
patch(
"steam_backlog_enforcer.library_hider._run_as_user",
),
patch(
"steam_backlog_enforcer.library_hider.subprocess.run",
side_effect=results,
),
patch(
"steam_backlog_enforcer.library_hider.time.sleep",
),
):
_shutdown_steam()
def test_file_not_found(self) -> None:
with patch(
"steam_backlog_enforcer.library_hider._run_as_user",
side_effect=FileNotFoundError,
):
_shutdown_steam() # Should not raise
def test_timeout(self) -> None:
mock_result = MagicMock(returncode=0) # Still running
with (
patch(
"steam_backlog_enforcer.library_hider._run_as_user",
),
patch(
"steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
),
patch(
"steam_backlog_enforcer.library_hider.time.sleep",
),
):
_shutdown_steam() # Should complete loop without raising
class TestLaunchSteamWithDebug:
"""Tests for _launch_steam_with_debug."""
def test_launches(self) -> None:
with patch(
"steam_backlog_enforcer.library_hider._run_as_user",
) as mock_run:
_launch_steam_with_debug()
mock_run.assert_called_once()
class TestEnsureSteamDebugPort:
"""Tests for ensure_steam_debug_port."""
@pytest.fixture(autouse=True)
def _steam_present(self) -> Iterator[None]:
"""Pretend Steam is installed for every test in this class.
Without it the function raises before reaching any launch logic,
which on a test machine without Steam would make the cases below
vacuous. The absent case has its own test.
"""
with patch(
"steam_backlog_enforcer.library_hider.steam_is_installed",
return_value=True,
):
yield
def test_raises_when_steam_absent(self) -> None:
"""An uninstalled Steam must fail fast, before any launch attempt.
Regression guard: without this the caller spent ~45s waiting on a
CDP port that no process was ever going to open.
"""
with (
patch(
"steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer.library_hider.steam_is_installed",
return_value=False,
),
patch(
"steam_backlog_enforcer.library_hider._launch_steam_with_debug",
) as mock_launch,
pytest.raises(SteamUnavailableError, match="not installed"),
):
ensure_steam_debug_port()
mock_launch.assert_not_called()
def test_already_available(self) -> None:
with patch(
"steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=True,
):
ensure_steam_debug_port()
def test_starts_fresh(self) -> None:
with (
patch(
"steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer.library_hider._is_steam_running",
return_value=False,
),
patch(
"steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=True,
),
patch(
"steam_backlog_enforcer.library_hider._wait_for_collections_ready",
return_value=True,
),
):
ensure_steam_debug_port()
def test_restarts_running_steam(self) -> None:
with (
patch(
"steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer.library_hider._is_steam_running",
return_value=True,
),
patch(
"steam_backlog_enforcer.library_hider._shutdown_steam",
),
patch(
"steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=True,
),
patch(
"steam_backlog_enforcer.library_hider._wait_for_collections_ready",
return_value=True,
),
):
ensure_steam_debug_port()
def test_cdp_timeout(self) -> None:
with (
patch(
"steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer.library_hider._is_steam_running",
return_value=False,
),
patch(
"steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=False,
),
pytest.raises(RuntimeError, match="Timed out waiting for Steam CDP"),
):
ensure_steam_debug_port()
def test_collections_timeout(self) -> None:
with (
patch(
"steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"steam_backlog_enforcer.library_hider._is_steam_running",
return_value=False,
),
patch(
"steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=True,
),
patch(
"steam_backlog_enforcer.library_hider._wait_for_collections_ready",
return_value=False,
),
pytest.raises(
RuntimeError, match="Timed out waiting for Steam collections"
),
):
ensure_steam_debug_port()

View File

@ -0,0 +1,154 @@
"""Tests for library_hider's hide/unhide operations.
Split from test_library_hider_part2.py to keep both files under the 250-line cap.
"""
from __future__ import annotations
from unittest.mock import patch
from steam_backlog_enforcer._steam_errors import (
SteamUnavailableError,
SteamUpdateInProgressError,
)
from steam_backlog_enforcer._steam_launch import restart_steam
from steam_backlog_enforcer.library_hider import (
hide_other_games,
try_hide_other_games,
unhide_all_games,
)
PKG = "steam_backlog_enforcer.library_hider"
STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch"
LIBRARY_HIDER_PKG = "steam_backlog_enforcer.library_hider"
class TestRestartSteam:
"""Tests for restart_steam."""
def test_cdp_ready(self) -> None:
with (
patch("steam_backlog_enforcer._steam_launch._shutdown_steam"),
patch("steam_backlog_enforcer._steam_launch._launch_steam_with_debug"),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
return_value=True,
),
):
restart_steam()
def test_cdp_not_ready(self) -> None:
with (
patch("steam_backlog_enforcer._steam_launch._shutdown_steam"),
patch("steam_backlog_enforcer._steam_launch._launch_steam_with_debug"),
patch(
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
return_value=False,
),
):
restart_steam()
class TestHideOtherGames:
"""Tests for hide_other_games."""
def test_hides(self) -> None:
with (
patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"),
patch(
"steam_backlog_enforcer.library_hider._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 5}'}},
},
),
patch(
"steam_backlog_enforcer.library_hider._cdp_result_value",
return_value='{"totalHidden": 5}',
),
):
count = hide_other_games([1, 2, 3], {1})
assert count == 5
def test_empty_list(self) -> None:
with (
patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"),
patch(
"steam_backlog_enforcer.library_hider._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 0}'}},
},
),
patch(
"steam_backlog_enforcer.library_hider._cdp_result_value",
return_value='{"totalHidden": 0}',
),
):
count = hide_other_games([1], {1})
assert count == 0
def test_no_allowed(self) -> None:
with (
patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"),
patch(
"steam_backlog_enforcer.library_hider._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 2}'}},
},
),
patch(
"steam_backlog_enforcer.library_hider._cdp_result_value",
return_value='{"totalHidden": 2}',
),
):
count = hide_other_games([1, 2], set())
assert count == 2
class TestTryHideOtherGames:
"""Tests for the graceful wrapper around hide_other_games.
Regression guard: an unreachable Steam (or a deferred restart while a game
update is in flight) used to escape as a traceback out of every
interactive command that reconciles the library.
"""
def test_success_returns_count_and_no_reason(self) -> None:
with patch(f"{PKG}.hide_other_games", return_value=7):
assert try_hide_other_games([1, 2], {1}) == (7, None)
def test_steam_unavailable_is_reported_not_raised(self) -> None:
with patch(
f"{PKG}.hide_other_games",
side_effect=SteamUnavailableError("Steam is not installed"),
):
hidden, reason = try_hide_other_games([1, 2], {1})
assert hidden == 0
assert reason == "Steam is not installed"
def test_update_in_progress_is_reported_not_raised(self) -> None:
with patch(
f"{PKG}.hide_other_games",
side_effect=SteamUpdateInProgressError("update in progress"),
):
hidden, reason = try_hide_other_games([1, 2], {1})
assert hidden == 0
assert reason == "update in progress"
class TestUnhideAllGames:
"""Tests for unhide_all_games."""
def test_unhides(self) -> None:
with (
patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"),
patch(
"steam_backlog_enforcer.library_hider._evaluate_js",
return_value={"result": {"result": {"value": '{"count": 10}'}}},
),
patch(
"steam_backlog_enforcer.library_hider._cdp_result_value",
return_value='{"count": 10}',
),
):
count = unhide_all_games([1, 2, 3])
assert count == 10

View File

@ -6,21 +6,22 @@ import os
import tempfile
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer.library_hider import (
_SPAWNED,
SteamUnavailableError,
SteamUpdateInProgressError,
_reap_spawned,
from steam_backlog_enforcer._steam_launch import (
_resolve_desktop_user,
_run_as_user,
hide_other_games,
restart_steam,
steam_is_installed,
try_hide_other_games,
unhide_all_games,
)
from steam_backlog_enforcer._steam_process import (
_SPAWNED,
_reap_spawned,
_run_as_user,
)
PKG = "steam_backlog_enforcer.library_hider"
CDP_PKG = "steam_backlog_enforcer._cdp"
STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch"
STEAM_PROCESS_PKG = "steam_backlog_enforcer._steam_process"
LIBRARY_HIDER_PKG = "steam_backlog_enforcer.library_hider"
ENV_PKG = "steam_backlog_enforcer._desktop_env"
class TestResolveDesktopUser:
@ -69,12 +70,12 @@ class TestSteamIsInstalled:
"""Tests for steam_is_installed."""
def test_true_when_binary_exists(self) -> None:
with patch(f"{PKG}.Path") as mock_path:
with patch("steam_backlog_enforcer._steam_launch.Path") as mock_path:
mock_path.return_value.exists.return_value = True
assert steam_is_installed() is True
def test_false_when_binary_missing(self) -> None:
with patch(f"{PKG}.Path") as mock_path:
with patch("steam_backlog_enforcer._steam_launch.Path") as mock_path:
mock_path.return_value.exists.return_value = False
assert steam_is_installed() is False
@ -85,7 +86,7 @@ class TestSteamIsInstalled:
package is uninstalled - which is exactly how a dead Steam went on
looking installed and got launched ~1000 times.
"""
with patch(f"{PKG}.Path") as mock_path:
with patch("steam_backlog_enforcer._steam_launch.Path") as mock_path:
mock_path.return_value.exists.return_value = False
steam_is_installed()
mock_path.assert_called_once_with("/usr/bin/steam")
@ -127,8 +128,8 @@ class TestRunAsUser:
"""Every launch must be tracked, or it can never be reaped."""
_SPAWNED.clear()
with (
patch(f"{PKG}.os.geteuid", return_value=1000),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=1000),
patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "alice")
try:
@ -138,8 +139,8 @@ class TestRunAsUser:
def test_non_root_runs_directly(self) -> None:
with (
patch(f"{PKG}.os.geteuid", return_value=1000),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=1000),
patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam", "-shutdown"], "alice")
mock_popen.assert_called_once()
@ -150,13 +151,13 @@ class TestRunAsUser:
mock_pw = MagicMock()
mock_pw.pw_uid = 1001
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw),
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
patch(f"{ENV_PKG}.pwd.getpwnam", return_value=mock_pw),
patch.dict(
os.environ,
{"DISPLAY": ":1", "XAUTHORITY": tempfile.gettempdir() + "/.X"},
),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam", "-shutdown"], "alice")
mock_popen.assert_called_once()
@ -167,9 +168,9 @@ class TestRunAsUser:
def test_root_user_key_error(self) -> None:
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError("no user")),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
patch(f"{ENV_PKG}.pwd.getpwnam", side_effect=KeyError("no user")),
patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "unknownuser")
mock_popen.assert_called_once()
@ -178,24 +179,26 @@ class TestRunAsUser:
assert "sudo" in cmd[0]
def test_root_user_none(self) -> None:
"""When user is None and euid is 0, runs directly."""
"""When user is None and euid is 0, refuses rather than running as root.
Running directly here is what put a root Steam — and its "Cannot run
as root user" modal — on the user's display.
"""
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], None)
cmd = mock_popen.call_args[0][0]
assert cmd == ["steam"]
mock_popen.assert_not_called()
def test_root_user_is_root(self) -> None:
"""When user is 'root', runs directly."""
"""When user is 'root', refuses rather than running as root."""
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "root")
cmd = mock_popen.call_args[0][0]
assert cmd == ["steam"]
mock_popen.assert_not_called()
def test_root_uses_env_defaults(self) -> None:
"""When DBUS/XAUTHORITY/DISPLAY not in env, uses defaults."""
@ -206,137 +209,12 @@ class TestRunAsUser:
env_copy.pop("XAUTHORITY", None)
env_copy.pop("DISPLAY", None)
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw),
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
patch(f"{ENV_PKG}.pwd.getpwnam", return_value=mock_pw),
patch.dict(os.environ, env_copy, clear=True),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "bob")
cmd = mock_popen.call_args[0][0]
assert any("DISPLAY=:0" in arg for arg in cmd)
assert any("/home/bob/.Xauthority" in arg for arg in cmd)
class TestRestartSteam:
"""Tests for restart_steam."""
def test_cdp_ready(self) -> None:
with (
patch(f"{PKG}._shutdown_steam"),
patch(f"{PKG}._launch_steam_with_debug"),
patch(f"{PKG}._wait_for_cdp_ready", return_value=True),
):
restart_steam()
def test_cdp_not_ready(self) -> None:
with (
patch(f"{PKG}._shutdown_steam"),
patch(f"{PKG}._launch_steam_with_debug"),
patch(f"{PKG}._wait_for_cdp_ready", return_value=False),
):
restart_steam()
class TestHideOtherGames:
"""Tests for hide_other_games."""
def test_hides(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 5}'}},
},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"totalHidden": 5}',
),
):
count = hide_other_games([1, 2, 3], {1})
assert count == 5
def test_empty_list(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 0}'}},
},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"totalHidden": 0}',
),
):
count = hide_other_games([1], {1})
assert count == 0
def test_no_allowed(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 2}'}},
},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"totalHidden": 2}',
),
):
count = hide_other_games([1, 2], set())
assert count == 2
class TestTryHideOtherGames:
"""Tests for the graceful wrapper around hide_other_games.
Regression guard: an unreachable Steam (or a deferred restart while a game
update is in flight) used to escape as a traceback out of every
interactive command that reconciles the library.
"""
def test_success_returns_count_and_no_reason(self) -> None:
with patch(f"{PKG}.hide_other_games", return_value=7):
assert try_hide_other_games([1, 2], {1}) == (7, None)
def test_steam_unavailable_is_reported_not_raised(self) -> None:
with patch(
f"{PKG}.hide_other_games",
side_effect=SteamUnavailableError("Steam is not installed"),
):
hidden, reason = try_hide_other_games([1, 2], {1})
assert hidden == 0
assert reason == "Steam is not installed"
def test_update_in_progress_is_reported_not_raised(self) -> None:
with patch(
f"{PKG}.hide_other_games",
side_effect=SteamUpdateInProgressError("update in progress"),
):
hidden, reason = try_hide_other_games([1, 2], {1})
assert hidden == 0
assert reason == "update in progress"
class TestUnhideAllGames:
"""Tests for unhide_all_games."""
def test_unhides(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={"result": {"result": {"value": '{"count": 10}'}}},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"count": 10}',
),
):
count = unhide_all_games([1, 2, 3])
assert count == 10

View File

@ -0,0 +1,133 @@
"""Tests for the Steam lifecycle: is it running, has it a CDP port, restart it.
Split to keep every test file under the 250-line cap.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer._steam_launch import (
_is_steam_running,
_steam_has_debug_port,
_wait_for_cdp_ready,
_wait_for_collections_ready,
)
class TestIsSteamRunning:
"""Tests for _is_steam_running."""
def test_running(self) -> None:
mock_result = MagicMock(returncode=0)
with patch(
"steam_backlog_enforcer._steam_launch.subprocess.run",
return_value=mock_result,
):
assert _is_steam_running() is True
def test_not_running(self) -> None:
mock_result = MagicMock(returncode=1)
with patch(
"steam_backlog_enforcer._steam_launch.subprocess.run",
return_value=mock_result,
):
assert _is_steam_running() is False
class TestSteamHasDebugPort:
"""Tests for _steam_has_debug_port."""
def test_has_port(self) -> None:
with patch(
"steam_backlog_enforcer._steam_launch._get_shared_js_ws_url",
return_value="ws://test",
):
assert _steam_has_debug_port() is True
def test_no_port(self) -> None:
with patch(
"steam_backlog_enforcer._steam_launch._get_shared_js_ws_url",
return_value=None,
):
assert _steam_has_debug_port() is False
class TestWaitForCdpReady:
"""Tests for _wait_for_cdp_ready."""
def test_ready_immediately(self) -> None:
with patch(
"steam_backlog_enforcer._steam_launch._get_shared_js_ws_url",
return_value="ws://test",
):
assert _wait_for_cdp_ready() is True
def test_timeout(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_launch._get_shared_js_ws_url",
return_value=None,
),
patch(
"steam_backlog_enforcer._steam_launch.time.sleep",
),
patch(
"steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_cdp_ready() is False
class TestWaitForCollectionsReady:
"""Tests for _wait_for_collections_ready."""
def test_ready(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_launch._evaluate_js",
return_value={"result": {"result": {"value": "ok"}}},
),
patch(
"steam_backlog_enforcer._steam_launch._cdp_result_value",
return_value="ok",
),
):
assert _wait_for_collections_ready() is True
def test_not_ready_then_ready(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_launch._evaluate_js",
return_value={"result": {"result": {"value": "not_ready"}}},
),
patch(
"steam_backlog_enforcer._steam_launch._cdp_result_value",
side_effect=["not_ready", "ok"],
),
patch(
"steam_backlog_enforcer._steam_launch.time.sleep",
),
patch(
"steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_collections_ready() is True
def test_timeout(self) -> None:
with (
patch(
"steam_backlog_enforcer._steam_launch._evaluate_js",
side_effect=RuntimeError,
),
patch(
"steam_backlog_enforcer._steam_launch.time.sleep",
),
patch(
"steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_collections_ready() is False

View File

@ -0,0 +1,80 @@
"""Tests for shutting Steam down and relaunching it with the debug port.
Split to keep every test file under the 250-line cap.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer._steam_launch import (
_launch_steam_with_debug,
_shutdown_steam,
)
class TestShutdownSteam:
"""Tests for _shutdown_steam."""
def test_exits_immediately(self) -> None:
mock_result = MagicMock(returncode=1) # Not running
with (
patch(
"steam_backlog_enforcer._steam_launch._run_as_user",
),
patch(
"steam_backlog_enforcer._steam_launch.subprocess.run",
return_value=mock_result,
),
):
_shutdown_steam()
def test_waits_for_exit(self) -> None:
results = [MagicMock(returncode=0), MagicMock(returncode=1)]
with (
patch(
"steam_backlog_enforcer._steam_launch._run_as_user",
),
patch(
"steam_backlog_enforcer._steam_launch.subprocess.run",
side_effect=results,
),
patch(
"steam_backlog_enforcer._steam_launch.time.sleep",
),
):
_shutdown_steam()
def test_file_not_found(self) -> None:
with patch(
"steam_backlog_enforcer._steam_launch._run_as_user",
side_effect=FileNotFoundError,
):
_shutdown_steam() # Should not raise
def test_timeout(self) -> None:
mock_result = MagicMock(returncode=0) # Still running
with (
patch(
"steam_backlog_enforcer._steam_launch._run_as_user",
),
patch(
"steam_backlog_enforcer._steam_launch.subprocess.run",
return_value=mock_result,
),
patch(
"steam_backlog_enforcer._steam_launch.time.sleep",
),
):
_shutdown_steam() # Should complete loop without raising
class TestLaunchSteamWithDebug:
"""Tests for _launch_steam_with_debug."""
def test_launches(self) -> None:
with patch(
"steam_backlog_enforcer._steam_launch._run_as_user",
) as mock_run:
_launch_steam_with_debug()
mock_run.assert_called_once()

View File

@ -12,21 +12,18 @@ from unittest.mock import patch
import pytest
from steam_backlog_enforcer._steam_errors import SteamUpdateInProgressError
from steam_backlog_enforcer._steam_launch import ensure_steam_debug_port, restart_steam
from steam_backlog_enforcer._steam_state import (
_manifest_transfer_active,
steam_update_in_progress,
)
from steam_backlog_enforcer.library_hider import (
SteamUpdateInProgressError,
ensure_steam_debug_port,
restart_steam,
)
if TYPE_CHECKING:
from pathlib import Path
SS = "steam_backlog_enforcer._steam_state"
LH = "steam_backlog_enforcer.library_hider"
LH = "steam_backlog_enforcer._steam_launch"
def _manifest(*, to_download: int, downloaded: int, to_stage: int, staged: int) -> str: