Split total-block, playtime and MCP by responsibility

Each of these was one file doing several jobs, so they are split along
what they actually do rather than at arbitrary line offsets:

- _total_block: the lock file and the status derived from it stay in
  _total_block; making Steam *not installed* moves to _total_block_purge,
  making it *unreachable* to _total_block_hosts / _total_block_iptables.
  The generic pacman verbs (owner, installed, remove) become _pacman,
  since nothing about them is total-block-specific. Domain and process
  name tables become data modules.
- _playtime: budget state and persistence (_playtime_state), pure
  transitions (_playtime_budget), cmdline process detection
  (_playtime_procs), the warn/cutoff/kill ladder (_playtime_cutoff),
  desktop notifications (_playtime_notify), process-tree walking and
  killing (_playtime_kill), mountinfo parsing (_playtime_mountinfo) and
  the shared privileged-command helper (_playtime_run).
- _mcp: read-only tools (_mcp_query) split from mutating ones
  (_mcp_actions, _mcp_gaming_time), so "what can this server change?" is
  answerable from one file. _mcp keeps the server object and entry point.

No re-export shims: callers import from the module that defines the name.
That keeps a stale patch target loud (AttributeError) instead of letting
it pass while intercepting nothing.

`ruff check --select F821,F401` is run after every extraction -- an earlier
mechanical attempt moved functions without the module-level names they
referenced, which imported fine and only failed when called (349 errors),
so it was reverted and redone by hand.

The autouse fixtures in conftest are now imported by name and held by
__all__; ruff --unsafe-fixes had deleted them as unused, since pytest
resolves fixtures by name.

1324 tests pass at 100.00% coverage in the clean venv; `main status` is
byte-identical to a worktree at HEAD. 49 -> 39 files over the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
This commit is contained in:
Krzysztof Rudnicki 2026-08-23 20:52:08 +02:00
parent 45ff4eb7c2
commit b5b4b35c07
47 changed files with 4558 additions and 3631 deletions

View File

