mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 20:27:03 +01:00
The enforcer now imports the Steam/launcher/counted-process scan from leisure_watch.procscan (v0.1.0, shared with the leisure watcher) and drops its own copies (_playtime_procs, _counted_procs, _total_block_launchers and enforcer's two scanners). Every counted-process read passes config.CONFIG_FILE explicitly, as before. Each tick also bills a shadow leisure budget beside the enforced one, from the same incoming stamp and delta clamp, before accumulate: paused (held gatelock grab or i3lock) -> shadow_paused_seconds; else a qualifying game, a "leisure" now.json, or an untrusted/missing now.json (fail closed) bills shadow_seconds once, split over shadow_per_source. shadow_budget_seconds is earned_time.resolve(leisure=True) from the same answers, held as a same-day high-water mark. Not billed under a total block. Shadow verdict changes go to budget.jsonl as shadow_change (the live game view skips them); /api/budget and MCP get_gaming_time carry a "shadow" block. Enforced fields, cutoff, warnings and existing log records are unchanged. Also fixes two tests that assumed earned_time < 0.14 internals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
251 lines
8.1 KiB
Python
251 lines
8.1 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 UTC, datetime
|
|
import json
|
|
import logging
|
|
import shutil
|
|
import subprocess
|
|
|
|
from leisure_watch.procscan import LAUNCHER_PROCESS_NAMES, kill_target_names
|
|
|
|
from steam_backlog_enforcer import config
|
|
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_purge import (
|
|
STEAM_PACKAGE,
|
|
is_steam_installed,
|
|
kill_steam_and_launchers,
|
|
purge_steam_and_proton,
|
|
uninstall_steam_package,
|
|
)
|
|
from steam_backlog_enforcer.enforcer import kill_processes_by_name
|
|
from steam_backlog_enforcer.store_blocker import flush_dns_cache
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TOTAL_BLOCK_LOCK_FILE = config.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(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=UTC)
|
|
if isinstance(started_at, int | float)
|
|
else None
|
|
)
|
|
until_dt = (
|
|
datetime.fromtimestamp(until, tz=UTC)
|
|
if isinstance(until, int | float)
|
|
else None
|
|
)
|
|
|
|
now = datetime.now(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)
|
|
# Kill only: kill_steam_and_launchers() uninstalls what it is given, and a
|
|
# counted process is a game the user chose to bill, not a launcher to purge.
|
|
killed += kill_processes_by_name(kill_target_names(config.CONFIG_FILE))
|
|
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)
|
|
kill_processes_by_name(kill_target_names(config.CONFIG_FILE))
|
|
|
|
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.")
|