steam-backlog-enforcer/steam_backlog_enforcer/_total_block_purge.py
Krzysztof kuhy Rudnicki e82ddd9a4b 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 12:37:01 +02:00

220 lines
7.4 KiB
Python

"""Removing Steam and Proton: processes, packages and filesystem remnants.
One responsibility -- making Steam and its Proton helpers *not present* --
expressed over the generic pacman primitives in
:mod:`steam_backlog_enforcer._pacman`. The network side of the block lives
in :mod:`steam_backlog_enforcer._total_block_net`; the two are independent.
"""
from __future__ import annotations
import contextlib
from datetime import UTC, datetime
import json
import logging
from pathlib import Path
import shutil
from leisure_watch.procscan import (
STEAM_CLIENT_PROCESS_NAMES,
get_pids_by_process_names,
)
from steam_backlog_enforcer._pacman import (
is_package_installed,
pacman_owner,
uninstall_package,
)
from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
from steam_backlog_enforcer.enforcer import kill_processes_by_name
logger = logging.getLogger(__name__)
STEAM_PACKAGE = "steam"
_STEAM_PURGE_LOG_FILE = CONFIG_DIR / "total_block_purge_log.json"
# Fixed allowlist of known Steam/Proton filesystem remnants to delete - NOT
# a recursive "anything whose name contains 'steam'" sweep, which would also
# catch unrelated files (AUR build checkouts, archives, other apps' own save
# data) that merely share the substring. Confirmed present on the reference
# machine: ~/.steam (symlink farm), ~/steam (secondary/portable install),
# ~/.local/share/Steam (the real install - steamapps, userdata, screenshots,
# and compatibilitytools.d's GE-Proton builds all live under here).
_STEAM_REMNANT_PATHS: tuple[Path, ...] = (
Path.home() / ".steam",
Path.home() / "steam",
Path.home() / ".local" / "share" / "Steam",
Path.home() / ".steampath",
Path.home() / ".steampid",
Path.home() / ".config" / "steamtinkerlaunch",
Path.home() / ".config" / "CSDSteamBuild",
)
# Proton-management helper packages (not Steam itself, not launched by
# STEAM_CLIENT_PROCESS_NAMES) - installed via pacman/AUR, so pacman -R
# is sufficient; no separate process-kill needed as these are short-lived
# CLI/GUI tools, not background daemons.
_PROTON_HELPER_PACKAGES: tuple[str, ...] = (
"protondb-tags-git",
"protonhax-git",
"protontricks-git",
"protonup-ng-git",
"protonup-qt",
)
def is_steam_installed() -> bool:
"""Return True if the ``steam`` pacman package is currently installed."""
return is_package_installed(STEAM_PACKAGE)
def uninstall_steam_package() -> bool:
"""Remove the ``steam`` pacman package.
Returns True on success or if it was already absent.
"""
return uninstall_package(STEAM_PACKAGE)
def kill_and_uninstall_launchers(
launcher_process_names: frozenset[str],
) -> list[tuple[int, str]]:
"""Kill running third-party launchers and uninstall their pacman package.
Resolves each PID's ``/proc/<pid>/exe`` target *before* sending SIGTERM,
since the symlink stops resolving once the process has exited. Package
removal is best-effort: launchers installed outside pacman (flatpak,
AppImage, a wine prefix) simply have no owning package and are just
killed again next tick, same as before this existed.
Args:
launcher_process_names: Launcher process names to kill.
Returns:
The ``(pid, name)`` pairs that were killed.
"""
pids = get_pids_by_process_names(launcher_process_names)
exe_paths: dict[int, str] = {}
for pid in pids:
with contextlib.suppress(OSError):
exe_paths[pid] = str(Path(f"/proc/{pid}/exe").resolve(strict=True))
killed = kill_processes_by_name(launcher_process_names)
packages: set[str] = set()
for pid, _name in killed:
exe_path = exe_paths.get(pid)
if exe_path is not None:
package = pacman_owner(exe_path)
if package is not None:
packages.add(package)
for package in packages:
if not uninstall_package(package):
logger.warning(
"Total block: failed to uninstall launcher package %s", package
)
return killed
def kill_steam_and_launchers(
launcher_process_names: frozenset[str],
) -> list[tuple[int, str]]:
"""Kill Steam client and known third-party launcher processes.
Args:
launcher_process_names: Launcher process names to kill.
Returns:
The ``(pid, name)`` pairs that were killed.
"""
steam_killed = kill_processes_by_name(STEAM_CLIENT_PROCESS_NAMES)
launcher_killed = kill_and_uninstall_launchers(launcher_process_names)
return steam_killed + launcher_killed
def _uninstall_proton_helpers() -> list[str]:
"""Uninstall known Proton-management helper packages that are present.
Returns the subset of :data:`_PROTON_HELPER_PACKAGES` that were actually
installed and successfully removed (for logging), not the full fixed
list.
"""
removed: list[str] = []
for package in _PROTON_HELPER_PACKAGES:
if not is_package_installed(package):
continue
if uninstall_package(package):
removed.append(package)
else:
logger.warning("Total block: failed to uninstall proton helper %s", package)
return removed
def _remove_steam_remnants() -> list[str]:
"""Delete the curated Steam/Proton filesystem remnants that exist.
Symlinks are unlinked directly rather than following them into
:func:`shutil.rmtree`, since e.g. ``~/.steampath`` -> a file inside
``~/.steam``, which this same pass may already have removed.
"""
removed: list[str] = []
for path in _STEAM_REMNANT_PATHS:
if not (path.is_symlink() or path.exists()):
continue
try:
if path.is_symlink() or path.is_file():
path.unlink()
else:
shutil.rmtree(path)
except OSError:
logger.exception("Failed to remove steam remnant %s", path)
continue
removed.append(str(path))
return removed
def _log_steam_purge(removed_paths: list[str], removed_packages: list[str]) -> None:
"""Append a timestamped record of a Steam/Proton purge to disk.
A no-op when nothing was actually removed, so the log only grows on
ticks that did real work (this runs every enforce tick while the block
is active - see ``enforce_total_block_tick``).
"""
if not removed_paths and not removed_packages:
return
try:
existing = json.loads(_STEAM_PURGE_LOG_FILE.read_text(encoding="utf-8"))
if not isinstance(existing, list):
existing = []
except OSError, json.JSONDecodeError, ValueError:
existing = []
existing.append(
{
"timestamp": datetime.now(UTC).isoformat(),
"removed_paths": removed_paths,
"removed_packages": removed_packages,
}
)
_atomic_write(_STEAM_PURGE_LOG_FILE, json.dumps(existing, indent=2) + "\n")
def purge_steam_and_proton() -> None:
"""Remove curated Steam/Proton filesystem remnants and helper packages.
Best-effort and re-run every enforce tick, same as the Steam-package
reappearance check: a stat() per fixed path and a ``pacman -Qi`` per
helper package are cheap even when there is nothing left to do.
"""
removed_paths = _remove_steam_remnants()
removed_packages = _uninstall_proton_helpers()
if removed_paths or removed_packages:
logger.info(
"Total block: purged steam path(s) %s, proton package(s) %s",
removed_paths,
removed_packages,
)
_log_steam_purge(removed_paths, removed_packages)