steam-backlog-enforcer/steam_backlog_enforcer/config.py
Krzysztof kuhy Rudnicki 0c05d71008 Add a daily gaming-time budget with a binary-level cutoff
Accumulates time spent in Steam games and other launchers, and once the
daily budget (default 8h) is spent, shuts Steam down, kills the games, and
makes them unlaunchable until the next 06:00.

The gaming day starts at 06:00 local rather than midnight so a late session
counts against the day it began in; rollover is checked lazily against a
stored day key, so it survives suspend, reboot and daemon restarts with no
timer of its own. Elapsed time comes from wall-clock deltas clamped to two
tick intervals, which keeps suspends, missed ticks and NTP steps honest.

The block is a read-only bind mount of a refusal stub over all four launcher
entry points -- /usr/bin/steam is only a two-line exec wrapper, so masking it
alone leaves bin_steam.sh, Steam's own steam.sh and lutris live. A bind mount
mutates no bytes, umount restores exactly, and a reboot clears it
unconditionally; chmod/rename would leave Steam broken if the daemon died
mid-block. /proc/self/mountinfo is the authority on what is masked, never the
saved state, which is what makes a stale block release even if the state file
was deleted. The mount is deferred one tick past the cutoff so
`steam -shutdown` still works and games close cleanly.

Launchers are matched by cmdline, not comm: /usr/bin/lutris is a python3
script, so the kernel records its comm as "python3" and the existing scan
never sees it. The kill set is wider than the budget predicate, walking down
from each launcher, because a Lutris-launched Wine game carries no SteamAppId.

State is chattr +i'd -- ~/.config is user-writable, so root ownership alone
would not stop `rm` from resetting the counter -- and a missing state file
while mounts are live fails closed rather than lifting the block.

Also fixes a pre-existing bug: do_enforce returned when no game was assigned,
exiting the process. Since the budget is accounted for from inside that loop,
it silently stopped enforcing whenever a game was finished but not rescanned.

Verified on the desktop with a real game: AoE2 counted 18 processes, tripped
at the 60s demo budget, all processes killed, all four paths masked and
refusing, then gaming-unblock restored them byte-identical with
`pacman -Qkk steam lutris` reporting 0 altered files.

1322 tests pass at 100.00% branch coverage; pacman validated the ALPM hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbEaXapjMSkxyzgvbdkUFb
2026-08-04 20:00:08 +02:00

243 lines
8.0 KiB
Python

