From e82ddd9a4b3ad9acb61d3b1475199017308a0ee7 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 19 Oct 2026 12:37:01 +0200 Subject: [PATCH] playtime: shadow leisure billing (C4); /proc scan from leisure_watch 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 Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N --- .pre-commit-config.yaml | 2 + pyproject.toml | 4 + requirements.txt | 1 + steam_backlog_enforcer/_attribution_labels.py | 7 +- steam_backlog_enforcer/_budget_log_tail.py | 8 +- steam_backlog_enforcer/_budget_resolve.py | 31 ++- steam_backlog_enforcer/_budget_view.py | 2 + steam_backlog_enforcer/_counted_procs.py | 245 ------------------ steam_backlog_enforcer/_gaming_days.py | 17 ++ steam_backlog_enforcer/_mcp_gaming_time.py | 2 + steam_backlog_enforcer/_playtime.py | 27 +- steam_backlog_enforcer/_playtime_cutoff.py | 8 +- steam_backlog_enforcer/_playtime_kill.py | 16 +- steam_backlog_enforcer/_playtime_procs.py | 190 -------------- steam_backlog_enforcer/_playtime_record.py | 72 +++++ steam_backlog_enforcer/_playtime_session.py | 14 +- steam_backlog_enforcer/_playtime_state.py | 74 ++---- steam_backlog_enforcer/_shadow.py | 141 ++++++++++ steam_backlog_enforcer/_shadow_log.py | 58 +++++ steam_backlog_enforcer/_shadow_view.py | 33 +++ .../_steam_restart_guard.py | 3 +- steam_backlog_enforcer/_total_block.py | 12 +- .../_total_block_launchers.py | 45 ---- steam_backlog_enforcer/_total_block_purge.py | 11 +- steam_backlog_enforcer/config.py | 5 +- steam_backlog_enforcer/enforcer.py | 66 +---- steam_backlog_enforcer/tests/_fake_session.py | 19 ++ .../tests/_isolate_playtime.py | 1 - .../tests/_isolate_shadow.py | 34 +++ .../tests/_no_real_effects.py | 4 +- .../tests/_no_workout_http.py | 7 + steam_backlog_enforcer/tests/conftest.py | 2 + .../tests/test_attribution_labels.py | 3 +- .../tests/test_budget_resolve_reading.py | 8 +- .../tests/test_counted_procs.py | 146 ----------- steam_backlog_enforcer/tests/test_enforcer.py | 77 ------ .../tests/test_enforcer_part2.py | 75 ------ .../tests/test_playtime_attribution.py | 189 -------------- .../tests/test_playtime_notify.py | 22 ++ .../tests/test_playtime_procs.py | 144 ---------- .../tests/test_playtime_session.py | 7 + steam_backlog_enforcer/tests/test_shadow.py | 144 ++++++++++ .../tests/test_shadow_budget.py | 181 +++++++++++++ .../tests/test_shadow_log.py | 65 +++++ .../tests/test_total_block_purge.py | 3 +- 45 files changed, 958 insertions(+), 1267 deletions(-) delete mode 100644 steam_backlog_enforcer/_counted_procs.py delete mode 100644 steam_backlog_enforcer/_playtime_procs.py create mode 100644 steam_backlog_enforcer/_playtime_record.py create mode 100644 steam_backlog_enforcer/_shadow.py create mode 100644 steam_backlog_enforcer/_shadow_log.py create mode 100644 steam_backlog_enforcer/_shadow_view.py delete mode 100644 steam_backlog_enforcer/_total_block_launchers.py create mode 100644 steam_backlog_enforcer/tests/_isolate_shadow.py delete mode 100644 steam_backlog_enforcer/tests/test_counted_procs.py delete mode 100644 steam_backlog_enforcer/tests/test_playtime_attribution.py create mode 100644 steam_backlog_enforcer/tests/test_playtime_notify.py delete mode 100644 steam_backlog_enforcer/tests/test_playtime_procs.py create mode 100644 steam_backlog_enforcer/tests/test_shadow.py create mode 100644 steam_backlog_enforcer/tests/test_shadow_budget.py create mode 100644 steam_backlog_enforcer/tests/test_shadow_log.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 934a4e8..e61b1b6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -111,6 +111,7 @@ repos: additional_dependencies: - types-requests - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.14.0#subdirectory=earned_time + - leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.1.0 # =========================================================================== # PYLINT @@ -131,6 +132,7 @@ repos: additional_dependencies: - aiohttp==3.14.5 - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.14.0#subdirectory=earned_time + - leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.1.0 - howlongtobeatpy==1.0.24 - mcp==2.3.0 - pytest diff --git a/pyproject.toml b/pyproject.toml index b296a54..45ba86e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,10 @@ dependencies = [ # The earner registry (gaming budget terms); stdlib-only, so the root # daemon imports it from the desktop user's site-packages. "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.14.0#subdirectory=earned_time", + # The shared /proc game scan and the signed leisure facts (shadow budget, + # contract C4); stdlib-only, imported by root from the user site like + # earned-time. + "leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.1.0", ] # Optional extras. The `mcp` group is ONLY needed by the MCP server diff --git a/requirements.txt b/requirements.txt index 2628518..72592d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ codespell==2.4.3 coverage==7.16.2 earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.14.0#subdirectory=earned_time howlongtobeatpy==1.0.24 +leisure-watch @ git+https://github.com/kuhyx/leisure-watch@leisure-watch-v0.1.0 mcp==2.3.0 mypy==2.4.0 pre-commit==4.7.0 diff --git a/steam_backlog_enforcer/_attribution_labels.py b/steam_backlog_enforcer/_attribution_labels.py index 94de29a..5e8e2b8 100644 --- a/steam_backlog_enforcer/_attribution_labels.py +++ b/steam_backlog_enforcer/_attribution_labels.py @@ -16,8 +16,9 @@ import contextlib import re from typing import TYPE_CHECKING, Final -from steam_backlog_enforcer import _steam_state -from steam_backlog_enforcer._counted_procs import labels_by_key, load_counted_processes +from leisure_watch.procscan import labels_by_key, load_counted_processes + +from steam_backlog_enforcer import _steam_state, config if TYPE_CHECKING: from collections.abc import Iterable @@ -74,7 +75,7 @@ def label_for(key: str) -> str: return _steam_name(int(raw)) or key return key if key.startswith(_PROC_PREFIX): - return labels_by_key(load_counted_processes()).get(key, key) + return labels_by_key(load_counted_processes(config.CONFIG_FILE)).get(key, key) if key.startswith(_LAUNCHER_PREFIX): return key[len(_LAUNCHER_PREFIX) :] return key diff --git a/steam_backlog_enforcer/_budget_log_tail.py b/steam_backlog_enforcer/_budget_log_tail.py index 9aa4352..c6344c9 100644 --- a/steam_backlog_enforcer/_budget_log_tail.py +++ b/steam_backlog_enforcer/_budget_log_tail.py @@ -23,17 +23,20 @@ import json import logging from typing import Any, Final +from leisure_watch.procscan import process_name + from steam_backlog_enforcer._playtime_log import ( EVENT_MANUAL_ADJUSTMENT, budget_log_path, ) -from steam_backlog_enforcer._playtime_procs import process_name +from steam_backlog_enforcer._shadow_log import EVENT_SHADOW_CHANGE logger = logging.getLogger(__name__) # Enough for several records at any plausible size, small enough to read on # every poll without thinking about it. _TAIL_BYTES: Final = 64 * 1024 +_NOT_VERDICTS: Final = frozenset({EVENT_MANUAL_ADJUSTMENT, EVENT_SHADOW_CHANGE}) @dataclass(frozen=True) @@ -85,7 +88,8 @@ def _last_record(*, demo: bool) -> dict[str, Any] | None: # A manual adjustment (e.g. a gaming reset) is an audit note, not a # tick verdict: returning it would blank the live status until the # next verdict change or heartbeat. - if isinstance(record, dict) and record.get("event") != EVENT_MANUAL_ADJUSTMENT: + # A shadow verdict (C4) is leisure, not a game tick: same reasoning. + if isinstance(record, dict) and record.get("event") not in _NOT_VERDICTS: return record return None diff --git a/steam_backlog_enforcer/_budget_resolve.py b/steam_backlog_enforcer/_budget_resolve.py index 5a5950f..9a8457f 100644 --- a/steam_backlog_enforcer/_budget_resolve.py +++ b/steam_backlog_enforcer/_budget_resolve.py @@ -58,6 +58,8 @@ from steam_backlog_enforcer._reading_bonus import read_today from steam_backlog_enforcer._workout_budget import workout_logged_today if TYPE_CHECKING: + from datetime import date + from steam_backlog_enforcer.config import Config logger = logging.getLogger(__name__) @@ -101,6 +103,9 @@ class BudgetResolution: reading_seconds: Seconds added by a credited reading session, or 0. earned_seconds: Seconds added per earner name, every registered earner included -- the generic form of the three fields above. + shadow_seconds: The same answers priced as leisure + (``earned_time.resolve(leisure=True)``, contract C4). Shown, never + enforced. """ seconds: float @@ -110,6 +115,23 @@ class BudgetResolution: reason: str reading_seconds: float = 0.0 earned_seconds: dict[str, float] = field(default_factory=dict) + shadow_seconds: float = 0.0 + + +def _price( + answers: dict[str, int | bool | None], + today: date, + registry: tuple[earned_time.Earner, ...], + first_paid: dict[str, date | None] | None, + *, + leisure: bool, +) -> earned_time.Resolution: + """Price one set of answers; ``leisure=False`` is exactly the old call.""" + if first_paid is None: + return earned_time.resolve(answers, today, registry, leisure=leisure) + return earned_time.resolve( + answers, today, registry, first_credits=first_paid, leisure=leisure + ) def _describe(name: str, label: str, answer: int | None) -> str: @@ -164,11 +186,9 @@ def resolve_budget(config: Config) -> BudgetResolution: answers = _answers(config, registry) # Maturity only delays a penalty: a gate that never paid out costs nothing. first_paid = first_credits(registry, today) - day = ( - earned_time.resolve(answers, today, registry) - if first_paid is None - else earned_time.resolve(answers, today, registry, first_credits=first_paid) - ) + day = _price(answers, today, registry, first_paid, leisure=False) + # The same answers, asked once: the shadow can never see a different day. + shadow = _price(answers, today, registry, first_paid, leisure=True) earned = {t.earner.name: t.gaming_minutes * _SECONDS_PER_MINUTE for t in day.terms} total = day.gaming_minutes * _SECONDS_PER_MINUTE parts = ", ".join( @@ -188,4 +208,5 @@ def resolve_budget(config: Config) -> BudgetResolution: reason=reason, reading_seconds=earned.get("reading", 0.0), earned_seconds=earned, + shadow_seconds=shadow.gaming_minutes * _SECONDS_PER_MINUTE, ) diff --git a/steam_backlog_enforcer/_budget_view.py b/steam_backlog_enforcer/_budget_view.py index 00094a8..c0152e4 100644 --- a/steam_backlog_enforcer/_budget_view.py +++ b/steam_backlog_enforcer/_budget_view.py @@ -29,6 +29,7 @@ from steam_backlog_enforcer._playtime_state import ( rules_for, state_path, ) +from steam_backlog_enforcer._shadow_view import shadow_view from steam_backlog_enforcer.config import Config, State if TYPE_CHECKING: @@ -136,6 +137,7 @@ def build_today(state: PlaytimeState, rules: PlaytimeRules) -> dict[str, Any]: "next_warning_seconds": next_warning(state, rules), "warned_seconds": list(state.warned_seconds), "games": today_games(state), + "shadow": shadow_view(state), } diff --git a/steam_backlog_enforcer/_counted_procs.py b/steam_backlog_enforcer/_counted_procs.py deleted file mode 100644 index 5142eae..0000000 --- a/steam_backlog_enforcer/_counted_procs.py +++ /dev/null @@ -1,245 +0,0 @@ -"""User-declared non-Steam processes that count against the gaming budget. - -Steam games announce themselves through ``SteamAppId``; everything else has to -be named. :mod:`_total_block_launchers` already names sixteen *launchers*, but -that frozenset is consumed by the total-block killer, so adding a game to it -silently makes the game killable. This module is the separate, user-editable -list: ``counted_processes`` in ``config.json``. - -Loading is deliberately independent of :class:`~config.Config`. The two kill -paths that need these names — ``_playtime_kill.steam_and_launcher_pids`` and -``_total_block.enforce_total_block_tick`` — take no arguments and never build a -``Config``, so a ``Config``-only accessor could not reach them. -""" - -from __future__ import annotations - -from dataclasses import dataclass -import json -import logging -from typing import Any - -from steam_backlog_enforcer import config - -logger = logging.getLogger(__name__) - -# Names that must never be matched. Every name here is either an interpreter -# that hosts unrelated programs or a shell: `counted_processes` entries are -# kill targets, and `sh` in the set would SIGTERM every shell on the system. -# osu!lazer is launched through a `/bin/sh` wrapper, so this is a live hazard, -# not a hypothetical one. -FORBIDDEN_NAMES = frozenset( - { - "bash", - "dash", - "dotnet", - "env", - "java", - "mono", - "node", - "perl", - "python", - "python3", - "ruby", - "sh", - "wine", - "wine64", - "zsh", - } -) - - -@dataclass(frozen=True) -class CountedProcess: - """One user-declared non-Steam game that bills the budget.""" - - id: str - """Stable slug; forms the ``proc:`` attribution key.""" - label: str - """Display name shown in the UI and stored in history labels.""" - names: frozenset[str] - """Process basenames to match against ``comm`` and argv.""" - - -# osu!lazer ships as the AUR `osu-lazer-bin` package: `/usr/bin/osu-lazer` is a -# `/bin/sh` wrapper that execs `/opt/osu-lazer/osu.AppImage`, which self-mounts -# a squashfs and execs the game itself. All three layers are named so that -# whichever one the detector sees first, the time is billed. -DEFAULT_COUNTED_PROCESSES: tuple[CountedProcess, ...] = ( - CountedProcess( - id="osu-lazer", - label="osu!lazer", - names=frozenset({"osu-lazer", "osu.AppImage", "osu!"}), - ), -) - - -def parse_counted_processes(raw: object) -> tuple[CountedProcess, ...]: - """Build validated entries from ``config.json``'s ``counted_processes``. - - Invalid entries are dropped with a warning rather than raising: a typo in a - hand-edited config must not take the whole enforcer down, and the failure - mode of dropping one entry (that game stops billing) is visible in the UI. - - Args: - raw: The deserialised ``counted_processes`` value. - - Returns: - The valid entries, in file order. - """ - if not isinstance(raw, list): - logger.warning("counted_processes is not a list; ignoring it.") - return () - - entries: list[CountedProcess] = [] - seen: set[str] = set() - for item in raw: - entry = _parse_entry(item) - if entry is None or entry.id in seen: - continue - seen.add(entry.id) - entries.append(entry) - return tuple(entries) - - -def _parse_entry(item: object) -> CountedProcess | None: - """Validate one ``counted_processes`` element. - - Args: - item: A single deserialised entry. - - Returns: - The entry, or ``None`` if it is unusable. - """ - if not isinstance(item, dict): - logger.warning("counted_processes entry is not an object; skipping.") - return None - - entry_id = _text(item.get("id")) - if not entry_id: - logger.warning("counted_processes entry has no id; skipping.") - return None - - names = _clean_names(entry_id, item.get("names")) - if not names: - logger.warning("counted_processes entry %r has no usable names.", entry_id) - return None - - return CountedProcess( - id=entry_id, - label=_text(item.get("label")) or entry_id, - names=names, - ) - - -def _clean_names(entry_id: str, raw: object) -> frozenset[str]: - """Return the matchable names in *raw*, dropping forbidden ones. - - Args: - entry_id: Owning entry id, for the warning message. - raw: The deserialised ``names`` value. - - Returns: - The usable names. - """ - if not isinstance(raw, list): - return frozenset() - - names: set[str] = set() - for value in raw: - name = _text(value) - if not name: - continue - if name in FORBIDDEN_NAMES: - logger.warning( - "counted_processes entry %r names %r, which hosts unrelated " - "programs and is a kill target; refusing it.", - entry_id, - name, - ) - continue - names.add(name) - return frozenset(names) - - -def _text(value: object) -> str: - """Return *value* as a stripped string, or ``""`` if it is not one. - - Args: - value: Any deserialised JSON value. - - Returns: - The trimmed string. - """ - return value.strip() if isinstance(value, str) else "" - - -def load_counted_processes() -> tuple[CountedProcess, ...]: - """Read ``counted_processes`` straight from ``config.json``. - - Used by the kill paths, which have no ``Config`` to consult. A missing or - unreadable file yields the defaults, so a fresh install still bills - osu!lazer. - - Returns: - The configured entries, or :data:`DEFAULT_COUNTED_PROCESSES`. - """ - try: - data: Any = json.loads(config.CONFIG_FILE.read_text(encoding="utf-8")) - except OSError, ValueError: - return DEFAULT_COUNTED_PROCESSES - - if not isinstance(data, dict) or "counted_processes" not in data: - return DEFAULT_COUNTED_PROCESSES - return parse_counted_processes(data["counted_processes"]) - - -def counted_process_names(entries: tuple[CountedProcess, ...]) -> frozenset[str]: - """Return every matchable name across *entries*. - - Args: - entries: Configured entries. - - Returns: - The union of their names. - """ - if not entries: - return frozenset() - return frozenset().union(*(entry.names for entry in entries)) - - -def key_by_name(entries: tuple[CountedProcess, ...]) -> dict[str, str]: - """Map each matchable name to its ``proc:`` attribution key. - - Args: - entries: Configured entries. - - Returns: - Mapping of process name to attribution key. - """ - return {name: f"proc:{entry.id}" for entry in entries for name in entry.names} - - -def labels_by_key(entries: tuple[CountedProcess, ...]) -> dict[str, str]: - """Map each ``proc:`` key to its display label. - - Args: - entries: Configured entries. - - Returns: - Mapping of attribution key to label. - """ - return {f"proc:{entry.id}": entry.label for entry in entries} - - -def kill_target_names() -> frozenset[str]: - """Return the counted-process names the kill paths should also target. - - The budget cutoff and the total block kill these; the - ``kill_unauthorized_games`` path does not — it matches on ``SteamAppId``, - which a non-Steam game never sets, so it excludes them for free. - - Returns: - Every configured matchable name. - """ - return counted_process_names(load_counted_processes()) diff --git a/steam_backlog_enforcer/_gaming_days.py b/steam_backlog_enforcer/_gaming_days.py index 2cf4358..bc41b9a 100644 --- a/steam_backlog_enforcer/_gaming_days.py +++ b/steam_backlog_enforcer/_gaming_days.py @@ -59,3 +59,20 @@ def held_today(stored: PlaytimeState | None, now: datetime) -> float: if stored is None or stored.day_key != gaming_day_key(now): return 0.0 return stored.budget_seconds + + +def held_shadow_today(stored: PlaytimeState | None, now: datetime) -> float: + """Return the shadow leisure budget already granted in *now*'s gaming day. + + The same high-water rule as :func:`held_today`, for ``shadow_budget_seconds``. + + Args: + stored: The persisted production state, or ``None`` if unreadable. + now: Local timestamp for this tick. + + Returns: + The recorded shadow high-water budget; 0 when the state is another day's. + """ + if stored is None or stored.day_key != gaming_day_key(now): + return 0.0 + return stored.shadow_budget_seconds diff --git a/steam_backlog_enforcer/_mcp_gaming_time.py b/steam_backlog_enforcer/_mcp_gaming_time.py index 04ac19d..d59f60c 100644 --- a/steam_backlog_enforcer/_mcp_gaming_time.py +++ b/steam_backlog_enforcer/_mcp_gaming_time.py @@ -66,6 +66,8 @@ def get_gaming_time() -> dict[str, Any]: # Which games make up seconds_used, largest first, with whatever the # focus probe could not attribute as a trailing "unattributed" slice. "games": today["games"], + # The shadow leisure budget (C4): shown, never enforced. + "shadow": today["shadow"], } diff --git a/steam_backlog_enforcer/_playtime.py b/steam_backlog_enforcer/_playtime.py index 205a1d3..97bf809 100644 --- a/steam_backlog_enforcer/_playtime.py +++ b/steam_backlog_enforcer/_playtime.py @@ -20,9 +20,12 @@ 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. +``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): it +is measured from the same stamp and clamp, before ``accumulate``, and never +touches an enforced field. Under a total block it is not billed. """ from __future__ import annotations @@ -33,6 +36,9 @@ import logging import time from typing import TYPE_CHECKING +from leisure_watch.procscan import attributed_key, 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._playtime_block import mounted_targets from steam_backlog_enforcer._playtime_budget import ( @@ -45,7 +51,6 @@ from steam_backlog_enforcer._playtime_cutoff import ( _warn, reconcile_for, ) -from steam_backlog_enforcer._playtime_procs import attributed_key, qualifying_pids from steam_backlog_enforcer._playtime_state import ( PlaytimeRules, PlaytimeState, @@ -53,6 +58,7 @@ from steam_backlog_enforcer._playtime_state import ( 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: @@ -96,7 +102,11 @@ def playtime_tick( # 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) + 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(): @@ -108,9 +118,13 @@ def playtime_tick( session.history.observe(state, demo=demo) return - owners = qualifying_pids(rules) + 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 + ) state = accumulate( state, now=now, @@ -120,6 +134,7 @@ def playtime_tick( ) state = _policy(state, rules, now=now) 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) diff --git a/steam_backlog_enforcer/_playtime_cutoff.py b/steam_backlog_enforcer/_playtime_cutoff.py index d86fbb0..89cf69b 100644 --- a/steam_backlog_enforcer/_playtime_cutoff.py +++ b/steam_backlog_enforcer/_playtime_cutoff.py @@ -12,6 +12,9 @@ from dataclasses import replace import logging 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._playtime_block import ( reconcile, ) @@ -22,7 +25,6 @@ from steam_backlog_enforcer._playtime_kill import ( 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, @@ -148,4 +150,6 @@ def _kill_set(rules: PlaytimeRules) -> set[int]: Returns: PIDs to signal. """ - return set(qualifying_pids(rules)) | steam_and_launcher_pids() + return ( + set(qualifying_pids(rules, config_mod.CONFIG_FILE)) | steam_and_launcher_pids() + ) diff --git a/steam_backlog_enforcer/_playtime_kill.py b/steam_backlog_enforcer/_playtime_kill.py index b9f6eb3..e4526c7 100644 --- a/steam_backlog_enforcer/_playtime_kill.py +++ b/steam_backlog_enforcer/_playtime_kill.py @@ -15,13 +15,15 @@ from pathlib import Path import shutil import signal -from steam_backlog_enforcer._counted_procs import kill_target_names -from steam_backlog_enforcer._playtime_run import _run -from steam_backlog_enforcer._total_block_launchers import ( +from leisure_watch.procscan import ( LAUNCHER_PROCESS_NAMES, STEAM_CLIENT_PROCESS_NAMES, + get_pids_by_process_names, + kill_target_names, ) -from steam_backlog_enforcer.enforcer import get_pids_by_process_names + +from steam_backlog_enforcer import config +from steam_backlog_enforcer._playtime_run import _run logger = logging.getLogger(__name__) @@ -54,7 +56,11 @@ def steam_and_launcher_pids() -> set[int]: Returns: The launcher and client PIDs. """ - names = STEAM_CLIENT_PROCESS_NAMES | LAUNCHER_PROCESS_NAMES | kill_target_names() + names = ( + STEAM_CLIENT_PROCESS_NAMES + | LAUNCHER_PROCESS_NAMES + | kill_target_names(config.CONFIG_FILE) + ) return set(get_pids_by_process_names(names)) diff --git a/steam_backlog_enforcer/_playtime_procs.py b/steam_backlog_enforcer/_playtime_procs.py deleted file mode 100644 index 60ffbc9..0000000 --- a/steam_backlog_enforcer/_playtime_procs.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Identifying gaming processes by their /proc cmdline. - -Process-name matching alone misses launchers run through an interpreter -(``lutris`` is a Python script; several Minecraft launchers are ``java -jar``), -so this reads argv and looks past the interpreter to the real program. -""" - -from __future__ import annotations - -import os -from pathlib import Path -from typing import TYPE_CHECKING - -from steam_backlog_enforcer._counted_procs import key_by_name, load_counted_processes -from steam_backlog_enforcer._total_block_launchers import LAUNCHER_PROCESS_NAMES -from steam_backlog_enforcer.enforcer import ( - get_pids_by_process_names, - get_running_steam_game_pids, -) - -if TYPE_CHECKING: - from steam_backlog_enforcer._playtime_state import PlaytimeRules - -_PROC = Path("/proc") - -# 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 `