steam-backlog-enforcer/steam_backlog_enforcer/_ctl_ops.py
Krzysztof kuhy Rudnicki 1bdf360193 pick keeps already-picked games; fast install reports progress
pick and pick-manual no longer uninstall games that are already allowed:
the outgoing assignment is kept as a released pick (installed, no lock)
until 'pick --replace', done/check, or abandon-pick. The allowed set is
capped at max_manual_picks + 1; a pick over it is refused before the
candidate list / confirmation, naming the games. status and the web
Picks card list kept games with an Abandon button.

Fast install now tracks the installer log from the spawn offset: web
jobs drive the progress bar (per-depot %, phase) and echo a line every
30 s; daemon-started runs are exposed via ctl status / /api/daemon and
shown on the Dashboard.

Also: web deps bumped for dependency freshness (react-query 5.104.5,
typescript-eslint 8.72.0, vite 8.3.5, node 24.22.1 in CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb
2026-10-23 20:14:12 +02:00

171 lines
6.2 KiB
Python

"""The allowlisted ops of the control socket.
``OPS`` is the whole attack surface: a request naming anything else is
refused as an unknown command. There is no stop, no disable and no generic
command execution -- every op does one named, bounded thing. Each privileged
op re-checks its own typed phrase, argument bounds and the same locks the CLI
honours, because the web server in front of it is not trusted to have done so.
"""
from __future__ import annotations
import contextlib
import logging
import os
import subprocess
import time
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer._ctl_context import (
check_locks,
hold_tick,
int_arg,
require_phrase,
)
from steam_backlog_enforcer._ctl_protocol import OP_FAILED, CtlError
from steam_backlog_enforcer._ctl_reset import RESET_OPS
from steam_backlog_enforcer._ctl_restart import RESTART_OPS, restart_available_at
from steam_backlog_enforcer._fast_install_progress import fast_install_status
from steam_backlog_enforcer._playtime_block import release_block
from steam_backlog_enforcer._store_window import MAX_WINDOW_MINUTES, open_store_window
from steam_backlog_enforcer._total_block import (
get_total_block_status,
start_total_block,
)
from steam_backlog_enforcer.config import CONFIG_DIR, STATE_FILE, State
if TYPE_CHECKING:
from collections.abc import Callable
from steam_backlog_enforcer._ctl_context import CtlContext
logger = logging.getLogger(__name__)
UNIT_NAME: Final = "steam-backlog-enforcer.service"
DEFAULT_JOURNAL_LINES: Final = 50
MAX_JOURNAL_LINES: Final = 200
_MAX_LINE_CHARS: Final = 500
_JOURNAL_TIMEOUT_SECONDS: Final = 5
_JOURNALCTL: Final = "/usr/bin/journalctl"
# A block is irreversible from the app; a typo like 3650 must not be one
# confirmation away. The phrase repeats the number, which is the other half.
MAX_BLOCK_DAYS: Final = 365
def op_ping(_ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]:
"""Liveness probe."""
return {"pong": True}
def op_status(ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]:
"""The daemon's state, start time, pid, next restart and fast installs.
``fast_installs`` are the steam-game-installer runs the daemon started,
as of the enforce loop's last read of their log: this thread never reads
the file. It rides on ``status`` rather than a file of its own because the
web server runs as the desktop user and a root-owned file is no use to it.
"""
return {
"state": "restarting" if ctx.restart_event.is_set() else "running",
"started_at": ctx.started_at.isoformat(),
"pid": os.getpid(),
"restart_available_at": restart_available_at(),
"fast_installs": fast_install_status(time.monotonic()),
}
def op_journal_tail(_ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""The last *lines* journal lines of the enforcer unit (capped)."""
lines = (
int_arg(args, "lines", 1, MAX_JOURNAL_LINES)
if "lines" in args
else DEFAULT_JOURNAL_LINES
)
try:
result = subprocess.run(
[
_JOURNALCTL,
"-u",
UNIT_NAME,
"-n",
str(lines),
"--no-pager",
"-q",
"-o",
"short-iso",
],
capture_output=True,
text=True,
errors="replace",
timeout=_JOURNAL_TIMEOUT_SECONDS,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
raise CtlError(OP_FAILED, f"journalctl failed: {exc}") from exc
return {
"lines": [line[:_MAX_LINE_CHARS] for line in result.stdout.splitlines()],
}
def _hand_back_state_file() -> None:
"""Give state.json back to the desktop user if root just created it.
``State.save`` keeps an existing file's owner, but a file that did not
exist yet comes out root-owned and would lock the unprivileged CLI (and
web server) out of its own state.
"""
with contextlib.suppress(OSError):
owner = CONFIG_DIR.stat()
if STATE_FILE.stat().st_uid != owner.st_uid:
os.chown(STATE_FILE, owner.st_uid, owner.st_gid)
def op_store_unblock(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Open the timed store window (1 to 30 minutes), as ``run.sh unblock`` does."""
minutes = int_arg(args, "minutes", 1, MAX_WINDOW_MINUTES)
require_phrase("unblock", args.get("phrase"), minutes=minutes)
with hold_tick(ctx):
check_locks("unblock")
try:
until = open_store_window(State.load(), minutes)
except RuntimeError as exc:
raise CtlError(OP_FAILED, str(exc)) from exc
finally:
_hand_back_state_file()
logger.warning("Store window opened for %d min (control socket)", minutes)
return {"minutes": minutes, "until": until.isoformat()}
def op_block_gaming(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Start a total gaming block of *days* days. There is no undo."""
days = int_arg(args, "days", 1, MAX_BLOCK_DAYS)
require_phrase("block-gaming", args.get("phrase"), days=days)
with hold_tick(ctx):
check_locks("block-gaming")
logger.warning("Total gaming block for %d day(s) requested (socket)", days)
if not start_total_block(days):
raise CtlError(OP_FAILED, "failed to engage the block (see the log)")
until = get_total_block_status().until
return {"days": days, "until": until.isoformat() if until else None}
def op_gaming_unblock(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]:
"""Release every playtime mount (the recovery hatch)."""
require_phrase("gaming-unblock", args.get("phrase"))
with hold_tick(ctx):
check_locks("gaming-unblock")
released = release_block()
return {"released": [str(path) for path in released]}
OPS: Final[dict[str, Callable[[CtlContext, dict[str, object]], dict[str, object]]]] = {
"ping": op_ping,
"status": op_status,
"journal_tail": op_journal_tail,
"store_unblock": op_store_unblock,
"block_gaming": op_block_gaming,
"gaming_unblock": op_gaming_unblock,
**RESET_OPS,
**RESTART_OPS,
}