mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 17:33:48 +01:00
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
126 lines
3.7 KiB
Python
126 lines
3.7 KiB
Python
"""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()
|