mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
Dormant until earned_time.leisure_active(gaming day) -- the switch file written by `leisure-watch go-live`. From that day: - enforced `seconds` are billed by the shadow rule (lock pause, fail-closed, once per wall clock); budget from plain resolve(); warnings and the cutoff say "Leisure". - at 0, beside the unchanged Steam path, _leisure_kill SIGTERMs (SIGKILL 30 s later, per process) every focus/media pid in now.json except browsers, every failclosed browser pid, and -- with now.json untrusted -- every browser, media player and wsg-grabber reviewer. Root/system, lockers and the enforcer's own chain are never signalled. Kills go to budget.jsonl. - tests: an autouse fixture redirects both switch readers, so no test reads /etc/leisure-watch/leisure_from; existing tests unchanged. Re-pins earned-time to v0.15.0 (leisure_active). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
243 lines
9.2 KiB
Python
243 lines
9.2 KiB
Python
"""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``. The scan (``leisure_watch.procscan``, shared with the leisure
|
|
watcher) therefore reads argv as well as ``comm``.
|
|
|
|
**Shadow leisure time rides the same tick** (:mod:`_shadow`, contract C4),
|
|
from the same stamp and clamp, before ``accumulate``; under a total block it
|
|
is not billed. From the go-live day (:mod:`_leisure_mode`, Phase 2) its rule
|
|
bills the enforced count too, and at 0 :mod:`_leisure_kill` ends leisure apps.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import replace
|
|
from datetime import UTC, datetime
|
|
import logging
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from leisure_watch.procscan import qualifying_pids
|
|
|
|
from steam_backlog_enforcer import config as config_mod
|
|
from steam_backlog_enforcer._gaming_days import gaming_day_key
|
|
from steam_backlog_enforcer._leisure_mode import Tick, bill_tick
|
|
from steam_backlog_enforcer._playtime_block import mounted_targets
|
|
from steam_backlog_enforcer._playtime_budget import roll_over
|
|
from steam_backlog_enforcer._playtime_cutoff import (
|
|
_begin_cutoff,
|
|
_sustain_block,
|
|
_warn,
|
|
reconcile_for,
|
|
)
|
|
from steam_backlog_enforcer._playtime_state import (
|
|
PlaytimeRules,
|
|
PlaytimeState,
|
|
load_state,
|
|
rules_for,
|
|
save_state,
|
|
)
|
|
from steam_backlog_enforcer._shadow import observe_shadow, shadow_accumulate
|
|
from steam_backlog_enforcer._total_block import is_total_block_active
|
|
|
|
if TYPE_CHECKING:
|
|
from steam_backlog_enforcer._playtime_session import PlaytimeSession
|
|
from steam_backlog_enforcer.config import Config
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
_SECONDS_PER_MINUTE = 60
|
|
_MINUTES_PER_HOUR = 60
|
|
|
|
|
|
def playtime_tick(
|
|
config: Config,
|
|
*,
|
|
interval: float,
|
|
session: PlaytimeSession,
|
|
demo: bool = False,
|
|
) -> None:
|
|
"""Account for this tick's gaming time and enforce the daily budget.
|
|
|
|
Called first in every enforce-loop iteration, before any other guard, so
|
|
that time is still counted (and a 06:00 release still happens) in the
|
|
situations where the rest of the loop early-returns.
|
|
|
|
A qualifying process running is sufficient on its own — there is no
|
|
engagement gate on top of it.
|
|
|
|
Args:
|
|
config: Loaded user configuration.
|
|
interval: Nominal seconds between enforce-loop ticks.
|
|
session: Cross-tick logging state.
|
|
demo: Whether this is a short-budget demo run.
|
|
"""
|
|
now = datetime.now(UTC).astimezone()
|
|
rules = rules_for(config, demo=demo)
|
|
state = roll_over(_state_or_recover(rules, now=now), day_key=gaming_day_key(now))
|
|
# Recorded every tick, total-block branch included: it is what the next
|
|
# 06:00 roll-over measures this day's leftover against. A high-water mark,
|
|
# because earners can reset at midnight -- before the gaming day ends --
|
|
# and a 05:59 dip must not erase the evening's earned-but-unspent time.
|
|
state = replace(
|
|
state,
|
|
budget_seconds=max(state.budget_seconds, rules.budget_seconds),
|
|
shadow_budget_seconds=max(
|
|
state.shadow_budget_seconds, rules.shadow_budget_seconds
|
|
),
|
|
)
|
|
|
|
if is_total_block_active():
|
|
# The total block runs `pacman -R steam` every tick; our bind mounts
|
|
# would make that fail EBUSY. It is strictly stronger — get out of its
|
|
# way, and stop accruing against a budget nobody can spend.
|
|
reconcile_for(rules, should_block=False)
|
|
save_state(state, demo=demo)
|
|
session.history.observe(state, demo=demo)
|
|
return
|
|
|
|
owners = qualifying_pids(rules, config_mod.CONFIG_FILE)
|
|
pids = set(owners)
|
|
|
|
shadow = observe_shadow(owners, stamp=now.timestamp())
|
|
state = shadow_accumulate(
|
|
state, stamp=now.timestamp(), interval=interval, verdict=shadow
|
|
)
|
|
tick = Tick(now=now, interval=interval, owners=owners, verdict=shadow)
|
|
state, rules = bill_tick(state, rules, tick)
|
|
state = _policy(state, rules, now=now)
|
|
if rules.leisure:
|
|
session.leisure.enforce(state, rules, verdict=shadow, stamp=now.timestamp())
|
|
session.journal.observe(pids, state, rules=rules, now_monotonic=time.monotonic())
|
|
session.shadow.observe(shadow, state)
|
|
save_state(state, demo=demo)
|
|
session.history.observe(state, demo=demo)
|
|
|
|
|
|
def _state_or_recover(rules: PlaytimeRules, *, now: datetime) -> PlaytimeState:
|
|
"""Load state, synthesising a fail-closed record if it is unusable.
|
|
|
|
If the state file is gone but the binaries are still masked, the only safe
|
|
reading is that the file was deleted to lift the block. Synthesising an
|
|
exhausted budget keeps it in force; the normal 06:00 rollover then lifts it
|
|
with no special case.
|
|
|
|
Args:
|
|
rules: Policy for this tick.
|
|
now: Timezone-aware local timestamp.
|
|
|
|
Returns:
|
|
Loaded or synthesised state.
|
|
"""
|
|
loaded = load_state(demo=rules.demo)
|
|
if loaded is not None:
|
|
return loaded
|
|
|
|
stamp = now.timestamp()
|
|
fresh = PlaytimeState(day_key=gaming_day_key(now), last_tick_at=stamp)
|
|
if not mounted_targets():
|
|
return fresh
|
|
|
|
logger.warning("Playtime state missing while blocked; failing closed.")
|
|
return replace(fresh, seconds=rules.budget_seconds, blocked_at=stamp)
|
|
|
|
|
|
def _release_after_raise(
|
|
state: PlaytimeState,
|
|
rules: PlaytimeRules,
|
|
) -> PlaytimeState:
|
|
"""Clear block bookkeeping after the day's budget rose past what was spent.
|
|
|
|
Only ``roll_over`` at the 06:00 boundary ever reset ``blocked_at`` before
|
|
the workout coupling existed, because the budget could not move within a
|
|
day. It can now: logging a workout adds 2h and solving a LeetCode problem
|
|
adds 1h, and the mounts come down on the very next tick. The trigger is
|
|
``state.seconds < rules.budget_seconds`` -- the number alone, never which
|
|
earner moved it -- so both raises release identically. Two things have to
|
|
come down with them.
|
|
|
|
``blocked_at`` — otherwise ``is_blocked()`` stays true and ``build_today``
|
|
keeps reporting ``"blocked": true``, so the UI would claim you were blocked
|
|
while gaming worked fine. That is the exact "state disagrees with reality"
|
|
failure the mount-visibility check exists to catch.
|
|
|
|
``warned_seconds`` — the thresholds are keyed on seconds *remaining*, so a
|
|
raise makes remaining climb back through values already recorded as fired.
|
|
Left alone, the extra hour or two would arrive with no warnings at all.
|
|
|
|
Args:
|
|
state: Accounting state for the current gaming day.
|
|
rules: Policy for this tick, holding the newly raised budget.
|
|
|
|
Returns:
|
|
The state with block bookkeeping cleared.
|
|
"""
|
|
logger.warning(
|
|
"Gaming budget rose to %.1fh past the %.0fs already spent — releasing "
|
|
"the block and re-arming warnings for the remaining time.",
|
|
rules.budget_seconds / 3600,
|
|
state.seconds,
|
|
)
|
|
return replace(state, blocked_at=0.0, warned_seconds=[])
|
|
|
|
|
|
def _policy(
|
|
state: PlaytimeState,
|
|
rules: PlaytimeRules,
|
|
*,
|
|
now: datetime,
|
|
) -> PlaytimeState:
|
|
"""Apply warnings, the cutoff, and block upkeep to *state*.
|
|
|
|
Args:
|
|
state: Accounting state after this tick's accumulation.
|
|
rules: Policy for this tick.
|
|
now: Timezone-aware local timestamp.
|
|
|
|
Returns:
|
|
The updated state.
|
|
"""
|
|
if not rules.enforcement:
|
|
# A kill switch must still release: leaving live mounts behind would
|
|
# make "disabled" mean "permanently blocked".
|
|
reconcile_for(rules, should_block=False)
|
|
return state
|
|
|
|
if state.seconds < rules.budget_seconds:
|
|
reconcile_for(rules, should_block=False)
|
|
if state.is_blocked():
|
|
state = _release_after_raise(state, rules)
|
|
# A raise that never reached the cutoff lifts remaining back above
|
|
# thresholds already fired; re-arm them or the new end comes unwarned.
|
|
remaining = rules.budget_seconds - state.seconds
|
|
rearmed = [t for t in state.warned_seconds if t >= remaining]
|
|
if rearmed != state.warned_seconds:
|
|
state = replace(state, warned_seconds=rearmed)
|
|
return _warn(state, rules)
|
|
|
|
if not state.is_blocked():
|
|
return _begin_cutoff(state, rules, now=now)
|
|
|
|
return _sustain_block(state, rules, now=now)
|