mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
104 lines
3.5 KiB
Python
104 lines
3.5 KiB
Python
"""A bounded window in which the Steam store stays unblocked.
|
|
|
|
``run.sh unblock`` used to comment the store lines out of ``/etc/hosts`` once
|
|
and walk away. Anything that reinstalls the hosts file -- the hourly
|
|
``periodic-system-maintenance`` job, any other ``hosts-blocker/install.sh`` run
|
|
-- put the block straight back, sometimes within seconds, so a purchase could
|
|
not be finished. The window moves that job into the daemon: until the
|
|
deadline it re-applies the unblock whenever the block reappears, and after it
|
|
re-blocks exactly once.
|
|
|
|
Only the CLI writes the deadline (``State.store_unblocked_until``). The daemon
|
|
runs as root and never saves state for this: a root-written ``state.json``
|
|
would lock the unprivileged CLI out of its own file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from steam_backlog_enforcer.store_blocker import (
|
|
block_store,
|
|
hosts_blocks_store,
|
|
unblock_store,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from steam_backlog_enforcer.config import Config, State
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_WINDOW_MINUTES = 15
|
|
MAX_WINDOW_MINUTES = 30
|
|
|
|
# Deadlines this daemon process has already re-blocked for. In memory on
|
|
# purpose (see module docstring); a restarted daemon re-blocks at startup anyway.
|
|
_reblocked_for: set[str] = set()
|
|
|
|
|
|
def open_store_window(state: State, minutes: int) -> datetime:
|
|
"""Record a store window ending *minutes* from now and unblock at once.
|
|
|
|
Args:
|
|
state: Enforcer state; saved with the new deadline.
|
|
minutes: Window length, 1 to ``MAX_WINDOW_MINUTES``.
|
|
|
|
Returns:
|
|
The UTC deadline.
|
|
|
|
Raises:
|
|
ValueError: If *minutes* is out of range.
|
|
RuntimeError: If the store could not be unblocked right now.
|
|
"""
|
|
if not 1 <= minutes <= MAX_WINDOW_MINUTES:
|
|
msg = f"minutes must be between 1 and {MAX_WINDOW_MINUTES}, got {minutes}"
|
|
raise ValueError(msg)
|
|
until = datetime.now(UTC) + timedelta(minutes=minutes)
|
|
state.store_unblocked_until = until.isoformat()
|
|
state.save()
|
|
if not unblock_store():
|
|
msg = "could not unblock the store (sudo?)"
|
|
raise RuntimeError(msg)
|
|
return until
|
|
|
|
|
|
def _deadline(state: State) -> datetime | None:
|
|
"""The window's deadline, or ``None`` when unset or unparsable."""
|
|
raw = state.store_unblocked_until
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(raw)
|
|
except ValueError:
|
|
logger.warning("Ignoring malformed store_unblocked_until %r", raw)
|
|
return None
|
|
|
|
|
|
def store_window_tick(config: Config, state: State) -> None:
|
|
"""Keep the store open inside the window; re-block once it has passed.
|
|
|
|
Runs every daemon iteration. Outside a window it only parses one string,
|
|
so it costs nothing; inside one it reads ``/etc/hosts`` (no subprocess)
|
|
and shells out only when the block has actually come back.
|
|
|
|
Args:
|
|
config: Enforcer configuration (``block_store`` gates the re-block).
|
|
state: Freshly reloaded enforcer state.
|
|
"""
|
|
until = _deadline(state)
|
|
if until is None:
|
|
return
|
|
if datetime.now(UTC) < until:
|
|
if hosts_blocks_store():
|
|
logger.info("Store window open until %s: block came back, undoing", until)
|
|
unblock_store()
|
|
return
|
|
if state.store_unblocked_until in _reblocked_for:
|
|
return
|
|
_reblocked_for.add(state.store_unblocked_until)
|
|
if config.block_store:
|
|
logger.info("Store window ended at %s: re-blocking the store", until)
|
|
block_store()
|