@ -14,7 +14,12 @@ import os
from typing import TYPE_CHECKING
from steam_backlog_enforcer._enforce_loop import do_enforce
from steam_backlog_enforcer._playtime import (
from steam_backlog_enforcer._playtime_block import (
block_targets,
mounted_targets,
release_block,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
gaming_day_key,
load_state,
@ -22,11 +27,6 @@ from steam_backlog_enforcer._playtime import (
save_state,
state_path,
)
from steam_backlog_enforcer._playtime_block import (
block_targets,
mounted_targets,
release_block,
)
from steam_backlog_enforcer.game_install import _echo
if TYPE_CHECKING:

View File

@ -1,426 +1,19 @@
"""MCP (Model Context Protocol) server for Steam Backlog Enforcer.
"""MCP server entry point for the Steam Backlog Enforcer.
Exposes the enforcer's read surface and two *gated* write actions as typed MCP
tools, so an MCP client (Claude Code and its subagents) can query and — with
explicit confirmation — drive the backlog without shelling out to the CLI.
Run via the dedicated venv that has the ``mcp`` extra installed::
~/.venvs/steam-backlog-mcp/bin/python -m steam_backlog_enforcer._mcp
(see ``scripts/setup_mcp.sh`` and the repo-root ``.mcp.json``).
Safety invariants (do not break when adding tools):
* **stdout is the JSON-RPC channel.** This module and every function a tool
calls must never write to stdout. All logging is routed to STDERR below, and
tools call only stdout-free leaf helpers (never the ``cmd_*`` handlers, which
``_echo`` to stdout / ``input()`` / ``sys.exit()``).
* **No secret ever leaves.** There is no tool that returns ``Config`` or reads
``config.json``; read tools load only ``State`` and the secrets-free web
dataset. Game-name lookups use the on-disk snapshot only (no Steam API key).
* **Writes are gated.** Every write tool defaults to a dry-run preview and
mutates only when ``confirm=True``. These write tools must never be added to
a permission allowlist (a subagent could then bypass the human).
The tools themselves live in :mod:`steam_backlog_enforcer._mcp_query` (read
only) and :mod:`steam_backlog_enforcer._mcp_actions` (state changing); both
are imported here for their registration side effect.
"""
from __future__ import annotations
from datetime import datetime, timezone
import logging
import os
import sys
from typing import Any
from mcp.server.fastmcp import FastMCP
from steam_backlog_enforcer import _mcp_actions, _mcp_gaming_time, _mcp_query
from steam_backlog_enforcer._mcp_server import logger, mcp
from steam_backlog_enforcer._actions import (
ABANDON_COOLDOWN_DAYS,
MANUAL_GRACE_DAYS,
abandon_manual_pick,
active_manual_picks,
apply_manual_pick,
can_abandon_manual_pick,
find_manual_pick,
manual_pick_grace_remaining,
status_payload,
)
from steam_backlog_enforcer._allowed_games import MANUAL_LOCK_DAYS
from steam_backlog_enforcer._playtime import (
PlaytimeState,
gaming_day_key,
load_state,
rules_for,
save_state,
)
from steam_backlog_enforcer._playtime_block import mounted_targets, release_block
from steam_backlog_enforcer._total_block import start_total_block
from steam_backlog_enforcer._web_dataset import build_web_dataset, dataset_to_payload
from steam_backlog_enforcer.config import Config, State, load_snapshot
from steam_backlog_enforcer.steam_api import GameInfo
# Log to STDERR only — STDOUT carries the MCP JSON-RPC protocol frames, so a
# single stray stdout write would corrupt the stream and kill the session.
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [%(levelname)s] steam-mcp: %(message)s",
)
logger = logging.getLogger(__name__)
mcp = FastMCP("steam-backlog-enforcer")
_DEFAULT_LIST_LIMIT = 50
def _resolve_game_name(app_id: int) -> str | None:
"""Resolve a game name from the on-disk snapshot only (no Config / API).
Deliberately snapshot-only: it never constructs ``Config`` and never touches
the Steam API key, so it is safe to call from an MCP tool. A game absent from
the last snapshot simply resolves to ``None``.
Args:
app_id: The Steam app id to look up.
Returns:
The game name, or ``None`` if not present in the snapshot.
"""
snapshot = load_snapshot()
if snapshot:
for entry in snapshot:
if entry.get("app_id") == app_id:
return str(entry["name"])
return None
def _backlog_sort_key(game: GameInfo) -> tuple[int, float]:
"""Sort incomplete games shortest-completionist-first, unknowns last."""
if game.completionist_hours > 0:
return (0, game.completionist_hours)
return (1, 0.0)
# ──────────────────────────────────────────────────────────────
# Read tools (State-only; never expose Config / the Steam API key)
# ──────────────────────────────────────────────────────────────
@mcp.tool()
def get_dataset() -> dict[str, Any]:
"""Return the full, secrets-free backlog dataset.
Includes every incomplete candidate game with HowLongToBeat times,
ProtonDB tiers, pace-vs-HLTB calibration, and the default CLI thresholds —
the same projection the local web UI consumes. Reads on-disk caches only
(no network, no Steam API key).
"""
return dataset_to_payload(build_web_dataset(State.load()))
@mcp.tool()
def get_status() -> dict[str, Any]:
"""Return the current enforcer status.
Reports the assigned game, finished count, whether the Steam store is
blocked, installed-game count, any active total block (with days remaining),
and whether a manual-pick lock is in force.
"""
return status_payload(State.load())
@mcp.tool()
def get_stats() -> dict[str, Any]:
"""Return backlog completion-time estimates.
A focused subset of the dataset: the default qualifying-games summary
(rush / leisure / worst-case totals) and the player's measured pace versus
HowLongToBeat.
"""
payload = dataset_to_payload(build_web_dataset(State.load()))
return {
"default_summary": payload["default_summary"],
"pace_vs_hltb": payload["pace_vs_hltb"],
}
@mcp.tool()
def list_backlog(limit: int = _DEFAULT_LIST_LIMIT) -> dict[str, Any]:
"""List incomplete games, shortest-completionist-first, capped at ``limit``.
Args:
limit: Maximum number of games to return (non-positive returns none).
"""
snapshot = load_snapshot()
if snapshot is None:
return {
"total": 0,
"returned": 0,
"games": [],
"note": "No snapshot found. Run 'scan' first.",
}
games = [GameInfo.from_snapshot(entry) for entry in snapshot]
incomplete = sorted((g for g in games if not g.is_complete), key=_backlog_sort_key)
capped = incomplete[: max(0, limit)]
return {
"total": len(incomplete),
"returned": len(capped),
"games": [
{
"app_id": g.app_id,
"name": g.name,
"completion_pct": round(g.completion_pct, 1),
"completionist_hours": g.completionist_hours,
}
for g in capped
],
}
# ──────────────────────────────────────────────────────────────
# Gated write tools (preview unless confirm=True; NEVER allowlist these)
# ──────────────────────────────────────────────────────────────
@mcp.tool()
def pick_manual(app_id: int, *, confirm: bool = False) -> dict[str, Any]:
"""Lock the enforcer to a game by Steam app_id (gated write).
With ``confirm=False`` (the default) this performs **no** mutation and
returns a preview of what confirming would do. Call again with
``confirm=True`` to apply. Applying overrides the current assignment and
locks all other commands for ``MANUAL_LOCK_DAYS`` days (or until 100%
achievements). Unlike the CLI's ``pick-manual``, this mutates **state only**
— it never uninstalls, installs, or hides games.
Args:
app_id: The Steam app id to lock in (must exist in the last snapshot).
confirm: Set ``True`` to actually apply the pick; otherwise preview.
"""
game_name = _resolve_game_name(app_id)
if game_name is None:
return {
"ok": False,
"reason": f"AppID={app_id} not found in the snapshot. Run 'scan' first.",
}
if not confirm:
return {
"ok": True,
"preview": True,
"action": "pick_manual",
"app_id": app_id,
"game_name": game_name,
"effect": (
"Overrides the current assignment and locks all other commands "
f"for {MANUAL_LOCK_DAYS} days or until 100% achievements."
),
"confirm_required": True,
}
state = State.load()
refused = apply_manual_pick(
state,
app_id,
game_name,
max_picks=Config.load().max_manual_picks,
)
if refused is not None:
return {"ok": False, "reason": refused}
logger.info("pick_manual applied: %s (AppID=%s)", game_name, app_id)
return {
"ok": True,
"applied": True,
"action": "pick_manual",
"app_id": app_id,
"game_name": game_name,
}
@mcp.tool()
def abandon_pick(app_id: int, *, confirm: bool = False) -> dict[str, Any]:
"""Undo a manual pick inside its grace window (gated write).
With ``confirm=False`` (the default) this performs **no** mutation and
returns a preview. Only works within ``MANUAL_GRACE_DAYS`` days of the
pick. Like ``pick_manual``, this mutates **state only** — the CLI's
``abandon-pick`` additionally uninstalls the abandoned game.
Args:
app_id: The manually-picked app id to back out of.
confirm: Set ``True`` to actually abandon the pick; otherwise preview.
"""
state = State.load()
picks = active_manual_picks(state)
if not picks:
return {"ok": False, "reason": "No manual pick is active."}
pick = find_manual_pick(state, app_id)
if pick is None:
listed = ", ".join(f"AppID={p['app_id']}" for p in picks)
return {
"ok": False,
"reason": (
f"AppID={app_id} is not one of the active manual picks ({listed})."
),
}
remaining = manual_pick_grace_remaining(state, app_id)
if not can_abandon_manual_pick(state, app_id):
return {
"ok": False,
"reason": (
f"The {MANUAL_GRACE_DAYS}-day grace period has expired; "
"the pick can no longer be abandoned."
),
}
game_name = str(pick["game_name"])
if not confirm:
return {
"ok": True,
"preview": True,
"action": "abandon_pick",
"app_id": app_id,
"game_name": game_name,
"grace_days_left": remaining,
"effect": (
"Releases the manual pick lock, clears the assignment, and "
f"keeps the game out of auto-assignment for "
f"{ABANDON_COOLDOWN_DAYS} days."
),
"confirm_required": True,
}
abandon_manual_pick(state, app_id)
logger.info("abandon_pick applied: %s (AppID=%s)", game_name, app_id)
return {
"ok": True,
"applied": True,
"action": "abandon_pick",
"app_id": app_id,
"game_name": game_name,
}
@mcp.tool()
def block_gaming(days: int, *, confirm: bool = False) -> dict[str, Any]:
"""Start a total gaming block for ``days`` days (gated, privileged write).
With ``confirm=False`` (the default) returns a preview only. With
``confirm=True`` it attempts the block, which **requires root** (it edits
``/etc/hosts`` and uninstalls Steam). The MCP server runs unprivileged, so
this will normally return ``{"ok": false, "reason": "requires elevated
privileges"}`` rather than succeeding. There is no in-app undo.
Args:
days: Whole number of days to block all gaming (must be >= 1).
confirm: Set ``True`` to attempt the block; otherwise preview.
"""
if days < 1:
return {"ok": False, "reason": "days must be at least 1."}
if not confirm:
return {
"ok": True,
"preview": True,
"action": "block_gaming",
"days": days,
"effect": (
f"For {days} day(s): uninstalls Steam, kills game launchers, and "
"blocks Steam + game-website domains. There is NO in-app undo."
),
"requires_root": True,
"confirm_required": True,
}
try:
applied = start_total_block(days)
except (OSError, RuntimeError) as exc: # never crash the server on a write
logger.warning("block_gaming failed: %s", exc)
return {"ok": False, "reason": "requires elevated privileges"}
if not applied:
return {"ok": False, "reason": "requires elevated privileges"}
logger.info("block_gaming applied: %d day(s)", days)
return {"ok": True, "applied": True, "action": "block_gaming", "days": days}
@mcp.tool()
def get_gaming_time() -> dict[str, Any]:
"""Return today's gaming-budget usage and whether the cutoff has engaged.
The gaming day starts at 06:00 local time, so a session played after
midnight still counts against the day it began in. Reads on-disk state and
the live mount table only — no network, no Steam API key, no config secrets.
"""
config = Config.load()
rules = rules_for(config, demo=False)
stored = load_state(demo=False)
masked = sorted(str(path) for path in mounted_targets())
if stored is None:
return {
"ok": True,
"recorded": False,
"budget_seconds": rules.budget_seconds,
"enforcement": rules.enforcement,
"masked_launchers": masked,
}
return {
"ok": True,
"recorded": True,
"gaming_day": stored.day_key,
"day_starts_at": "06:00 local",
"seconds_used": round(stored.seconds, 1),
"budget_seconds": rules.budget_seconds,
"seconds_remaining": round(max(0.0, rules.budget_seconds - stored.seconds), 1),
"blocked": stored.is_blocked(),
"enforcement": rules.enforcement,
"counts_launchers": rules.count_launchers,
"masked_launchers": masked,
}
@mcp.tool()
def reset_gaming_time(*, confirm: bool = False) -> dict[str, Any]:
"""Reset today's gaming counter and lift the block (gated, privileged write).
With ``confirm=False`` (the default) returns a preview and mutates nothing.
With ``confirm=True`` it attempts the reset, which **requires root** (it
releases bind mounts over ``/usr/bin/steam`` and friends). The MCP server
runs unprivileged, so this will normally return ``{"ok": false, "reason":
"requires elevated privileges"}`` rather than succeeding.
Args:
confirm: Set ``True`` to attempt the reset; otherwise preview.
"""
if not confirm:
return {
"ok": True,
"preview": True,
"action": "reset_gaming_time",
"effect": (
"Zeroes today's gaming counter and unmasks every launcher "
"binary, restoring a full daily budget immediately."
),
"requires_root": True,
"confirm_required": True,
}
if os.geteuid() != 0:
return {"ok": False, "reason": "requires elevated privileges"}
try:
released = release_block()
now = datetime.now(timezone.utc).astimezone()
save_state(
PlaytimeState(day_key=gaming_day_key(now), last_tick_at=now.timestamp()),
demo=False,
)
except (OSError, ValueError) as exc: # never crash the server on a write
logger.warning("reset_gaming_time failed: %s", exc)
return {"ok": False, "reason": "requires elevated privileges"}
logger.info("reset_gaming_time applied; released %d mount(s)", len(released))
return {
"ok": True,
"applied": True,
"action": "reset_gaming_time",
"released": [str(path) for path in released],
}
# Imported for their @mcp.tool() registration side effect.
__all__ = ["_mcp_actions", "_mcp_gaming_time", "_mcp_query", "main", "mcp"]
def main() -> None:

View File

@ -0,0 +1,188 @@
"""Mutating MCP tools: manual picks, abandonment and gaming blocks.
Every tool here takes an explicit ``confirm`` flag, so a model cannot change
enforcement state on a single unconfirmed call. The read-only half lives in
:mod:`steam_backlog_enforcer._mcp_query`.
"""
from __future__ import annotations
from typing import Any
from steam_backlog_enforcer._actions import (
ABANDON_COOLDOWN_DAYS,
MANUAL_GRACE_DAYS,
abandon_manual_pick,
active_manual_picks,
apply_manual_pick,
can_abandon_manual_pick,
find_manual_pick,
manual_pick_grace_remaining,
)
from steam_backlog_enforcer._allowed_games import MANUAL_LOCK_DAYS
from steam_backlog_enforcer._mcp_server import (
_resolve_game_name,
logger,
mcp,
)
from steam_backlog_enforcer._total_block import start_total_block
from steam_backlog_enforcer.config import Config, State
@mcp.tool()
def pick_manual(app_id: int, *, confirm: bool = False) -> dict[str, Any]:
"""Lock the enforcer to a game by Steam app_id (gated write).
With ``confirm=False`` (the default) this performs **no** mutation and
returns a preview of what confirming would do. Call again with
``confirm=True`` to apply. Applying overrides the current assignment and
locks all other commands for ``MANUAL_LOCK_DAYS`` days (or until 100%
achievements). Unlike the CLI's ``pick-manual``, this mutates **state only**
— it never uninstalls, installs, or hides games.
Args:
app_id: The Steam app id to lock in (must exist in the last snapshot).
confirm: Set ``True`` to actually apply the pick; otherwise preview.
"""
game_name = _resolve_game_name(app_id)
if game_name is None:
return {
"ok": False,
"reason": f"AppID={app_id} not found in the snapshot. Run 'scan' first.",
}
if not confirm:
return {
"ok": True,
"preview": True,
"action": "pick_manual",
"app_id": app_id,
"game_name": game_name,
"effect": (
"Overrides the current assignment and locks all other commands "
f"for {MANUAL_LOCK_DAYS} days or until 100% achievements."
),
"confirm_required": True,
}
state = State.load()
refused = apply_manual_pick(
state,
app_id,
game_name,
max_picks=Config.load().max_manual_picks,
)
if refused is not None:
return {"ok": False, "reason": refused}
logger.info("pick_manual applied: %s (AppID=%s)", game_name, app_id)
return {
"ok": True,
"applied": True,
"action": "pick_manual",
"app_id": app_id,
"game_name": game_name,
}
@mcp.tool()
def abandon_pick(app_id: int, *, confirm: bool = False) -> dict[str, Any]:
"""Undo a manual pick inside its grace window (gated write).
With ``confirm=False`` (the default) this performs **no** mutation and
returns a preview. Only works within ``MANUAL_GRACE_DAYS`` days of the
pick. Like ``pick_manual``, this mutates **state only** — the CLI's
``abandon-pick`` additionally uninstalls the abandoned game.
Args:
app_id: The manually-picked app id to back out of.
confirm: Set ``True`` to actually abandon the pick; otherwise preview.
"""
state = State.load()
picks = active_manual_picks(state)
if not picks:
return {"ok": False, "reason": "No manual pick is active."}
pick = find_manual_pick(state, app_id)
if pick is None:
listed = ", ".join(f"AppID={p['app_id']}" for p in picks)
return {
"ok": False,
"reason": (
f"AppID={app_id} is not one of the active manual picks ({listed})."
),
}
remaining = manual_pick_grace_remaining(state, app_id)
if not can_abandon_manual_pick(state, app_id):
return {
"ok": False,
"reason": (
f"The {MANUAL_GRACE_DAYS}-day grace period has expired; "
"the pick can no longer be abandoned."
),
}
game_name = str(pick["game_name"])
if not confirm:
return {
"ok": True,
"preview": True,
"action": "abandon_pick",
"app_id": app_id,
"game_name": game_name,
"grace_days_left": remaining,
"effect": (
"Releases the manual pick lock, clears the assignment, and "
f"keeps the game out of auto-assignment for "
f"{ABANDON_COOLDOWN_DAYS} days."
),
"confirm_required": True,
}
abandon_manual_pick(state, app_id)
logger.info("abandon_pick applied: %s (AppID=%s)", game_name, app_id)
return {
"ok": True,
"applied": True,
"action": "abandon_pick",
"app_id": app_id,
"game_name": game_name,
}
@mcp.tool()
def block_gaming(days: int, *, confirm: bool = False) -> dict[str, Any]:
"""Start a total gaming block for ``days`` days (gated, privileged write).
With ``confirm=False`` (the default) returns a preview only. With
``confirm=True`` it attempts the block, which **requires root** (it edits
``/etc/hosts`` and uninstalls Steam). The MCP server runs unprivileged, so
this will normally return ``{"ok": false, "reason": "requires elevated
privileges"}`` rather than succeeding. There is no in-app undo.
Args:
days: Whole number of days to block all gaming (must be >= 1).
confirm: Set ``True`` to attempt the block; otherwise preview.
"""
if days < 1:
return {"ok": False, "reason": "days must be at least 1."}
if not confirm:
return {
"ok": True,
"preview": True,
"action": "block_gaming",
"days": days,
"effect": (
f"For {days} day(s): uninstalls Steam, kills game launchers, and "
"blocks Steam + game-website domains. There is NO in-app undo."
),
"requires_root": True,
"confirm_required": True,
}
try:
applied = start_total_block(days)
except (OSError, RuntimeError) as exc: # never crash the server on a write
logger.warning("block_gaming failed: %s", exc)
return {"ok": False, "reason": "requires elevated privileges"}
if not applied:
return {"ok": False, "reason": "requires elevated privileges"}
logger.info("block_gaming applied: %d day(s)", days)
return {"ok": True, "applied": True, "action": "block_gaming", "days": days}

View File

@ -0,0 +1,109 @@
"""MCP tools for the daily gaming-time budget.
Reading today's usage and resetting it are separated from the pick/block
tools because they operate on the playtime state, not on which game is
assigned.
"""
from __future__ import annotations
from datetime import datetime, timezone
import os
from typing import Any
from steam_backlog_enforcer._mcp_server import (
logger,
mcp,
)
from steam_backlog_enforcer._playtime import (
PlaytimeState,
gaming_day_key,
load_state,
rules_for,
save_state,
)
from steam_backlog_enforcer._playtime_block import mounted_targets, release_block
from steam_backlog_enforcer.config import Config
@mcp.tool()
def get_gaming_time() -> dict[str, Any]:
"""Return today's gaming-budget usage and whether the cutoff has engaged.
The gaming day starts at 06:00 local time, so a session played after
midnight still counts against the day it began in. Reads on-disk state and
the live mount table only — no network, no Steam API key, no config secrets.
"""
config = Config.load()
rules = rules_for(config, demo=False)
stored = load_state(demo=False)
masked = sorted(str(path) for path in mounted_targets())
if stored is None:
return {
"ok": True,
"recorded": False,
"budget_seconds": rules.budget_seconds,
"enforcement": rules.enforcement,
"masked_launchers": masked,
}
return {
"ok": True,
"recorded": True,
"gaming_day": stored.day_key,
"day_starts_at": "06:00 local",
"seconds_used": round(stored.seconds, 1),
"budget_seconds": rules.budget_seconds,
"seconds_remaining": round(max(0.0, rules.budget_seconds - stored.seconds), 1),
"blocked": stored.is_blocked(),
"enforcement": rules.enforcement,
"counts_launchers": rules.count_launchers,
"masked_launchers": masked,
}
@mcp.tool()
def reset_gaming_time(*, confirm: bool = False) -> dict[str, Any]:
"""Reset today's gaming counter and lift the block (gated, privileged write).
With ``confirm=False`` (the default) returns a preview and mutates nothing.
With ``confirm=True`` it attempts the reset, which **requires root** (it
releases bind mounts over ``/usr/bin/steam`` and friends). The MCP server
runs unprivileged, so this will normally return ``{"ok": false, "reason":
"requires elevated privileges"}`` rather than succeeding.
Args:
confirm: Set ``True`` to attempt the reset; otherwise preview.
"""
if not confirm:
return {
"ok": True,
"preview": True,
"action": "reset_gaming_time",
"effect": (
"Zeroes today's gaming counter and unmasks every launcher "
"binary, restoring a full daily budget immediately."
),
"requires_root": True,
"confirm_required": True,
}
if os.geteuid() != 0:
return {"ok": False, "reason": "requires elevated privileges"}
try:
released = release_block()
now = datetime.now(timezone.utc).astimezone()
save_state(
PlaytimeState(day_key=gaming_day_key(now), last_tick_at=now.timestamp()),
demo=False,
)
except (OSError, ValueError) as exc: # never crash the server on a write
logger.warning("reset_gaming_time failed: %s", exc)
return {"ok": False, "reason": "requires elevated privileges"}
logger.info("reset_gaming_time applied; released %d mount(s)", len(released))
return {
"ok": True,
"applied": True,
"action": "reset_gaming_time",
"released": [str(path) for path in released],
}

View File

@ -0,0 +1,98 @@
"""Read-only MCP tools: dataset, status, stats and the backlog list.
Separated from the mutating tools in
:mod:`steam_backlog_enforcer._mcp_actions` so that "what can this server
change?" is answerable by looking at one file.
"""
from __future__ import annotations
from typing import Any
from steam_backlog_enforcer._actions import (
status_payload,
)
from steam_backlog_enforcer._mcp_server import (
_DEFAULT_LIST_LIMIT,
_backlog_sort_key,
mcp,
)
from steam_backlog_enforcer._web_dataset import build_web_dataset, dataset_to_payload
from steam_backlog_enforcer.config import State, load_snapshot
from steam_backlog_enforcer.steam_api import GameInfo
@mcp.tool()
def get_dataset() -> dict[str, Any]:
"""Return the full, secrets-free backlog dataset.
Includes every incomplete candidate game with HowLongToBeat times,
ProtonDB tiers, pace-vs-HLTB calibration, and the default CLI thresholds —
the same projection the local web UI consumes. Reads on-disk caches only
(no network, no Steam API key).
"""
return dataset_to_payload(build_web_dataset(State.load()))
@mcp.tool()
def get_status() -> dict[str, Any]:
"""Return the current enforcer status.
Reports the assigned game, finished count, whether the Steam store is
blocked, installed-game count, any active total block (with days remaining),
and whether a manual-pick lock is in force.
"""
return status_payload(State.load())
@mcp.tool()
def get_stats() -> dict[str, Any]:
"""Return backlog completion-time estimates.
A focused subset of the dataset: the default qualifying-games summary
(rush / leisure / worst-case totals) and the player's measured pace versus
HowLongToBeat.
"""
payload = dataset_to_payload(build_web_dataset(State.load()))
return {
"default_summary": payload["default_summary"],
"pace_vs_hltb": payload["pace_vs_hltb"],
}
@mcp.tool()
def list_backlog(limit: int = _DEFAULT_LIST_LIMIT) -> dict[str, Any]:
"""List incomplete games, shortest-completionist-first, capped at ``limit``.
Args:
limit: Maximum number of games to return (non-positive returns none).
"""
snapshot = load_snapshot()
if snapshot is None:
return {
"total": 0,
"returned": 0,
"games": [],
"note": "No snapshot found. Run 'scan' first.",
}
games = [GameInfo.from_snapshot(entry) for entry in snapshot]
incomplete = sorted((g for g in games if not g.is_complete), key=_backlog_sort_key)
capped = incomplete[: max(0, limit)]
return {
"total": len(incomplete),
"returned": len(capped),
"games": [
{
"app_id": g.app_id,
"name": g.name,
"completion_pct": round(g.completion_pct, 1),
"completionist_hours": g.completionist_hours,
}
for g in capped
],
}
# ──────────────────────────────────────────────────────────────
# Gated write tools (preview unless confirm=True; NEVER allowlist these)
# ──────────────────────────────────────────────────────────────

View File

@ -0,0 +1,83 @@
"""MCP (Model Context Protocol) server for Steam Backlog Enforcer.
Exposes the enforcer's read surface and two *gated* write actions as typed MCP
tools, so an MCP client (Claude Code and its subagents) can query and — with
explicit confirmation — drive the backlog without shelling out to the CLI.
Run via the dedicated venv that has the ``mcp`` extra installed::
~/.venvs/steam-backlog-mcp/bin/python -m steam_backlog_enforcer._mcp
(see ``scripts/setup_mcp.sh`` and the repo-root ``.mcp.json``).
Safety invariants (do not break when adding tools):
* **stdout is the JSON-RPC channel.** This module and every function a tool
calls must never write to stdout. All logging is routed to STDERR below, and
tools call only stdout-free leaf helpers (never the ``cmd_*`` handlers, which
``_echo`` to stdout / ``input()`` / ``sys.exit()``).
* **No secret ever leaves.** There is no tool that returns ``Config`` or reads
``config.json``; read tools load only ``State`` and the secrets-free web
dataset. Game-name lookups use the on-disk snapshot only (no Steam API key).
* **Writes are gated.** Every write tool defaults to a dry-run preview and
mutates only when ``confirm=True``. These write tools must never be added to
a permission allowlist (a subagent could then bypass the human).
"""
from __future__ import annotations
import logging
import sys
from typing import TYPE_CHECKING
from mcp.server.fastmcp import FastMCP
from steam_backlog_enforcer.config import load_snapshot
if TYPE_CHECKING:
from steam_backlog_enforcer.steam_api import GameInfo
# Log to STDERR only — STDOUT carries the MCP JSON-RPC protocol frames, so a
# single stray stdout write would corrupt the stream and kill the session.
logging.basicConfig(
level=logging.INFO,
stream=sys.stderr,
format="%(asctime)s [%(levelname)s] steam-mcp: %(message)s",
)
logger = logging.getLogger(__name__)
mcp = FastMCP("steam-backlog-enforcer")
_DEFAULT_LIST_LIMIT = 50
def _resolve_game_name(app_id: int) -> str | None:
"""Resolve a game name from the on-disk snapshot only (no Config / API).
Deliberately snapshot-only: it never constructs ``Config`` and never touches
the Steam API key, so it is safe to call from an MCP tool. A game absent from
the last snapshot simply resolves to ``None``.
Args:
app_id: The Steam app id to look up.
Returns:
The game name, or ``None`` if not present in the snapshot.
"""
snapshot = load_snapshot()
if snapshot:
for entry in snapshot:
if entry.get("app_id") == app_id:
return str(entry["name"])
return None
def _backlog_sort_key(game: GameInfo) -> tuple[int, float]:
"""Sort incomplete games shortest-completionist-first, unknowns last."""
if game.completionist_hours > 0:
return (0, game.completionist_hours)
return (1, 0.0)
# ──────────────────────────────────────────────────────────────
# Read tools (State-only; never expose Config / the Steam API key)
# ──────────────────────────────────────────────────────────────

View File

@ -0,0 +1,78 @@
"""Thin, reusable wrappers around the pacman package manager.
These three operations (query owner, query installed, remove) carry no
total-block policy of their own -- they are the generic primitives the
total-block purge is written in terms of, which is why they live here
rather than inside it.
"""
from __future__ import annotations
import logging
import shutil
import subprocess
logger = logging.getLogger(__name__)
_SUDO = shutil.which("sudo") or "/usr/bin/sudo"
# Call pacman.orig directly (bypassing pacman_wrapper's interactive
# word-unscramble challenge for "steam") - this is the tool's own
# authorized action, not a user bypass attempt, and enforce_total_block_tick
# must be able to run unattended.
PACMAN = shutil.which("pacman.orig") or shutil.which("pacman") or "/usr/bin/pacman.orig"
def pacman_owner(path: str) -> str | None:
"""Return the pacman package name that owns *path*, or None."""
result = subprocess.run(
[PACMAN, "-Qo", path],
capture_output=True,
text=True,
timeout=10,
check=False,
)
if result.returncode != 0:
return None
marker = " is owned by "
if marker not in result.stdout:
return None
tail = result.stdout.split(marker, 1)[1].strip()
return tail.split()[0] if tail else None
def is_package_installed(package: str) -> bool:
"""Return True if *package* is currently installed via pacman."""
result = subprocess.run(
[PACMAN, "-Qi", package],
capture_output=True,
timeout=10,
check=False,
)
return result.returncode == 0
def uninstall_package(package: str) -> bool:
"""Remove *package* via pacman. Returns True on success or if absent."""
try:
result = subprocess.run(
[_SUDO, PACMAN, "-R", "--noconfirm", package],
capture_output=True,
text=True,
timeout=120,
check=False,
)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to run pacman -R %s", package)
return False
if result.returncode == 0:
return True
if "target not found" in (result.stderr or "").lower():
return True
logger.error(
"pacman -R %s failed (rc=%d): %s",
package,
result.returncode,
result.stderr[-500:] if result.stderr else "",
)
return False

View File

@ -27,398 +27,45 @@ is why :func:`get_pids_by_cmdline_names` exists alongside
from __future__ import annotations
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta, timezone
import json
from dataclasses import replace
from datetime import datetime, timezone
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING
from steam_backlog_enforcer._playtime_block import (
kill_gaming_processes,
mounted_targets,
reconcile,
request_steam_shutdown,
steam_and_launcher_pids,
)
from steam_backlog_enforcer._total_block import (
LAUNCHER_PROCESS_NAMES,
is_total_block_active,
from steam_backlog_enforcer._playtime_budget import (
accumulate,
roll_over,
)
from steam_backlog_enforcer._whitelist import _try_set_immutable, unlock_for_write
from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
from steam_backlog_enforcer.enforcer import (
get_pids_by_process_names,
get_running_steam_game_pids,
from steam_backlog_enforcer._playtime_cutoff import (
_begin_cutoff,
_sustain_block,
_warn,
)
from steam_backlog_enforcer.library_hider import _resolve_desktop_user, _run_as_user
from steam_backlog_enforcer._playtime_procs import qualifying_pids
from steam_backlog_enforcer._playtime_state import (
PlaytimeRules,
PlaytimeState,
gaming_day_key,
load_state,
rules_for,
save_state,
)
from steam_backlog_enforcer._total_block import is_total_block_active
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
PLAYTIME_STATE_FILE = CONFIG_DIR / "playtime_state.json"
PLAYTIME_DEMO_STATE_FILE = CONFIG_DIR / "playtime_demo_state.json"
_PROC = Path("/proc")
_SCHEMA_VERSION = 1
# The gaming day starts here, local time. 05:59 belongs to the previous day.
_DAY_BOUNDARY_HOURS = 6
# Each tick's measured delta is capped at this many tick intervals. Two is the
# smallest value that tolerates one skipped tick without inflating the count.
_DELTA_CLAMP_FACTOR = 2.0
_DEFAULT_BUDGET_SECONDS = 8 * 3600.0
_DEMO_BUDGET_SECONDS = 60.0
# Seconds remaining at which to warn, descending.
_WARN_AT: tuple[int, ...] = (3600, 1800, 600, 300)
_DEMO_WARN_AT: tuple[int, ...] = (30, 10)
# Grace between "budget exhausted" and masking the Steam binaries. Masking
# makes `steam -shutdown` a no-op, so Steam must be given a moment to close
# its games cleanly first — otherwise every single cutoff is a hard SIGTERM.
_SHUTDOWN_GRACE_SECONDS = 3.0
_SIGKILL_AFTER_SECONDS = 30.0
_DEMO_SIGKILL_AFTER_SECONDS = 10.0
# argv[0] basenames that tell us nothing: the real program is argv[1].
_INTERPRETERS = frozenset({"bash", "env", "java", "perl", "python", "python3", "sh"})
# An interpreter invocation needs at least `<interp> <script>` to name a program.
_MIN_ARGV_FOR_INTERPRETER = 2
_SECONDS_PER_MINUTE = 60
_MINUTES_PER_HOUR = 60
@dataclass
class PlaytimeState:
"""Per-gaming-day playtime accounting, persisted between ticks."""
schema_version: int = _SCHEMA_VERSION
day_key: str = ""
"""Gaming day this record covers, ``YYYY-MM-DD``, 06:00-shifted."""
seconds: float = 0.0
"""Qualifying seconds accrued during ``day_key``."""
last_tick_at: float = 0.0
"""Epoch seconds of the last accounting tick; ``0.0`` means never ticked."""
blocked_at: float = 0.0
"""Epoch seconds when the cutoff engaged; ``0.0`` means not blocked."""
warned_seconds: list[int] = field(default_factory=list)
"""Warning thresholds already fired during ``day_key``."""
def is_blocked(self) -> bool:
"""Whether the cutoff has engaged for this gaming day.
Deliberately a method rather than a field: ``save`` serialises
``self.__dict__``, so a derived field would be persisted and could
drift out of step with ``blocked_at``.
"""
return self.blocked_at > 0.0
@dataclass(frozen=True)
class PlaytimeRules:
"""Resolved budget policy for one tick.
Frozen and passed explicitly so that every decision function stays pure
and testable without touching ``Config`` or the clock.
"""
budget_seconds: float
warn_at: tuple[int, ...]
sigkill_after: float
count_launchers: bool
enforcement: bool
demo: bool
def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
"""Build the policy for this tick from *config*.
Demo mode changes the budget and the warning thresholds and nothing else —
in particular it uses the same qualifying-process predicate as production,
because a demo that exercises different logic proves nothing about it.
Args:
config: Loaded user configuration.
demo: Whether this is a short-budget demo run.
Returns:
The rules governing this tick.
"""
return PlaytimeRules(
budget_seconds=_DEMO_BUDGET_SECONDS
if demo
else float(config.daily_gaming_seconds),
warn_at=_DEMO_WARN_AT if demo else _WARN_AT,
sigkill_after=(_DEMO_SIGKILL_AFTER_SECONDS if demo else _SIGKILL_AFTER_SECONDS),
count_launchers=config.count_launcher_processes,
enforcement=config.playtime_enforcement,
demo=demo,
)
def gaming_day_key(now: datetime) -> str:
"""Return the ``YYYY-MM-DD`` gaming day containing *now*.
The boundary is 06:00 local, so 02:00 on the 5th belongs to the 4th.
Args:
now: Timezone-aware local timestamp.
Returns:
The gaming day key.
"""
return (now - timedelta(hours=_DAY_BOUNDARY_HOURS)).date().isoformat()
def state_path(*, demo: bool) -> Path:
"""Return the state file for a demo or production run.
Demo runs use a separate file so a demo can never consume or clobber the
real day's counter.
Args:
demo: Whether this is a demo run.
Returns:
Path to the state file.
"""
return PLAYTIME_DEMO_STATE_FILE if demo else PLAYTIME_STATE_FILE
def load_state(*, demo: bool) -> PlaytimeState | None:
"""Load persisted state, or ``None`` if it is absent or unusable.
``None`` deliberately covers "missing", "corrupt" and "written by a
different schema" alike: the caller's recovery path is the same for all
three, and treating an unreadable file as a fresh day is exactly the
reset-by-deletion the immutable flag exists to prevent.
Args:
demo: Whether this is a demo run.
Returns:
The stored state, or ``None`` if it could not be read.
"""
path = state_path(demo=demo)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
logger.warning("Playtime state at %s is unreadable; assuming tampered.", path)
return None
if not isinstance(data, dict) or data.get("schema_version") != _SCHEMA_VERSION:
logger.warning("Playtime state at %s has an unknown schema.", path)
return None
fields = PlaytimeState.__dataclass_fields__
return PlaytimeState(**{k: v for k, v in data.items() if k in fields})
def save_state(state: PlaytimeState, *, demo: bool) -> None:
"""Persist *state*, re-applying the immutable flag for production runs.
``_atomic_write`` finishes with ``rename(2)``, which fails ``EPERM`` against
an immutable destination — so clearing the flag first is mandatory, not
decorative. Demo state is left mutable: it carries no enforcement weight and
an immutable copy could not be deleted during cleanup, even by root.
Args:
state: State to write.
demo: Whether this is a demo run.
"""
path = state_path(demo=demo)
if not demo:
unlock_for_write(path)
_atomic_write(path, json.dumps(state.__dict__, indent=2) + "\n")
if not demo:
_try_set_immutable(path, immutable=True)
def get_pids_by_cmdline_names(names: frozenset[str]) -> dict[int, str]:
"""Scan ``/proc/*/cmdline`` for processes whose program name is in *names*.
Complements ``enforcer.get_pids_by_process_names``, which matches on
``comm`` and therefore cannot see interpreter-launched programs: the kernel
records ``/usr/bin/lutris`` as ``python3``. When ``argv[0]``'s basename is a
known interpreter this falls through to ``argv[1]``.
The daemon itself runs under ``python3``, so its own PID is skipped.
Args:
names: Program basenames to match.
Returns:
Mapping of PID to the matched name.
"""
own_pid = os.getpid()
found: dict[int, str] = {}
for entry in _PROC.iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
if pid == own_pid:
continue
matched = _match_cmdline(entry, names)
if matched is not None:
found[pid] = matched
return found
def _match_cmdline(entry: Path, names: frozenset[str]) -> str | None:
"""Return the name in *names* that *entry*'s cmdline runs, if any.
Args:
entry: A ``/proc/<pid>`` directory.
names: Program basenames to match.
Returns:
The matched name, or ``None``.
"""
try:
raw = (entry / "cmdline").read_bytes()
except (OSError, ValueError):
return None
argv = [
part for part in raw.decode("utf-8", errors="replace").split("\x00") if part
]
if not argv:
return None
first = Path(argv[0]).name
if first in names:
return first
if first not in _INTERPRETERS or len(argv) < _MIN_ARGV_FOR_INTERPRETER:
return None
second = Path(argv[1]).name
return second if second in names else None
def qualifying_pids(rules: PlaytimeRules) -> set[int]:
"""Return PIDs whose runtime counts against the daily budget.
Steam games are identified by the ``SteamAppId`` environment variable, using
the same ``!= 0`` predicate ``enforcer.enforce_allowed_game`` uses to exclude
the Steam client tree — browsing the store is not gaming.
Args:
rules: Policy for this tick.
Returns:
The set of qualifying PIDs.
"""
pids = {pid for pid, app_id in get_running_steam_game_pids().items() if app_id != 0}
if rules.count_launchers:
pids |= set(get_pids_by_process_names(LAUNCHER_PROCESS_NAMES))
pids |= set(get_pids_by_cmdline_names(LAUNCHER_PROCESS_NAMES))
return pids
def accumulate(
state: PlaytimeState,
*,
now: datetime,
qualifying: set[int],
interval: float,
) -> PlaytimeState:
"""Add this tick's elapsed gaming time to *state*.
Pure. Every argument is mandatory, including the clock — a defaulted ``now``
would add a branch that exists only to be covered.
The delta is clamped to ``_DELTA_CLAMP_FACTOR`` intervals so that a suspend,
a skipped tick or a forward NTP step cannot inflate the count, and floored
at zero so a backward step cannot refund time.
Args:
state: Current accounting state.
now: Timezone-aware local timestamp for this tick.
qualifying: PIDs currently counting against the budget.
interval: Nominal seconds between ticks.
Returns:
The updated state.
"""
stamp = now.timestamp()
if state.last_tick_at <= 0.0:
return replace(state, last_tick_at=stamp)
delta = stamp - state.last_tick_at
if delta <= 0.0 or not qualifying:
return replace(state, last_tick_at=stamp)
credited = min(delta, interval * _DELTA_CLAMP_FACTOR)
return replace(state, last_tick_at=stamp, seconds=state.seconds + credited)
def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
"""Reset *state* if the gaming day has changed.
Pure. ``last_tick_at`` carries across the boundary so the first tick of the
new day measures a sane delta rather than restarting from zero.
Args:
state: Current accounting state.
day_key: The gaming day *now* falls in.
Returns:
Either *state* unchanged, or a fresh record for *day_key*.
"""
if state.day_key == day_key:
return state
return PlaytimeState(day_key=day_key, last_tick_at=state.last_tick_at)
def pending_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None:
"""Return the largest un-fired warning threshold *state* has crossed.
Pure. Returning only the largest matters when a tick is skipped: crossing
two thresholds at once should warn once, not twice.
Args:
state: Current accounting state.
rules: Policy for this tick.
Returns:
The threshold in seconds-remaining, or ``None`` if none is due.
"""
remaining = rules.budget_seconds - state.seconds
for threshold in rules.warn_at:
if remaining <= threshold and threshold not in state.warned_seconds:
return threshold
return None
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.
Args:
title: Notification title.
body: Notification body.
"""
user = _resolve_desktop_user()
try:
_run_as_user(["notify-send", title, body, "--icon=dialog-warning"], user)
except (OSError, ValueError):
logger.debug("Could not send desktop notification.")
def playtime_tick(config: Config, *, interval: float, demo: bool = False) -> None:
"""Account for this tick's gaming time and enforce the daily budget.
@ -510,108 +157,3 @@ def _policy(
return _begin_cutoff(state, rules, now=now)
return _sustain_block(state, rules, now=now)
def _warn(state: PlaytimeState, rules: PlaytimeRules) -> PlaytimeState:
"""Fire the due warning, if any, and record that it fired.
Args:
state: Current accounting state.
rules: Policy for this tick.
Returns:
The updated state.
"""
threshold = pending_warning(state, rules)
if threshold is None:
return state
notify_desktop_user(
"Gaming time running out",
f"{_humanise(threshold)} of today's gaming budget left.",
)
return replace(state, warned_seconds=[*state.warned_seconds, threshold])
def _begin_cutoff(
state: PlaytimeState,
rules: PlaytimeRules,
*,
now: datetime,
) -> PlaytimeState:
"""Start the cutoff: ask Steam to close cleanly, then SIGTERM the games.
No mount happens on this tick. Masking the Steam binaries turns
``steam -shutdown`` into a no-op, which would cost a clean close — and
therefore a cloud-save flush — on every single cutoff.
Args:
state: Current accounting state.
rules: Policy for this tick.
now: Timezone-aware local timestamp.
Returns:
The updated state.
"""
logger.warning("Daily gaming budget exhausted; shutting Steam down.")
notify_desktop_user(
"Gaming time is up",
"Daily budget used up. Steam and games are shutting down. Unblocks at 06:00.",
)
request_steam_shutdown()
kill_gaming_processes(_kill_set(rules), force=False)
return replace(state, blocked_at=now.timestamp())
def _sustain_block(
state: PlaytimeState,
rules: PlaytimeRules,
*,
now: datetime,
) -> PlaytimeState:
"""Hold the block: mask the binaries once the grace has passed, keep killing.
Args:
state: Current accounting state.
rules: Policy for this tick.
now: Timezone-aware local timestamp.
Returns:
*state*, unchanged — upkeep mutates the system, not the record.
"""
elapsed = now.timestamp() - state.blocked_at
if elapsed >= _SHUTDOWN_GRACE_SECONDS:
reconcile(should_block=True)
kill_gaming_processes(_kill_set(rules), force=elapsed >= rules.sigkill_after)
return state
def _kill_set(rules: PlaytimeRules) -> set[int]:
"""Return every PID the cutoff should terminate.
Wider than :func:`qualifying_pids` on purpose. A Lutris-launched Wine game
has no ``SteamAppId`` and its ``comm`` is the game's own binary, so neither
matcher sees it — only walking down from the launcher does.
Args:
rules: Policy for this tick.
Returns:
PIDs to signal.
"""
return qualifying_pids(rules) | steam_and_launcher_pids()
def _humanise(seconds: int) -> str:
"""Render a warning threshold as a short human phrase.
Args:
seconds: Seconds remaining.
Returns:
A phrase such as ``"30 minutes"`` or ``"10 seconds"``.
"""
if seconds < _SECONDS_PER_MINUTE:
return f"{seconds} seconds"
minutes = seconds // _SECONDS_PER_MINUTE
return "1 hour" if minutes == _MINUTES_PER_HOUR else f"{minutes} minutes"

View File

@ -33,19 +33,12 @@ that at runtime.
from __future__ import annotations
import contextlib
import logging
import os
from pathlib import Path
import shutil
import signal
import subprocess
from steam_backlog_enforcer._total_block import (
LAUNCHER_PROCESS_NAMES,
STEAM_CLIENT_PROCESS_NAMES,
)
from steam_backlog_enforcer.enforcer import get_pids_by_process_names
from steam_backlog_enforcer._playtime_mountinfo import _mountpoints
from steam_backlog_enforcer._playtime_run import _run
logger = logging.getLogger(__name__)
@ -84,8 +77,6 @@ _MAX_UMOUNT_PASSES = 20
_MOUNTINFO_MIN_FIELDS = 5
_MOUNTPOINT_FIELD = 4
_STAT_PPID_INDEX = 1
_MAX_PROCESS_TREE_DEPTH = 32
_STUB_MODE = 0o755
@ -111,69 +102,6 @@ def block_targets() -> tuple[Path, ...]:
return BLOCK_TARGETS
def _run(cmd: list[str]) -> bool:
"""Run a privileged command, reporting success.
The single subprocess seam in this module, so tests can assert on exact
argv without ever executing ``mount``.
Args:
cmd: Argument vector to execute.
Returns:
True if the command exited zero.
"""
try:
result = subprocess.run(cmd, capture_output=True, check=False, timeout=15)
except (OSError, subprocess.SubprocessError):
logger.exception("Playtime block command failed: %s", cmd)
return False
if result.returncode != 0:
logger.warning("Playtime block command exited %d: %s", result.returncode, cmd)
return result.returncode == 0
def _unescape_mountinfo(field: str) -> str:
"""Decode the octal escapes the kernel writes into mountinfo paths.
Args:
field: A raw mountinfo field.
Returns:
The decoded field.
"""
return (
field.replace(r"\040", " ")
.replace(r"\011", "\t")
.replace(r"\012", "\n")
.replace(r"\134", "\\")
)
def _mountpoints(path: Path) -> set[Path]:
"""Parse *path* as mountinfo and return the mount points it lists.
Args:
path: A mountinfo file.
Returns:
Every mount point named in the file.
"""
try:
raw = path.read_text(encoding="utf-8")
except (OSError, ValueError):
logger.warning("Could not read mountinfo at %s.", path)
return set()
points: set[Path] = set()
for line in raw.splitlines():
fields = line.split(" ")
if len(fields) < _MOUNTINFO_MIN_FIELDS:
continue
points.add(Path(_unescape_mountinfo(fields[_MOUNTPOINT_FIELD])))
return points
def mounted_targets() -> set[Path]:
"""Return the block targets currently masked by a mount.
@ -298,152 +226,6 @@ def reconcile(*, should_block: bool) -> tuple[list[Path], list[Path]]:
return masked, []
def request_steam_shutdown() -> None:
"""Ask Steam to close cleanly, without waiting for it.
``library_hider._shutdown_steam`` polls for up to 30 seconds, which would
stall the 3-second enforce loop for ten ticks. The cutoff sequence gives
Steam its grace period across ticks instead.
"""
steam = shutil.which("steam")
if steam is None:
logger.info("Steam binary not on PATH; skipping clean shutdown.")
return
_run([steam, "-shutdown"])
def steam_and_launcher_pids() -> set[int]:
"""Return PIDs of the Steam client and every known game launcher.
Returns:
The launcher and client PIDs.
"""
names = STEAM_CLIENT_PROCESS_NAMES | LAUNCHER_PROCESS_NAMES
return set(get_pids_by_process_names(names))
def _read_ppid(pid: int) -> int | None:
"""Return the parent PID of *pid*, or ``None`` if it cannot be read.
``/proc/<pid>/stat`` embeds ``comm`` in parentheses and ``comm`` may contain
spaces and parentheses, so the fields after it are found by splitting on the
last ``") "`` rather than by whitespace position.
Args:
pid: Process to inspect.
Returns:
The parent PID, or ``None``.
"""
try:
raw = (_PROC / str(pid) / "stat").read_text(encoding="utf-8")
except (OSError, ValueError):
return None
_, _, rest = raw.rpartition(") ")
fields = rest.split()
if len(fields) <= _STAT_PPID_INDEX:
return None
try:
return int(fields[_STAT_PPID_INDEX])
except ValueError:
return None
def _child_map() -> dict[int, list[int]]:
"""Build a parent-PID to child-PIDs map from ``/proc``.
Returns:
Mapping of parent PID to its direct children.
"""
children: dict[int, list[int]] = {}
try:
entries = list(_PROC.iterdir())
except OSError:
return children
for entry in entries:
if not entry.name.isdigit():
continue
pid = int(entry.name)
ppid = _read_ppid(pid)
if ppid is not None:
children.setdefault(ppid, []).append(pid)
return children
def descendant_pids(roots: set[int]) -> set[int]:
"""Return every process descended from *roots*.
A Lutris-launched Wine game carries no ``SteamAppId`` and its ``comm`` is
the game's own binary, so neither of the budget matchers can see it. Walking
down from the launcher is the only thing that does.
The daemon's own process and its ancestors are excluded, so a misidentified
root cannot make the enforcer kill itself.
Args:
roots: Processes to walk down from.
Returns:
The transitive descendants, excluding *roots* themselves.
"""
children = _child_map()
protected = _own_process_chain()
found: set[int] = set()
frontier = [pid for pid in roots if pid not in protected]
for _ in range(_MAX_PROCESS_TREE_DEPTH):
if not frontier:
break
nxt: list[int] = []
for pid in frontier:
for child in children.get(pid, []):
if child in found or child in protected:
continue
found.add(child)
nxt.append(child)
frontier = nxt
return found
def _own_process_chain() -> set[int]:
"""Return this process and its ancestors.
Returns:
PIDs that must never be signalled.
"""
chain: set[int] = set()
pid: int | None = os.getpid()
for _ in range(_MAX_PROCESS_TREE_DEPTH):
if pid is None or pid <= 0 or pid in chain:
break
chain.add(pid)
pid = _read_ppid(pid)
return chain
def kill_gaming_processes(pids: set[int], *, force: bool) -> list[int]:
"""Signal every process in *pids*, plus everything descended from them.
Args:
pids: Processes to terminate.
force: Send ``SIGKILL`` instead of ``SIGTERM``.
Returns:
The PIDs actually signalled.
"""
protected = _own_process_chain()
targets = (pids | descendant_pids(pids)) - protected
sig = signal.SIGKILL if force else signal.SIGTERM
signalled: list[int] = []
for pid in sorted(targets):
with contextlib.suppress(ProcessLookupError, PermissionError):
os.kill(pid, sig)
signalled.append(pid)
return signalled
def _mount_bin() -> str:
"""Return the ``mount`` executable path.

View File

@ -0,0 +1,98 @@
"""Pure state transitions for the daily playtime budget.
Separated from persistence: nothing here touches the disk or the clock
beyond what it is handed, which is what makes the budget arithmetic
straightforward to test.
"""
from __future__ import annotations
from dataclasses import replace
import logging
from typing import TYPE_CHECKING
from steam_backlog_enforcer._playtime_state import (
_DELTA_CLAMP_FACTOR,
PlaytimeRules,
PlaytimeState,
)
if TYPE_CHECKING:
from datetime import datetime
logger = logging.getLogger(__name__)
def accumulate(
state: PlaytimeState,
*,
now: datetime,
qualifying: set[int],
interval: float,
) -> PlaytimeState:
"""Add this tick's elapsed gaming time to *state*.
Pure. Every argument is mandatory, including the clock — a defaulted ``now``
would add a branch that exists only to be covered.
The delta is clamped to ``_DELTA_CLAMP_FACTOR`` intervals so that a suspend,
a skipped tick or a forward NTP step cannot inflate the count, and floored
at zero so a backward step cannot refund time.
Args:
state: Current accounting state.
now: Timezone-aware local timestamp for this tick.
qualifying: PIDs currently counting against the budget.
interval: Nominal seconds between ticks.
Returns:
The updated state.
"""
stamp = now.timestamp()
if state.last_tick_at <= 0.0:
return replace(state, last_tick_at=stamp)
delta = stamp - state.last_tick_at
if delta <= 0.0 or not qualifying:
return replace(state, last_tick_at=stamp)
credited = min(delta, interval * _DELTA_CLAMP_FACTOR)
return replace(state, last_tick_at=stamp, seconds=state.seconds + credited)
def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
"""Reset *state* if the gaming day has changed.
Pure. ``last_tick_at`` carries across the boundary so the first tick of the
new day measures a sane delta rather than restarting from zero.
Args:
state: Current accounting state.
day_key: The gaming day *now* falls in.
Returns:
Either *state* unchanged, or a fresh record for *day_key*.
"""
if state.day_key == day_key:
return state
return PlaytimeState(day_key=day_key, last_tick_at=state.last_tick_at)
def pending_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None:
"""Return the largest un-fired warning threshold *state* has crossed.
Pure. Returning only the largest matters when a tick is skipped: crossing
two thresholds at once should warn once, not twice.
Args:
state: Current accounting state.
rules: Policy for this tick.
Returns:
The threshold in seconds-remaining, or ``None`` if none is due.
"""
remaining = rules.budget_seconds - state.seconds
for threshold in rules.warn_at:
if remaining <= threshold and threshold not in state.warned_seconds:
return threshold
return None

View File

@ -0,0 +1,125 @@
"""Warning, cutoff and kill steps of the playtime policy.
The escalation ladder once a budget is spent: warn, ask Steam to shut down
cleanly, then mask its binaries and SIGTERM/SIGKILL what is still running.
The grace between shutdown and masking exists because masking makes
``steam -shutdown`` itself a no-op.
"""
from __future__ import annotations
from dataclasses import replace
import logging
from typing import TYPE_CHECKING
from steam_backlog_enforcer._playtime_block import (
reconcile,
)
from steam_backlog_enforcer._playtime_budget import pending_warning
from steam_backlog_enforcer._playtime_kill import (
kill_gaming_processes,
request_steam_shutdown,
steam_and_launcher_pids,
)
from steam_backlog_enforcer._playtime_notify import _humanise, notify_desktop_user
from steam_backlog_enforcer._playtime_procs import qualifying_pids
from steam_backlog_enforcer._playtime_state import (
_SHUTDOWN_GRACE_SECONDS,
PlaytimeRules,
PlaytimeState,
)
if TYPE_CHECKING:
from datetime import datetime
logger = logging.getLogger(__name__)
def _warn(state: PlaytimeState, rules: PlaytimeRules) -> PlaytimeState:
"""Fire the due warning, if any, and record that it fired.
Args:
state: Current accounting state.
rules: Policy for this tick.
Returns:
The updated state.
"""
threshold = pending_warning(state, rules)
if threshold is None:
return state
notify_desktop_user(
"Gaming time running out",
f"{_humanise(threshold)} of today's gaming budget left.",
)
return replace(state, warned_seconds=[*state.warned_seconds, threshold])
def _begin_cutoff(
state: PlaytimeState,
rules: PlaytimeRules,
*,
now: datetime,
) -> PlaytimeState:
"""Start the cutoff: ask Steam to close cleanly, then SIGTERM the games.
No mount happens on this tick. Masking the Steam binaries turns
``steam -shutdown`` into a no-op, which would cost a clean close — and
therefore a cloud-save flush — on every single cutoff.
Args:
state: Current accounting state.
rules: Policy for this tick.
now: Timezone-aware local timestamp.
Returns:
The updated state.
"""
logger.warning("Daily gaming budget exhausted; shutting Steam down.")
notify_desktop_user(
"Gaming time is up",
"Daily budget used up. Steam and games are shutting down. Unblocks at 06:00.",
)
request_steam_shutdown()
kill_gaming_processes(_kill_set(rules), force=False)
return replace(state, blocked_at=now.timestamp())
def _sustain_block(
state: PlaytimeState,
rules: PlaytimeRules,
*,
now: datetime,
) -> PlaytimeState:
"""Hold the block: mask the binaries once the grace has passed, keep killing.
Args:
state: Current accounting state.
rules: Policy for this tick.
now: Timezone-aware local timestamp.
Returns:
*state*, unchanged — upkeep mutates the system, not the record.
"""
elapsed = now.timestamp() - state.blocked_at
if elapsed >= _SHUTDOWN_GRACE_SECONDS:
reconcile(should_block=True)
kill_gaming_processes(_kill_set(rules), force=elapsed >= rules.sigkill_after)
return state
def _kill_set(rules: PlaytimeRules) -> set[int]:
"""Return every PID the cutoff should terminate.
Wider than :func:`qualifying_pids` on purpose. A Lutris-launched Wine game
has no ``SteamAppId`` and its ``comm`` is the game's own binary, so neither
matcher sees it — only walking down from the launcher does.
Args:
rules: Policy for this tick.
Returns:
PIDs to signal.
"""
return qualifying_pids(rules) | steam_and_launcher_pids()

View File

@ -0,0 +1,176 @@
"""Finding and stopping the processes a playtime cutoff must end.
Killing Steam's own PIDs is not enough: a running game is a *descendant* of
the client, so the tree is walked from the launcher roots downwards. The
enforcer's own process chain is excluded, since it is itself a child of the
same session on a desktop install.
"""
from __future__ import annotations
import contextlib
import logging
import os
from pathlib import Path
import shutil
import signal
from steam_backlog_enforcer._playtime_run import _run
from steam_backlog_enforcer._total_block_launchers import (
LAUNCHER_PROCESS_NAMES,
STEAM_CLIENT_PROCESS_NAMES,
)
from steam_backlog_enforcer.enforcer import get_pids_by_process_names
logger = logging.getLogger(__name__)
_PROC = Path("/proc")
_STAT_PPID_INDEX = 1
_MAX_PROCESS_TREE_DEPTH = 32
def request_steam_shutdown() -> None:
"""Ask Steam to close cleanly, without waiting for it.
``library_hider._shutdown_steam`` polls for up to 30 seconds, which would
stall the 3-second enforce loop for ten ticks. The cutoff sequence gives
Steam its grace period across ticks instead.
"""
steam = shutil.which("steam")
if steam is None:
logger.info("Steam binary not on PATH; skipping clean shutdown.")
return
_run([steam, "-shutdown"])
def steam_and_launcher_pids() -> set[int]:
"""Return PIDs of the Steam client and every known game launcher.
Returns:
The launcher and client PIDs.
"""
names = STEAM_CLIENT_PROCESS_NAMES | LAUNCHER_PROCESS_NAMES
return set(get_pids_by_process_names(names))
def _read_ppid(pid: int) -> int | None:
"""Return the parent PID of *pid*, or ``None`` if it cannot be read.
``/proc/<pid>/stat`` embeds ``comm`` in parentheses and ``comm`` may contain
spaces and parentheses, so the fields after it are found by splitting on the
last ``") "`` rather than by whitespace position.
Args:
pid: Process to inspect.
Returns:
The parent PID, or ``None``.
"""
try:
raw = (_PROC / str(pid) / "stat").read_text(encoding="utf-8")
except (OSError, ValueError):
return None
_, _, rest = raw.rpartition(") ")
fields = rest.split()
if len(fields) <= _STAT_PPID_INDEX:
return None
try:
return int(fields[_STAT_PPID_INDEX])
except ValueError:
return None
def _child_map() -> dict[int, list[int]]:
"""Build a parent-PID to child-PIDs map from ``/proc``.
Returns:
Mapping of parent PID to its direct children.
"""
children: dict[int, list[int]] = {}
try:
entries = list(_PROC.iterdir())
except OSError:
return children
for entry in entries:
if not entry.name.isdigit():
continue
pid = int(entry.name)
ppid = _read_ppid(pid)
if ppid is not None:
children.setdefault(ppid, []).append(pid)
return children
def descendant_pids(roots: set[int]) -> set[int]:
"""Return every process descended from *roots*.
A Lutris-launched Wine game carries no ``SteamAppId`` and its ``comm`` is
the game's own binary, so neither of the budget matchers can see it. Walking
down from the launcher is the only thing that does.
The daemon's own process and its ancestors are excluded, so a misidentified
root cannot make the enforcer kill itself.
Args:
roots: Processes to walk down from.
Returns:
The transitive descendants, excluding *roots* themselves.
"""
children = _child_map()
protected = _own_process_chain()
found: set[int] = set()
frontier = [pid for pid in roots if pid not in protected]
for _ in range(_MAX_PROCESS_TREE_DEPTH):
if not frontier:
break
nxt: list[int] = []
for pid in frontier:
for child in children.get(pid, []):
if child in found or child in protected:
continue
found.add(child)
nxt.append(child)
frontier = nxt
return found
def _own_process_chain() -> set[int]:
"""Return this process and its ancestors.
Returns:
PIDs that must never be signalled.
"""
chain: set[int] = set()
pid: int | None = os.getpid()
for _ in range(_MAX_PROCESS_TREE_DEPTH):
if pid is None or pid <= 0 or pid in chain:
break
chain.add(pid)
pid = _read_ppid(pid)
return chain
def kill_gaming_processes(pids: set[int], *, force: bool) -> list[int]:
"""Signal every process in *pids*, plus everything descended from them.
Args:
pids: Processes to terminate.
force: Send ``SIGKILL`` instead of ``SIGTERM``.
Returns:
The PIDs actually signalled.
"""
protected = _own_process_chain()
targets = (pids | descendant_pids(pids)) - protected
sig = signal.SIGKILL if force else signal.SIGTERM
signalled: list[int] = []
for pid in sorted(targets):
with contextlib.suppress(ProcessLookupError, PermissionError):
os.kill(pid, sig)
signalled.append(pid)
return signalled

View File

@ -0,0 +1,59 @@
"""Reading the kernel's mount table to see which blocks are live.
``/proc/self/mountinfo`` is the source of truth rather than our own record of
what we mounted: a bind mount can survive a crash or be removed by hand, and
the enforcer has to reconcile against reality on every tick.
"""
from __future__ import annotations
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
_INIT_MOUNTINFO_PATH = Path("/proc/1/mountinfo")
MOUNTINFO_PATH = Path("/proc/self/mountinfo")
_MOUNTINFO_MIN_FIELDS = 5
_MOUNTPOINT_FIELD = 4
def _unescape_mountinfo(field: str) -> str:
"""Decode the octal escapes the kernel writes into mountinfo paths.
Args:
field: A raw mountinfo field.
Returns:
The decoded field.
"""
return (
field.replace(r"\040", " ")
.replace(r"\011", "\t")
.replace(r"\012", "\n")
.replace(r"\134", "\\")
)
def _mountpoints(path: Path) -> set[Path]:
"""Parse *path* as mountinfo and return the mount points it lists.
Args:
path: A mountinfo file.
Returns:
Every mount point named in the file.
"""
try:
raw = path.read_text(encoding="utf-8")
except (OSError, ValueError):
logger.warning("Could not read mountinfo at %s.", path)
return set()
points: set[Path] = set()
for line in raw.splitlines():
fields = line.split(" ")
if len(fields) < _MOUNTINFO_MIN_FIELDS:
continue
points.add(Path(_unescape_mountinfo(fields[_MOUNTPOINT_FIELD])))
return points

View File

@ -0,0 +1,51 @@
"""Desktop notifications for the playtime budget, and time formatting.
Notifications must reach the logged-in desktop session, not the root systemd
context the enforcer runs in, which is why they are dispatched through
``_run_as_user``.
"""
from __future__ import annotations
import logging
from steam_backlog_enforcer.library_hider import _resolve_desktop_user, _run_as_user
logger = logging.getLogger(__name__)
_SECONDS_PER_MINUTE = 60
_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.
Args:
title: Notification title.
body: Notification body.
"""
user = _resolve_desktop_user()
try:
_run_as_user(["notify-send", title, body, "--icon=dialog-warning"], user)
except (OSError, ValueError):
logger.debug("Could not send desktop notification.")
def _humanise(seconds: int) -> str:
"""Render a warning threshold as a short human phrase.
Args:
seconds: Seconds remaining.
Returns:
A phrase such as ``"30 minutes"`` or ``"10 seconds"``.
"""
if seconds < _SECONDS_PER_MINUTE:
return f"{seconds} seconds"
minutes = seconds // _SECONDS_PER_MINUTE
return "1 hour" if minutes == _MINUTES_PER_HOUR else f"{minutes} minutes"

View File

@ -0,0 +1,111 @@
"""Identifying gaming processes by their /proc cmdline.
Process-name matching alone misses launchers run through an interpreter
(``lutris`` is a Python script; several Minecraft launchers are ``java -jar``),
so this reads argv and looks past the interpreter to the real program.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import TYPE_CHECKING
from steam_backlog_enforcer._total_block_launchers import LAUNCHER_PROCESS_NAMES
from steam_backlog_enforcer.enforcer import (
get_pids_by_process_names,
get_running_steam_game_pids,
)
if TYPE_CHECKING:
from steam_backlog_enforcer._playtime_state import PlaytimeRules
_PROC = Path("/proc")
# argv[0] basenames that tell us nothing: the real program is argv[1].
_INTERPRETERS = frozenset({"bash", "env", "java", "perl", "python", "python3", "sh"})
# An interpreter invocation needs at least `<interp> <script>` to name a program.
_MIN_ARGV_FOR_INTERPRETER = 2
def get_pids_by_cmdline_names(names: frozenset[str]) -> dict[int, str]:
"""Scan ``/proc/*/cmdline`` for processes whose program name is in *names*.
Complements ``enforcer.get_pids_by_process_names``, which matches on
``comm`` and therefore cannot see interpreter-launched programs: the kernel
records ``/usr/bin/lutris`` as ``python3``. When ``argv[0]``'s basename is a
known interpreter this falls through to ``argv[1]``.
The daemon itself runs under ``python3``, so its own PID is skipped.
Args:
names: Program basenames to match.
Returns:
Mapping of PID to the matched name.
"""
own_pid = os.getpid()
found: dict[int, str] = {}
for entry in _PROC.iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
if pid == own_pid:
continue
matched = _match_cmdline(entry, names)
if matched is not None:
found[pid] = matched
return found
def _match_cmdline(entry: Path, names: frozenset[str]) -> str | None:
"""Return the name in *names* that *entry*'s cmdline runs, if any.
Args:
entry: A ``/proc/<pid>`` directory.
names: Program basenames to match.
Returns:
The matched name, or ``None``.
"""
try:
raw = (entry / "cmdline").read_bytes()
except (OSError, ValueError):
return None
argv = [
part for part in raw.decode("utf-8", errors="replace").split("\x00") if part
]
if not argv:
return None
first = Path(argv[0]).name
if first in names:
return first
if first not in _INTERPRETERS or len(argv) < _MIN_ARGV_FOR_INTERPRETER:
return None
second = Path(argv[1]).name
return second if second in names else None
def qualifying_pids(rules: PlaytimeRules) -> set[int]:
"""Return PIDs whose runtime counts against the daily budget.
Steam games are identified by the ``SteamAppId`` environment variable, using
the same ``!= 0`` predicate ``enforcer.enforce_allowed_game`` uses to exclude
the Steam client tree — browsing the store is not gaming.
Args:
rules: Policy for this tick.
Returns:
The set of qualifying PIDs.
"""
pids = {pid for pid, app_id in get_running_steam_game_pids().items() if app_id != 0}
if rules.count_launchers:
pids |= set(get_pids_by_process_names(LAUNCHER_PROCESS_NAMES))
pids |= set(get_pids_by_cmdline_names(LAUNCHER_PROCESS_NAMES))
return pids

View File

@ -0,0 +1,34 @@
"""Running the privileged commands a playtime block needs.
One helper, shared by the mount side and the process-kill side, so the
"log the failure and carry on" policy is written once.
"""
from __future__ import annotations
import logging
import subprocess
logger = logging.getLogger(__name__)
def _run(cmd: list[str]) -> bool:
"""Run a privileged command, reporting success.
The single subprocess seam in this module, so tests can assert on exact
argv without ever executing ``mount``.
Args:
cmd: Argument vector to execute.
Returns:
True if the command exited zero.
"""
try:
result = subprocess.run(cmd, capture_output=True, check=False, timeout=15)
except (OSError, subprocess.SubprocessError):
logger.exception("Playtime block command failed: %s", cmd)
return False
if result.returncode != 0:
logger.warning("Playtime block command exited %d: %s", result.returncode, cmd)
return result.returncode == 0

View File

@ -0,0 +1,196 @@
"""The daily playtime budget: its state, its rules, and its persistence.
A gaming "day" runs 06:00-05:59 local, so late-night sessions count against
the day they started in. State is written atomically and re-read every tick,
which is what lets the enforcer survive a restart mid-session.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import json
import logging
from typing import TYPE_CHECKING
from steam_backlog_enforcer._whitelist import _try_set_immutable, unlock_for_write
from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
if TYPE_CHECKING:
from pathlib import Path
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
PLAYTIME_STATE_FILE = CONFIG_DIR / "playtime_state.json"
PLAYTIME_DEMO_STATE_FILE = CONFIG_DIR / "playtime_demo_state.json"
_SCHEMA_VERSION = 1
# The gaming day starts here, local time. 05:59 belongs to the previous day.
_DAY_BOUNDARY_HOURS = 6
# Each tick's measured delta is capped at this many tick intervals. Two is the
# smallest value that tolerates one skipped tick without inflating the count.
_DELTA_CLAMP_FACTOR = 2.0
_DEFAULT_BUDGET_SECONDS = 8 * 3600.0
_DEMO_BUDGET_SECONDS = 60.0
# Seconds remaining at which to warn, descending.
_WARN_AT: tuple[int, ...] = (3600, 1800, 600, 300)
_DEMO_WARN_AT: tuple[int, ...] = (30, 10)
# Grace between "budget exhausted" and masking the Steam binaries. Masking
# makes `steam -shutdown` a no-op, so Steam must be given a moment to close
# its games cleanly first — otherwise every single cutoff is a hard SIGTERM.
_SHUTDOWN_GRACE_SECONDS = 3.0
_SIGKILL_AFTER_SECONDS = 30.0
_DEMO_SIGKILL_AFTER_SECONDS = 10.0
@dataclass
class PlaytimeState:
"""Per-gaming-day playtime accounting, persisted between ticks."""
schema_version: int = _SCHEMA_VERSION
day_key: str = ""
"""Gaming day this record covers, ``YYYY-MM-DD``, 06:00-shifted."""
seconds: float = 0.0
"""Qualifying seconds accrued during ``day_key``."""
last_tick_at: float = 0.0
"""Epoch seconds of the last accounting tick; ``0.0`` means never ticked."""
blocked_at: float = 0.0
"""Epoch seconds when the cutoff engaged; ``0.0`` means not blocked."""
warned_seconds: list[int] = field(default_factory=list)
"""Warning thresholds already fired during ``day_key``."""
def is_blocked(self) -> bool:
"""Whether the cutoff has engaged for this gaming day.
Deliberately a method rather than a field: ``save`` serialises
``self.__dict__``, so a derived field would be persisted and could
drift out of step with ``blocked_at``.
"""
return self.blocked_at > 0.0
@dataclass(frozen=True)
class PlaytimeRules:
"""Resolved budget policy for one tick.
Frozen and passed explicitly so that every decision function stays pure
and testable without touching ``Config`` or the clock.
"""
budget_seconds: float
warn_at: tuple[int, ...]
sigkill_after: float
count_launchers: bool
enforcement: bool
demo: bool
def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
"""Build the policy for this tick from *config*.
Demo mode changes the budget and the warning thresholds and nothing else —
in particular it uses the same qualifying-process predicate as production,
because a demo that exercises different logic proves nothing about it.
Args:
config: Loaded user configuration.
demo: Whether this is a short-budget demo run.
Returns:
The rules governing this tick.
"""
return PlaytimeRules(
budget_seconds=_DEMO_BUDGET_SECONDS
if demo
else float(config.daily_gaming_seconds),
warn_at=_DEMO_WARN_AT if demo else _WARN_AT,
sigkill_after=(_DEMO_SIGKILL_AFTER_SECONDS if demo else _SIGKILL_AFTER_SECONDS),
count_launchers=config.count_launcher_processes,
enforcement=config.playtime_enforcement,
demo=demo,
)
def gaming_day_key(now: datetime) -> str:
"""Return the ``YYYY-MM-DD`` gaming day containing *now*.
The boundary is 06:00 local, so 02:00 on the 5th belongs to the 4th.
Args:
now: Timezone-aware local timestamp.
Returns:
The gaming day key.
"""
return (now - timedelta(hours=_DAY_BOUNDARY_HOURS)).date().isoformat()
def state_path(*, demo: bool) -> Path:
"""Return the state file for a demo or production run.
Demo runs use a separate file so a demo can never consume or clobber the
real day's counter.
Args:
demo: Whether this is a demo run.
Returns:
Path to the state file.
"""
return PLAYTIME_DEMO_STATE_FILE if demo else PLAYTIME_STATE_FILE
def load_state(*, demo: bool) -> PlaytimeState | None:
"""Load persisted state, or ``None`` if it is absent or unusable.
``None`` deliberately covers "missing", "corrupt" and "written by a
different schema" alike: the caller's recovery path is the same for all
three, and treating an unreadable file as a fresh day is exactly the
reset-by-deletion the immutable flag exists to prevent.
Args:
demo: Whether this is a demo run.
Returns:
The stored state, or ``None`` if it could not be read.
"""
path = state_path(demo=demo)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
logger.warning("Playtime state at %s is unreadable; assuming tampered.", path)
return None
if not isinstance(data, dict) or data.get("schema_version") != _SCHEMA_VERSION:
logger.warning("Playtime state at %s has an unknown schema.", path)
return None
fields = PlaytimeState.__dataclass_fields__
return PlaytimeState(**{k: v for k, v in data.items() if k in fields})
def save_state(state: PlaytimeState, *, demo: bool) -> None:
"""Persist *state*, re-applying the immutable flag for production runs.
``_atomic_write`` finishes with ``rename(2)``, which fails ``EPERM`` against
an immutable destination — so clearing the flag first is mandatory, not
decorative. Demo state is left mutable: it carries no enforcement weight and
an immutable copy could not be deleted during cleanup, even by root.
Args:
state: State to write.
demo: Whether this is a demo run.
"""
path = state_path(demo=demo)
if not demo:
unlock_for_write(path)
_atomic_write(path, json.dumps(state.__dict__, indent=2) + "\n")
if not demo:
_try_set_immutable(path, immutable=True)

View File

@ -1,189 +1,56 @@
"""Total gaming block: no in-app command to lift it early.
"""Total gaming block: the lock, its status, and the lifecycle that drives it.
Uninstalls Steam, kills all game/launcher processes, blocks all Steam +
game-website domains, purges known Steam/Proton filesystem remnants, and
uninstalls Proton helper packages, for a fixed number of days.
This module owns the *state* of a total block -- the guard-lib-protected lock
file and the status derived from it -- and orchestrates the two independent
mechanisms that enforce it:
Tamper-resistance is provided by guard-lib (~/guard-lib): the lock file's
``until`` timestamp is protected by a bind-mounted, chattr-immutable
file-guard instance, and pacman itself refuses to reinstall/upgrade the
``steam`` package while the lock is active (package-block).
* :mod:`steam_backlog_enforcer._total_block_purge` makes Steam not installed;
* :mod:`steam_backlog_enforcer._total_block_hosts` and
:mod:`steam_backlog_enforcer._total_block_iptables` make it unreachable.
There is no in-app command to lift a block early. Tamper-resistance comes
from guard-lib (~/guard-lib): the lock file's ``until`` timestamp is
protected by a bind-mounted, chattr-immutable file-guard instance, and pacman
itself refuses to reinstall/upgrade the ``steam`` package while the lock is
active (package-block).
"""
from __future__ import annotations
import contextlib
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import logging
from pathlib import Path
import shutil
import socket
import subprocess
from steam_backlog_enforcer.config import (
BLOCKED_DOMAINS,
CONFIG_DIR,
HOSTS_FILE,
_atomic_write,
from steam_backlog_enforcer._total_block_hosts import (
apply_total_block_hosts,
remove_total_block_hosts,
)
from steam_backlog_enforcer.enforcer import (
get_pids_by_process_names,
kill_processes_by_name,
from steam_backlog_enforcer._total_block_iptables import (
apply_total_block_iptables,
remove_total_block_iptables,
)
from steam_backlog_enforcer.store_blocker import (
_disable_hosts_protection,
_enable_hosts_protection,
_sudo_write_hosts,
flush_dns_cache,
from steam_backlog_enforcer._total_block_launchers import LAUNCHER_PROCESS_NAMES
from steam_backlog_enforcer._total_block_purge import (
STEAM_PACKAGE,
is_steam_installed,
kill_steam_and_launchers,
purge_steam_and_proton,
uninstall_steam_package,
)
from steam_backlog_enforcer.config import CONFIG_DIR
from steam_backlog_enforcer.store_blocker import flush_dns_cache
logger = logging.getLogger(__name__)
TOTAL_BLOCK_LOCK_FILE = CONFIG_DIR / "total_block_lock.json"
_IPTABLES_IP_CACHE_FILE = CONFIG_DIR / "total_block_ip_cache.json"
_PACKAGE_BLOCK_NAME = "steam-block"
_STEAM_PACKAGE = "steam"
# Steam's own client processes.
STEAM_CLIENT_PROCESS_NAMES = frozenset({"steam", "steamwebhelper", "steam.sh"})
# Third-party game launchers, best-effort match by process name.
LAUNCHER_PROCESS_NAMES = frozenset(
{
"EpicGamesLauncher",
"legendary",
"lutris",
"heroic",
"GalaxyClient",
"itch",
"bottles",
"minecraft-launcher",
"prismlauncher",
"multimc",
"polymc",
"ATLauncher",
"GDLauncher",
"gdlauncher-carbon",
"TLauncher",
"modrinth-app",
}
)
# Known limitation, not engineered around in this pass: any launcher run
# via an interpreter rather than its own compiled binary shows up in
# /proc/*/comm as the INTERPRETER's name, not its own - process-name
# matching won't catch those. Confirmed live for "lutris" (a Python
# script, appears as `python3`), and documented upstream for some
# Minecraft launchers (TLauncher, ATLauncher, GDLauncher - exec'd as
# `java -jar ...`, appear as `java`). Matching the interpreter name
# itself is NOT a fix: kill_processes_by_name runs inside this very
# enforcer process, which is itself `python3` - adding it to the set
# would SIGTERM the daemon and every other Python process on the system.
# Consistent with the "best-effort" framing already agreed for non-Steam
# blocking; the hosts+iptables domain blocking below is the backstop for
# launchers this can't catch by process name.
TOTAL_BLOCK_DOMAINS = [
*BLOCKED_DOMAINS,
"steamcommunity.com",
"api.steampowered.com",
"login.steampowered.com",
"help.steampowered.com",
"steamcontent.com",
"steamstatic.com",
"steamusercontent.com",
"cdn.steamstatic.com",
]
# Browser/flash game sites. Note itch.io overlaps with the "itch" desktop
# app process kill above (web storefront vs. desktop client).
GAME_WEBSITE_DOMAINS = [
"newgrounds.com",
"armorgames.com",
"kongregate.com",
"crazygames.com",
"poki.com",
"miniclip.com",
"addictinggames.com",
"y8.com",
"coolmathgames.com",
"itch.io",
]
def _expand_with_www(domains: list[str]) -> list[str]:
"""Add a ``www.`` variant for each bare second-level domain.
Most of these sites 301-redirect their apex domain to ``www.<domain>``
(confirmed live for newgrounds.com) - blocking only the apex leaves the
www subdomain reachable through both the hosts-file entry and the
iptables IP block. Domains that already carry a subdomain (e.g.
store.steampowered.com) are left as-is.
"""
expanded: list[str] = []
for domain in domains:
expanded.append(domain)
if domain.count(".") == 1:
expanded.append(f"www.{domain}")
return expanded
_ALL_TOTAL_BLOCK_DOMAINS = _expand_with_www(
[*TOTAL_BLOCK_DOMAINS, *GAME_WEBSITE_DOMAINS]
)
_HOSTS_BLOCK_BEGIN = "# BEGIN steam-backlog-enforcer total-block\n"
_HOSTS_BLOCK_END = "# END steam-backlog-enforcer total-block\n"
_SUDO = shutil.which("sudo") or "/usr/bin/sudo"
_GUARDCTL = shutil.which("guardctl") or "/usr/local/bin/guardctl"
# Call pacman.orig directly (bypassing pacman_wrapper's interactive
# word-unscramble challenge for "steam") - this is the tool's own
# authorized action, not a user bypass attempt, and enforce_total_block_tick
# must be able to run unattended.
_PACMAN = (
shutil.which("pacman.orig") or shutil.which("pacman") or "/usr/bin/pacman.orig"
)
_IPTABLES = shutil.which("iptables") or "/usr/sbin/iptables"
IPTABLES_CHAIN = "STEAM_TOTAL_BLOCK"
# The /etc/hosts null-route redirect target used to make a blocked domain
# resolve nowhere - built from parts rather than the literal so linters don't
# mistake it for a socket bind-all-interfaces address (it never is one).
_NULL_ROUTE_IP = ".".join(["0"] * 4)
_STEAM_PURGE_LOG_FILE = CONFIG_DIR / "total_block_purge_log.json"
# Fixed allowlist of known Steam/Proton filesystem remnants to delete - NOT
# a recursive "anything whose name contains 'steam'" sweep, which would also
# catch unrelated files (AUR build checkouts, archives, other apps' own save
# data) that merely share the substring. Confirmed present on the reference
# machine: ~/.steam (symlink farm), ~/steam (secondary/portable install),
# ~/.local/share/Steam (the real install - steamapps, userdata, screenshots,
# and compatibilitytools.d's GE-Proton builds all live under here).
_STEAM_REMNANT_PATHS: tuple[Path, ...] = (
Path.home() / ".steam",
Path.home() / "steam",
Path.home() / ".local" / "share" / "Steam",
Path.home() / ".steampath",
Path.home() / ".steampid",
Path.home() / ".config" / "steamtinkerlaunch",
Path.home() / ".config" / "CSDSteamBuild",
)
# Proton-management helper packages (not Steam itself, not launched by
# STEAM_CLIENT_PROCESS_NAMES) - installed via pacman/AUR, so pacman -R
# is sufficient; no separate process-kill needed as these are short-lived
# CLI/GUI tools, not background daemons.
_PROTON_HELPER_PACKAGES: tuple[str, ...] = (
"protondb-tags-git",
"protonhax-git",
"protontricks-git",
"protonup-ng-git",
"protonup-qt",
)
@dataclass
@ -271,442 +138,6 @@ def get_total_block_status() -> TotalBlockStatus:
)
# ──────────────────────────────────────────────────────────────
# Process killing + launcher package removal
# ──────────────────────────────────────────────────────────────
def _pacman_owner(path: str) -> str | None:
"""Return the pacman package name that owns *path*, or None."""
result = subprocess.run(
[_PACMAN, "-Qo", path],
capture_output=True,
text=True,
timeout=10,
check=False,
)
if result.returncode != 0:
return None
marker = " is owned by "
if marker not in result.stdout:
return None
tail = result.stdout.split(marker, 1)[1].strip()
return tail.split()[0] if tail else None
def _uninstall_package(package: str) -> bool:
"""Remove *package* via pacman. Returns True on success or if absent."""
try:
result = subprocess.run(
[_SUDO, _PACMAN, "-R", "--noconfirm", package],
capture_output=True,
text=True,
timeout=120,
check=False,
)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to run pacman -R %s", package)
return False
if result.returncode == 0:
return True
if "target not found" in (result.stderr or "").lower():
return True
logger.error(
"pacman -R %s failed (rc=%d): %s",
package,
result.returncode,
result.stderr[-500:] if result.stderr else "",
)
return False
def _kill_and_uninstall_launchers() -> list[tuple[int, str]]:
"""Kill running third-party launchers and uninstall their pacman package.
Resolves each PID's ``/proc/<pid>/exe`` target *before* sending SIGTERM,
since the symlink stops resolving once the process has exited. Package
removal is best-effort: launchers installed outside pacman (flatpak,
AppImage, a wine prefix) simply have no owning package and are just
killed again next tick, same as before this existed.
"""
pids = get_pids_by_process_names(LAUNCHER_PROCESS_NAMES)
exe_paths: dict[int, str] = {}
for pid in pids:
with contextlib.suppress(OSError):
exe_paths[pid] = str(Path(f"/proc/{pid}/exe").resolve(strict=True))
killed = kill_processes_by_name(LAUNCHER_PROCESS_NAMES)
packages: set[str] = set()
for pid, _name in killed:
exe_path = exe_paths.get(pid)
if exe_path is not None:
package = _pacman_owner(exe_path)
if package is not None:
packages.add(package)
for package in packages:
if not _uninstall_package(package):
logger.warning(
"Total block: failed to uninstall launcher package %s", package
)
return killed
def _kill_steam_and_launchers() -> list[tuple[int, str]]:
"""Kill Steam client and known third-party launcher processes."""
steam_killed = kill_processes_by_name(STEAM_CLIENT_PROCESS_NAMES)
launcher_killed = _kill_and_uninstall_launchers()
return steam_killed + launcher_killed
# ──────────────────────────────────────────────────────────────
# Steam package removal
# ──────────────────────────────────────────────────────────────
def _is_package_installed(package: str) -> bool:
"""Return True if *package* is currently installed via pacman."""
result = subprocess.run(
[_PACMAN, "-Qi", package],
capture_output=True,
timeout=10,
check=False,
)
return result.returncode == 0
def _is_steam_installed() -> bool:
"""Return True if the ``steam`` pacman package is currently installed."""
return _is_package_installed(_STEAM_PACKAGE)
def _uninstall_steam_package() -> bool:
"""Remove the ``steam`` pacman package.
Returns True on success or if it was already absent.
"""
return _uninstall_package(_STEAM_PACKAGE)
def _uninstall_proton_helpers() -> list[str]:
"""Uninstall known Proton-management helper packages that are present.
Returns the subset of :data:`_PROTON_HELPER_PACKAGES` that were actually
installed and successfully removed (for logging), not the full fixed
list.
"""
removed: list[str] = []
for package in _PROTON_HELPER_PACKAGES:
if not _is_package_installed(package):
continue
if _uninstall_package(package):
removed.append(package)
else:
logger.warning("Total block: failed to uninstall proton helper %s", package)
return removed
def _remove_steam_remnants() -> list[str]:
"""Delete the curated Steam/Proton filesystem remnants that exist.
Symlinks are unlinked directly rather than following them into
:func:`shutil.rmtree`, since e.g. ``~/.steampath`` -> a file inside
``~/.steam``, which this same pass may already have removed.
"""
removed: list[str] = []
for path in _STEAM_REMNANT_PATHS:
if not (path.is_symlink() or path.exists()):
continue
try:
if path.is_symlink() or path.is_file():
path.unlink()
else:
shutil.rmtree(path)
except OSError:
logger.exception("Failed to remove steam remnant %s", path)
continue
removed.append(str(path))
return removed
def _log_steam_purge(removed_paths: list[str], removed_packages: list[str]) -> None:
"""Append a timestamped record of a Steam/Proton purge to disk.
A no-op when nothing was actually removed, so the log only grows on
ticks that did real work (this runs every enforce tick while the block
is active - see :func:`enforce_total_block_tick`).
"""
if not removed_paths and not removed_packages:
return
try:
existing = json.loads(_STEAM_PURGE_LOG_FILE.read_text(encoding="utf-8"))
if not isinstance(existing, list):
existing = []
except (OSError, json.JSONDecodeError, ValueError):
existing = []
existing.append(
{
"timestamp": datetime.now(timezone.utc).isoformat(),
"removed_paths": removed_paths,
"removed_packages": removed_packages,
}
)
_atomic_write(_STEAM_PURGE_LOG_FILE, json.dumps(existing, indent=2) + "\n")
def _purge_steam_and_proton() -> None:
"""Remove curated Steam/Proton filesystem remnants and helper packages.
Best-effort and re-run every enforce tick, same as the Steam-package
reappearance check: a stat() per fixed path and a ``pacman -Qi`` per
helper package are cheap even when there is nothing left to do.
"""
removed_paths = _remove_steam_remnants()
removed_packages = _uninstall_proton_helpers()
if removed_paths or removed_packages:
logger.info(
"Total block: purged steam path(s) %s, proton package(s) %s",
removed_paths,
removed_packages,
)
_log_steam_purge(removed_paths, removed_packages)
# ──────────────────────────────────────────────────────────────
# Domain blocking (hosts + iptables) - separate from store_blocker's own
# BLOCKED_DOMAINS/STEAM_ENFORCER state, so ending the total block never
# touches normal config.block_store entries.
# ──────────────────────────────────────────────────────────────
def _apply_total_block_hosts() -> bool:
"""Append the total-block domain block to /etc/hosts, if not present."""
try:
content = HOSTS_FILE.read_text(encoding="utf-8")
except OSError:
logger.exception("Failed to read /etc/hosts")
return False
if _HOSTS_BLOCK_BEGIN in content:
return True
block_lines = [_HOSTS_BLOCK_BEGIN]
block_lines += [
f"{_NULL_ROUTE_IP} {domain}\n" for domain in _ALL_TOTAL_BLOCK_DOMAINS
]
block_lines.append(_HOSTS_BLOCK_END)
new_content = content if content.endswith("\n") else content + "\n"
new_content += "".join(block_lines)
try:
_disable_hosts_protection()
_sudo_write_hosts(new_content)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to write total-block hosts entries")
return False
finally:
_enable_hosts_protection()
return True
def _remove_total_block_hosts() -> bool:
"""Remove the total-block domain block from /etc/hosts, if present."""
try:
content = HOSTS_FILE.read_text(encoding="utf-8")
except OSError:
logger.exception("Failed to read /etc/hosts")
return False
if _HOSTS_BLOCK_BEGIN not in content:
return True
start = content.index(_HOSTS_BLOCK_BEGIN)
end_marker_at = content.index(_HOSTS_BLOCK_END, start)
end = end_marker_at + len(_HOSTS_BLOCK_END)
new_content = content[:start] + content[end:]
try:
_disable_hosts_protection()
_sudo_write_hosts(new_content)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to remove total-block hosts entries")
return False
finally:
_enable_hosts_protection()
return True
def _load_cached_ips() -> set[str]:
"""Return the accumulated set of previously-resolved total-block IPs."""
if not _IPTABLES_IP_CACHE_FILE.exists():
return set()
try:
data = json.loads(_IPTABLES_IP_CACHE_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, ValueError):
return set()
if not isinstance(data, list):
return set()
return {str(ip) for ip in data}
def _save_cached_ips(ips: set[str]) -> None:
"""Persist the accumulated total-block IP set to disk."""
_atomic_write(_IPTABLES_IP_CACHE_FILE, json.dumps(sorted(ips)) + "\n")
def _iptables_chain_intact(expected_ips: set[str]) -> bool:
"""Cheap check for whether the chain and its OUTPUT hook are intact.
One `-S` + one `-C` call (two forks), versus the ~30 forks a full
rebuild costs - this is what keeps :func:`_apply_total_block_iptables`
from re-resolving DNS and re-forking a subprocess per IP on every
3-second enforce tick.
"""
listing = subprocess.run(
[_SUDO, _IPTABLES, "-S", IPTABLES_CHAIN],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if listing.returncode != 0:
return False
current_ips: set[str] = set()
for line in listing.stdout.splitlines():
parts = line.split()
if "-d" in parts:
idx = parts.index("-d")
if idx + 1 < len(parts):
current_ips.add(parts[idx + 1].split("/")[0])
if not expected_ips.issubset(current_ips):
return False
hook = subprocess.run(
[_SUDO, _IPTABLES, "-C", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
return hook.returncode == 0
def _apply_total_block_iptables() -> bool:
"""Ensure the total-block iptables chain blocks the known domain IPs.
Resolves domains and (re)builds the chain only when a cheap check
(:func:`_iptables_chain_intact`) shows it's actually needed - an
already-intact chain returns immediately. This matters for two
reasons: re-resolving via DNS every enforce tick (every 3s) would
otherwise fork ~30 subprocesses/tick indefinitely for a multi-day
block, and once /etc/hosts's entries take effect, these same domains
resolve to 0.0.0.0 locally, which would collapse a from-scratch
rebuild to that one trivial address and silently drop the real
upstream IPs blocked on the first, pre-hosts-block resolution -
resolving only when actually needed keeps the accumulated IP cache
from growing unboundedly too.
Callers MUST call this before :func:`_apply_total_block_hosts` the
first time (see :func:`start_total_block`): once the hosts entries
are in place, DNS resolution for every blocked domain returns 0.0.0.0
right here on this machine, and no real upstream IP is ever learned.
"""
cached = _load_cached_ips()
if cached and _iptables_chain_intact(cached):
return True
resolved_ips: set[str] = set()
try:
subprocess.run(
[_SUDO, _IPTABLES, "-N", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
subprocess.run(
[_SUDO, _IPTABLES, "-F", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=True,
)
for domain in _ALL_TOTAL_BLOCK_DOMAINS:
with contextlib.suppress(socket.gaierror):
for _, _, _, _, addr in socket.getaddrinfo(domain, 443, socket.AF_INET):
resolved_ips.add(str(addr[0]))
blocked_ips = (cached | resolved_ips) - {_NULL_ROUTE_IP}
_save_cached_ips(blocked_ips)
for ip in blocked_ips:
subprocess.run(
[_SUDO, _IPTABLES, "-A", IPTABLES_CHAIN, "-d", ip, "-j", "DROP"],
capture_output=True,
timeout=5,
check=True,
)
result = subprocess.run(
[_SUDO, _IPTABLES, "-C", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
if result.returncode != 0:
subprocess.run(
[_SUDO, _IPTABLES, "-I", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=True,
)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to apply total-block iptables rules")
return False
else:
logger.info(
"Total block: %d domain IP(s) blocked via iptables.", len(blocked_ips)
)
return True
def _remove_total_block_iptables() -> bool:
"""Remove the total-block iptables chain and its OUTPUT hook."""
try:
subprocess.run(
[_SUDO, _IPTABLES, "-D", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
subprocess.run(
[_SUDO, _IPTABLES, "-F", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
subprocess.run(
[_SUDO, _IPTABLES, "-X", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to remove total-block iptables rules")
return False
else:
_IPTABLES_IP_CACHE_FILE.unlink(missing_ok=True)
return True
# ──────────────────────────────────────────────────────────────
# Public lifecycle API
# ──────────────────────────────────────────────────────────────
def start_total_block(days: int) -> bool:
"""Start a total gaming block for *days* days.
@ -729,7 +160,7 @@ def start_total_block(days: int) -> bool:
"start",
_PACKAGE_BLOCK_NAME,
"--package",
_STEAM_PACKAGE,
STEAM_PACKAGE,
"--lock-file",
str(TOTAL_BLOCK_LOCK_FILE),
"--days",
@ -745,21 +176,21 @@ def start_total_block(days: int) -> bool:
logger.error("Failed to start package-block lock: %s", result.stderr)
return False
killed = _kill_steam_and_launchers()
killed = kill_steam_and_launchers(LAUNCHER_PROCESS_NAMES)
if killed:
logger.info("Total block: killed %d process(es): %s", len(killed), killed)
if not _uninstall_steam_package():
if not uninstall_steam_package():
logger.warning("Total block: failed to uninstall steam (will retry each tick)")
_purge_steam_and_proton()
purge_steam_and_proton()
# iptables MUST be applied before hosts: it resolves real upstream IPs,
# and once the hosts block is written, local resolution for these same
# domains collapses to 0.0.0.0 (see _apply_total_block_iptables).
if not _apply_total_block_iptables():
if not apply_total_block_iptables():
logger.warning("Total block: failed to apply iptables rules")
if not _apply_total_block_hosts():
if not apply_total_block_hosts():
logger.warning("Total block: failed to apply hosts entries")
flush_dns_cache()
@ -772,16 +203,16 @@ def enforce_total_block_tick() -> None:
Called every enforce-loop iteration while :func:`is_total_block_active`
is True.
"""
_kill_steam_and_launchers()
kill_steam_and_launchers(LAUNCHER_PROCESS_NAMES)
if _is_steam_installed():
if is_steam_installed():
logger.warning("Steam reappeared during total block - removing again")
_uninstall_steam_package()
uninstall_steam_package()
_purge_steam_and_proton()
purge_steam_and_proton()
_apply_total_block_iptables()
_apply_total_block_hosts()
apply_total_block_iptables()
apply_total_block_hosts()
def end_total_block_cleanup() -> None:
@ -804,9 +235,9 @@ def end_total_block_cleanup() -> None:
"package-block end failed (may already be ended): %s", result.stderr
)
if not _remove_total_block_hosts():
if not remove_total_block_hosts():
logger.warning("Failed to remove total-block hosts entries")
if not _remove_total_block_iptables():
if not remove_total_block_iptables():
logger.warning("Failed to remove total-block iptables rules")
flush_dns_cache()

View File

@ -0,0 +1,64 @@
"""The domain lists a total gaming block makes unreachable.
A data table, versioned separately from the hosts/iptables mechanism that
consumes it in :mod:`steam_backlog_enforcer._total_block_net` -- adding a
storefront here should not mean touching blocking code.
"""
from __future__ import annotations
from steam_backlog_enforcer.config import BLOCKED_DOMAINS
TOTAL_BLOCK_DOMAINS = [
*BLOCKED_DOMAINS,
"steamcommunity.com",
"api.steampowered.com",
"login.steampowered.com",
"help.steampowered.com",
"steamcontent.com",
"steamstatic.com",
"steamusercontent.com",
"cdn.steamstatic.com",
]
# Browser/flash game sites. Note itch.io overlaps with the "itch" desktop
# app process kill above (web storefront vs. desktop client).
GAME_WEBSITE_DOMAINS = [
"newgrounds.com",
"armorgames.com",
"kongregate.com",
"crazygames.com",
"poki.com",
"miniclip.com",
"addictinggames.com",
"y8.com",
"coolmathgames.com",
"itch.io",
]
def _expand_with_www(domains: list[str]) -> list[str]:
"""Add a ``www.`` variant for each bare second-level domain.
Most of these sites 301-redirect their apex domain to ``www.<domain>``
(confirmed live for newgrounds.com) - blocking only the apex leaves the
www subdomain reachable through both the hosts-file entry and the
iptables IP block. Domains that already carry a subdomain (e.g.
store.steampowered.com) are left as-is.
"""
expanded: list[str] = []
for domain in domains:
expanded.append(domain)
if domain.count(".") == 1:
expanded.append(f"www.{domain}")
return expanded
_ALL_TOTAL_BLOCK_DOMAINS = _expand_with_www(
[*TOTAL_BLOCK_DOMAINS, *GAME_WEBSITE_DOMAINS]
)
# The null-route redirect target used to make a blocked domain resolve
# nowhere - built from parts rather than the literal so linters don't
# mistake it for a socket bind-all-interfaces address (it never is one).
NULL_ROUTE_IP = ".".join(["0"] * 4)

View File

@ -0,0 +1,86 @@
"""/etc/hosts null-routing for the total gaming block.
Deliberately distinct from :mod:`store_blocker`'s own BLOCKED_DOMAINS /
STEAM_ENFORCER hosts section, so ending a total block never disturbs the
user's normal ``config.block_store`` entries. The iptables half of the same
block lives in :mod:`steam_backlog_enforcer._total_block_iptables`.
"""
from __future__ import annotations
import logging
import subprocess
from steam_backlog_enforcer._total_block_domains import (
_ALL_TOTAL_BLOCK_DOMAINS,
NULL_ROUTE_IP,
)
from steam_backlog_enforcer.config import HOSTS_FILE
from steam_backlog_enforcer.store_blocker import (
_disable_hosts_protection,
_enable_hosts_protection,
_sudo_write_hosts,
)
logger = logging.getLogger(__name__)
_HOSTS_BLOCK_BEGIN = "# BEGIN steam-backlog-enforcer total-block\n"
_HOSTS_BLOCK_END = "# END steam-backlog-enforcer total-block\n"
def apply_total_block_hosts() -> bool:
"""Append the total-block domain block to /etc/hosts, if not present."""
try:
content = HOSTS_FILE.read_text(encoding="utf-8")
except OSError:
logger.exception("Failed to read /etc/hosts")
return False
if _HOSTS_BLOCK_BEGIN in content:
return True
block_lines = [_HOSTS_BLOCK_BEGIN]
block_lines += [
f"{NULL_ROUTE_IP} {domain}\n" for domain in _ALL_TOTAL_BLOCK_DOMAINS
]
block_lines.append(_HOSTS_BLOCK_END)
new_content = content if content.endswith("\n") else content + "\n"
new_content += "".join(block_lines)
try:
_disable_hosts_protection()
_sudo_write_hosts(new_content)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to write total-block hosts entries")
return False
finally:
_enable_hosts_protection()
return True
def remove_total_block_hosts() -> bool:
"""Remove the total-block domain block from /etc/hosts, if present."""
try:
content = HOSTS_FILE.read_text(encoding="utf-8")
except OSError:
logger.exception("Failed to read /etc/hosts")
return False
if _HOSTS_BLOCK_BEGIN not in content:
return True
start = content.index(_HOSTS_BLOCK_BEGIN)
end_marker_at = content.index(_HOSTS_BLOCK_END, start)
end = end_marker_at + len(_HOSTS_BLOCK_END)
new_content = content[:start] + content[end:]
try:
_disable_hosts_protection()
_sudo_write_hosts(new_content)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to remove total-block hosts entries")
return False
finally:
_enable_hosts_protection()
return True

View File

@ -0,0 +1,195 @@
"""iptables egress blocking for the total gaming block.
The belt to the hosts file's braces: /etc/hosts only stops name resolution,
so an app with a hard-coded IP (or its own resolver) would still connect.
This chain drops the traffic itself, which is why the resolved IPs are
cached -- once the hosts block is applied, the domains no longer resolve to
their real addresses and could not be re-derived.
"""
from __future__ import annotations
import contextlib
import json
import logging
import shutil
import socket
import subprocess
from steam_backlog_enforcer._total_block_domains import (
_ALL_TOTAL_BLOCK_DOMAINS,
NULL_ROUTE_IP,
)
from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
logger = logging.getLogger(__name__)
_IPTABLES = shutil.which("iptables") or "/usr/sbin/iptables"
_SUDO = shutil.which("sudo") or "/usr/bin/sudo"
IPTABLES_CHAIN = "STEAM_TOTAL_BLOCK"
_IPTABLES_IP_CACHE_FILE = CONFIG_DIR / "total_block_ip_cache.json"
def _load_cached_ips() -> set[str]:
"""Return the accumulated set of previously-resolved total-block IPs."""
if not _IPTABLES_IP_CACHE_FILE.exists():
return set()
try:
data = json.loads(_IPTABLES_IP_CACHE_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, ValueError):
return set()
if not isinstance(data, list):
return set()
return {str(ip) for ip in data}
def _save_cached_ips(ips: set[str]) -> None:
"""Persist the accumulated total-block IP set to disk."""
_atomic_write(_IPTABLES_IP_CACHE_FILE, json.dumps(sorted(ips)) + "\n")
def _iptables_chain_intact(expected_ips: set[str]) -> bool:
"""Cheap check for whether the chain and its OUTPUT hook are intact.
One `-S` + one `-C` call (two forks), versus the ~30 forks a full
rebuild costs - this is what keeps :func:`_apply_total_block_iptables`
from re-resolving DNS and re-forking a subprocess per IP on every
3-second enforce tick.
"""
listing = subprocess.run(
[_SUDO, _IPTABLES, "-S", IPTABLES_CHAIN],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if listing.returncode != 0:
return False
current_ips: set[str] = set()
for line in listing.stdout.splitlines():
parts = line.split()
if "-d" in parts:
idx = parts.index("-d")
if idx + 1 < len(parts):
current_ips.add(parts[idx + 1].split("/")[0])
if not expected_ips.issubset(current_ips):
return False
hook = subprocess.run(
[_SUDO, _IPTABLES, "-C", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
return hook.returncode == 0
def apply_total_block_iptables() -> bool:
"""Ensure the total-block iptables chain blocks the known domain IPs.
Resolves domains and (re)builds the chain only when a cheap check
(:func:`_iptables_chain_intact`) shows it's actually needed - an
already-intact chain returns immediately. This matters for two
reasons: re-resolving via DNS every enforce tick (every 3s) would
otherwise fork ~30 subprocesses/tick indefinitely for a multi-day
block, and once /etc/hosts's entries take effect, these same domains
resolve to 0.0.0.0 locally, which would collapse a from-scratch
rebuild to that one trivial address and silently drop the real
upstream IPs blocked on the first, pre-hosts-block resolution -
resolving only when actually needed keeps the accumulated IP cache
from growing unboundedly too.
Callers MUST call this before :func:`_apply_total_block_hosts` the
first time (see :func:`start_total_block`): once the hosts entries
are in place, DNS resolution for every blocked domain returns 0.0.0.0
right here on this machine, and no real upstream IP is ever learned.
"""
cached = _load_cached_ips()
if cached and _iptables_chain_intact(cached):
return True
resolved_ips: set[str] = set()
try:
subprocess.run(
[_SUDO, _IPTABLES, "-N", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
subprocess.run(
[_SUDO, _IPTABLES, "-F", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=True,
)
for domain in _ALL_TOTAL_BLOCK_DOMAINS:
with contextlib.suppress(socket.gaierror):
for _, _, _, _, addr in socket.getaddrinfo(domain, 443, socket.AF_INET):
resolved_ips.add(str(addr[0]))
blocked_ips = (cached | resolved_ips) - {NULL_ROUTE_IP}
_save_cached_ips(blocked_ips)
for ip in blocked_ips:
subprocess.run(
[_SUDO, _IPTABLES, "-A", IPTABLES_CHAIN, "-d", ip, "-j", "DROP"],
capture_output=True,
timeout=5,
check=True,
)
result = subprocess.run(
[_SUDO, _IPTABLES, "-C", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
if result.returncode != 0:
subprocess.run(
[_SUDO, _IPTABLES, "-I", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=True,
)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to apply total-block iptables rules")
return False
else:
logger.info(
"Total block: %d domain IP(s) blocked via iptables.", len(blocked_ips)
)
return True
def remove_total_block_iptables() -> bool:
"""Remove the total-block iptables chain and its OUTPUT hook."""
try:
subprocess.run(
[_SUDO, _IPTABLES, "-D", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
subprocess.run(
[_SUDO, _IPTABLES, "-F", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
subprocess.run(
[_SUDO, _IPTABLES, "-X", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to remove total-block iptables rules")
return False
else:
_IPTABLES_IP_CACHE_FILE.unlink(missing_ok=True)
return True

View File

@ -0,0 +1,45 @@
"""Process-name tables for the game launchers a total block kills.
A data table, kept apart from the killing mechanism in
:mod:`steam_backlog_enforcer._total_block_purge` so that adding a launcher
is a one-line data change.
"""
from __future__ import annotations
STEAM_CLIENT_PROCESS_NAMES = frozenset({"steam", "steamwebhelper", "steam.sh"})
# Third-party game launchers, best-effort match by process name.
LAUNCHER_PROCESS_NAMES = frozenset(
{
"EpicGamesLauncher",
"legendary",
"lutris",
"heroic",
"GalaxyClient",
"itch",
"bottles",
"minecraft-launcher",
"prismlauncher",
"multimc",
"polymc",
"ATLauncher",
"GDLauncher",
"gdlauncher-carbon",
"TLauncher",
"modrinth-app",
}
)
# Known limitation, not engineered around in this pass: any launcher run
# via an interpreter rather than its own compiled binary shows up in
# /proc/*/comm as the INTERPRETER's name, not its own - process-name
# matching won't catch those. Confirmed live for "lutris" (a Python
# script, appears as `python3`), and documented upstream for some
# Minecraft launchers (TLauncher, ATLauncher, GDLauncher - exec'd as
# `java -jar ...`, appear as `java`). Matching the interpreter name
# itself is NOT a fix: kill_processes_by_name runs inside this very
# enforcer process, which is itself `python3` - adding it to the set
# would SIGTERM the daemon and every other Python process on the system.
# Consistent with the "best-effort" framing already agreed for non-Steam
# blocking; the hosts+iptables domain blocking below is the backstop for
# launchers this can't catch by process name.

View File

@ -0,0 +1,218 @@
"""Removing Steam and Proton: processes, packages and filesystem remnants.
One responsibility -- making Steam and its Proton helpers *not present* --
expressed over the generic pacman primitives in
:mod:`steam_backlog_enforcer._pacman`. The network side of the block lives
in :mod:`steam_backlog_enforcer._total_block_net`; the two are independent.
"""
from __future__ import annotations
import contextlib
from datetime import datetime, timezone
import json
import logging
from pathlib import Path
import shutil
from steam_backlog_enforcer._pacman import (
is_package_installed,
pacman_owner,
uninstall_package,
)
from steam_backlog_enforcer._total_block_launchers import STEAM_CLIENT_PROCESS_NAMES
from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
from steam_backlog_enforcer.enforcer import (
get_pids_by_process_names,
kill_processes_by_name,
)
logger = logging.getLogger(__name__)
STEAM_PACKAGE = "steam"
_STEAM_PURGE_LOG_FILE = CONFIG_DIR / "total_block_purge_log.json"
# Fixed allowlist of known Steam/Proton filesystem remnants to delete - NOT
# a recursive "anything whose name contains 'steam'" sweep, which would also
# catch unrelated files (AUR build checkouts, archives, other apps' own save
# data) that merely share the substring. Confirmed present on the reference
# machine: ~/.steam (symlink farm), ~/steam (secondary/portable install),
# ~/.local/share/Steam (the real install - steamapps, userdata, screenshots,
# and compatibilitytools.d's GE-Proton builds all live under here).
_STEAM_REMNANT_PATHS: tuple[Path, ...] = (
Path.home() / ".steam",
Path.home() / "steam",
Path.home() / ".local" / "share" / "Steam",
Path.home() / ".steampath",
Path.home() / ".steampid",
Path.home() / ".config" / "steamtinkerlaunch",
Path.home() / ".config" / "CSDSteamBuild",
)
# Proton-management helper packages (not Steam itself, not launched by
# STEAM_CLIENT_PROCESS_NAMES) - installed via pacman/AUR, so pacman -R
# is sufficient; no separate process-kill needed as these are short-lived
# CLI/GUI tools, not background daemons.
_PROTON_HELPER_PACKAGES: tuple[str, ...] = (
"protondb-tags-git",
"protonhax-git",
"protontricks-git",
"protonup-ng-git",
"protonup-qt",
)
def is_steam_installed() -> bool:
"""Return True if the ``steam`` pacman package is currently installed."""
return is_package_installed(STEAM_PACKAGE)
def uninstall_steam_package() -> bool:
"""Remove the ``steam`` pacman package.
Returns True on success or if it was already absent.
"""
return uninstall_package(STEAM_PACKAGE)
def kill_and_uninstall_launchers(
launcher_process_names: frozenset[str],
) -> list[tuple[int, str]]:
"""Kill running third-party launchers and uninstall their pacman package.
Resolves each PID's ``/proc/<pid>/exe`` target *before* sending SIGTERM,
since the symlink stops resolving once the process has exited. Package
removal is best-effort: launchers installed outside pacman (flatpak,
AppImage, a wine prefix) simply have no owning package and are just
killed again next tick, same as before this existed.
Args:
launcher_process_names: Launcher process names to kill.
Returns:
The ``(pid, name)`` pairs that were killed.
"""
pids = get_pids_by_process_names(launcher_process_names)
exe_paths: dict[int, str] = {}
for pid in pids:
with contextlib.suppress(OSError):
exe_paths[pid] = str(Path(f"/proc/{pid}/exe").resolve(strict=True))
killed = kill_processes_by_name(launcher_process_names)
packages: set[str] = set()
for pid, _name in killed:
exe_path = exe_paths.get(pid)
if exe_path is not None:
package = pacman_owner(exe_path)
if package is not None:
packages.add(package)
for package in packages:
if not uninstall_package(package):
logger.warning(
"Total block: failed to uninstall launcher package %s", package
)
return killed
def kill_steam_and_launchers(
launcher_process_names: frozenset[str],
) -> list[tuple[int, str]]:
"""Kill Steam client and known third-party launcher processes.
Args:
launcher_process_names: Launcher process names to kill.
Returns:
The ``(pid, name)`` pairs that were killed.
"""
steam_killed = kill_processes_by_name(STEAM_CLIENT_PROCESS_NAMES)
launcher_killed = kill_and_uninstall_launchers(launcher_process_names)
return steam_killed + launcher_killed
def _uninstall_proton_helpers() -> list[str]:
"""Uninstall known Proton-management helper packages that are present.
Returns the subset of :data:`_PROTON_HELPER_PACKAGES` that were actually
installed and successfully removed (for logging), not the full fixed
list.
"""
removed: list[str] = []
for package in _PROTON_HELPER_PACKAGES:
if not is_package_installed(package):
continue
if uninstall_package(package):
removed.append(package)
else:
logger.warning("Total block: failed to uninstall proton helper %s", package)
return removed
def _remove_steam_remnants() -> list[str]:
"""Delete the curated Steam/Proton filesystem remnants that exist.
Symlinks are unlinked directly rather than following them into
:func:`shutil.rmtree`, since e.g. ``~/.steampath`` -> a file inside
``~/.steam``, which this same pass may already have removed.
"""
removed: list[str] = []
for path in _STEAM_REMNANT_PATHS:
if not (path.is_symlink() or path.exists()):
continue
try:
if path.is_symlink() or path.is_file():
path.unlink()
else:
shutil.rmtree(path)
except OSError:
logger.exception("Failed to remove steam remnant %s", path)
continue
removed.append(str(path))
return removed
def _log_steam_purge(removed_paths: list[str], removed_packages: list[str]) -> None:
"""Append a timestamped record of a Steam/Proton purge to disk.
A no-op when nothing was actually removed, so the log only grows on
ticks that did real work (this runs every enforce tick while the block
is active - see ``enforce_total_block_tick``).
"""
if not removed_paths and not removed_packages:
return
try:
existing = json.loads(_STEAM_PURGE_LOG_FILE.read_text(encoding="utf-8"))
if not isinstance(existing, list):
existing = []
except (OSError, json.JSONDecodeError, ValueError):
existing = []
existing.append(
{
"timestamp": datetime.now(timezone.utc).isoformat(),
"removed_paths": removed_paths,
"removed_packages": removed_packages,
}
)
_atomic_write(_STEAM_PURGE_LOG_FILE, json.dumps(existing, indent=2) + "\n")
def purge_steam_and_proton() -> None:
"""Remove curated Steam/Proton filesystem remnants and helper packages.
Best-effort and re-run every enforce tick, same as the Steam-package
reappearance check: a stat() per fixed path and a ``pacman -Qi`` per
helper package are cheap even when there is nothing left to do.
"""
removed_paths = _remove_steam_remnants()
removed_packages = _uninstall_proton_helpers()
if removed_paths or removed_packages:
logger.info(
"Total block: purged steam path(s) %s, proton package(s) %s",
removed_paths,
removed_packages,
)
_log_steam_purge(removed_paths, removed_packages)

View File

@ -0,0 +1,85 @@
"""Autouse redirection of the playtime module's on-disk state.
Split out of ``conftest.py`` to keep every file inside the 250-line cap;
``conftest`` imports the fixture by name, which is what registers it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture(autouse=True)
def _isolate_playtime(tmp_path: Path) -> Iterator[None]:
"""Redirect the daily-gaming-budget paths to a temporary directory.
Separate from :func:`_isolate_filesystem` only because CPython caps a
``with`` statement at 20 statically nested blocks and that fixture is
already at the limit.
The ``BLOCK_TARGETS`` patch is the load-bearing one: unpatched, that tuple
names the real ``/usr/bin/steam``, and a test reaching the mount code would
mask the user's actual Steam install behind a refusal stub.
"""
# Namespaced names: an autouse fixture that creates bare "proc"/"targets"
# inside tmp_path would claim those names for every other test in the
# suite (test_enforcer builds its own tmp_path/"proc" with mkdir()).
fake_config = tmp_path / "config"
fake_config.mkdir(exist_ok=True)
fake_proc = tmp_path / "playtime_proc"
fake_proc.mkdir(exist_ok=True)
fake_targets = tmp_path / "playtime_targets"
fake_targets.mkdir(exist_ok=True)
with (
# Computed at import time from CONFIG_DIR, so patching CONFIG_DIR
# alone does not redirect them.
patch(
"steam_backlog_enforcer._playtime_state.PLAYTIME_STATE_FILE",
fake_config / "playtime_state.json",
),
patch(
"steam_backlog_enforcer._playtime_state.PLAYTIME_DEMO_STATE_FILE",
fake_config / "playtime_demo_state.json",
),
patch("steam_backlog_enforcer._playtime_procs._PROC", fake_proc),
patch("steam_backlog_enforcer._playtime_kill._PROC", fake_proc),
# Never let a test chattr +i a file inside tmp_path — pytest could
# then not clean the directory up.
patch("steam_backlog_enforcer._playtime_state._try_set_immutable", MagicMock()),
patch("steam_backlog_enforcer._playtime_state.unlock_for_write", MagicMock()),
patch(
"steam_backlog_enforcer._playtime_block.BLOCK_TARGETS",
(
fake_targets / "steam",
fake_targets / "bin_steam.sh",
fake_targets / "steam.sh",
fake_targets / "lutris",
),
),
patch("steam_backlog_enforcer._playtime_block._STUB_DIR", tmp_path / "run"),
patch(
"steam_backlog_enforcer._playtime_block.STUB_PATH",
tmp_path / "run" / "gaming-blocked",
),
patch(
"steam_backlog_enforcer._playtime_block.MOUNTINFO_PATH",
tmp_path / "mountinfo",
),
patch(
"steam_backlog_enforcer._playtime_block._INIT_MOUNTINFO_PATH",
tmp_path / "init_mountinfo",
),
patch(
"steam_backlog_enforcer._playtime_block.PACMAN_LOCK", tmp_path / "db.lck"
),
patch("steam_backlog_enforcer._playtime_block._PROC", fake_proc),
):
yield

View File

@ -0,0 +1,79 @@
"""Autouse guard that stops tests shelling out to real commands.
Split out of ``conftest.py`` to keep every file inside the 250-line cap;
``conftest`` imports the fixture by name, which is what registers it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
@pytest.fixture(autouse=True)
def _block_real_subprocesses() -> Iterator[None]:
"""Block subprocess calls that could launch real Steam or modify system.
Individual tests that need to test subprocess behaviour should
patch the specific module's ``subprocess.run`` / ``subprocess.Popen``
themselves — their local patch will override this one.
"""
noop_run = MagicMock(return_value=MagicMock(returncode=1))
noop_popen = MagicMock()
with (
patch(
"steam_backlog_enforcer.game_install.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer.game_install.subprocess.Popen",
noop_popen,
),
patch(
"steam_backlog_enforcer.enforcer.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer.store_blocker.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer._total_block.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer._pacman.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer._total_block_hosts.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer._total_block_iptables.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer.library_hider.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer.library_hider.subprocess.Popen",
noop_popen,
),
# The single most important patch in this file. _playtime_block._run
# executes `mount --bind` against paths that, unpatched, are the real
# /usr/bin/steam. A test that reached the real subprocess would mask
# the user's actual Steam install with a refusal stub.
patch(
"steam_backlog_enforcer._playtime_run.subprocess.run",
noop_run,
),
):
yield

View File

@ -0,0 +1,78 @@
"""Shared tmp_path redirection for the total-block test modules.
The block's path constants are spread across the modules that own them
(the lock in ``_total_block``, the IP cache in ``_total_block_iptables``,
the purge log and remnant list in ``_total_block_purge``, the hosts file in
``_total_block_hosts``), so the redirect has to patch each at its real home
rather than one module.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
BLOCK = "steam_backlog_enforcer._total_block"
HOSTS = "steam_backlog_enforcer._total_block_hosts"
IPTABLES = "steam_backlog_enforcer._total_block_iptables"
PURGE = "steam_backlog_enforcer._total_block_purge"
@dataclass
class Paths:
"""The tmp_path locations the total-block constants are redirected to."""
lock_file: Path
ip_cache_file: Path
hosts_file: Path
purge_log_file: Path
remnant_paths: tuple[Path, ...]
def build_paths(tmp_path: Path) -> Paths:
"""Build the redirected path set rooted at *tmp_path*.
Args:
tmp_path: pytest's per-test temporary directory.
Returns:
The populated :class:`Paths`.
"""
remnant_paths = (
tmp_path / "home" / ".steam",
tmp_path / "home" / "steam",
tmp_path / "home" / ".local" / "share" / "Steam",
tmp_path / "home" / ".steampath",
tmp_path / "home" / ".steampid",
tmp_path / "home" / ".config" / "steamtinkerlaunch",
tmp_path / "home" / ".config" / "CSDSteamBuild",
)
return Paths(
lock_file=tmp_path / "total_block_lock.json",
ip_cache_file=tmp_path / "total_block_ip_cache.json",
hosts_file=tmp_path / "hosts",
purge_log_file=tmp_path / "total_block_purge_log.json",
remnant_paths=remnant_paths,
)
def write_lock(
paths_obj: Paths, started_at: float, until: float, days: int = 1
) -> None:
"""Write a total-block lock file into the redirected location.
Args:
paths_obj: The redirected path set.
started_at: Lock start timestamp.
until: Lock expiry timestamp.
days: Configured block length.
"""
paths_obj.lock_file.parent.mkdir(parents=True, exist_ok=True)
paths_obj.lock_file.write_text(
json.dumps({"started_at": started_at, "until": until, "days": days}),
encoding="utf-8",
)

View File

@ -20,6 +20,23 @@ from unittest.mock import MagicMock, patch
import pytest
# Imported for its autouse side effect: naming it here registers it.
from steam_backlog_enforcer.tests._isolate_playtime import _isolate_playtime
from steam_backlog_enforcer.tests._no_subprocess import _block_real_subprocesses
# Re-exported so ruff --fix does not delete the imports above: pytest
# registers autouse fixtures by name, so they look unused to the linter.
__all__ = ["_block_real_subprocesses", "_isolate_playtime"]
from steam_backlog_enforcer.tests._total_block_paths import (
BLOCK,
HOSTS,
IPTABLES,
PURGE,
Paths,
build_paths,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@ -80,7 +97,7 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
fake_hosts,
),
patch(
"steam_backlog_enforcer._total_block.HOSTS_FILE",
"steam_backlog_enforcer._total_block_hosts.HOSTS_FILE",
fake_hosts,
),
patch(
@ -96,18 +113,18 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
fake_config / "total_block_lock.json",
),
patch(
"steam_backlog_enforcer._total_block._IPTABLES_IP_CACHE_FILE",
"steam_backlog_enforcer._total_block_iptables._IPTABLES_IP_CACHE_FILE",
fake_config / "total_block_ip_cache.json",
),
patch(
"steam_backlog_enforcer._total_block._STEAM_PURGE_LOG_FILE",
"steam_backlog_enforcer._total_block_purge._STEAM_PURGE_LOG_FILE",
fake_config / "total_block_purge_log.json",
),
# Steam/Proton remnant paths (real ~/.steam, ~/.local/share/Steam,
# etc. - _remove_steam_remnants() deletes these, so tests must never
# see the real ones)
patch(
"steam_backlog_enforcer._total_block._STEAM_REMNANT_PATHS",
"steam_backlog_enforcer._total_block_purge._STEAM_REMNANT_PATHS",
(
tmp_path / "fake_home" / ".steam",
tmp_path / "fake_home" / "steam",
@ -137,126 +154,6 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
yield
@pytest.fixture(autouse=True)
def _isolate_playtime(tmp_path: Path) -> Iterator[None]:
"""Redirect the daily-gaming-budget paths to a temporary directory.
Separate from :func:`_isolate_filesystem` only because CPython caps a
``with`` statement at 20 statically nested blocks and that fixture is
already at the limit.
The ``BLOCK_TARGETS`` patch is the load-bearing one: unpatched, that tuple
names the real ``/usr/bin/steam``, and a test reaching the mount code would
mask the user's actual Steam install behind a refusal stub.
"""
# Namespaced names: an autouse fixture that creates bare "proc"/"targets"
# inside tmp_path would claim those names for every other test in the
# suite (test_enforcer builds its own tmp_path/"proc" with mkdir()).
fake_config = tmp_path / "config"
fake_config.mkdir(exist_ok=True)
fake_proc = tmp_path / "playtime_proc"
fake_proc.mkdir(exist_ok=True)
fake_targets = tmp_path / "playtime_targets"
fake_targets.mkdir(exist_ok=True)
with (
# Computed at import time from CONFIG_DIR, so patching CONFIG_DIR
# alone does not redirect them.
patch(
"steam_backlog_enforcer._playtime.PLAYTIME_STATE_FILE",
fake_config / "playtime_state.json",
),
patch(
"steam_backlog_enforcer._playtime.PLAYTIME_DEMO_STATE_FILE",
fake_config / "playtime_demo_state.json",
),
patch("steam_backlog_enforcer._playtime._PROC", fake_proc),
# Never let a test chattr +i a file inside tmp_path — pytest could
# then not clean the directory up.
patch("steam_backlog_enforcer._playtime._try_set_immutable", MagicMock()),
patch("steam_backlog_enforcer._playtime.unlock_for_write", MagicMock()),
patch(
"steam_backlog_enforcer._playtime_block.BLOCK_TARGETS",
(
fake_targets / "steam",
fake_targets / "bin_steam.sh",
fake_targets / "steam.sh",
fake_targets / "lutris",
),
),
patch("steam_backlog_enforcer._playtime_block._STUB_DIR", tmp_path / "run"),
patch(
"steam_backlog_enforcer._playtime_block.STUB_PATH",
tmp_path / "run" / "gaming-blocked",
),
patch(
"steam_backlog_enforcer._playtime_block.MOUNTINFO_PATH",
tmp_path / "mountinfo",
),
patch(
"steam_backlog_enforcer._playtime_block._INIT_MOUNTINFO_PATH",
tmp_path / "init_mountinfo",
),
patch(
"steam_backlog_enforcer._playtime_block.PACMAN_LOCK", tmp_path / "db.lck"
),
patch("steam_backlog_enforcer._playtime_block._PROC", fake_proc),
):
yield
@pytest.fixture(autouse=True)
def _block_real_subprocesses() -> Iterator[None]:
"""Block subprocess calls that could launch real Steam or modify system.
Individual tests that need to test subprocess behaviour should
patch the specific module's ``subprocess.run`` / ``subprocess.Popen``
themselves — their local patch will override this one.
"""
noop_run = MagicMock(return_value=MagicMock(returncode=1))
noop_popen = MagicMock()
with (
patch(
"steam_backlog_enforcer.game_install.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer.game_install.subprocess.Popen",
noop_popen,
),
patch(
"steam_backlog_enforcer.enforcer.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer.store_blocker.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer._total_block.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer.library_hider.subprocess.run",
noop_run,
),
patch(
"steam_backlog_enforcer.library_hider.subprocess.Popen",
noop_popen,
),
# The single most important patch in this file. _playtime_block._run
# executes `mount --bind` against paths that, unpatched, are the real
# /usr/bin/steam. A test that reached the real subprocess would mask
# the user's actual Steam install with a refusal stub.
patch(
"steam_backlog_enforcer._playtime_block.subprocess.run",
noop_run,
),
):
yield
@pytest.fixture(autouse=True)
def _no_real_sleep() -> Iterator[None]:
"""No-op every ``time.sleep`` used by the package.
@ -273,3 +170,26 @@ def _no_real_sleep() -> Iterator[None]:
patch("steam_backlog_enforcer._enforce_loop.time.sleep", noop),
):
yield
# ──────────────────────────────────────────────────────────────
# Total-block path redirection
#
# Lives here rather than in a helper module because pytest resolves
# fixtures by name: an imported fixture looks unused to ruff, and this
# repo runs `ruff --fix --unsafe-fixes`, which deletes the import.
# ──────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def total_block_paths(tmp_path: Path) -> Iterator[Paths]:
"""Redirect every total-block path constant to tmp_path for one test."""
built = build_paths(tmp_path)
with (
patch(f"{BLOCK}.TOTAL_BLOCK_LOCK_FILE", built.lock_file),
patch(f"{IPTABLES}._IPTABLES_IP_CACHE_FILE", built.ip_cache_file),
patch(f"{HOSTS}.HOSTS_FILE", built.hosts_file),
patch(f"{PURGE}._STEAM_PURGE_LOG_FILE", built.purge_log_file),
patch(f"{PURGE}._STEAM_REMNANT_PATHS", built.remnant_paths),
):
yield built

View File

@ -10,7 +10,11 @@ from steam_backlog_enforcer._cmd_playtime import (
cmd_gaming_status,
cmd_gaming_unblock,
)
from steam_backlog_enforcer._playtime import PlaytimeState, load_state, save_state
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
load_state,
save_state,
)
from steam_backlog_enforcer.config import Config, State
PKG = "steam_backlog_enforcer._cmd_playtime"

View File

@ -1,17 +1,12 @@
"""Tests for the MCP server tools in ``_mcp``.
Tools are patched at the ``_mcp`` module namespace (where the helpers are
imported), keeping each tool's own logic under test while isolating the
already-tested leaf functions.
"""
"""Tests for the read-only MCP tools."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from steam_backlog_enforcer import _mcp
from steam_backlog_enforcer.config import State
from steam_backlog_enforcer import _mcp_actions as mcp_actions
from steam_backlog_enforcer import _mcp_query as mcp_query
from steam_backlog_enforcer import _mcp_server as mcp_server
from steam_backlog_enforcer.steam_api import GameInfo
@ -29,20 +24,20 @@ def _game(app_id: int, hours: float) -> GameInfo:
class TestReadTools:
def test_get_dataset(self) -> None:
with (
patch.object(_mcp, "State") as state,
patch.object(_mcp, "build_web_dataset", return_value="DS") as bwd,
patch.object(_mcp, "dataset_to_payload", return_value={"x": 1}) as dtp,
patch.object(mcp_query, "State") as state,
patch.object(mcp_query, "build_web_dataset", return_value="DS") as bwd,
patch.object(mcp_query, "dataset_to_payload", return_value={"x": 1}) as dtp,
):
assert _mcp.get_dataset() == {"x": 1}
assert mcp_query.get_dataset() == {"x": 1}
bwd.assert_called_once_with(state.load.return_value)
dtp.assert_called_once_with("DS")
def test_get_status(self) -> None:
with (
patch.object(_mcp, "State") as state,
patch.object(_mcp, "status_payload", return_value={"ok": 1}) as sp,
patch.object(mcp_query, "State") as state,
patch.object(mcp_query, "status_payload", return_value={"ok": 1}) as sp,
):
assert _mcp.get_status() == {"ok": 1}
assert mcp_query.get_status() == {"ok": 1}
sp.assert_called_once_with(state.load.return_value)
def test_get_stats_subsets_dataset(self) -> None:
@ -52,18 +47,18 @@ class TestReadTools:
"games": ["ignored"],
}
with (
patch.object(_mcp, "State"),
patch.object(_mcp, "build_web_dataset"),
patch.object(_mcp, "dataset_to_payload", return_value=payload),
patch.object(mcp_query, "State"),
patch.object(mcp_query, "build_web_dataset"),
patch.object(mcp_query, "dataset_to_payload", return_value=payload),
):
out = _mcp.get_stats()
out = mcp_query.get_stats()
assert out == {"default_summary": {"qualifying": 3}, "pace_vs_hltb": None}
class TestListBacklog:
def test_no_snapshot_returns_note(self) -> None:
with patch.object(_mcp, "load_snapshot", return_value=None):
out = _mcp.list_backlog()
with patch.object(mcp_query, "load_snapshot", return_value=None):
out = mcp_query.list_backlog()
assert out["total"] == 0
assert out["games"] == []
assert "note" in out
@ -101,8 +96,8 @@ class TestListBacklog:
"completionist_hours": -1,
},
]
with patch.object(_mcp, "load_snapshot", return_value=snap):
out = _mcp.list_backlog(limit=2)
with patch.object(mcp_query, "load_snapshot", return_value=snap):
out = mcp_query.list_backlog(limit=2)
assert out["total"] == 3
assert out["returned"] == 2
assert [g["app_id"] for g in out["games"]] == [70, 440]
@ -117,8 +112,8 @@ class TestListBacklog:
"unlocked_achievements": 1,
}
]
with patch.object(_mcp, "load_snapshot", return_value=snap):
out = _mcp.list_backlog(limit=-5)
with patch.object(mcp_query, "load_snapshot", return_value=snap):
out = mcp_query.list_backlog(limit=-5)
assert out["returned"] == 0
assert out["games"] == []
@ -126,255 +121,20 @@ class TestListBacklog:
class TestResolveAndSort:
def test_resolve_found(self) -> None:
with patch.object(
_mcp, "load_snapshot", return_value=[{"app_id": 440, "name": "TF2"}]
mcp_server, "load_snapshot", return_value=[{"app_id": 440, "name": "TF2"}]
):
assert _mcp._resolve_game_name(440) == "TF2"
assert mcp_actions._resolve_game_name(440) == "TF2"
def test_resolve_missing(self) -> None:
with patch.object(
_mcp, "load_snapshot", return_value=[{"app_id": 1, "name": "X"}]
mcp_server, "load_snapshot", return_value=[{"app_id": 1, "name": "X"}]
):
assert _mcp._resolve_game_name(440) is None
assert mcp_actions._resolve_game_name(440) is None
def test_resolve_no_snapshot(self) -> None:
with patch.object(_mcp, "load_snapshot", return_value=None):
assert _mcp._resolve_game_name(440) is None
with patch.object(mcp_query, "load_snapshot", return_value=None):
assert mcp_actions._resolve_game_name(440) is None
def test_sort_key_branches(self) -> None:
assert _mcp._backlog_sort_key(_game(1, 5.0)) == (0, 5.0)
assert _mcp._backlog_sort_key(_game(2, -1)) == (1, 0.0)
class TestPickManualGate:
def test_not_found(self) -> None:
with patch.object(_mcp, "load_snapshot", return_value=[]):
out = _mcp.pick_manual(440)
assert out["ok"] is False
assert "not found" in out["reason"]
def test_preview_does_not_mutate(self) -> None:
with (
patch.object(
_mcp, "load_snapshot", return_value=[{"app_id": 440, "name": "TF2"}]
),
patch.object(_mcp, "apply_manual_pick") as amp,
):
out = _mcp.pick_manual(440)
assert out["preview"] is True
assert out["game_name"] == "TF2"
amp.assert_not_called()
def test_confirm_applies(self) -> None:
with (
patch.object(
_mcp, "load_snapshot", return_value=[{"app_id": 440, "name": "TF2"}]
),
patch.object(_mcp, "State") as state,
patch.object(_mcp, "Config") as config,
patch.object(_mcp, "apply_manual_pick", return_value=None) as amp,
):
out = _mcp.pick_manual(440, confirm=True)
assert out["applied"] is True
assert out["app_id"] == 440
amp.assert_called_once_with(
state.load.return_value,
440,
"TF2",
max_picks=config.load.return_value.max_manual_picks,
)
class TestAbandonPickGate:
"""The MCP escape hatch mirrors the CLI grace rules, state-only."""
def _state(self, *, days_ago: float = 1.0, app_id: int = 440) -> State:
started = (datetime.now(timezone.utc) - timedelta(days=days_ago)).isoformat()
return State(
manual_picks=[
{"app_id": app_id, "game_name": "TF2", "started_at": started}
],
current_app_id=app_id,
current_game_name="TF2",
)
def test_no_active_pick(self) -> None:
with patch.object(_mcp.State, "load", return_value=State()):
out = _mcp.abandon_pick(440)
assert out["ok"] is False
assert "No manual pick" in out["reason"]
def test_wrong_app_id(self) -> None:
with patch.object(_mcp.State, "load", return_value=self._state()):
out = _mcp.abandon_pick(999)
assert out["ok"] is False
assert "not one of the active manual picks" in out["reason"]
def test_expired_grace(self) -> None:
state = self._state(days_ago=_mcp.MANUAL_GRACE_DAYS + 1)
with patch.object(_mcp.State, "load", return_value=state):
out = _mcp.abandon_pick(440)
assert out["ok"] is False
assert "grace period has expired" in out["reason"]
def test_preview_does_not_mutate(self) -> None:
with (
patch.object(_mcp.State, "load", return_value=self._state()),
patch.object(_mcp, "abandon_manual_pick") as amp,
):
out = _mcp.abandon_pick(440)
assert out["preview"] is True
assert out["game_name"] == "TF2"
assert out["grace_days_left"] > 0
amp.assert_not_called()
def test_confirm_applies(self) -> None:
state = self._state()
with (
patch.object(_mcp.State, "load", return_value=state),
patch.object(_mcp, "abandon_manual_pick") as amp,
):
out = _mcp.abandon_pick(440, confirm=True)
assert out["applied"] is True
assert out["app_id"] == 440
amp.assert_called_once_with(state, 440)
def test_refused_at_cap(self) -> None:
with (
patch.object(
_mcp, "load_snapshot", return_value=[{"app_id": 440, "name": "TF2"}]
),
patch.object(_mcp, "State"),
patch.object(_mcp, "Config"),
patch.object(_mcp, "apply_manual_pick", return_value="cap reached"),
):
out = _mcp.pick_manual(440, confirm=True)
assert out["ok"] is False
assert out["reason"] == "cap reached"
class TestBlockGamingGate:
def test_invalid_days(self) -> None:
out = _mcp.block_gaming(0)
assert out["ok"] is False
def test_preview(self) -> None:
out = _mcp.block_gaming(3)
assert out["preview"] is True
assert out["requires_root"] is True
def test_confirm_success(self) -> None:
with patch.object(_mcp, "start_total_block", return_value=True):
out = _mcp.block_gaming(3, confirm=True)
assert out["applied"] is True
assert out["days"] == 3
def test_confirm_unprivileged_returns_gracefully(self) -> None:
with patch.object(_mcp, "start_total_block", return_value=False):
out = _mcp.block_gaming(3, confirm=True)
assert out["ok"] is False
assert "privileges" in out["reason"]
def test_confirm_oserror_returns_gracefully(self) -> None:
with patch.object(_mcp, "start_total_block", side_effect=OSError("boom")):
out = _mcp.block_gaming(3, confirm=True)
assert out["ok"] is False
assert "privileges" in out["reason"]
class TestGetGamingTime:
def test_reports_unrecorded_state(self) -> None:
with (
patch.object(_mcp, "load_state", return_value=None),
patch.object(_mcp, "mounted_targets", return_value=set()),
):
out = _mcp.get_gaming_time()
assert out["recorded"] is False
assert out["budget_seconds"] == 8 * 3600
def test_reports_usage(self) -> None:
stored = _mcp.PlaytimeState(day_key="2026-07-27", seconds=100.0)
with (
patch.object(_mcp, "load_state", return_value=stored),
patch.object(_mcp, "mounted_targets", return_value=set()),
):
out = _mcp.get_gaming_time()
assert out["recorded"] is True
assert out["gaming_day"] == "2026-07-27"
assert out["seconds_used"] == 100.0
assert out["seconds_remaining"] == 8 * 3600 - 100.0
assert out["blocked"] is False
def test_remaining_never_goes_negative(self) -> None:
stored = _mcp.PlaytimeState(day_key="d", seconds=10**9)
with (
patch.object(_mcp, "load_state", return_value=stored),
patch.object(_mcp, "mounted_targets", return_value=set()),
):
out = _mcp.get_gaming_time()
assert out["seconds_remaining"] == 0.0
def test_lists_masked_launchers(self) -> None:
stored = _mcp.PlaytimeState(day_key="d", seconds=1.0, blocked_at=2.0)
with (
patch.object(_mcp, "load_state", return_value=stored),
patch.object(_mcp, "mounted_targets", return_value={"/usr/bin/steam"}),
):
out = _mcp.get_gaming_time()
assert out["blocked"] is True
assert out["masked_launchers"] == ["/usr/bin/steam"]
def test_leaks_no_secret(self) -> None:
"""No Config secret may cross the MCP boundary."""
with (
patch.object(_mcp, "load_state", return_value=None),
patch.object(_mcp, "mounted_targets", return_value=set()),
):
out = _mcp.get_gaming_time()
assert "steam_api_key" not in out
assert "steam_id" not in out
class TestResetGamingTime:
def test_preview_by_default(self) -> None:
with patch.object(_mcp, "release_block") as mock_release:
out = _mcp.reset_gaming_time()
mock_release.assert_not_called()
assert out["preview"] is True
assert out["confirm_required"] is True
assert out["requires_root"] is True
def test_confirm_unprivileged_returns_gracefully(self) -> None:
with (
patch.object(_mcp.os, "geteuid", return_value=1000),
patch.object(_mcp, "release_block") as mock_release,
):
out = _mcp.reset_gaming_time(confirm=True)
mock_release.assert_not_called()
assert out["ok"] is False
assert "privileges" in out["reason"]
def test_confirm_applies_as_root(self) -> None:
with (
patch.object(_mcp.os, "geteuid", return_value=0),
patch.object(_mcp, "release_block", return_value=["/usr/bin/steam"]),
patch.object(_mcp, "save_state") as mock_save,
):
out = _mcp.reset_gaming_time(confirm=True)
mock_save.assert_called_once()
assert out["applied"] is True
assert out["released"] == ["/usr/bin/steam"]
def test_confirm_oserror_returns_gracefully(self) -> None:
with (
patch.object(_mcp.os, "geteuid", return_value=0),
patch.object(_mcp, "release_block", side_effect=OSError("boom")),
):
out = _mcp.reset_gaming_time(confirm=True)
assert out["ok"] is False
assert "privileges" in out["reason"]
def test_main_runs_stdio_server() -> None:
with patch.object(_mcp.mcp, "run") as run:
_mcp.main()
run.assert_called_once_with()
assert mcp_query._backlog_sort_key(_game(1, 5.0)) == (0, 5.0)
assert mcp_query._backlog_sort_key(_game(2, -1)) == (1, 0.0)

View File

@ -0,0 +1,155 @@
"""Tests for the mutating MCP tools and their confirm gates."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from steam_backlog_enforcer import _mcp_actions as mcp_actions
from steam_backlog_enforcer import _mcp_query as mcp_query
from steam_backlog_enforcer import _mcp_server as mcp_server
from steam_backlog_enforcer.config import State
class TestPickManualGate:
def test_not_found(self) -> None:
with patch.object(mcp_query, "load_snapshot", return_value=[]):
out = mcp_actions.pick_manual(440)
assert out["ok"] is False
assert "not found" in out["reason"]
def test_preview_does_not_mutate(self) -> None:
with (
patch.object(
mcp_server,
"load_snapshot",
return_value=[{"app_id": 440, "name": "TF2"}],
),
patch.object(mcp_actions, "apply_manual_pick") as amp,
):
out = mcp_actions.pick_manual(440)
assert out["preview"] is True
assert out["game_name"] == "TF2"
amp.assert_not_called()
def test_confirm_applies(self) -> None:
with (
patch.object(
mcp_server,
"load_snapshot",
return_value=[{"app_id": 440, "name": "TF2"}],
),
patch.object(mcp_actions, "State") as state,
patch.object(mcp_actions, "Config") as config,
patch.object(mcp_actions, "apply_manual_pick", return_value=None) as amp,
):
out = mcp_actions.pick_manual(440, confirm=True)
assert out["applied"] is True
assert out["app_id"] == 440
amp.assert_called_once_with(
state.load.return_value,
440,
"TF2",
max_picks=config.load.return_value.max_manual_picks,
)
class TestAbandonPickGate:
"""The MCP escape hatch mirrors the CLI grace rules, state-only."""
def _state(self, *, days_ago: float = 1.0, app_id: int = 440) -> State:
started = (datetime.now(timezone.utc) - timedelta(days=days_ago)).isoformat()
return State(
manual_picks=[
{"app_id": app_id, "game_name": "TF2", "started_at": started}
],
current_app_id=app_id,
current_game_name="TF2",
)
def test_no_active_pick(self) -> None:
with patch.object(mcp_query.State, "load", return_value=State()):
out = mcp_actions.abandon_pick(440)
assert out["ok"] is False
assert "No manual pick" in out["reason"]
def test_wrong_app_id(self) -> None:
with patch.object(mcp_query.State, "load", return_value=self._state()):
out = mcp_actions.abandon_pick(999)
assert out["ok"] is False
assert "not one of the active manual picks" in out["reason"]
def test_expired_grace(self) -> None:
state = self._state(days_ago=mcp_actions.MANUAL_GRACE_DAYS + 1)
with patch.object(mcp_query.State, "load", return_value=state):
out = mcp_actions.abandon_pick(440)
assert out["ok"] is False
assert "grace period has expired" in out["reason"]
def test_preview_does_not_mutate(self) -> None:
with (
patch.object(mcp_query.State, "load", return_value=self._state()),
patch.object(mcp_actions, "abandon_manual_pick") as amp,
):
out = mcp_actions.abandon_pick(440)
assert out["preview"] is True
assert out["game_name"] == "TF2"
assert out["grace_days_left"] > 0
amp.assert_not_called()
def test_confirm_applies(self) -> None:
state = self._state()
with (
patch.object(mcp_query.State, "load", return_value=state),
patch.object(mcp_actions, "abandon_manual_pick") as amp,
):
out = mcp_actions.abandon_pick(440, confirm=True)
assert out["applied"] is True
assert out["app_id"] == 440
amp.assert_called_once_with(state, 440)
def test_refused_at_cap(self) -> None:
with (
patch.object(
mcp_server,
"load_snapshot",
return_value=[{"app_id": 440, "name": "TF2"}],
),
patch.object(mcp_actions, "State"),
patch.object(mcp_actions, "Config"),
patch.object(mcp_actions, "apply_manual_pick", return_value="cap reached"),
):
out = mcp_actions.pick_manual(440, confirm=True)
assert out["ok"] is False
assert out["reason"] == "cap reached"
class TestBlockGamingGate:
def test_invalid_days(self) -> None:
out = mcp_actions.block_gaming(0)
assert out["ok"] is False
def test_preview(self) -> None:
out = mcp_actions.block_gaming(3)
assert out["preview"] is True
assert out["requires_root"] is True
def test_confirm_success(self) -> None:
with patch.object(mcp_actions, "start_total_block", return_value=True):
out = mcp_actions.block_gaming(3, confirm=True)
assert out["applied"] is True
assert out["days"] == 3
def test_confirm_unprivileged_returns_gracefully(self) -> None:
with patch.object(mcp_actions, "start_total_block", return_value=False):
out = mcp_actions.block_gaming(3, confirm=True)
assert out["ok"] is False
assert "privileges" in out["reason"]
def test_confirm_oserror_returns_gracefully(self) -> None:
with patch.object(
mcp_actions, "start_total_block", side_effect=OSError("boom")
):
out = mcp_actions.block_gaming(3, confirm=True)
assert out["ok"] is False
assert "privileges" in out["reason"]

View File

@ -0,0 +1,112 @@
"""Tests for the gaming-time MCP tools."""
from __future__ import annotations
from unittest.mock import patch
from steam_backlog_enforcer import _mcp
from steam_backlog_enforcer import _mcp_gaming_time as mcp_gaming_time
from steam_backlog_enforcer import _mcp_server as mcp_server
class TestGetGamingTime:
def test_reports_unrecorded_state(self) -> None:
with (
patch.object(mcp_gaming_time, "load_state", return_value=None),
patch.object(mcp_gaming_time, "mounted_targets", return_value=set()),
):
out = mcp_gaming_time.get_gaming_time()
assert out["recorded"] is False
assert out["budget_seconds"] == 8 * 3600
def test_reports_usage(self) -> None:
stored = mcp_gaming_time.PlaytimeState(day_key="2026-07-27", seconds=100.0)
with (
patch.object(mcp_gaming_time, "load_state", return_value=stored),
patch.object(mcp_gaming_time, "mounted_targets", return_value=set()),
):
out = mcp_gaming_time.get_gaming_time()
assert out["recorded"] is True
assert out["gaming_day"] == "2026-07-27"
assert out["seconds_used"] == 100.0
assert out["seconds_remaining"] == 8 * 3600 - 100.0
assert out["blocked"] is False
def test_remaining_never_goes_negative(self) -> None:
stored = mcp_gaming_time.PlaytimeState(day_key="d", seconds=10**9)
with (
patch.object(mcp_gaming_time, "load_state", return_value=stored),
patch.object(mcp_gaming_time, "mounted_targets", return_value=set()),
):
out = mcp_gaming_time.get_gaming_time()
assert out["seconds_remaining"] == 0.0
def test_lists_masked_launchers(self) -> None:
stored = mcp_gaming_time.PlaytimeState(day_key="d", seconds=1.0, blocked_at=2.0)
with (
patch.object(mcp_gaming_time, "load_state", return_value=stored),
patch.object(
mcp_gaming_time, "mounted_targets", return_value={"/usr/bin/steam"}
),
):
out = mcp_gaming_time.get_gaming_time()
assert out["blocked"] is True
assert out["masked_launchers"] == ["/usr/bin/steam"]
def test_leaks_no_secret(self) -> None:
"""No Config secret may cross the MCP boundary."""
with (
patch.object(mcp_gaming_time, "load_state", return_value=None),
patch.object(mcp_gaming_time, "mounted_targets", return_value=set()),
):
out = mcp_gaming_time.get_gaming_time()
assert "steam_api_key" not in out
assert "steam_id" not in out
class TestResetGamingTime:
def test_preview_by_default(self) -> None:
with patch.object(mcp_gaming_time, "release_block") as mock_release:
out = mcp_gaming_time.reset_gaming_time()
mock_release.assert_not_called()
assert out["preview"] is True
assert out["confirm_required"] is True
assert out["requires_root"] is True
def test_confirm_unprivileged_returns_gracefully(self) -> None:
with (
patch.object(mcp_gaming_time.os, "geteuid", return_value=1000),
patch.object(mcp_gaming_time, "release_block") as mock_release,
):
out = mcp_gaming_time.reset_gaming_time(confirm=True)
mock_release.assert_not_called()
assert out["ok"] is False
assert "privileges" in out["reason"]
def test_confirm_applies_as_root(self) -> None:
with (
patch.object(mcp_gaming_time.os, "geteuid", return_value=0),
patch.object(
mcp_gaming_time, "release_block", return_value=["/usr/bin/steam"]
),
patch.object(mcp_gaming_time, "save_state") as mock_save,
):
out = mcp_gaming_time.reset_gaming_time(confirm=True)
mock_save.assert_called_once()
assert out["applied"] is True
assert out["released"] == ["/usr/bin/steam"]
def test_confirm_oserror_returns_gracefully(self) -> None:
with (
patch.object(mcp_gaming_time.os, "geteuid", return_value=0),
patch.object(mcp_gaming_time, "release_block", side_effect=OSError("boom")),
):
out = mcp_gaming_time.reset_gaming_time(confirm=True)
assert out["ok"] is False
assert "privileges" in out["reason"]
def test_main_runs_stdio_server() -> None:
with patch.object(mcp_server.mcp, "run") as run:
_mcp.main()
run.assert_called_once_with()

View File

@ -0,0 +1,84 @@
"""Tests for the generic pacman wrappers."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer._pacman import (
is_package_installed,
pacman_owner,
uninstall_package,
)
PKG = "steam_backlog_enforcer._pacman"
class TestPacmanOwner:
"""Tests for pacman owner."""
def test_owned_path_returns_package_name(self) -> None:
"""Test that owned path returns package name."""
result = MagicMock(
returncode=0,
stdout="/usr/bin/prismlauncher is owned by prismlauncher-git 11.0.0-1\n",
)
with patch(f"{PKG}.subprocess.run", return_value=result):
assert pacman_owner("/usr/bin/prismlauncher") == "prismlauncher-git"
def test_unowned_path_returns_none(self) -> None:
"""Test that unowned path returns none."""
result = MagicMock(returncode=1, stdout="")
with patch(f"{PKG}.subprocess.run", return_value=result):
assert pacman_owner("/opt/foo/bar") is None
def test_unexpected_output_format_returns_none(self) -> None:
"""Test that unexpected output format returns none."""
result = MagicMock(returncode=0, stdout="something unexpected\n")
with patch(f"{PKG}.subprocess.run", return_value=result):
assert pacman_owner("/usr/bin/x") is None
class TestUninstallPackage:
"""Tests for uninstall package."""
def test_success(self) -> None:
"""Test that success."""
with patch(
f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0, stderr="")
):
assert uninstall_package("foo") is True
def test_already_absent_treated_as_success(self) -> None:
"""Test that already absent treated as success."""
with patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=1, stderr="error: target not found: foo"),
):
assert uninstall_package("foo") is True
def test_real_failure_returns_false(self) -> None:
"""Test that real failure returns false."""
with patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=1, stderr="some other error"),
):
assert uninstall_package("foo") is False
def test_subprocess_error_returns_false(self) -> None:
"""Test that subprocess error returns false."""
with patch(f"{PKG}.subprocess.run", side_effect=OSError):
assert uninstall_package("foo") is False
class TestIsPackageInstalled:
"""Tests for is package installed."""
def test_installed(self) -> None:
"""Test that installed."""
with patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)):
assert is_package_installed("protonup-qt") is True
def test_not_installed(self) -> None:
"""Test that not installed."""
with patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=1)):
assert is_package_installed("protonup-qt") is False