"""Configuration management for Steam Backlog Enforcer."""
from __future__ import annotations
import contextlib
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import json
import logging
import os
from pathlib import Path
import sys
import tempfile
from typing import Any
CONFIG_DIR = Path.home() / ".config" / "steam_backlog_enforcer"
CONFIG_FILE = CONFIG_DIR / "config.json"
STATE_FILE = CONFIG_DIR / "state.json"
SNAPSHOT_FILE = CONFIG_DIR / "snapshot.json"
LOG_FILE = CONFIG_DIR / "enforcer.log"
# Steam store domains to block.
BLOCKED_DOMAINS = [
"store.steampowered.com",
"checkout.steampowered.com",
"store.akamai.steamstatic.com",
"storefront.steampowered.com",
"store.cloudflare.steamstatic.com",
]
HOSTS_FILE = Path("/etc/hosts")
logger = logging.getLogger(__name__)
def _atomic_write(path: Path, data: str) -> None:
"""Write data to a file atomically via a temporary file + rename."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
tmp_path = Path(tmp)
try:
os.write(fd, data.encode("utf-8"))
os.close(fd)
tmp_path.replace(path)
except BaseException:
with contextlib.suppress(OSError):
os.close(fd)
with contextlib.suppress(OSError):
tmp_path.unlink()
raise
@dataclass
class Config:
"""User configuration."""
steam_api_key: str = ""
steam_id: str = ""
block_store: bool = True
kill_unauthorized_games: bool = True
uninstall_other_games: bool = True
desktop_notifications: bool = True
max_manual_picks: int = 3
"""How many games may be manually locked in at once.
All active picks stay installed and visible; the enforcer treats them as
one allowed set. Raising this weakens enforcement proportionally.
"""
daily_gaming_seconds: int = 8 * 3600
"""Gaming seconds allowed per gaming day, which starts at 06:00 local.
Spending this budget shuts Steam down and masks the launcher binaries until
the next 06:00. See :mod:`steam_backlog_enforcer._playtime`.
"""
count_launcher_processes: bool = True
"""Whether time spent in non-Steam launchers counts against the budget.
Set False if a launcher legitimately idles in the tray all day. Leaving it
True is the safer default: under-counting is the failure mode that quietly
defeats the budget entirely.
"""
playtime_enforcement: bool = True
"""Master switch for the daily gaming budget.
Disabling stops the cutoff but never strands a live block — the release
path runs regardless, so "disabled" cannot come to mean "blocked forever".
"""
def save(self) -> None:
"""Persist config to disk."""
_atomic_write(
CONFIG_FILE,
json.dumps(self.__dict__, indent=2) + "\n",
)
@classmethod
def load(cls) -> Config:
"""Load config from disk, or return defaults."""
if CONFIG_FILE.exists():
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
return cls(
**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}
)
return cls()
@dataclass
class State:
"""Persistent state across runs."""
current_app_id: int | None = None
current_game_name: str = ""
finished_app_ids: list[int] = field(default_factory=list)
skipped_until: dict[str, str] = field(default_factory=dict)
enforcement_started_at: str = ""
"""ISO-8601 UTC timestamp set on the first game assignment."""
"""Map of ``str(app_id)`` → ISO-8601 UTC timestamp when the skip expires.
Games in this map are excluded from auto-assignment until the timestamp
elapses. Populated when the user declines a freshly-picked game via the
interactive prompt in ``cmd_done``.
"""
manual_pick_app_id: int | None = None
manual_pick_game_name: str = ""
manual_pick_started_at: str = ""
"""Legacy single-slot manual pick, migrated into ``manual_picks`` on load.
Kept as fields so an older ``state.json`` still deserialises; they are
blanked once migrated and are never written again.
"""
manual_picks: list[dict[str, Any]] = field(default_factory=list)
"""Active manual picks, newest last.
Each entry is ``{"app_id": int, "game_name": str, "started_at": iso}``.
Plain dicts rather than a dataclass because ``save`` serialises
``self.__dict__`` straight to JSON.
"""
def skip_for_days(self, app_id: int, days: int) -> None:
"""Mark ``app_id`` as skipped for ``days`` days from now (UTC)."""
expires = datetime.now(timezone.utc) + timedelta(days=days)
self.skipped_until[str(app_id)] = expires.isoformat()
def active_skipped_ids(self) -> set[int]:
"""Return currently-skipped app IDs and prune expired entries.
Mutates ``self.skipped_until`` to drop expired or malformed entries.
Callers should ``save()`` if they want the prune persisted.
"""
now = datetime.now(timezone.utc)
active: set[int] = set()
to_remove: list[str] = []
for aid_str, ts in self.skipped_until.items():
try:
expiry = datetime.fromisoformat(ts)
except ValueError:
to_remove.append(aid_str)
continue
if expiry > now:
active.add(int(aid_str))
else:
to_remove.append(aid_str)
for aid_str in to_remove:
del self.skipped_until[aid_str]
return active
def save(self) -> None:
"""Persist state to disk."""
_atomic_write(
STATE_FILE,
json.dumps(self.__dict__, indent=2) + "\n",
)
@classmethod
def load(cls) -> State:
"""Load state from disk, or return defaults."""
if STATE_FILE.exists():
try:
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, ValueError):
logger.warning("Corrupt state file, using defaults.")
return cls()
state = cls(
**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}
)
state._migrate_legacy_manual_pick()
return state
return cls()
def _migrate_legacy_manual_pick(self) -> None:
"""Fold a pre-multi-pick single manual pick into ``manual_picks``.
A live lock written by the old single-slot code must survive the
upgrade, so the legacy fields are read once, converted, and cleared.
Migration happens in memory; the next ``save`` persists it.
"""
if self.manual_pick_app_id is None or self.manual_picks:
return
self.manual_picks = [
{
"app_id": self.manual_pick_app_id,
"game_name": self.manual_pick_game_name,
"started_at": self.manual_pick_started_at,
}
]
self.manual_pick_app_id = None
self.manual_pick_game_name = ""
self.manual_pick_started_at = ""
def save_snapshot(data: list[dict[str, Any]]) -> None:
"""Save an achievement snapshot to disk."""
_atomic_write(
SNAPSHOT_FILE,
json.dumps(data, indent=2) + "\n",
)
def load_snapshot() -> list[dict[str, Any]] | None:
"""Load the cached achievement snapshot, or None if absent."""
if SNAPSHOT_FILE.exists():
result: list[dict[str, Any]] = json.loads(
SNAPSHOT_FILE.read_text(encoding="utf-8")
)
return result
return None
def interactive_setup() -> Config:
"""Run first-time interactive setup."""
api_key = input("Enter your Steam Web API key: ").strip()
if not api_key:
sys.exit(1)
steam_id = input("Enter your Steam64 ID: ").strip()
if not steam_id:
sys.exit(1)
config = Config(steam_api_key=api_key, steam_id=steam_id)
config.save()
CONFIG_FILE.chmod(0o600)
return config