mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
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:
parent
2ad87524e4
commit
21fe2f7742
95
steam_backlog_enforcer/_cdp.py
Normal file
95
steam_backlog_enforcer/_cdp.py
Normal 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
|
||||
@ -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
|
||||
|
||||
26
steam_backlog_enforcer/_echo.py
Normal file
26
steam_backlog_enforcer/_echo.py
Normal 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.
|
||||
@ -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,
|
||||
|
||||
127
steam_backlog_enforcer/_enforce_steps.py
Normal file
127
steam_backlog_enforcer/_enforce_steps.py
Normal 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")
|
||||
101
steam_backlog_enforcer/_game_names.py
Normal file
101
steam_backlog_enforcer/_game_names.py
Normal 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.
|
||||
121
steam_backlog_enforcer/_owned_apps_cache.py
Normal file
121
steam_backlog_enforcer/_owned_apps_cache.py
Normal 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).
|
||||
@ -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.
|
||||
|
||||
44
steam_backlog_enforcer/_protected_apps.py
Normal file
44
steam_backlog_enforcer/_protected_apps.py
Normal 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
|
||||
}
|
||||
123
steam_backlog_enforcer/_steam_client.py
Normal file
123
steam_backlog_enforcer/_steam_client.py
Normal 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()
|
||||
40
steam_backlog_enforcer/_steam_errors.py
Normal file
40
steam_backlog_enforcer/_steam_errors.py
Normal 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.
|
||||
"""
|
||||
248
steam_backlog_enforcer/_steam_launch.py
Normal file
248
steam_backlog_enforcer/_steam_launch.py
Normal 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.")
|
||||
58
steam_backlog_enforcer/_steam_process.py
Normal file
58
steam_backlog_enforcer/_steam_process.py
Normal 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,
|
||||
)
|
||||
)
|
||||
@ -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.
|
||||
|
||||
@ -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.")
|
||||
|
||||
@ -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()
|
||||
|
||||
174
steam_backlog_enforcer/game_uninstall.py
Normal file
174
steam_backlog_enforcer/game_uninstall.py
Normal 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()
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
):
|
||||
|
||||
@ -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()
|
||||
|
||||
161
steam_backlog_enforcer/tests/test_desktop_session_gating.py
Normal file
161
steam_backlog_enforcer/tests/test_desktop_session_gating.py
Normal 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()
|
||||
113
steam_backlog_enforcer/tests/test_desktop_user_cmd.py
Normal file
113
steam_backlog_enforcer/tests/test_desktop_user_cmd.py
Normal 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
|
||||
153
steam_backlog_enforcer/tests/test_do_enforce.py
Normal file
153
steam_backlog_enforcer/tests/test_do_enforce.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
202
steam_backlog_enforcer/tests/test_enforce_steps.py
Normal file
202
steam_backlog_enforcer/tests/test_enforce_steps.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
167
steam_backlog_enforcer/tests/test_ensure_debug_port.py
Normal file
167
steam_backlog_enforcer/tests/test_ensure_debug_port.py
Normal 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()
|
||||
189
steam_backlog_enforcer/tests/test_ensure_steam_running.py
Normal file
189
steam_backlog_enforcer/tests/test_ensure_steam_running.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
86
steam_backlog_enforcer/tests/test_game_install_guard.py
Normal file
86
steam_backlog_enforcer/tests/test_game_install_guard.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
119
steam_backlog_enforcer/tests/test_game_names.py
Normal file
119
steam_backlog_enforcer/tests/test_game_names.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
154
steam_backlog_enforcer/tests/test_library_hider_ops.py
Normal file
154
steam_backlog_enforcer/tests/test_library_hider_ops.py
Normal 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
|
||||
@ -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
|
||||
|
||||
133
steam_backlog_enforcer/tests/test_steam_launch_lifecycle.py
Normal file
133
steam_backlog_enforcer/tests/test_steam_launch_lifecycle.py
Normal 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
|
||||
80
steam_backlog_enforcer/tests/test_steam_restart.py
Normal file
80
steam_backlog_enforcer/tests/test_steam_restart.py
Normal 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()
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user