View File

@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest
from steam_backlog_enforcer._playtime import (
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
gaming_day_key,
load_state,
@ -102,7 +102,7 @@ class TestSaveLoadRoundTrip:
def test_unreadable_file_is_none(self) -> None:
save_state(PlaytimeState(day_key="x"), demo=False)
with patch(f"{PKG}.Path.read_text", side_effect=OSError("nope")):
with patch("pathlib.Path.read_text", side_effect=OSError("nope")):
assert load_state(demo=False) is None
def test_wrong_schema_version_is_none(self) -> None:
@ -129,8 +129,12 @@ class TestSaveStateImmutability:
def test_production_save_unlocks_then_relocks(self) -> None:
"""rename(2) onto an immutable file is EPERM, so the unlock is required."""
with (
patch(f"{PKG}.unlock_for_write") as mock_unlock,
patch(f"{PKG}._try_set_immutable") as mock_lock,
patch(
"steam_backlog_enforcer._playtime_state.unlock_for_write"
) as mock_unlock,
patch(
"steam_backlog_enforcer._playtime_state._try_set_immutable"
) as mock_lock,
):
save_state(PlaytimeState(day_key="x"), demo=False)
mock_unlock.assert_called_once_with(state_path(demo=False))
@ -139,8 +143,12 @@ class TestSaveStateImmutability:
def test_demo_save_is_left_mutable(self) -> None:
"""An immutable demo file could not be deleted during cleanup."""
with (
patch(f"{PKG}.unlock_for_write") as mock_unlock,
patch(f"{PKG}._try_set_immutable") as mock_lock,
patch(
"steam_backlog_enforcer._playtime_state.unlock_for_write"
) as mock_unlock,
patch(
"steam_backlog_enforcer._playtime_state._try_set_immutable"
) as mock_lock,
):
save_state(PlaytimeState(day_key="x"), demo=True)
mock_unlock.assert_not_called()

View File

@ -10,14 +10,18 @@ from steam_backlog_enforcer import _playtime_block as blk
from steam_backlog_enforcer._playtime_block import (
_ensure_stub,
_mount_bin,
_mountpoints,
_run,
_umount_bin,
_unescape_mountinfo,
block_targets,
mounted_targets,
mounts_are_visible,
)
from steam_backlog_enforcer._playtime_mountinfo import (
_mountpoints,
_unescape_mountinfo,
)
from steam_backlog_enforcer._playtime_run import (
_run,
)
if TYPE_CHECKING:
from pathlib import Path
@ -124,30 +128,39 @@ class TestEnsureStub:
assert _ensure_stub() is True
def test_returns_false_on_oserror(self) -> None:
with patch(f"{PKG}.Path.mkdir", side_effect=OSError("boom")):
with patch(
"steam_backlog_enforcer._playtime_block.Path.mkdir",
side_effect=OSError("boom"),
):
assert _ensure_stub() is False
class TestRun:
def test_true_on_zero_exit(self) -> None:
with patch(
f"{PKG}.subprocess.run",
"steam_backlog_enforcer._playtime_run.subprocess.run",
return_value=MagicMock(returncode=0),
) as mock_run:
assert _run(["mount", "--bind", "a", "b"]) is True
mock_run.assert_called_once()
def test_false_on_nonzero_exit(self) -> None:
with patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=1)):
with patch(
"steam_backlog_enforcer._playtime_run.subprocess.run",
return_value=MagicMock(returncode=1),
):
assert _run(["mount"]) is False
def test_false_on_oserror(self) -> None:
with patch(f"{PKG}.subprocess.run", side_effect=OSError("nope")):
with patch(
"steam_backlog_enforcer._playtime_run.subprocess.run",
side_effect=OSError("nope"),
):
assert _run(["mount"]) is False
def test_false_on_timeout(self) -> None:
with patch(
f"{PKG}.subprocess.run",
"steam_backlog_enforcer._playtime_run.subprocess.run",
side_effect=subprocess.TimeoutExpired("mount", 15),
):
assert _run(["mount"]) is False
@ -155,19 +168,29 @@ class TestRun:
class TestBinaryResolution:
def test_mount_bin_found(self) -> None:
with patch(f"{PKG}.shutil.which", return_value="/bin/mount"):
with patch(
"steam_backlog_enforcer._playtime_block.shutil.which",
return_value="/bin/mount",
):
assert _mount_bin() == "/bin/mount"
def test_mount_bin_fallback(self) -> None:
with patch(f"{PKG}.shutil.which", return_value=None):
with patch(
"steam_backlog_enforcer._playtime_block.shutil.which", return_value=None
):
assert _mount_bin() == "/usr/bin/mount"
def test_umount_bin_found(self) -> None:
with patch(f"{PKG}.shutil.which", return_value="/bin/umount"):
with patch(
"steam_backlog_enforcer._playtime_block.shutil.which",
return_value="/bin/umount",
):
assert _umount_bin() == "/bin/umount"
def test_umount_bin_fallback(self) -> None:
with patch(f"{PKG}.shutil.which", return_value=None):
with patch(
"steam_backlog_enforcer._playtime_block.shutil.which", return_value=None
):
assert _umount_bin() == "/usr/bin/umount"

