steam-backlog-enforcer/steam_backlog_enforcer/_total_block.py
Krzysztof kuhy Rudnicki b5b4b35c07 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
2026-08-23 20:52:08 +02:00

245 lines
7.8 KiB
Python

"""Total gaming block: the lock, its status, and the lifecycle that drives it.
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:
* :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
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import logging
import shutil
import subprocess
from steam_backlog_enforcer._total_block_hosts import (
apply_total_block_hosts,
remove_total_block_hosts,
)
from steam_backlog_enforcer._total_block_iptables import (
apply_total_block_iptables,
remove_total_block_iptables,
)
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"
_PACKAGE_BLOCK_NAME = "steam-block"
_SUDO = shutil.which("sudo") or "/usr/bin/sudo"
_GUARDCTL = shutil.which("guardctl") or "/usr/local/bin/guardctl"
@dataclass
class TotalBlockStatus:
"""Snapshot of the total-block lock state."""
active: bool
started_at: datetime | None
until: datetime | None
days: int
days_remaining: float
def _read_lock() -> dict[str, object] | None:
"""Read and parse the total-block lock file, or None if absent/invalid."""
if not TOTAL_BLOCK_LOCK_FILE.exists():
return None
try:
data = json.loads(TOTAL_BLOCK_LOCK_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, ValueError):
return None
if not isinstance(data, dict):
return None
return data
def is_total_block_active() -> bool:
"""Return True if a total gaming block is currently in force."""
data = _read_lock()
if data is None:
return False
until = data.get("until")
if not isinstance(until, int | float):
return False
return datetime.now(timezone.utc).timestamp() < until
def total_block_needs_cleanup() -> bool:
"""True if a total-block lock file exists on disk but has expired.
Distinguishes "never started" (no lock file - nothing to do) from
"expired, not yet cleaned up" (lock file present, `until` has passed) -
the latter needs :func:`end_total_block_cleanup` called exactly once.
``guardctl package-block end`` deletes the lock file, so this is
naturally self-terminating once cleanup has run.
"""
return _read_lock() is not None and not is_total_block_active()
def get_total_block_status() -> TotalBlockStatus:
"""Return a snapshot of the current total-block lock state."""
data = _read_lock()
if data is None:
return TotalBlockStatus(
active=False, started_at=None, until=None, days=0, days_remaining=0.0
)
started_at = data.get("started_at")
until = data.get("until")
days = data.get("days")
started_dt = (
datetime.fromtimestamp(started_at, tz=timezone.utc)
if isinstance(started_at, int | float)
else None
)
until_dt = (
datetime.fromtimestamp(until, tz=timezone.utc)
if isinstance(until, int | float)
else None
)
now = datetime.now(timezone.utc)
active = until_dt is not None and now < until_dt
days_remaining = (
(until_dt - now).total_seconds() / 86400 if active and until_dt else 0.0
)
return TotalBlockStatus(
active=active,
started_at=started_dt,
until=until_dt,
days=days if isinstance(days, int) else 0,
days_remaining=max(0.0, days_remaining),
)
def start_total_block(days: int) -> bool:
"""Start a total gaming block for *days* days.
Registers the package-block lock (bind-mounted, tamper-resistant) via
guard-lib first - that is the actual enforcement mechanism and must
succeed for the block to be considered active. Killing processes,
uninstalling Steam, and applying domain blocks are best-effort follow-up
steps (logged on failure, re-attempted every enforce tick via
:func:`enforce_total_block_tick`), since none of them being instantly
perfect should prevent the lock itself from engaging.
Returns:
True if the package-block lock was successfully registered.
"""
result = subprocess.run(
[
_SUDO,
_GUARDCTL,
"package-block",
"start",
_PACKAGE_BLOCK_NAME,
"--package",
STEAM_PACKAGE,
"--lock-file",
str(TOTAL_BLOCK_LOCK_FILE),
"--days",
str(days),
"--bind-mount",
],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
logger.error("Failed to start package-block lock: %s", result.stderr)
return False
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():
logger.warning("Total block: failed to uninstall steam (will retry each tick)")
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():
logger.warning("Total block: failed to apply iptables rules")
if not apply_total_block_hosts():
logger.warning("Total block: failed to apply hosts entries")
flush_dns_cache()
return True
def enforce_total_block_tick() -> None:
"""Re-assert the total block.
Called every enforce-loop iteration while :func:`is_total_block_active`
is True.
"""
kill_steam_and_launchers(LAUNCHER_PROCESS_NAMES)
if is_steam_installed():
logger.warning("Steam reappeared during total block - removing again")
uninstall_steam_package()
purge_steam_and_proton()
apply_total_block_iptables()
apply_total_block_hosts()
def end_total_block_cleanup() -> None:
"""Clean up after the total-block lock has naturally expired.
Ends the package-block lock (guard-lib), removes total-block-specific
hosts/iptables entries, leaving normal ``config.block_store`` state
untouched. Does *not* reinstall Steam or restore killed processes -
the user is free to reinstall/relaunch once the block has expired.
"""
result = subprocess.run(
[_SUDO, _GUARDCTL, "package-block", "end", _PACKAGE_BLOCK_NAME],
capture_output=True,
text=True,
timeout=30,
check=False,
)
if result.returncode != 0:
logger.warning(
"package-block end failed (may already be ended): %s", result.stderr
)
if not remove_total_block_hosts():
logger.warning("Failed to remove total-block hosts entries")
if not remove_total_block_iptables():
logger.warning("Failed to remove total-block iptables rules")
flush_dns_cache()
logger.info("Total gaming block ended - normal enforcement resumes.")