From 0c05d7100851f8f8a3e0b149a1caf302145a785e Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 4 Aug 2026 20:00:08 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01QbEaXapjMSkxyzgvbdkUFb --- install.sh | 10 + .../50-steam-backlog-playtime-unblock.hook | 29 + steam-backlog-enforcer.service | 9 + steam_backlog_enforcer/_cmd_playtime.py | 150 +++++ steam_backlog_enforcer/_enforce_loop.py | 31 +- steam_backlog_enforcer/_mcp.py | 93 +++ steam_backlog_enforcer/_playtime.py | 617 ++++++++++++++++++ steam_backlog_enforcer/_playtime_block.py | 462 +++++++++++++ steam_backlog_enforcer/config.py | 19 + steam_backlog_enforcer/main.py | 77 ++- steam_backlog_enforcer/tests/conftest.py | 76 +++ .../tests/test_cmd_playtime.py | 215 ++++++ .../tests/test_enforce_loop_part2.py | 24 +- .../tests/test_enforce_loop_part3.py | 78 ++- .../tests/test_main_part5.py | 76 ++- steam_backlog_enforcer/tests/test_mcp.py | 93 +++ steam_backlog_enforcer/tests/test_playtime.py | 197 ++++++ .../tests/test_playtime_block.py | 182 ++++++ .../tests/test_playtime_block_part2.py | 438 +++++++++++++ .../tests/test_playtime_part2.py | 283 ++++++++ .../tests/test_playtime_tick.py | 350 ++++++++++ 21 files changed, 3477 insertions(+), 32 deletions(-) create mode 100644 pacman-hooks/50-steam-backlog-playtime-unblock.hook create mode 100644 steam_backlog_enforcer/_cmd_playtime.py create mode 100644 steam_backlog_enforcer/_playtime.py create mode 100644 steam_backlog_enforcer/_playtime_block.py create mode 100644 steam_backlog_enforcer/tests/test_cmd_playtime.py create mode 100644 steam_backlog_enforcer/tests/test_playtime.py create mode 100644 steam_backlog_enforcer/tests/test_playtime_block.py create mode 100644 steam_backlog_enforcer/tests/test_playtime_block_part2.py create mode 100644 steam_backlog_enforcer/tests/test_playtime_part2.py create mode 100644 steam_backlog_enforcer/tests/test_playtime_tick.py diff --git a/install.sh b/install.sh index ff310d2..8e624cb 100755 --- a/install.sh +++ b/install.sh @@ -53,6 +53,16 @@ if [[ "${ans,,}" == "y" ]]; then s|__DESKTOP_USER__|$DESKTOP_USER|" \ "$SERVICE_SRC" > "$SERVICE_DST" + # Daily-gaming-budget pacman hook. While the budget is exhausted a refusal + # stub is bind-mounted over /usr/bin/steam and friends; a bind mount makes + # package extraction fail EBUSY, so the mounts must be dropped before any + # transaction touching those packages. + HOOK_SRC="$SCRIPT_DIR/pacman-hooks/50-steam-backlog-playtime-unblock.hook" + HOOK_DST="/etc/pacman.d/hooks/50-steam-backlog-playtime-unblock.hook" + mkdir -p /etc/pacman.d/hooks + install -m 644 "$HOOK_SRC" "$HOOK_DST" + echo "Installed pacman hook: $HOOK_DST" + systemctl daemon-reload systemctl enable steam-backlog-enforcer echo "Service installed and enabled." diff --git a/pacman-hooks/50-steam-backlog-playtime-unblock.hook b/pacman-hooks/50-steam-backlog-playtime-unblock.hook new file mode 100644 index 0000000..2baf3ed --- /dev/null +++ b/pacman-hooks/50-steam-backlog-playtime-unblock.hook @@ -0,0 +1,29 @@ +# steam-backlog-enforcer: release daily-gaming-budget bind mounts before pacman +# touches the packages they mask. +# +# While the daily gaming budget is exhausted, a refusal stub is bind-mounted +# over the launcher entry points. A bind-mounted file is a mountpoint, so +# unlink(2)/rename(2) over it returns EBUSY and package extraction fails. This +# hook clears the mounts for the duration of the transaction; the enforcer's +# 3-second tick re-applies them afterwards, so there is no PostTransaction half. +# +# The daemon side also refuses to (re-)mount while /var/lib/pacman/db.lck +# exists, which is what stops a tick from remounting mid-transaction. +# +# KEEP THE TARGET LIST IN SYNC with BLOCK_TARGETS in +# steam_backlog_enforcer/_playtime_block.py. The user-owned +# ~/.local/share/Steam/steam.sh is deliberately absent here: it is not owned by +# any package, so no pacman transaction can collide with it. + +[Trigger] +Operation = Install +Operation = Upgrade +Operation = Remove +Type = Package +Target = steam +Target = lutris + +[Action] +Description = steam-backlog-enforcer: releasing playtime bind mounts +When = PreTransaction +Exec = /bin/sh -c 'for t in /usr/bin/steam /usr/lib/steam/bin_steam.sh /usr/bin/lutris; do i=0; while mountpoint -q "$t" && [ "$i" -lt 20 ]; do umount -l "$t" || break; i=$((i+1)); done; done' diff --git a/steam-backlog-enforcer.service b/steam-backlog-enforcer.service index 976781a..0cf2642 100644 --- a/steam-backlog-enforcer.service +++ b/steam-backlog-enforcer.service @@ -16,6 +16,15 @@ Environment=STEAM_ENFORCER_DESKTOP_USER=__DESKTOP_USER__ Environment=DISPLAY=:0 # Hardening: enforcer must not be easily killed. OOMScoreAdjust=-900 +# +# DO NOT ADD PrivateMounts=, PrivateTmp=, ProtectSystem= OR ProtectHome= HERE. +# The daily gaming budget enforces its cutoff by bind-mounting a refusal stub +# over /usr/bin/steam and the other launcher entry points. Any of those +# directives gives this service a private mount namespace, so those mounts stay +# invisible to the rest of the system: the launchers keep working while the +# counter keeps running, and the block silently becomes a no-op. That failure +# mode is invisible from the outside, which is why it is spelled out here. +# _playtime_block.mounts_are_visible() logs an error if it ever happens. [Install] WantedBy=multi-user.target diff --git a/steam_backlog_enforcer/_cmd_playtime.py b/steam_backlog_enforcer/_cmd_playtime.py new file mode 100644 index 0000000..abe8f9a --- /dev/null +++ b/steam_backlog_enforcer/_cmd_playtime.py @@ -0,0 +1,150 @@ +"""CLI handlers for the daily gaming budget. + +Every ``_echo`` / ``input`` / ``sys.exit`` for this feature lives here rather +than in :mod:`steam_backlog_enforcer._playtime` or +:mod:`steam_backlog_enforcer._playtime_block`, so the MCP server can import +those modules' leaf helpers without any risk of writing to stdout — which is +the JSON-RPC channel. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +import os +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._enforce_loop import do_enforce +from steam_backlog_enforcer._playtime import ( + PlaytimeState, + gaming_day_key, + load_state, + rules_for, + 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: + from steam_backlog_enforcer.config import Config, State + +_DEMO_FLAG = "--demo" + + +def cmd_enforce(config: Config, state: State, args: list[str]) -> int: + """Run the enforcement loop. + + Args: + config: Enforcer configuration. + state: Current enforcer state. + args: Arguments after the command name. + + Returns: + Process exit code. + """ + unknown = [arg for arg in args if arg != _DEMO_FLAG] + if unknown: + # Never ignore an unrecognised flag here: a mistyped "--Demo" would + # silently run the real 8-hour budget during what was meant to be a + # 60-second demo, and the mistake only shows up hours later. + _echo(f"Unknown argument(s): {' '.join(unknown)}") + _echo(f"Usage: enforce [{_DEMO_FLAG}]") + return 2 + + demo = _DEMO_FLAG in args + if demo: + _echo("DEMO MODE: gaming budget is 60 seconds, using a separate state file.") + do_enforce(config, state, demo=demo) + return 0 + + +def cmd_gaming_status(config: Config, _state: State) -> None: + """Print today's gaming-budget usage and block state. + + Args: + config: Enforcer configuration. + _state: Unused; required by the command dispatch signature. + """ + for demo in (False, True): + rules = rules_for(config, demo=demo) + stored = load_state(demo=demo) + if stored is None and demo: + continue + _echo(f"{'Demo' if demo else 'Gaming'} budget:") + _echo(f" state file: {state_path(demo=demo)}") + _echo(f" budget_seconds: {rules.budget_seconds:.0f}") + if stored is None: + _echo(" (no state recorded yet)") + else: + remaining = max(0.0, rules.budget_seconds - stored.seconds) + _echo(f" gaming day: {stored.day_key} (starts 06:00 local)") + _echo(f" used: {stored.seconds:.0f}s") + _echo(f" remaining: {remaining:.0f}s") + _echo(f" blocked: {stored.is_blocked()}") + + masked = mounted_targets() + _echo(f"Masked launchers: {len(masked)}/{len(block_targets())}") + for target in sorted(masked): + _echo(f" {target}") + + +def cmd_gaming_reset(config: Config, _state: State) -> int: + """Zero today's gaming counter and lift the block. + + Args: + config: Enforcer configuration. + _state: Unused; required by the command dispatch signature. + + Returns: + Process exit code. + """ + if os.geteuid() != 0: + _echo("gaming-reset requires root (it releases bind mounts).") + return 1 + + _echo("This resets today's gaming counter and lifts the block.") + if input("Type YES to confirm: ").strip() != "YES": + _echo("Aborted.") + return 1 + + released = release_block() + now = datetime.now(timezone.utc).astimezone() + save_state( + PlaytimeState(day_key=gaming_day_key(now), last_tick_at=now.timestamp()), + demo=rules_for(config, demo=False).demo, + ) + _echo(f"Gaming counter reset. Released {len(released)} mount(s).") + return 0 + + +def cmd_gaming_unblock(args: list[str]) -> int: + """Release every playtime mount unconditionally. + + The recovery hatch: it consults no state at all, so it works when the state + file is unreadable, the daemon is down, or a block was left behind by a + previous run. + + Args: + args: Arguments after the command name. + + Returns: + Process exit code. + """ + if args: + _echo(f"Unknown argument(s): {' '.join(args)}") + return 2 + if os.geteuid() != 0: + _echo("gaming-unblock requires root (it releases bind mounts).") + return 1 + + released = release_block() + if not released: + _echo("No playtime mounts were active.") + return 0 + for target in released: + _echo(f"Released {target}") + return 0 diff --git a/steam_backlog_enforcer/_enforce_loop.py b/steam_backlog_enforcer/_enforce_loop.py index 99da693..65cb0f9 100644 --- a/steam_backlog_enforcer/_enforce_loop.py +++ b/steam_backlog_enforcer/_enforce_loop.py @@ -8,6 +8,7 @@ import time from typing import Any from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games +from steam_backlog_enforcer._playtime import playtime_tick from steam_backlog_enforcer._total_block import ( end_total_block_cleanup, enforce_total_block_tick, @@ -283,13 +284,25 @@ def _enforce_hide_games(config: Config, state: State) -> None: _echo(" Library: games already hidden") -def _enforce_loop_iteration(config: Config, state: State) -> None: +def _enforce_loop_iteration( + config: Config, + state: State, + *, + demo: bool = False, +) -> None: """Perform one iteration of the enforcement loop. Args: config: Enforcer configuration. state: Current enforcer state. + demo: Run the gaming budget on a 60-second demo budget. """ + # Daily gaming budget runs FIRST and unconditionally. Every guard below + # returns early in situations where gaming time is still being spent (total + # block active, Steam absent, nothing assigned) and where a 06:00 release + # still has to happen — gating this on any of them would strand the block. + playtime_tick(config, interval=ENFORCE_INTERVAL, demo=demo) + # Total block takes priority over the assigned-game enforcement below - # while active, don't fight ourselves (e.g. installing the assigned # game while total-block tries to keep Steam uninstalled). @@ -336,7 +349,7 @@ def _enforce_loop_iteration(config: Config, state: State) -> None: lock_enforcement_files(CONFIG_FILE) -def do_enforce(config: Config, state: State) -> None: +def do_enforce(config: Config, state: State, *, demo: bool = False) -> None: """Run the enforcer: block store, uninstall other games, kill processes. This is a persistent loop that continuously: @@ -344,14 +357,24 @@ def do_enforce(config: Config, state: State) -> None: 2. Removes any newly-installed unauthorized games. 3. Auto-installs the assigned game if missing. 4. Kills any running unauthorized game processes. + 5. Accounts for the daily gaming budget and enforces its cutoff. + + Args: + config: Enforcer configuration. + state: Current enforcer state. + demo: Run the gaming budget on a 60-second demo budget. """ if is_total_block_active(): _echo( "Total gaming block ACTIVE - enforcing that instead of any assigned game." ) elif state.current_app_id is None: + # Fall through to the idle loop for the same reason as the Steam + # branch below, plus one of its own: the daily gaming budget is + # enforced from this loop, so returning here would silently stop + # counting playtime whenever a game is finished but not yet rescanned. _echo("No game assigned. Run 'scan' first.") - return + _echo(" (Daily gaming budget is still enforced.)") elif not steam_is_installed(): # Fall through to the idle loop rather than returning: returning exits # the process, and under Restart=always that is just the crash loop @@ -381,7 +404,7 @@ def do_enforce(config: Config, state: State) -> None: state.current_game_name = fresh.current_game_name state.finished_app_ids = fresh.finished_app_ids - _enforce_loop_iteration(config, state) + _enforce_loop_iteration(config, state, demo=demo) time.sleep(ENFORCE_INTERVAL) except KeyboardInterrupt: _echo("\nEnforcer stopped.") diff --git a/steam_backlog_enforcer/_mcp.py b/steam_backlog_enforcer/_mcp.py index cd769d3..a7df7c6 100644 --- a/steam_backlog_enforcer/_mcp.py +++ b/steam_backlog_enforcer/_mcp.py @@ -25,7 +25,9 @@ Safety invariants (do not break when adding tools): from __future__ import annotations +from datetime import datetime, timezone import logging +import os import sys from typing import Any @@ -43,6 +45,14 @@ from steam_backlog_enforcer._actions import ( 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 @@ -330,6 +340,89 @@ def block_gaming(days: int, *, confirm: bool = False) -> dict[str, Any]: 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], + } + + def main() -> None: """Run the MCP server over stdio (STDOUT = JSON-RPC, STDERR = logs).""" logger.info( diff --git a/steam_backlog_enforcer/_playtime.py b/steam_backlog_enforcer/_playtime.py new file mode 100644 index 0000000..a07767b --- /dev/null +++ b/steam_backlog_enforcer/_playtime.py @@ -0,0 +1,617 @@ +"""Daily gaming-time budget: accounting, day rollover and cutoff policy. + +Tracks how long the user has spent gaming during the current *gaming day* and, +once the budget is exhausted, drives the cutoff sequence implemented in +:mod:`steam_backlog_enforcer._playtime_block`. + +Three design points that are load-bearing: + +**The gaming day starts at 06:00 local, not midnight.** A session that runs +past midnight belongs to the day it started in; a midnight boundary would hand +out a fresh 8-hour budget in the middle of a late session. Rollover is checked +lazily on every tick against a stored day key rather than scheduled, so it +survives suspend, reboot and daemon restarts with no timer of its own. + +**Elapsed time is measured from wall-clock deltas, clamped.** Counting "one +tick = one interval" silently under-counts whenever the loop is slow and +over-counts nothing; measuring the raw delta over-counts wildly across a +suspend. Clamping each delta to a small multiple of the tick interval makes +suspends, missed ticks, daemon restarts and NTP steps all behave. + +**Launchers are matched by cmdline, not by ``comm``.** ``/usr/bin/lutris`` is a +``#!/usr/bin/env python3`` script, so the kernel records its ``comm`` as +``python3`` and the ``comm``-based scan in :mod:`enforcer` never sees it. That +is why :func:`get_pids_by_cmdline_names` exists alongside +``enforcer.get_pids_by_process_names`` rather than replacing it. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta, timezone +import json +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._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.library_hider import _resolve_desktop_user, _run_as_user + +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 `