View File

@ -1,29 +1,15 @@
"""Tests for applying/releasing the playtime block and the kill walk."""
from __future__ import annotations
import os
import signal
from typing import TYPE_CHECKING
from pathlib import Path
from unittest.mock import patch
from steam_backlog_enforcer._playtime_block import (
_child_map,
_own_process_chain,
_read_ppid,
apply_block,
block_targets,
descendant_pids,
kill_gaming_processes,
reconcile,
release_block,
request_steam_shutdown,
steam_and_launcher_pids,
)
if TYPE_CHECKING:
from pathlib import Path
PKG = "steam_backlog_enforcer._playtime_block"
@ -46,7 +32,7 @@ class TestApplyBlock:
_make_targets_exist()
_set_mounted(tmp_path, [])
(tmp_path / "db.lck").write_text("", encoding="utf-8")
with patch(f"{PKG}._run") as mock_run:
with patch("steam_backlog_enforcer._playtime_block._run") as mock_run:
assert apply_block() == []
mock_run.assert_not_called()
@ -55,7 +41,7 @@ class TestApplyBlock:
_set_mounted(tmp_path, [])
with (
patch(f"{PKG}._ensure_stub", return_value=False),
patch(f"{PKG}._run") as mock_run,
patch("steam_backlog_enforcer._playtime_block._run") as mock_run,
):
assert apply_block() == []
mock_run.assert_not_called()
@ -63,25 +49,27 @@ class TestApplyBlock:
def test_mounts_every_existing_target(self, tmp_path: Path) -> None:
_make_targets_exist()
_set_mounted(tmp_path, [])
with patch(f"{PKG}._run", return_value=True):
with patch("steam_backlog_enforcer._playtime_block._run", return_value=True):
assert apply_block() == list(block_targets())
def test_skips_missing_targets(self, tmp_path: Path) -> None:
_set_mounted(tmp_path, [])
block_targets()[0].write_text("x", encoding="utf-8")
with patch(f"{PKG}._run", return_value=True):
with patch("steam_backlog_enforcer._playtime_block._run", return_value=True):
assert apply_block() == [block_targets()[0]]
def test_skips_already_mounted(self, tmp_path: Path) -> None:
_make_targets_exist()
_set_mounted(tmp_path, [block_targets()[0]])
with patch(f"{PKG}._run", return_value=True):
with patch("steam_backlog_enforcer._playtime_block._run", return_value=True):
assert apply_block() == list(block_targets()[1:])
def test_bind_failure_skips_remount(self, tmp_path: Path) -> None:
_make_targets_exist()
_set_mounted(tmp_path, [])
with patch(f"{PKG}._run", return_value=False) as mock_run:
with patch(
"steam_backlog_enforcer._playtime_block._run", return_value=False
) as mock_run:
assert apply_block() == []
# One --bind attempt per target, and no remount followed any of them.
assert all("--bind" in call.args[0] for call in mock_run.call_args_list)
@ -90,15 +78,22 @@ class TestApplyBlock:
_make_targets_exist()
_set_mounted(tmp_path, [])
# First call (--bind) succeeds, second (remount) fails, repeatedly.
with patch(f"{PKG}._run", side_effect=[True, False] * 4):
with patch(
"steam_backlog_enforcer._playtime_block._run", side_effect=[True, False] * 4
):
assert apply_block() == []
def test_uses_mount_and_remount_argv(self, tmp_path: Path) -> None:
_set_mounted(tmp_path, [])
block_targets()[0].write_text("x", encoding="utf-8")
with (
patch(f"{PKG}._run", return_value=True) as mock_run,
patch(f"{PKG}._mount_bin", return_value="/bin/mount"),
patch(
"steam_backlog_enforcer._playtime_block._run", return_value=True
) as mock_run,
patch(
"steam_backlog_enforcer._playtime_block._mount_bin",
return_value="/bin/mount",
),
):
apply_block()
argvs = [call.args[0] for call in mock_run.call_args_list]
@ -109,7 +104,7 @@ class TestApplyBlock:
class TestReleaseBlock:
def test_nothing_mounted(self, tmp_path: Path) -> None:
_set_mounted(tmp_path, [])
with patch(f"{PKG}._run") as mock_run:
with patch("steam_backlog_enforcer._playtime_block._run") as mock_run:
assert release_block() == []
mock_run.assert_not_called()
@ -121,7 +116,7 @@ class TestReleaseBlock:
_set_mounted(tmp_path, [])
return True
with patch(f"{PKG}._run", side_effect=fake_run):
with patch("steam_backlog_enforcer._playtime_block._run", side_effect=fake_run):
assert release_block() == [target]
def test_loops_for_stacked_mounts(self, tmp_path: Path) -> None:
@ -134,14 +129,18 @@ class TestReleaseBlock:
_set_mounted(tmp_path, [target] if remaining["n"] > 0 else [])
return True
with patch(f"{PKG}._run", side_effect=fake_run) as mock_run:
with patch(
"steam_backlog_enforcer._playtime_block._run", side_effect=fake_run
) as mock_run:
assert release_block() == [target]
assert mock_run.call_count == 3
def test_breaks_when_umount_fails(self, tmp_path: Path) -> None:
target = block_targets()[0]
_set_mounted(tmp_path, [target])
with patch(f"{PKG}._run", return_value=False) as mock_run:
with patch(
"steam_backlog_enforcer._playtime_block._run", return_value=False
) as mock_run:
assert release_block() == []
assert mock_run.call_count == 1
@ -149,7 +148,9 @@ class TestReleaseBlock:
"""Always-succeeds-but-never-unmounts must not spin forever."""
target = block_targets()[0]
_set_mounted(tmp_path, [target])
with patch(f"{PKG}._run", return_value=True) as mock_run:
with patch(
"steam_backlog_enforcer._playtime_block._run", return_value=True
) as mock_run:
assert release_block() == []
assert mock_run.call_count == 20
@ -157,8 +158,13 @@ class TestReleaseBlock:
target = block_targets()[0]
_set_mounted(tmp_path, [target])
with (
patch(f"{PKG}._run", return_value=False) as mock_run,
patch(f"{PKG}._umount_bin", return_value="/bin/umount"),
patch(
"steam_backlog_enforcer._playtime_block._run", return_value=False
) as mock_run,
patch(
"steam_backlog_enforcer._playtime_block._umount_bin",
return_value="/bin/umount",
),
):
release_block()
assert mock_run.call_args_list[0].args[0] == ["/bin/umount", "-l", str(target)]
@ -173,7 +179,7 @@ class TestReconcile:
_set_mounted(tmp_path, [])
return True
with patch(f"{PKG}._run", side_effect=fake_run):
with patch("steam_backlog_enforcer._playtime_block._run", side_effect=fake_run):
masked, released = reconcile(should_block=False)
assert masked == []
assert released == [target]
@ -182,7 +188,7 @@ class TestReconcile:
_make_targets_exist()
_set_mounted(tmp_path, [])
with (
patch(f"{PKG}._run", return_value=True),
patch("steam_backlog_enforcer._playtime_block._run", return_value=True),
patch(f"{PKG}.mounts_are_visible", return_value=True),
):
masked, released = reconcile(should_block=True)
@ -193,7 +199,7 @@ class TestReconcile:
_make_targets_exist()
_set_mounted(tmp_path, [])
with (
patch(f"{PKG}._run", return_value=True),
patch("steam_backlog_enforcer._playtime_block._run", return_value=True),
patch(f"{PKG}.mounts_are_visible", return_value=False),
patch(f"{PKG}.logger.error") as mock_error,
):
@ -203,236 +209,8 @@ class TestReconcile:
def test_no_visibility_check_when_nothing_was_masked(self, tmp_path: Path) -> None:
_set_mounted(tmp_path, [])
with (
patch(f"{PKG}._run", return_value=True),
patch("steam_backlog_enforcer._playtime_block._run", return_value=True),
patch(f"{PKG}.mounts_are_visible") as mock_visible,
):
reconcile(should_block=True)
mock_visible.assert_not_called()
class TestRequestSteamShutdown:
def test_skips_when_steam_is_absent(self) -> None:
with (
patch(f"{PKG}.shutil.which", return_value=None),
patch(f"{PKG}._run") as mock_run,
):
request_steam_shutdown()
mock_run.assert_not_called()
def test_sends_shutdown(self) -> None:
with (
patch(f"{PKG}.shutil.which", return_value="/usr/bin/steam"),
patch(f"{PKG}._run", return_value=True) as mock_run,
):
request_steam_shutdown()
mock_run.assert_called_once_with(["/usr/bin/steam", "-shutdown"])
class TestSteamAndLauncherPids:
def test_unions_both_name_sets(self) -> None:
with patch(
f"{PKG}.get_pids_by_process_names",
return_value={11: "steam", 22: "lutris"},
) as mock_get:
assert steam_and_launcher_pids() == {11, 22}
names = mock_get.call_args.args[0]
assert "steam" in names
assert "lutris" in names
class TestReadPpid:
def _write_stat(self, tmp_path: Path, pid: int, comm: str, ppid: int) -> None:
proc = tmp_path / "playtime_proc" / str(pid)
proc.mkdir(parents=True, exist_ok=True)
(proc / "stat").write_text(
f"{pid} ({comm}) S {ppid} {pid} 0 0 -1 0", encoding="utf-8"
)
def test_reads_simple_comm(self, tmp_path: Path) -> None:
self._write_stat(tmp_path, 100, "steam", 1)
assert _read_ppid(100) == 1
def test_handles_comm_with_spaces_and_parens(self, tmp_path: Path) -> None:
"""comm can contain both, so field position alone cannot be trusted."""
self._write_stat(tmp_path, 101, "my game (x) 2", 42)
assert _read_ppid(101) == 42
def test_missing_file(self) -> None:
assert _read_ppid(999999) is None
def test_malformed_stat_too_few_fields(self, tmp_path: Path) -> None:
proc = tmp_path / "playtime_proc" / "102"
proc.mkdir(parents=True, exist_ok=True)
(proc / "stat").write_text("102 (x) S", encoding="utf-8")
assert _read_ppid(102) is None
def test_non_integer_ppid(self, tmp_path: Path) -> None:
proc = tmp_path / "playtime_proc" / "103"
proc.mkdir(parents=True, exist_ok=True)
(proc / "stat").write_text("103 (x) S notanumber 1", encoding="utf-8")
assert _read_ppid(103) is None
class TestChildMap:
def _write(self, tmp_path: Path, pid: int, ppid: int) -> None:
proc = tmp_path / "playtime_proc" / str(pid)
proc.mkdir(parents=True, exist_ok=True)
(proc / "stat").write_text(f"{pid} (p) S {ppid} 1 0", encoding="utf-8")
def test_builds_mapping(self, tmp_path: Path) -> None:
self._write(tmp_path, 10, 1)
self._write(tmp_path, 11, 10)
self._write(tmp_path, 12, 10)
mapping = _child_map()
assert sorted(mapping[10]) == [11, 12]
def test_skips_non_digit_entries(self, tmp_path: Path) -> None:
(tmp_path / "playtime_proc" / "cpuinfo").touch()
(tmp_path / "playtime_proc" / "self").mkdir()
self._write(tmp_path, 20, 1)
assert 1 in _child_map()
def test_unreadable_proc_returns_empty(self) -> None:
with patch(f"{PKG}.Path.iterdir", side_effect=OSError("nope")):
assert _child_map() == {}
def test_entry_without_stat_is_skipped(self, tmp_path: Path) -> None:
(tmp_path / "playtime_proc" / "30").mkdir()
self._write(tmp_path, 31, 1)
assert 30 not in {c for kids in _child_map().values() for c in kids}
class TestDescendantPids:
def test_walks_the_tree(self) -> None:
tree = {100: [101, 102], 101: [103]}
with (
patch(f"{PKG}._child_map", return_value=tree),
patch(f"{PKG}._own_process_chain", return_value=set()),
):
assert descendant_pids({100}) == {101, 102, 103}
def test_excludes_the_roots_themselves(self) -> None:
with (
patch(f"{PKG}._child_map", return_value={100: [101]}),
patch(f"{PKG}._own_process_chain", return_value=set()),
):
assert 100 not in descendant_pids({100})
def test_never_returns_our_own_process(self) -> None:
with (
patch(f"{PKG}._child_map", return_value={100: [101, 102]}),
patch(f"{PKG}._own_process_chain", return_value={101}),
):
assert descendant_pids({100}) == {102}
def test_protected_root_is_not_walked(self) -> None:
with (
patch(f"{PKG}._child_map", return_value={100: [101]}),
patch(f"{PKG}._own_process_chain", return_value={100}),
):
assert descendant_pids({100}) == set()
def test_no_children(self) -> None:
with (
patch(f"{PKG}._child_map", return_value={}),
patch(f"{PKG}._own_process_chain", return_value=set()),
):
assert descendant_pids({100}) == set()
def test_cycle_terminates(self) -> None:
with (
patch(f"{PKG}._child_map", return_value={1: [2], 2: [1]}),
patch(f"{PKG}._own_process_chain", return_value=set()),
):
assert descendant_pids({1}) == {2, 1}
def test_depth_is_bounded(self) -> None:
chain = {i: [i + 1] for i in range(1, 100)}
with (
patch(f"{PKG}._child_map", return_value=chain),
patch(f"{PKG}._own_process_chain", return_value=set()),
):
assert len(descendant_pids({1})) == 32
class TestOwnProcessChain:
def test_includes_self(self) -> None:
with patch(f"{PKG}._read_ppid", return_value=None):
assert _own_process_chain() == {os.getpid()}
def test_walks_to_init(self) -> None:
with patch(f"{PKG}._read_ppid", side_effect=[500, 1, 0]):
chain = _own_process_chain()
assert chain == {os.getpid(), 500, 1}
def test_stops_on_repeat(self) -> None:
with patch(f"{PKG}._read_ppid", return_value=os.getpid()):
assert _own_process_chain() == {os.getpid()}
def test_depth_is_bounded(self) -> None:
"""An unbroken ancestor chain must stop at the depth cap, not spin."""
counter = iter(range(10_000, 20_000))
with patch(f"{PKG}._read_ppid", side_effect=lambda _pid: next(counter)):
assert len(_own_process_chain()) == 32
class TestKillGamingProcesses:
def test_sigterm_by_default(self) -> None:
with (
patch(f"{PKG}.descendant_pids", return_value=set()),
patch(f"{PKG}._own_process_chain", return_value=set()),
patch(f"{PKG}.os.kill") as mock_kill,
):
assert kill_gaming_processes({7}, force=False) == [7]
mock_kill.assert_called_once_with(7, signal.SIGTERM)
def test_sigkill_when_forced(self) -> None:
with (
patch(f"{PKG}.descendant_pids", return_value=set()),
patch(f"{PKG}._own_process_chain", return_value=set()),
patch(f"{PKG}.os.kill") as mock_kill,
):
kill_gaming_processes({7}, force=True)
mock_kill.assert_called_once_with(7, signal.SIGKILL)
def test_includes_descendants(self) -> None:
with (
patch(f"{PKG}.descendant_pids", return_value={8, 9}),
patch(f"{PKG}._own_process_chain", return_value=set()),
patch(f"{PKG}.os.kill"),
):
assert kill_gaming_processes({7}, force=False) == [7, 8, 9]
def test_never_signals_our_own_chain(self) -> None:
with (
patch(f"{PKG}.descendant_pids", return_value=set()),
patch(f"{PKG}._own_process_chain", return_value={7}),
patch(f"{PKG}.os.kill") as mock_kill,
):
assert kill_gaming_processes({7, 8}, force=False) == [8]
mock_kill.assert_called_once_with(8, signal.SIGTERM)
def test_process_already_gone(self) -> None:
with (
patch(f"{PKG}.descendant_pids", return_value=set()),
patch(f"{PKG}._own_process_chain", return_value=set()),
patch(f"{PKG}.os.kill", side_effect=ProcessLookupError),
):
assert kill_gaming_processes({7}, force=False) == []
def test_permission_denied(self) -> None:
with (
patch(f"{PKG}.descendant_pids", return_value=set()),
patch(f"{PKG}._own_process_chain", return_value=set()),
patch(f"{PKG}.os.kill", side_effect=PermissionError),
):
assert kill_gaming_processes({7}, force=False) == []
def test_empty_set(self) -> None:
with (
patch(f"{PKG}.descendant_pids", return_value=set()),
patch(f"{PKG}._own_process_chain", return_value=set()),
patch(f"{PKG}.os.kill") as mock_kill,
):
assert kill_gaming_processes(set(), force=False) == []
mock_kill.assert_not_called()

View File

@ -0,0 +1,198 @@
"""Tests for the main CLI: the warn/cutoff/kill escalation."""
from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
import pytest
from steam_backlog_enforcer._playtime_cutoff import (
_begin_cutoff,
_kill_set,
_sustain_block,
_warn,
)
from steam_backlog_enforcer._playtime_notify import (
notify_desktop_user,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
rules_for,
)
from steam_backlog_enforcer.config import Config
PKG = "steam_backlog_enforcer._playtime"
LOCAL = timezone(timedelta(hours=2))
NOW = datetime(2026, 7, 27, 20, 0, tzinfo=LOCAL)
TODAY = "2026-07-27"
@pytest.fixture
def quiet_tick() -> object:
"""Neutralise every side effect the tick can produce."""
with ExitStack() as stack:
# Each name is patched in the module that *resolves* it, not in the
# package that re-exports it -- otherwise the patch never bites.
cutoff = "steam_backlog_enforcer._playtime_cutoff"
mocks = {
name: stack.enter_context(patch(f"{where}.{name}"))
for name, where in (
("reconcile", PKG),
("request_steam_shutdown", cutoff),
("kill_gaming_processes", cutoff),
("notify_desktop_user", cutoff),
("mounted_targets", PKG),
)
}
mocks["mounted_targets"].return_value = set()
stack.enter_context(patch(f"{PKG}.is_total_block_active", return_value=False))
yield mocks
def _rules(*, demo: bool = False, **cfg: object) -> object:
return rules_for(Config(**cfg), demo=demo)
class TestBeginCutoff:
def test_asks_steam_to_close_before_masking_anything(self) -> None:
"""Masking makes `steam -shutdown` a no-op, so order is load-bearing."""
state = PlaytimeState(day_key=TODAY, seconds=60.0)
with (
patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as mock_rec,
patch(
"steam_backlog_enforcer._playtime_cutoff.request_steam_shutdown"
) as mock_shutdown,
patch(
"steam_backlog_enforcer._playtime_cutoff.kill_gaming_processes"
) as mock_kill,
patch(
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
) as mock_notify,
patch(
"steam_backlog_enforcer._playtime_cutoff._kill_set", return_value={7}
),
):
out = _begin_cutoff(state, _rules(demo=True), now=NOW)
mock_shutdown.assert_called_once()
mock_rec.assert_not_called()
mock_kill.assert_called_once_with({7}, force=False)
mock_notify.assert_called_once()
assert out.blocked_at == NOW.timestamp()
class TestSustainBlock:
def _run(
self, elapsed: float, *, demo: bool = False
) -> tuple[MagicMock, MagicMock]:
state = PlaytimeState(
day_key=TODAY, seconds=10**6, blocked_at=NOW.timestamp() - elapsed
)
with (
patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as mock_rec,
patch(
"steam_backlog_enforcer._playtime_cutoff.kill_gaming_processes"
) as mock_kill,
patch(
"steam_backlog_enforcer._playtime_cutoff._kill_set", return_value={7}
),
):
_sustain_block(state, _rules(demo=demo), now=NOW)
return mock_rec, mock_kill
def test_grace_period_defers_the_mount(self) -> None:
mock_rec, mock_kill = self._run(1.0)
mock_rec.assert_not_called()
mock_kill.assert_called_once_with({7}, force=False)
def test_mounts_once_the_grace_has_passed(self) -> None:
mock_rec, _ = self._run(3.0)
mock_rec.assert_called_once_with(should_block=True)
def test_escalates_to_sigkill(self) -> None:
_, mock_kill = self._run(31.0)
mock_kill.assert_called_once_with({7}, force=True)
def test_no_escalation_just_before_the_threshold(self) -> None:
_, mock_kill = self._run(29.0)
mock_kill.assert_called_once_with({7}, force=False)
def test_demo_escalates_sooner(self) -> None:
_, mock_kill = self._run(11.0, demo=True)
mock_kill.assert_called_once_with({7}, force=True)
def test_state_is_unchanged(self) -> None:
state = PlaytimeState(day_key=TODAY, seconds=99.0, blocked_at=1.0)
with (
patch("steam_backlog_enforcer._playtime_cutoff.reconcile"),
patch("steam_backlog_enforcer._playtime_cutoff.kill_gaming_processes"),
patch(
"steam_backlog_enforcer._playtime_cutoff._kill_set", return_value=set()
),
):
out = _sustain_block(state, _rules(), now=NOW)
assert out is state
class TestKillSet:
def test_is_wider_than_the_budget_predicate(self) -> None:
"""A Lutris Wine game is invisible to both budget matchers."""
with (
patch(
"steam_backlog_enforcer._playtime_cutoff.qualifying_pids",
return_value={1},
),
patch(
"steam_backlog_enforcer._playtime_cutoff.steam_and_launcher_pids",
return_value={2},
),
):
assert _kill_set(_rules()) == {1, 2}
class TestWarn:
def test_records_the_threshold(self) -> None:
state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600])
with patch(
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
) as mock_notify:
out = _warn(state, _rules())
assert out.warned_seconds == [3600, 1800, 600, 300]
assert "5 minutes" in mock_notify.call_args.args[1]
def test_no_op_when_nothing_is_due(self) -> None:
state = PlaytimeState(seconds=0.0)
with patch(
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
) as mock_notify:
out = _warn(state, _rules())
mock_notify.assert_not_called()
assert out is state
class TestNotifyDesktopUser:
def test_routes_through_the_desktop_session(self) -> None:
"""notify-send as root with no DBUS address never reaches the user."""
with (
patch(
"steam_backlog_enforcer._playtime_notify._resolve_desktop_user",
return_value="kuhy",
),
patch("steam_backlog_enforcer._playtime_notify._run_as_user") as mock_run,
):
notify_desktop_user("T", "B")
cmd, user = mock_run.call_args.args
assert cmd[0] == "notify-send"
assert user == "kuhy"
def test_swallows_errors(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_notify._resolve_desktop_user",
return_value="kuhy",
),
patch(
"steam_backlog_enforcer._playtime_notify._run_as_user",
side_effect=OSError("no session"),
),
):
notify_desktop_user("T", "B")

View File

@ -0,0 +1,161 @@
"""Tests for stopping the processes a playtime cutoff must end."""
from __future__ import annotations
import signal
from unittest.mock import patch
from steam_backlog_enforcer._playtime_kill import (
kill_gaming_processes,
request_steam_shutdown,
steam_and_launcher_pids,
)
PKG = "steam_backlog_enforcer._playtime_kill"
class TestRequestSteamShutdown:
def test_skips_when_steam_is_absent(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill.shutil.which", return_value=None
),
patch("steam_backlog_enforcer._playtime_kill._run") as mock_run,
):
request_steam_shutdown()
mock_run.assert_not_called()
def test_sends_shutdown(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill.shutil.which",
return_value="/usr/bin/steam",
),
patch(
"steam_backlog_enforcer._playtime_kill._run", return_value=True
) as mock_run,
):
request_steam_shutdown()
mock_run.assert_called_once_with(["/usr/bin/steam", "-shutdown"])
class TestSteamAndLauncherPids:
def test_unions_both_name_sets(self) -> None:
with patch(
"steam_backlog_enforcer._playtime_kill.get_pids_by_process_names",
return_value={11: "steam", 22: "lutris"},
) as mock_get:
assert steam_and_launcher_pids() == {11, 22}
names = mock_get.call_args.args[0]
assert "steam" in names
assert "lutris" in names
class TestKillGamingProcesses:
def test_sigterm_by_default(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill.descendant_pids",
return_value=set(),
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
patch("steam_backlog_enforcer._playtime_kill.os.kill") as mock_kill,
):
assert kill_gaming_processes({7}, force=False) == [7]
mock_kill.assert_called_once_with(7, signal.SIGTERM)
def test_sigkill_when_forced(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill.descendant_pids",
return_value=set(),
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
patch("steam_backlog_enforcer._playtime_kill.os.kill") as mock_kill,
):
kill_gaming_processes({7}, force=True)
mock_kill.assert_called_once_with(7, signal.SIGKILL)
def test_includes_descendants(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill.descendant_pids",
return_value={8, 9},
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
patch("steam_backlog_enforcer._playtime_kill.os.kill"),
):
assert kill_gaming_processes({7}, force=False) == [7, 8, 9]
def test_never_signals_our_own_chain(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill.descendant_pids",
return_value=set(),
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value={7},
),
patch("steam_backlog_enforcer._playtime_kill.os.kill") as mock_kill,
):
assert kill_gaming_processes({7, 8}, force=False) == [8]
mock_kill.assert_called_once_with(8, signal.SIGTERM)
def test_process_already_gone(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill.descendant_pids",
return_value=set(),
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
patch(
"steam_backlog_enforcer._playtime_kill.os.kill",
side_effect=ProcessLookupError,
),
):
assert kill_gaming_processes({7}, force=False) == []
def test_permission_denied(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill.descendant_pids",
return_value=set(),
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
patch(
"steam_backlog_enforcer._playtime_kill.os.kill",
side_effect=PermissionError,
),
):
assert kill_gaming_processes({7}, force=False) == []
def test_empty_set(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill.descendant_pids",
return_value=set(),
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
patch("steam_backlog_enforcer._playtime_kill.os.kill") as mock_kill,
):
assert kill_gaming_processes(set(), force=False) == []
mock_kill.assert_not_called()

View File

@ -1,28 +1,18 @@
"""Tests for playtime accumulation, rollover, warnings and /proc scanning."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import os
from typing import TYPE_CHECKING
from unittest.mock import patch
from steam_backlog_enforcer._playtime import (
PlaytimeState,
_humanise,
_match_cmdline,
from steam_backlog_enforcer._playtime_budget import (
accumulate,
get_pids_by_cmdline_names,
pending_warning,
qualifying_pids,
roll_over,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
rules_for,
)
from steam_backlog_enforcer.config import Config
if TYPE_CHECKING:
from pathlib import Path
PKG = "steam_backlog_enforcer._playtime"
LOCAL = timezone(timedelta(hours=2))
NOW = datetime(2026, 7, 27, 20, 0, tzinfo=LOCAL)
@ -153,131 +143,3 @@ class TestPendingWarning:
def test_over_budget_still_reports(self) -> None:
state = PlaytimeState(seconds=8 * 3600 + 500, warned_seconds=[3600, 1800, 600])
assert pending_warning(state, self._rules()) == 300
class TestMatchCmdline:
def _proc(self, tmp_path: Path, pid: int, argv: list[str]) -> Path:
entry = tmp_path / "playtime_proc" / str(pid)
entry.mkdir(parents=True, exist_ok=True)
(entry / "cmdline").write_bytes(
b"\x00".join(a.encode() for a in argv) + b"\x00"
)
return entry
def test_direct_argv0_match(self, tmp_path: Path) -> None:
entry = self._proc(tmp_path, 10, ["/usr/bin/lutris", "--foo"])
assert _match_cmdline(entry, frozenset({"lutris"})) == "lutris"
def test_interpreter_falls_through_to_argv1(self, tmp_path: Path) -> None:
"""The real case: /usr/bin/lutris is a python3 script."""
entry = self._proc(tmp_path, 11, ["/usr/bin/python3", "/usr/bin/lutris"])
assert _match_cmdline(entry, frozenset({"lutris"})) == "lutris"
def test_interpreter_with_no_argv1(self, tmp_path: Path) -> None:
entry = self._proc(tmp_path, 12, ["python3"])
assert _match_cmdline(entry, frozenset({"lutris"})) is None
def test_interpreter_running_something_else(self, tmp_path: Path) -> None:
entry = self._proc(tmp_path, 13, ["python3", "/usr/bin/other"])
assert _match_cmdline(entry, frozenset({"lutris"})) is None
def test_non_interpreter_no_match(self, tmp_path: Path) -> None:
entry = self._proc(tmp_path, 14, ["/usr/bin/firefox"])
assert _match_cmdline(entry, frozenset({"lutris"})) is None
def test_empty_cmdline(self, tmp_path: Path) -> None:
entry = tmp_path / "playtime_proc" / "15"
entry.mkdir(parents=True, exist_ok=True)
(entry / "cmdline").write_bytes(b"")
assert _match_cmdline(entry, frozenset({"lutris"})) is None
def test_unreadable_cmdline(self, tmp_path: Path) -> None:
entry = tmp_path / "playtime_proc" / "16"
entry.mkdir(parents=True, exist_ok=True)
assert _match_cmdline(entry, frozenset({"lutris"})) is None
def test_undecodable_bytes_do_not_raise(self, tmp_path: Path) -> None:
entry = tmp_path / "playtime_proc" / "17"
entry.mkdir(parents=True, exist_ok=True)
(entry / "cmdline").write_bytes(b"\xff\xfe\x00")
assert _match_cmdline(entry, frozenset({"lutris"})) is None
class TestGetPidsByCmdlineNames:
def _proc(self, tmp_path: Path, pid: int, argv: list[str]) -> None:
entry = tmp_path / "playtime_proc" / str(pid)
entry.mkdir(parents=True, exist_ok=True)
(entry / "cmdline").write_bytes(
b"\x00".join(a.encode() for a in argv) + b"\x00"
)
def test_finds_matches(self, tmp_path: Path) -> None:
self._proc(tmp_path, 100, ["python3", "/usr/bin/lutris"])
self._proc(tmp_path, 101, ["/usr/bin/firefox"])
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {100: "lutris"}
def test_skips_non_digit_entries(self, tmp_path: Path) -> None:
(tmp_path / "playtime_proc" / "cpuinfo").touch()
(tmp_path / "playtime_proc" / "self").mkdir(exist_ok=True)
self._proc(tmp_path, 102, ["python3", "/usr/bin/lutris"])
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {102: "lutris"}
def test_never_matches_our_own_pid(self, tmp_path: Path) -> None:
"""The daemon is itself python3 - it must not count as a launcher."""
self._proc(tmp_path, os.getpid(), ["python3", "/usr/bin/lutris"])
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {}
def test_empty_proc(self) -> None:
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {}
class TestQualifyingPids:
def test_steam_games_only_when_launchers_are_off(self) -> None:
rules = rules_for(Config(count_launcher_processes=False), demo=False)
with (
patch(f"{PKG}.get_running_steam_game_pids", return_value={5: 440, 6: 0}),
patch(f"{PKG}.get_pids_by_process_names") as mock_comm,
):
assert qualifying_pids(rules) == {5}
mock_comm.assert_not_called()
def test_steam_client_appid_zero_does_not_count(self) -> None:
"""Browsing the store is not gaming."""
rules = rules_for(Config(count_launcher_processes=False), demo=False)
with patch(f"{PKG}.get_running_steam_game_pids", return_value={9: 0}):
assert qualifying_pids(rules) == set()
def test_unions_launchers_from_both_scanners(self) -> None:
rules = rules_for(Config(count_launcher_processes=True), demo=False)
with (
patch(f"{PKG}.get_running_steam_game_pids", return_value={5: 440}),
patch(f"{PKG}.get_pids_by_process_names", return_value={7: "heroic"}),
patch(f"{PKG}.get_pids_by_cmdline_names", return_value={8: "lutris"}),
):
assert qualifying_pids(rules) == {5, 7, 8}
def test_no_processes_at_all(self) -> None:
rules = rules_for(Config(), demo=False)
with (
patch(f"{PKG}.get_running_steam_game_pids", return_value={}),
patch(f"{PKG}.get_pids_by_process_names", return_value={}),
patch(f"{PKG}.get_pids_by_cmdline_names", return_value={}),
):
assert qualifying_pids(rules) == set()
class TestHumanise:
def test_seconds(self) -> None:
assert _humanise(30) == "30 seconds"
def test_exact_minute_boundary(self) -> None:
assert _humanise(60) == "1 minutes"
def test_minutes(self) -> None:
assert _humanise(300) == "5 minutes"
def test_one_hour_is_special_cased(self) -> None:
assert _humanise(3600) == "1 hour"
def test_thirty_minutes(self) -> None:
assert _humanise(1800) == "30 minutes"

View File

@ -0,0 +1,183 @@
"""Tests for the main CLI: cmdline-based process detection."""
from datetime import datetime, timedelta, timezone
import os
from pathlib import Path
from unittest.mock import patch
from steam_backlog_enforcer._playtime_notify import (
_humanise,
)
from steam_backlog_enforcer._playtime_procs import (
_match_cmdline,
get_pids_by_cmdline_names,
qualifying_pids,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
rules_for,
)
from steam_backlog_enforcer.config import Config
PKG = "steam_backlog_enforcer._playtime"
LOCAL = timezone(timedelta(hours=2))
NOW = datetime(2026, 7, 27, 20, 0, tzinfo=LOCAL)
INTERVAL = 3.0
def _state_ticked(seconds_ago: float, **kwargs: object) -> PlaytimeState:
return PlaytimeState(last_tick_at=NOW.timestamp() - seconds_ago, **kwargs)
class TestMatchCmdline:
def _proc(self, tmp_path: Path, pid: int, argv: list[str]) -> Path:
entry = tmp_path / "playtime_proc" / str(pid)
entry.mkdir(parents=True, exist_ok=True)
(entry / "cmdline").write_bytes(
b"\x00".join(a.encode() for a in argv) + b"\x00"
)
return entry
def test_direct_argv0_match(self, tmp_path: Path) -> None:
entry = self._proc(tmp_path, 10, ["/usr/bin/lutris", "--foo"])
assert _match_cmdline(entry, frozenset({"lutris"})) == "lutris"
def test_interpreter_falls_through_to_argv1(self, tmp_path: Path) -> None:
"""The real case: /usr/bin/lutris is a python3 script."""
entry = self._proc(tmp_path, 11, ["/usr/bin/python3", "/usr/bin/lutris"])
assert _match_cmdline(entry, frozenset({"lutris"})) == "lutris"
def test_interpreter_with_no_argv1(self, tmp_path: Path) -> None:
entry = self._proc(tmp_path, 12, ["python3"])
assert _match_cmdline(entry, frozenset({"lutris"})) is None
def test_interpreter_running_something_else(self, tmp_path: Path) -> None:
entry = self._proc(tmp_path, 13, ["python3", "/usr/bin/other"])
assert _match_cmdline(entry, frozenset({"lutris"})) is None
def test_non_interpreter_no_match(self, tmp_path: Path) -> None:
entry = self._proc(tmp_path, 14, ["/usr/bin/firefox"])
assert _match_cmdline(entry, frozenset({"lutris"})) is None
def test_empty_cmdline(self, tmp_path: Path) -> None:
entry = tmp_path / "playtime_proc" / "15"
entry.mkdir(parents=True, exist_ok=True)
(entry / "cmdline").write_bytes(b"")
assert _match_cmdline(entry, frozenset({"lutris"})) is None
def test_unreadable_cmdline(self, tmp_path: Path) -> None:
entry = tmp_path / "playtime_proc" / "16"
entry.mkdir(parents=True, exist_ok=True)
assert _match_cmdline(entry, frozenset({"lutris"})) is None
def test_undecodable_bytes_do_not_raise(self, tmp_path: Path) -> None:
entry = tmp_path / "playtime_proc" / "17"
entry.mkdir(parents=True, exist_ok=True)
(entry / "cmdline").write_bytes(b"\xff\xfe\x00")
assert _match_cmdline(entry, frozenset({"lutris"})) is None
class TestGetPidsByCmdlineNames:
def _proc(self, tmp_path: Path, pid: int, argv: list[str]) -> None:
entry = tmp_path / "playtime_proc" / str(pid)
entry.mkdir(parents=True, exist_ok=True)
(entry / "cmdline").write_bytes(
b"\x00".join(a.encode() for a in argv) + b"\x00"
)
def test_finds_matches(self, tmp_path: Path) -> None:
self._proc(tmp_path, 100, ["python3", "/usr/bin/lutris"])
self._proc(tmp_path, 101, ["/usr/bin/firefox"])
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {100: "lutris"}
def test_skips_non_digit_entries(self, tmp_path: Path) -> None:
(tmp_path / "playtime_proc" / "cpuinfo").touch()
(tmp_path / "playtime_proc" / "self").mkdir(exist_ok=True)
self._proc(tmp_path, 102, ["python3", "/usr/bin/lutris"])
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {102: "lutris"}
def test_never_matches_our_own_pid(self, tmp_path: Path) -> None:
"""The daemon is itself python3 - it must not count as a launcher."""
self._proc(tmp_path, os.getpid(), ["python3", "/usr/bin/lutris"])
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {}
def test_empty_proc(self) -> None:
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {}
class TestQualifyingPids:
def test_steam_games_only_when_launchers_are_off(self) -> None:
rules = rules_for(Config(count_launcher_processes=False), demo=False)
with (
patch(
"steam_backlog_enforcer._playtime_procs.get_running_steam_game_pids",
return_value={5: 440, 6: 0},
),
patch(
"steam_backlog_enforcer._playtime_procs.get_pids_by_process_names"
) as mock_comm,
):
assert qualifying_pids(rules) == {5}
mock_comm.assert_not_called()
def test_steam_client_appid_zero_does_not_count(self) -> None:
"""Browsing the store is not gaming."""
rules = rules_for(Config(count_launcher_processes=False), demo=False)
with patch(
"steam_backlog_enforcer._playtime_procs.get_running_steam_game_pids",
return_value={9: 0},
):
assert qualifying_pids(rules) == set()
def test_unions_launchers_from_both_scanners(self) -> None:
rules = rules_for(Config(count_launcher_processes=True), demo=False)
with (
patch(
"steam_backlog_enforcer._playtime_procs.get_running_steam_game_pids",
return_value={5: 440},
),
patch(
"steam_backlog_enforcer._playtime_procs.get_pids_by_process_names",
return_value={7: "heroic"},
),
patch(
"steam_backlog_enforcer._playtime_procs.get_pids_by_cmdline_names",
return_value={8: "lutris"},
),
):
assert qualifying_pids(rules) == {5, 7, 8}
def test_no_processes_at_all(self) -> None:
rules = rules_for(Config(), demo=False)
with (
patch(
"steam_backlog_enforcer._playtime_procs.get_running_steam_game_pids",
return_value={},
),
patch(
"steam_backlog_enforcer._playtime_procs.get_pids_by_process_names",
return_value={},
),
patch(
"steam_backlog_enforcer._playtime_procs.get_pids_by_cmdline_names",
return_value={},
),
):
assert qualifying_pids(rules) == set()
class TestHumanise:
def test_seconds(self) -> None:
assert _humanise(30) == "30 seconds"
def test_exact_minute_boundary(self) -> None:
assert _humanise(60) == "1 minutes"
def test_minutes(self) -> None:
assert _humanise(300) == "5 minutes"
def test_one_hour_is_special_cased(self) -> None:
assert _humanise(3600) == "1 hour"
def test_thirty_minutes(self) -> None:
assert _humanise(1800) == "30 minutes"

View File

@ -0,0 +1,204 @@
"""Tests for walking the process tree from the launcher roots."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from unittest.mock import patch
if TYPE_CHECKING:
from pathlib import Path
from steam_backlog_enforcer._playtime_kill import (
_child_map,
_own_process_chain,
_read_ppid,
descendant_pids,
)
PKG = "steam_backlog_enforcer._playtime_kill"
class TestReadPpid:
def _write_stat(self, tmp_path: Path, pid: int, comm: str, ppid: int) -> None:
proc = tmp_path / "playtime_proc" / str(pid)
proc.mkdir(parents=True, exist_ok=True)
(proc / "stat").write_text(
f"{pid} ({comm}) S {ppid} {pid} 0 0 -1 0", encoding="utf-8"
)
def test_reads_simple_comm(self, tmp_path: Path) -> None:
self._write_stat(tmp_path, 100, "steam", 1)
assert _read_ppid(100) == 1
def test_handles_comm_with_spaces_and_parens(self, tmp_path: Path) -> None:
"""comm can contain both, so field position alone cannot be trusted."""
self._write_stat(tmp_path, 101, "my game (x) 2", 42)
assert _read_ppid(101) == 42
def test_missing_file(self) -> None:
assert _read_ppid(999999) is None
def test_malformed_stat_too_few_fields(self, tmp_path: Path) -> None:
proc = tmp_path / "playtime_proc" / "102"
proc.mkdir(parents=True, exist_ok=True)
(proc / "stat").write_text("102 (x) S", encoding="utf-8")
assert _read_ppid(102) is None
def test_non_integer_ppid(self, tmp_path: Path) -> None:
proc = tmp_path / "playtime_proc" / "103"
proc.mkdir(parents=True, exist_ok=True)
(proc / "stat").write_text("103 (x) S notanumber 1", encoding="utf-8")
assert _read_ppid(103) is None
class TestChildMap:
def _write(self, tmp_path: Path, pid: int, ppid: int) -> None:
proc = tmp_path / "playtime_proc" / str(pid)
proc.mkdir(parents=True, exist_ok=True)
(proc / "stat").write_text(f"{pid} (p) S {ppid} 1 0", encoding="utf-8")
def test_builds_mapping(self, tmp_path: Path) -> None:
self._write(tmp_path, 10, 1)
self._write(tmp_path, 11, 10)
self._write(tmp_path, 12, 10)
mapping = _child_map()
assert sorted(mapping[10]) == [11, 12]
def test_skips_non_digit_entries(self, tmp_path: Path) -> None:
(tmp_path / "playtime_proc" / "cpuinfo").touch()
(tmp_path / "playtime_proc" / "self").mkdir()
self._write(tmp_path, 20, 1)
assert 1 in _child_map()
def test_unreadable_proc_returns_empty(self) -> None:
with patch(
"steam_backlog_enforcer._playtime_block.Path.iterdir",
side_effect=OSError("nope"),
):
assert _child_map() == {}
def test_entry_without_stat_is_skipped(self, tmp_path: Path) -> None:
(tmp_path / "playtime_proc" / "30").mkdir()
self._write(tmp_path, 31, 1)
assert 30 not in {c for kids in _child_map().values() for c in kids}
class TestDescendantPids:
def test_walks_the_tree(self) -> None:
tree = {100: [101, 102], 101: [103]}
with (
patch(
"steam_backlog_enforcer._playtime_kill._child_map", return_value=tree
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
):
assert descendant_pids({100}) == {101, 102, 103}
def test_excludes_the_roots_themselves(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill._child_map",
return_value={100: [101]},
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
):
assert 100 not in descendant_pids({100})
def test_never_returns_our_own_process(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill._child_map",
return_value={100: [101, 102]},
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value={101},
),
):
assert descendant_pids({100}) == {102}
def test_protected_root_is_not_walked(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill._child_map",
return_value={100: [101]},
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value={100},
),
):
assert descendant_pids({100}) == set()
def test_no_children(self) -> None:
with (
patch("steam_backlog_enforcer._playtime_kill._child_map", return_value={}),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
):
assert descendant_pids({100}) == set()
def test_cycle_terminates(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_kill._child_map",
return_value={1: [2], 2: [1]},
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
):
assert descendant_pids({1}) == {2, 1}
def test_depth_is_bounded(self) -> None:
chain = {i: [i + 1] for i in range(1, 100)}
with (
patch(
"steam_backlog_enforcer._playtime_kill._child_map", return_value=chain
),
patch(
"steam_backlog_enforcer._playtime_kill._own_process_chain",
return_value=set(),
),
):
assert len(descendant_pids({1})) == 32
class TestOwnProcessChain:
def test_includes_self(self) -> None:
with patch(
"steam_backlog_enforcer._playtime_kill._read_ppid", return_value=None
):
assert _own_process_chain() == {os.getpid()}
def test_walks_to_init(self) -> None:
with patch(
"steam_backlog_enforcer._playtime_kill._read_ppid", side_effect=[500, 1, 0]
):
chain = _own_process_chain()
assert chain == {os.getpid(), 500, 1}
def test_stops_on_repeat(self) -> None:
with patch(
"steam_backlog_enforcer._playtime_kill._read_ppid", return_value=os.getpid()
):
assert _own_process_chain() == {os.getpid()}
def test_depth_is_bounded(self) -> None:
"""An unbroken ancestor chain must stop at the depth cap, not spin."""
counter = iter(range(10_000, 20_000))
with patch(
"steam_backlog_enforcer._playtime_kill._read_ppid",
side_effect=lambda _pid: next(counter),
):
assert len(_own_process_chain()) == 32

View File

@ -1,24 +1,25 @@
"""Tests for the playtime tick state machine and cutoff sequence."""
from __future__ import annotations
from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
from datetime import (
datetime,
timedelta,
timezone,
)
from unittest.mock import (
patch,
)
import pytest
from steam_backlog_enforcer._playtime import (
PlaytimeState,
_begin_cutoff,
_kill_set,
_policy,
_state_or_recover,
_sustain_block,
_warn,
load_state,
notify_desktop_user,
playtime_tick,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
load_state,
rules_for,
save_state,
)
@ -34,14 +35,17 @@ TODAY = "2026-07-27"
def quiet_tick() -> object:
"""Neutralise every side effect the tick can produce."""
with ExitStack() as stack:
# Each name is patched in the module that *resolves* it, not in the
# package that re-exports it -- otherwise the patch never bites.
cutoff = "steam_backlog_enforcer._playtime_cutoff"
mocks = {
name: stack.enter_context(patch(f"{PKG}.{name}"))
for name in (
"reconcile",
"request_steam_shutdown",
"kill_gaming_processes",
"notify_desktop_user",
"mounted_targets",
name: stack.enter_context(patch(f"{where}.{name}"))
for name, where in (
("reconcile", PKG),
("request_steam_shutdown", cutoff),
("kill_gaming_processes", cutoff),
("notify_desktop_user", cutoff),
("mounted_targets", PKG),
)
}
mocks["mounted_targets"].return_value = set()
@ -76,7 +80,7 @@ class TestStateOrRecover:
assert out.is_blocked() is True
def test_fails_closed_on_corrupt_state(self) -> None:
from steam_backlog_enforcer._playtime import state_path
from steam_backlog_enforcer._playtime_state import state_path
state_path(demo=False).write_text("{bad", encoding="utf-8")
with patch(f"{PKG}.mounted_targets", return_value={"/usr/bin/steam"}):
@ -88,8 +92,10 @@ class TestPolicyBelowBudget:
def test_releases_and_warns(self) -> None:
state = PlaytimeState(day_key=TODAY, seconds=8 * 3600 - 3600)
with (
patch(f"{PKG}.reconcile") as mock_rec,
patch(f"{PKG}.notify_desktop_user") as mock_notify,
patch("steam_backlog_enforcer._playtime.reconcile") as mock_rec,
patch(
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
) as mock_notify,
):
out = _policy(state, _rules(), now=NOW)
mock_rec.assert_called_once_with(should_block=False)
@ -99,8 +105,10 @@ class TestPolicyBelowBudget:
def test_no_warning_when_far_from_budget(self) -> None:
state = PlaytimeState(day_key=TODAY, seconds=0.0)
with (
patch(f"{PKG}.reconcile"),
patch(f"{PKG}.notify_desktop_user") as mock_notify,
patch("steam_backlog_enforcer._playtime.reconcile"),
patch(
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
) as mock_notify,
):
out = _policy(state, _rules(), now=NOW)
mock_notify.assert_not_called()
@ -111,8 +119,8 @@ class TestPolicyAtOrOverBudget:
def test_engages_the_cutoff_when_not_yet_blocked(self) -> None:
state = PlaytimeState(day_key=TODAY, seconds=10**6)
with (
patch(f"{PKG}._begin_cutoff") as mock_begin,
patch(f"{PKG}._sustain_block") as mock_sustain,
patch("steam_backlog_enforcer._playtime._begin_cutoff") as mock_begin,
patch("steam_backlog_enforcer._playtime._sustain_block") as mock_sustain,
):
_policy(state, _rules(), now=NOW)
mock_begin.assert_called_once()
@ -121,8 +129,8 @@ class TestPolicyAtOrOverBudget:
def test_sustains_the_block_once_engaged(self) -> None:
state = PlaytimeState(day_key=TODAY, seconds=10**6, blocked_at=1.0)
with (
patch(f"{PKG}._begin_cutoff") as mock_begin,
patch(f"{PKG}._sustain_block") as mock_sustain,
patch("steam_backlog_enforcer._playtime._begin_cutoff") as mock_begin,
patch("steam_backlog_enforcer._playtime._sustain_block") as mock_sustain,
):
_policy(state, _rules(), now=NOW)
mock_begin.assert_not_called()
@ -134,8 +142,10 @@ class TestPolicyEnforcementDisabled:
"""Disabling must never come to mean 'blocked forever'."""
state = PlaytimeState(day_key=TODAY, seconds=10**6)
with (
patch(f"{PKG}.reconcile") as mock_rec,
patch(f"{PKG}.request_steam_shutdown") as mock_shutdown,
patch("steam_backlog_enforcer._playtime.reconcile") as mock_rec,
patch(
"steam_backlog_enforcer._playtime_cutoff.request_steam_shutdown"
) as mock_shutdown,
):
out = _policy(state, _rules(playtime_enforcement=False), now=NOW)
mock_rec.assert_called_once_with(should_block=False)
@ -143,118 +153,6 @@ class TestPolicyEnforcementDisabled:
assert out.is_blocked() is False
class TestBeginCutoff:
def test_asks_steam_to_close_before_masking_anything(self) -> None:
"""Masking makes `steam -shutdown` a no-op, so order is load-bearing."""
state = PlaytimeState(day_key=TODAY, seconds=60.0)
with (
patch(f"{PKG}.reconcile") as mock_rec,
patch(f"{PKG}.request_steam_shutdown") as mock_shutdown,
patch(f"{PKG}.kill_gaming_processes") as mock_kill,
patch(f"{PKG}.notify_desktop_user") as mock_notify,
patch(f"{PKG}._kill_set", return_value={7}),
):
out = _begin_cutoff(state, _rules(demo=True), now=NOW)
mock_shutdown.assert_called_once()
mock_rec.assert_not_called()
mock_kill.assert_called_once_with({7}, force=False)
mock_notify.assert_called_once()
assert out.blocked_at == NOW.timestamp()
class TestSustainBlock:
def _run(
self, elapsed: float, *, demo: bool = False
) -> tuple[MagicMock, MagicMock]:
state = PlaytimeState(
day_key=TODAY, seconds=10**6, blocked_at=NOW.timestamp() - elapsed
)
with (
patch(f"{PKG}.reconcile") as mock_rec,
patch(f"{PKG}.kill_gaming_processes") as mock_kill,
patch(f"{PKG}._kill_set", return_value={7}),
):
_sustain_block(state, _rules(demo=demo), now=NOW)
return mock_rec, mock_kill
def test_grace_period_defers_the_mount(self) -> None:
mock_rec, mock_kill = self._run(1.0)
mock_rec.assert_not_called()
mock_kill.assert_called_once_with({7}, force=False)
def test_mounts_once_the_grace_has_passed(self) -> None:
mock_rec, _ = self._run(3.0)
mock_rec.assert_called_once_with(should_block=True)
def test_escalates_to_sigkill(self) -> None:
_, mock_kill = self._run(31.0)
mock_kill.assert_called_once_with({7}, force=True)
def test_no_escalation_just_before_the_threshold(self) -> None:
_, mock_kill = self._run(29.0)
mock_kill.assert_called_once_with({7}, force=False)
def test_demo_escalates_sooner(self) -> None:
_, mock_kill = self._run(11.0, demo=True)
mock_kill.assert_called_once_with({7}, force=True)
def test_state_is_unchanged(self) -> None:
state = PlaytimeState(day_key=TODAY, seconds=99.0, blocked_at=1.0)
with (
patch(f"{PKG}.reconcile"),
patch(f"{PKG}.kill_gaming_processes"),
patch(f"{PKG}._kill_set", return_value=set()),
):
out = _sustain_block(state, _rules(), now=NOW)
assert out is state
class TestKillSet:
def test_is_wider_than_the_budget_predicate(self) -> None:
"""A Lutris Wine game is invisible to both budget matchers."""
with (
patch(f"{PKG}.qualifying_pids", return_value={1}),
patch(f"{PKG}.steam_and_launcher_pids", return_value={2}),
):
assert _kill_set(_rules()) == {1, 2}
class TestWarn:
def test_records_the_threshold(self) -> None:
state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600])
with patch(f"{PKG}.notify_desktop_user") as mock_notify:
out = _warn(state, _rules())
assert out.warned_seconds == [3600, 1800, 600, 300]
assert "5 minutes" in mock_notify.call_args.args[1]
def test_no_op_when_nothing_is_due(self) -> None:
state = PlaytimeState(seconds=0.0)
with patch(f"{PKG}.notify_desktop_user") as mock_notify:
out = _warn(state, _rules())
mock_notify.assert_not_called()
assert out is state
class TestNotifyDesktopUser:
def test_routes_through_the_desktop_session(self) -> None:
"""notify-send as root with no DBUS address never reaches the user."""
with (
patch(f"{PKG}._resolve_desktop_user", return_value="kuhy"),
patch(f"{PKG}._run_as_user") as mock_run,
):
notify_desktop_user("T", "B")
cmd, user = mock_run.call_args.args
assert cmd[0] == "notify-send"
assert user == "kuhy"
def test_swallows_errors(self) -> None:
with (
patch(f"{PKG}._resolve_desktop_user", return_value="kuhy"),
patch(f"{PKG}._run_as_user", side_effect=OSError("no session")),
):
notify_desktop_user("T", "B")
class TestPlaytimeTick:
def test_accumulates_while_a_game_runs(self, quiet_tick: dict) -> None:
save_state(
@ -318,7 +216,9 @@ class TestPlaytimeTick:
)
with (
patch(f"{PKG}.qualifying_pids", return_value={7}),
patch(f"{PKG}._kill_set", return_value={7}),
patch(
"steam_backlog_enforcer._playtime_cutoff._kill_set", return_value={7}
),
patch(f"{PKG}.datetime") as mock_dt,
):
mock_dt.now.return_value = NOW

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,128 @@
"""Tests for the /etc/hosts half of the network block."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
from steam_backlog_enforcer._total_block_hosts import (
_HOSTS_BLOCK_BEGIN,
_HOSTS_BLOCK_END,
apply_total_block_hosts,
remove_total_block_hosts,
)
if TYPE_CHECKING:
from steam_backlog_enforcer.tests._total_block_paths import (
Paths,
)
PKG = "steam_backlog_enforcer._total_block_hosts"
class TestApplyTotalBlockHosts:
"""Tests for apply total block hosts."""
def test_appends_block_when_absent(self, total_block_paths: Paths) -> None:
"""Test that appends block when absent."""
total_block_paths.hosts_file.write_text(
"127.0.0.1 localhost\n", encoding="utf-8"
)
with (
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection"),
patch(f"{PKG}._sudo_write_hosts") as mock_write,
):
assert apply_total_block_hosts() is True
written = mock_write.call_args.args[0]
assert _HOSTS_BLOCK_BEGIN in written
assert _HOSTS_BLOCK_END in written
assert "steamcommunity.com" in written
def test_already_present_is_noop(self, total_block_paths: Paths) -> None:
"""Test that already present is noop."""
total_block_paths.hosts_file.write_text(
f"127.0.0.1 localhost\n{_HOSTS_BLOCK_BEGIN}"
f"0.0.0.0 x.com\n{_HOSTS_BLOCK_END}",
encoding="utf-8",
)
with patch(f"{PKG}._sudo_write_hosts") as mock_write:
assert apply_total_block_hosts() is True
mock_write.assert_not_called()
def test_missing_hosts_file_returns_false(self) -> None:
"""Test that missing hosts file returns false."""
assert apply_total_block_hosts() is False
def test_write_failure_still_reenables_protection(
self, total_block_paths: Paths
) -> None:
"""Test that write failure still reenables protection."""
total_block_paths.hosts_file.write_text(
"127.0.0.1 localhost\n", encoding="utf-8"
)
with (
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection") as mock_enable,
patch(f"{PKG}._sudo_write_hosts", side_effect=OSError),
):
assert apply_total_block_hosts() is False
mock_enable.assert_called_once()
class TestRemoveTotalBlockHosts:
"""Tests for remove total block hosts."""
def test_removes_block_when_present(self, total_block_paths: Paths) -> None:
"""Test that removes block when present."""
total_block_paths.hosts_file.write_text(
f"127.0.0.1 localhost\n{_HOSTS_BLOCK_BEGIN}"
f"0.0.0.0 x.com\n{_HOSTS_BLOCK_END}"
"192.168.1.1 router\n",
encoding="utf-8",
)
with (
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection"),
patch(f"{PKG}._sudo_write_hosts") as mock_write,
):
assert remove_total_block_hosts() is True
written = mock_write.call_args.args[0]
assert _HOSTS_BLOCK_BEGIN not in written
assert "router" in written
assert "localhost" in written
def test_absent_is_noop(self, total_block_paths: Paths) -> None:
"""Test that absent is noop."""
total_block_paths.hosts_file.write_text(
"127.0.0.1 localhost\n", encoding="utf-8"
)
with patch(f"{PKG}._sudo_write_hosts") as mock_write:
assert remove_total_block_hosts() is True
mock_write.assert_not_called()
def test_missing_hosts_file_returns_false(self) -> None:
"""Test that missing hosts file returns false."""
assert remove_total_block_hosts() is False
def test_write_failure_still_reenables_protection(
self, total_block_paths: Paths
) -> None:
"""Test that write failure still reenables protection."""
total_block_paths.hosts_file.write_text(
f"127.0.0.1 localhost\n{_HOSTS_BLOCK_BEGIN}"
f"0.0.0.0 x.com\n{_HOSTS_BLOCK_END}",
encoding="utf-8",
)
with (
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection") as mock_enable,
patch(f"{PKG}._sudo_write_hosts", side_effect=OSError),
):
assert remove_total_block_hosts() is False
mock_enable.assert_called_once()
# ──────────────────────────────────────────────────────────────
# IP cache
# ──────────────────────────────────────────────────────────────

View File

@ -0,0 +1,194 @@
"""Tests for the iptables half of the network block."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer._total_block_iptables import (
_iptables_chain_intact,
_load_cached_ips,
_save_cached_ips,
apply_total_block_iptables,
remove_total_block_iptables,
)
if TYPE_CHECKING:
from steam_backlog_enforcer.tests._total_block_paths import (
Paths,
)
PKG = "steam_backlog_enforcer._total_block_iptables"
class TestIpCache:
"""Tests for ip cache."""
def test_load_no_file_returns_empty(self) -> None:
"""Test that load no file returns empty."""
assert _load_cached_ips() == set()
def test_save_then_load_round_trips(self) -> None:
"""Test that save then load round trips."""
_save_cached_ips({"1.2.3.4", "5.6.7.8"})
assert _load_cached_ips() == {"1.2.3.4", "5.6.7.8"}
def test_load_malformed_json_returns_empty(self, total_block_paths: Paths) -> None:
"""Test that load malformed json returns empty."""
total_block_paths.ip_cache_file.parent.mkdir(parents=True, exist_ok=True)
total_block_paths.ip_cache_file.write_text("not json", encoding="utf-8")
assert _load_cached_ips() == set()
def test_load_non_list_json_returns_empty(self, total_block_paths: Paths) -> None:
"""Test that load non list json returns empty."""
total_block_paths.ip_cache_file.parent.mkdir(parents=True, exist_ok=True)
total_block_paths.ip_cache_file.write_text(
json.dumps({"a": 1}), encoding="utf-8"
)
assert _load_cached_ips() == set()
# ──────────────────────────────────────────────────────────────
# iptables
# ──────────────────────────────────────────────────────────────
class TestIptablesChainIntact:
"""Tests for iptables chain intact."""
def test_missing_chain_returns_false(self) -> None:
"""Test that missing chain returns false."""
with patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=1)):
assert _iptables_chain_intact({"1.2.3.4"}) is False
def test_missing_ip_returns_false(self) -> None:
"""Test that missing ip returns false."""
listing = MagicMock(
returncode=0,
stdout="-N STEAM_TOTAL_BLOCK\n-A STEAM_TOTAL_BLOCK -d 9.9.9.9/32 -j DROP\n",
)
with patch(f"{PKG}.subprocess.run", return_value=listing):
assert _iptables_chain_intact({"1.2.3.4"}) is False
def test_all_ips_present_and_hooked_returns_true(self) -> None:
"""Test that all ips present and hooked returns true."""
listing = MagicMock(
returncode=0,
stdout=(
"-N STEAM_TOTAL_BLOCK\n"
"-A STEAM_TOTAL_BLOCK -d 1.2.3.4/32 -j DROP\n"
"-A STEAM_TOTAL_BLOCK -d 5.6.7.8/32 -j DROP\n"
),
)
hook_check = MagicMock(returncode=0)
with patch(f"{PKG}.subprocess.run", side_effect=[listing, hook_check]):
assert _iptables_chain_intact({"1.2.3.4", "5.6.7.8"}) is True
def test_ips_present_but_not_hooked_returns_false(self) -> None:
"""Test that ips present but not hooked returns false."""
listing = MagicMock(
returncode=0,
stdout="-A STEAM_TOTAL_BLOCK -d 1.2.3.4/32 -j DROP\n",
)
hook_check = MagicMock(returncode=1)
with patch(f"{PKG}.subprocess.run", side_effect=[listing, hook_check]):
assert _iptables_chain_intact({"1.2.3.4"}) is False
def test_malformed_trailing_d_flag_is_ignored(self) -> None:
"""A `-d` token with nothing after it (malformed/truncated rule
line) must not index past the end of `parts`."""
listing = MagicMock(
returncode=0,
stdout="-A STEAM_TOTAL_BLOCK -j DROP -d\n",
)
with patch(f"{PKG}.subprocess.run", return_value=listing):
assert _iptables_chain_intact({"1.2.3.4"}) is False
class TestApplyTotalBlockIptables:
"""Tests for apply total block iptables."""
def test_intact_chain_short_circuits(self) -> None:
"""Test that intact chain short circuits."""
_save_cached_ips({"1.2.3.4"})
with (
patch(f"{PKG}._iptables_chain_intact", return_value=True),
patch(f"{PKG}.subprocess.run") as mock_run,
):
assert apply_total_block_iptables() is True
mock_run.assert_not_called()
def test_rebuilds_when_not_intact(self) -> None:
"""Test that rebuilds when not intact."""
with (
patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)),
patch(
f"{PKG}.socket.getaddrinfo",
return_value=[(None, None, None, None, ("9.9.9.9", 443))],
),
):
assert apply_total_block_iptables() is True
assert "9.9.9.9" in _load_cached_ips()
def test_dns_failure_skips_that_domain(self) -> None:
"""Test that dns failure skips that domain."""
import socket as real_socket
with (
patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)),
patch(f"{PKG}.socket.getaddrinfo", side_effect=real_socket.gaierror),
):
assert apply_total_block_iptables() is True
assert _load_cached_ips() == set()
def test_subprocess_error_returns_false(self) -> None:
"""Test that subprocess error returns false."""
with (
patch(
f"{PKG}.subprocess.run",
side_effect=[MagicMock(returncode=0), OSError],
),
patch(f"{PKG}.socket.getaddrinfo", return_value=[]),
):
assert apply_total_block_iptables() is False
def test_inserts_output_hook_when_missing(self) -> None:
"""Test that inserts output hook when missing."""
def run_side_effect(cmd: list[str], **_kwargs: object) -> MagicMock:
if "-C" in cmd:
return MagicMock(returncode=1)
return MagicMock(returncode=0)
with (
patch(f"{PKG}.subprocess.run", side_effect=run_side_effect),
patch(f"{PKG}.socket.getaddrinfo", return_value=[]),
):
assert apply_total_block_iptables() is True
class TestRemoveTotalBlockIptables:
"""Tests for remove total block iptables."""
def test_removes_chain_and_cache(self, total_block_paths: Paths) -> None:
"""Test that removes chain and cache."""
_save_cached_ips({"1.2.3.4"})
with patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)):
assert remove_total_block_iptables() is True
assert not total_block_paths.ip_cache_file.exists()
def test_no_cache_file_is_fine(self) -> None:
"""Test that no cache file is fine."""
with patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)):
assert remove_total_block_iptables() is True
def test_subprocess_error_returns_false(self) -> None:
"""Test that subprocess error returns false."""
with patch(f"{PKG}.subprocess.run", side_effect=OSError):
assert remove_total_block_iptables() is False
# ──────────────────────────────────────────────────────────────
# Public lifecycle API
# ──────────────────────────────────────────────────────────────

View File

@ -0,0 +1,204 @@
"""Tests for start, tick and cleanup."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer._total_block import (
end_total_block_cleanup,
enforce_total_block_tick,
start_total_block,
)
from steam_backlog_enforcer._total_block_iptables import (
IPTABLES_CHAIN,
)
PKG = "steam_backlog_enforcer._total_block"
class TestStartTotalBlock:
"""Tests for start total block."""
def test_success(self) -> None:
"""Test that success."""
with (
patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=0, stderr=""),
),
patch(f"{PKG}.kill_steam_and_launchers", return_value=[]),
patch(f"{PKG}.uninstall_steam_package", return_value=True),
patch(f"{PKG}.purge_steam_and_proton"),
patch(f"{PKG}.apply_total_block_hosts", return_value=True),
patch(f"{PKG}.apply_total_block_iptables", return_value=True),
patch(f"{PKG}.flush_dns_cache"),
):
assert start_total_block(1) is True
def test_calls_purge_steam_and_proton(self) -> None:
"""Test that calls purge steam and proton."""
with (
patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=0, stderr=""),
),
patch(f"{PKG}.kill_steam_and_launchers", return_value=[]),
patch(f"{PKG}.uninstall_steam_package", return_value=True),
patch(f"{PKG}.purge_steam_and_proton") as mock_purge,
patch(f"{PKG}.apply_total_block_hosts", return_value=True),
patch(f"{PKG}.apply_total_block_iptables", return_value=True),
patch(f"{PKG}.flush_dns_cache"),
):
start_total_block(1)
mock_purge.assert_called_once()
def test_package_block_start_failure_aborts(self) -> None:
"""Test that package block start failure aborts."""
with patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=1, stderr="guardctl error"),
):
assert start_total_block(1) is False
def test_best_effort_steps_dont_block_success(self) -> None:
"""Even if kill/uninstall/hosts/iptables all fail, the lock
registering successfully is what start_total_block reports."""
with (
patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=0, stderr=""),
),
patch(f"{PKG}.kill_steam_and_launchers", return_value=[]),
patch(f"{PKG}.uninstall_steam_package", return_value=False),
patch(f"{PKG}.purge_steam_and_proton"),
patch(f"{PKG}.apply_total_block_hosts", return_value=False),
patch(f"{PKG}.apply_total_block_iptables", return_value=False),
patch(f"{PKG}.flush_dns_cache"),
):
assert start_total_block(1) is True
def test_logs_when_processes_were_killed(self) -> None:
"""Test that logs when processes were killed."""
with (
patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=0, stderr=""),
),
patch(f"{PKG}.kill_steam_and_launchers", return_value=[(1, "steam")]),
patch(f"{PKG}.uninstall_steam_package", return_value=True),
patch(f"{PKG}.purge_steam_and_proton"),
patch(f"{PKG}.apply_total_block_hosts", return_value=True),
patch(f"{PKG}.apply_total_block_iptables", return_value=True),
patch(f"{PKG}.flush_dns_cache"),
):
assert start_total_block(1) is True
class TestEnforceTotalBlockTick:
"""Tests for enforce total block tick."""
def test_reinstalls_steam_if_reappeared(self) -> None:
"""Test that reinstalls steam if reappeared."""
with (
patch(f"{PKG}.kill_steam_and_launchers", return_value=[]),
patch(f"{PKG}.is_steam_installed", return_value=True),
patch(f"{PKG}.uninstall_steam_package") as mock_uninstall,
patch(f"{PKG}.purge_steam_and_proton"),
patch(f"{PKG}.apply_total_block_hosts", return_value=True),
patch(f"{PKG}.apply_total_block_iptables", return_value=True),
):
enforce_total_block_tick()
mock_uninstall.assert_called_once()
def test_no_reinstall_when_steam_absent(self) -> None:
"""Test that no reinstall when steam absent."""
with (
patch(f"{PKG}.kill_steam_and_launchers", return_value=[]),
patch(f"{PKG}.is_steam_installed", return_value=False),
patch(f"{PKG}.uninstall_steam_package") as mock_uninstall,
patch(f"{PKG}.purge_steam_and_proton"),
patch(f"{PKG}.apply_total_block_hosts", return_value=True),
patch(f"{PKG}.apply_total_block_iptables", return_value=True),
):
enforce_total_block_tick()
mock_uninstall.assert_not_called()
def test_purges_steam_and_proton_every_tick(self) -> None:
"""Test that purges steam and proton every tick."""
with (
patch(f"{PKG}.kill_steam_and_launchers", return_value=[]),
patch(f"{PKG}.is_steam_installed", return_value=False),
patch(f"{PKG}.uninstall_steam_package"),
patch(f"{PKG}.purge_steam_and_proton") as mock_purge,
patch(f"{PKG}.apply_total_block_hosts", return_value=True),
patch(f"{PKG}.apply_total_block_iptables", return_value=True),
):
enforce_total_block_tick()
mock_purge.assert_called_once()
class TestEndTotalBlockCleanup:
"""Tests for end total block cleanup."""
def test_ends_lock_and_removes_blocks(self) -> None:
"""Test that ends lock and removes blocks."""
with (
patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=0, stderr=""),
),
patch(f"{PKG}.remove_total_block_hosts", return_value=True) as mock_hosts,
patch(f"{PKG}.remove_total_block_iptables", return_value=True) as mock_ipt,
patch(f"{PKG}.flush_dns_cache"),
):
end_total_block_cleanup()
mock_hosts.assert_called_once()
mock_ipt.assert_called_once()
def test_package_block_end_failure_still_cleans_up_rest(self) -> None:
"""Test that package block end failure still cleans up rest."""
with (
patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=1, stderr="already ended"),
),
patch(f"{PKG}.remove_total_block_hosts", return_value=True) as mock_hosts,
patch(f"{PKG}.remove_total_block_iptables", return_value=True) as mock_ipt,
patch(f"{PKG}.flush_dns_cache"),
):
end_total_block_cleanup()
mock_hosts.assert_called_once()
mock_ipt.assert_called_once()
def test_hosts_removal_failure_is_logged_not_raised(self) -> None:
"""Test that hosts removal failure is logged not raised."""
with (
patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=0, stderr=""),
),
patch(f"{PKG}.remove_total_block_hosts", return_value=False),
patch(f"{PKG}.remove_total_block_iptables", return_value=True),
patch(f"{PKG}.flush_dns_cache"),
):
end_total_block_cleanup() # must not raise
def test_iptables_removal_failure_is_logged_not_raised(self) -> None:
"""Test that iptables removal failure is logged not raised."""
with (
patch(
f"{PKG}.subprocess.run",
return_value=MagicMock(returncode=0, stderr=""),
),
patch(f"{PKG}.remove_total_block_hosts", return_value=True),
patch(f"{PKG}.remove_total_block_iptables", return_value=False),
patch(f"{PKG}.flush_dns_cache"),
):
end_total_block_cleanup() # must not raise
# Sanity: the module-level chain name constant is what everything above
# assumes when constructing fake iptables -S output.
def test_iptables_chain_name_constant() -> None:
"""Test that iptables chain name constant."""
assert IPTABLES_CHAIN == "STEAM_TOTAL_BLOCK"

View File

@ -0,0 +1,178 @@
"""Tests for killing Steam and launchers, and removing their packages."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer._total_block_launchers import LAUNCHER_PROCESS_NAMES
from steam_backlog_enforcer._total_block_purge import (
is_steam_installed,
kill_and_uninstall_launchers,
kill_steam_and_launchers,
uninstall_steam_package,
)
PKG = "steam_backlog_enforcer._total_block_purge"
class TestKillSteamAndLaunchers:
"""Tests for kill steam and launchers."""
def test_combines_steam_and_launcher_kills(self) -> None:
"""Test that combines steam and launcher kills."""
with (
patch(f"{PKG}.kill_processes_by_name", return_value=[(1, "steam")]),
patch(
f"{PKG}.kill_and_uninstall_launchers",
return_value=[(2, "prismlauncher")],
) as mock_launchers,
):
result = kill_steam_and_launchers(LAUNCHER_PROCESS_NAMES)
assert result == [(1, "steam"), (2, "prismlauncher")]
mock_launchers.assert_called_once()
class TestKillAndUninstallLaunchers:
"""Tests for kill and uninstall launchers."""
def test_no_launchers_running(self) -> None:
"""Test that no launchers running."""
with (
patch(f"{PKG}.get_pids_by_process_names", return_value={}),
patch(f"{PKG}.kill_processes_by_name", return_value=[]),
):
assert kill_and_uninstall_launchers(LAUNCHER_PROCESS_NAMES) == []
def test_kills_and_uninstalls_owned_package(self) -> None:
"""Test that kills and uninstalls owned package."""
with (
patch(
f"{PKG}.get_pids_by_process_names",
return_value={123: "prismlauncher"},
),
patch(f"{PKG}.Path") as mock_path_cls,
patch(
f"{PKG}.kill_processes_by_name",
return_value=[(123, "prismlauncher")],
),
patch(f"{PKG}.pacman_owner", return_value="prismlauncher-git"),
patch(f"{PKG}.uninstall_package", return_value=True) as mock_uninstall,
):
mock_path_cls.return_value.resolve.return_value = "/usr/bin/prismlauncher"
result = kill_and_uninstall_launchers(LAUNCHER_PROCESS_NAMES)
assert result == [(123, "prismlauncher")]
mock_uninstall.assert_called_once_with("prismlauncher-git")
def test_exe_path_unreadable_skips_uninstall(self) -> None:
"""Test that exe path unreadable skips uninstall."""
with (
patch(
f"{PKG}.get_pids_by_process_names",
return_value={123: "prismlauncher"},
),
patch(f"{PKG}.Path") as mock_path_cls,
patch(
f"{PKG}.kill_processes_by_name",
return_value=[(123, "prismlauncher")],
),
patch(f"{PKG}.pacman_owner") as mock_owner,
patch(f"{PKG}.uninstall_package") as mock_uninstall,
):
mock_path_cls.return_value.resolve.side_effect = OSError
result = kill_and_uninstall_launchers(LAUNCHER_PROCESS_NAMES)
assert result == [(123, "prismlauncher")]
mock_owner.assert_not_called()
mock_uninstall.assert_not_called()
def test_unowned_package_not_uninstalled(self) -> None:
"""Test that unowned package not uninstalled."""
with (
patch(f"{PKG}.get_pids_by_process_names", return_value={123: "custom"}),
patch(f"{PKG}.Path") as mock_path_cls,
patch(f"{PKG}.kill_processes_by_name", return_value=[(123, "custom")]),
patch(f"{PKG}.pacman_owner", return_value=None),
patch(f"{PKG}.uninstall_package") as mock_uninstall,
):
mock_path_cls.return_value.resolve.return_value = "/opt/custom/launcher"
kill_and_uninstall_launchers(LAUNCHER_PROCESS_NAMES)
mock_uninstall.assert_not_called()
def test_uninstall_failure_is_logged_not_raised(self) -> None:
"""Test that uninstall failure is logged not raised."""
with (
patch(
f"{PKG}.get_pids_by_process_names",
return_value={123: "prismlauncher"},
),
patch(f"{PKG}.Path") as mock_path_cls,
patch(
f"{PKG}.kill_processes_by_name",
return_value=[(123, "prismlauncher")],
),
patch(f"{PKG}.pacman_owner", return_value="prismlauncher-git"),
patch(f"{PKG}.uninstall_package", return_value=False),
):
mock_path_cls.return_value.resolve.return_value = "/usr/bin/prismlauncher"
kill_and_uninstall_launchers(LAUNCHER_PROCESS_NAMES) # must not raise
# ──────────────────────────────────────────────────────────────
# Steam package removal
# ──────────────────────────────────────────────────────────────
class TestIsSteamInstalled:
"""Tests for is steam installed."""
def test_installed(self) -> None:
"""Test that installed."""
with patch(
"steam_backlog_enforcer._pacman.subprocess.run",
return_value=MagicMock(returncode=0),
):
assert is_steam_installed() is True
def test_not_installed(self) -> None:
"""Test that not installed."""
with patch(
"steam_backlog_enforcer._pacman.subprocess.run",
return_value=MagicMock(returncode=1),
):
assert is_steam_installed() is False
class TestUninstallSteamPackage:
"""Tests for uninstall steam package."""
def test_success(self) -> None:
"""Test that success."""
with patch(
"steam_backlog_enforcer._pacman.subprocess.run",
return_value=MagicMock(returncode=0, stderr=""),
):
assert uninstall_steam_package() is True
def test_already_absent_treated_as_success(self) -> None:
"""Test that already absent treated as success."""
with patch(
"steam_backlog_enforcer._pacman.subprocess.run",
return_value=MagicMock(
returncode=1, stderr="error: target not found: steam"
),
):
assert uninstall_steam_package() is True
def test_real_failure_returns_false(self) -> None:
"""Test that real failure returns false."""
with patch(
"steam_backlog_enforcer._pacman.subprocess.run",
return_value=MagicMock(returncode=1, stderr="some other error"),
):
assert uninstall_steam_package() is False
def test_subprocess_error_returns_false(self) -> None:
"""Test that subprocess error returns false."""
with patch(
"steam_backlog_enforcer._pacman.subprocess.run", side_effect=OSError
):
assert uninstall_steam_package() is False

View File

@ -0,0 +1,173 @@
"""Tests for removing Proton helpers and filesystem remnants."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
from steam_backlog_enforcer._total_block_purge import (
_log_steam_purge,
_remove_steam_remnants,
_uninstall_proton_helpers,
purge_steam_and_proton,
)
if TYPE_CHECKING:
from steam_backlog_enforcer.tests._total_block_paths import (
Paths,
)
PKG = "steam_backlog_enforcer._total_block_purge"
class TestUninstallProtonHelpers:
"""Tests for uninstall proton helpers."""
def test_none_installed_removes_nothing(self) -> None:
"""Test that none installed removes nothing."""
with patch(f"{PKG}.is_package_installed", return_value=False):
assert _uninstall_proton_helpers() == []
def test_installed_packages_are_uninstalled(self) -> None:
"""Test that installed packages are uninstalled."""
installed = {"protonup-qt", "protontricks-git"}
with (
patch(f"{PKG}.is_package_installed", side_effect=lambda p: p in installed),
patch(f"{PKG}.uninstall_package", return_value=True) as mock_uninstall,
):
removed = _uninstall_proton_helpers()
assert set(removed) == installed
assert mock_uninstall.call_count == len(installed)
def test_failed_uninstall_is_logged_not_raised(self) -> None:
"""Test that failed uninstall is logged not raised."""
with (
patch(f"{PKG}.is_package_installed", return_value=True),
patch(f"{PKG}.uninstall_package", return_value=False),
):
assert _uninstall_proton_helpers() == []
class TestRemoveSteamRemnants:
"""Tests for remove steam remnants."""
def test_no_remnants_present(self, total_block_paths: Paths) -> None:
"""Test that no remnants present."""
assert _remove_steam_remnants() == []
def test_removes_directory(self, total_block_paths: Paths) -> None:
"""Test that removes directory."""
steam_dir = total_block_paths.remnant_paths[0]
(steam_dir / "steamapps").mkdir(parents=True)
removed = _remove_steam_remnants()
assert str(steam_dir) in removed
assert not steam_dir.exists()
def test_removes_symlink_without_following_into_rmtree(
self, total_block_paths: Paths
) -> None:
"""Test that removes symlink without following into rmtree."""
# Target lives outside the curated remnant list, so this test can
# tell "unlinked the symlink" apart from "rmtree'd its target".
external_target = (
total_block_paths.remnant_paths[0].parent / "external_target.pid"
)
external_target.parent.mkdir(parents=True, exist_ok=True)
external_target.write_text("123", encoding="utf-8")
symlink_path = total_block_paths.remnant_paths[4]
symlink_path.symlink_to(external_target)
removed = _remove_steam_remnants()
assert str(symlink_path) in removed
assert not symlink_path.exists()
assert not symlink_path.is_symlink()
assert external_target.exists()
def test_removal_failure_is_logged_not_raised(
self, total_block_paths: Paths
) -> None:
"""Test that removal failure is logged not raised."""
steam_dir = total_block_paths.remnant_paths[0]
steam_dir.mkdir(parents=True)
with patch(f"{PKG}.shutil.rmtree", side_effect=OSError):
assert _remove_steam_remnants() == []
class TestLogSteamPurge:
"""Tests for log steam purge."""
def test_noop_when_nothing_removed(self, total_block_paths: Paths) -> None:
"""Test that noop when nothing removed."""
_log_steam_purge([], [])
assert not total_block_paths.purge_log_file.exists()
def test_appends_entry(self, total_block_paths: Paths) -> None:
"""Test that appends entry."""
_log_steam_purge(["/home/kuhy/.steam"], ["protonup-qt"])
entries = json.loads(
total_block_paths.purge_log_file.read_text(encoding="utf-8")
)
assert len(entries) == 1
assert entries[0]["removed_paths"] == ["/home/kuhy/.steam"]
assert entries[0]["removed_packages"] == ["protonup-qt"]
_log_steam_purge(["/home/kuhy/steam"], [])
entries = json.loads(
total_block_paths.purge_log_file.read_text(encoding="utf-8")
)
assert len(entries) == 2
def test_corrupt_log_file_is_reset(self, total_block_paths: Paths) -> None:
"""Test that corrupt log file is reset."""
total_block_paths.purge_log_file.parent.mkdir(parents=True, exist_ok=True)
total_block_paths.purge_log_file.write_text("not json", encoding="utf-8")
_log_steam_purge(["/home/kuhy/.steam"], [])
entries = json.loads(
total_block_paths.purge_log_file.read_text(encoding="utf-8")
)
assert len(entries) == 1
def test_non_list_log_file_is_reset(self, total_block_paths: Paths) -> None:
"""Test that non list log file is reset."""
total_block_paths.purge_log_file.parent.mkdir(parents=True, exist_ok=True)
total_block_paths.purge_log_file.write_text(
json.dumps({"not": "a list"}), encoding="utf-8"
)
_log_steam_purge(["/home/kuhy/.steam"], [])
entries = json.loads(
total_block_paths.purge_log_file.read_text(encoding="utf-8")
)
assert len(entries) == 1
class TestPurgeSteamAndProton:
"""Tests for purge steam and proton."""
def test_delegates_and_logs(self, total_block_paths: Paths) -> None:
"""Test that delegates and logs."""
with (
patch(f"{PKG}._remove_steam_remnants", return_value=["/home/kuhy/.steam"]),
patch(f"{PKG}._uninstall_proton_helpers", return_value=["protonup-qt"]),
):
purge_steam_and_proton()
entries = json.loads(
total_block_paths.purge_log_file.read_text(encoding="utf-8")
)
assert entries[0]["removed_paths"] == ["/home/kuhy/.steam"]
assert entries[0]["removed_packages"] == ["protonup-qt"]
def test_nothing_removed_does_not_log(self, total_block_paths: Paths) -> None:
"""Test that nothing removed does not log."""
with (
patch(f"{PKG}._remove_steam_remnants", return_value=[]),
patch(f"{PKG}._uninstall_proton_helpers", return_value=[]),
):
purge_steam_and_proton()
assert not total_block_paths.purge_log_file.exists()
# ──────────────────────────────────────────────────────────────
# Hosts domain blocking
# ──────────────────────────────────────────────────────────────